mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b516d7636 | |||
| a7787d67d1 | |||
| 56ae1d3dd4 | |||
| 199f352c5f | |||
| dfd5872870 | |||
| 0d33658a0a | |||
| 03bff6500a | |||
| b379d71d19 | |||
| 1fe3831baf | |||
| 310924c5eb | |||
| 9d589b5059 | |||
| 470d7400f5 | |||
| 407853d8f3 | |||
| a45c4c149c | |||
| def7220bfc | |||
| b20b5a7f3b | |||
| e1f51ce53f | |||
| f6c5afe5a9 | |||
| 847771fe06 | |||
| e11d4a1fc9 | |||
| 1b99e64e67 | |||
| 5005898468 | |||
| 627e640c69 | |||
| b6b6f87969 | |||
| 9590b51a3f | |||
| f1476dc7d4 | |||
| b8bd88901a | |||
| f1adabcddc | |||
| a597364516 | |||
| c40aecbdbe | |||
| 24470e52a5 | |||
| d985fa4307 | |||
| 23bbc5cd14 | |||
| 6c1aabd5e0 | |||
| a16dd3d4ed | |||
| bbb1a19ed6 | |||
| ebf6fc07a1 | |||
| 04513d9692 | |||
| 146720e197 | |||
| b1f8cc04af | |||
| 082fe93e16 | |||
| 709c195905 | |||
| 3355b78d91 | |||
| b84c63d034 | |||
| 057b5a9dee | |||
| 61aefc0759 | |||
| f929f8f100 | |||
| 4a57013cf8 | |||
| 6b17dc6190 | |||
| e98b90fc51 | |||
| 2f17fc9613 | |||
| b8ea3ea091 | |||
| 82a5796159 | |||
| 9f38562237 | |||
| 5b4fb1f770 | |||
| cb88db6ce3 | |||
| 66fdd51f0d | |||
| 1b0e4e4610 | |||
| 842f1dcfdb | |||
| 067dfa341f | |||
| 98dd65cd60 | |||
| f826f7fc9b | |||
| f0afb6750e | |||
| 703d09f306 | |||
| aefaf140c1 | |||
| 27ecc46dc7 | |||
| 8c7c69c749 | |||
| 9b16d0b069 | |||
| ccc11dc92d | |||
| a47dabff22 | |||
| 1277ceb426 | |||
| 7b7335b7e9 | |||
| 7192fa8b7a | |||
| 4062b30409 | |||
| 44614c79c4 | |||
| dc67b7a3be | |||
| de1bd36a47 | |||
| 6c97a46dae | |||
| c387fe190b | |||
| 8721a2710b | |||
| c75e3b5bae | |||
| edc8d1d91e | |||
| e85735028c | |||
| 6c3299103c | |||
| 60e23fee16 | |||
| 0c380f31ac | |||
| 26aefa68f5 | |||
| 638788f8d0 | |||
| b005a15a4a | |||
| 0efa33cf33 | |||
| 7d6b8105f1 | |||
| a5a09c17fa | |||
| 10c6040b38 | |||
| f77f5a343e | |||
| 41bf4b140b | |||
| fd599277d6 | |||
| 2d27c49367 | |||
| 974271d6b1 | |||
| 985ee1e2ec | |||
| 83bee1e776 | |||
| 124714ca3a |
@@ -322,6 +322,7 @@ jobs:
|
|||||||
working-directory: packages/desktop
|
working-directory: packages/desktop
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: rtl-aware-development
|
||||||
|
description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars.
|
||||||
|
---
|
||||||
|
|
||||||
|
# RTL-Aware Development
|
||||||
|
|
||||||
|
Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content.
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL.
|
||||||
|
- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout.
|
||||||
|
- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Avoid */
|
||||||
|
padding-left: 12px;
|
||||||
|
right: 0;
|
||||||
|
border-right: 1px solid;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
/* Prefer */
|
||||||
|
padding-inline-start: 12px;
|
||||||
|
inset-inline-end: 0;
|
||||||
|
border-inline-end: 1px solid;
|
||||||
|
text-align: start;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Isolate mixed-direction text. Use `dir="auto"` or `<bdi>` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly.
|
||||||
|
- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern.
|
||||||
|
- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper.
|
||||||
|
- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`.
|
||||||
|
- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints.
|
||||||
|
|
||||||
|
## Test Matrix
|
||||||
|
|
||||||
|
- English + LTR
|
||||||
|
- English + forced RTL
|
||||||
|
- A real RTL locale + RTL
|
||||||
|
- Mixed RTL/LTR content, long labels, numbers, code, and paths
|
||||||
|
- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/)
|
||||||
|
- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/)
|
||||||
|
- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/)
|
||||||
|
- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir)
|
||||||
|
- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/)
|
||||||
|
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values)
|
||||||
|
- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir)
|
||||||
|
- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft)
|
||||||
|
- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/)
|
||||||
|
- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
|
||||||
|
- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
|
||||||
|
- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/)
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corvu/drawer": "catalog:",
|
"@corvu/drawer": "catalog:",
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "8.15.0",
|
"acorn": "8.15.0",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -381,7 +381,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
@@ -435,7 +435,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -449,7 +449,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -509,7 +509,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
@@ -540,7 +540,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
@@ -559,7 +559,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -690,7 +690,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -766,7 +766,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -781,7 +781,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
@@ -796,7 +796,7 @@
|
|||||||
},
|
},
|
||||||
"packages/session-ui": {
|
"packages/session-ui": {
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||||
@@ -814,11 +814,8 @@
|
|||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"katex": "0.16.27",
|
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-katex-extension": "5.1.6",
|
|
||||||
"marked-shiki": "catalog:",
|
|
||||||
"morphdom": "2.7.8",
|
"morphdom": "2.7.8",
|
||||||
"motion": "12.34.5",
|
"motion": "12.34.5",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
@@ -831,7 +828,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/katex": "0.16.7",
|
|
||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
@@ -840,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -853,7 +849,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
@@ -887,7 +883,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-athena": "3.933.0",
|
"@aws-sdk/client-athena": "3.933.0",
|
||||||
"@planetscale/database": "1.19.0",
|
"@planetscale/database": "1.19.0",
|
||||||
@@ -906,7 +902,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -948,7 +944,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -975,7 +971,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
@@ -991,7 +987,6 @@
|
|||||||
"katex": "0.16.27",
|
"katex": "0.16.27",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-katex-extension": "5.1.6",
|
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
"morphdom": "2.7.8",
|
"morphdom": "2.7.8",
|
||||||
"motion": "12.34.5",
|
"motion": "12.34.5",
|
||||||
@@ -1027,7 +1022,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1083,6 +1078,7 @@
|
|||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||||
|
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
@@ -1137,7 +1133,7 @@
|
|||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.6",
|
"marked": "18.0.7",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"opentui-spinner": "0.0.7",
|
"opentui-spinner": "0.0.7",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
@@ -4318,9 +4314,7 @@
|
|||||||
|
|
||||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||||
|
|
||||||
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
"marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="],
|
||||||
|
|
||||||
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
|
|
||||||
|
|
||||||
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
|
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=",
|
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
|
||||||
"aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=",
|
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
|
||||||
"aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=",
|
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
|
||||||
"x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU="
|
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -70,7 +70,7 @@
|
|||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.6",
|
"marked": "18.0.7",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"remend": "1.3.0",
|
"remend": "1.3.0",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
@@ -158,6 +158,7 @@
|
|||||||
"@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",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||||
|
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,16 @@
|
|||||||
|
|
||||||
- Always prefer `createStore` over multiple `createSignal` calls
|
- Always prefer `createStore` over multiple `createSignal` calls
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
|
||||||
|
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
|
||||||
|
|
||||||
## Tool Calling
|
## Tool Calling
|
||||||
|
|
||||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## Required Reading
|
||||||
|
|
||||||
|
- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides.
|
||||||
|
- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply.
|
||||||
|
|
||||||
|
## Test Hygiene
|
||||||
|
|
||||||
|
- Test user-visible behavior with isolated, deterministic data and scoped, unique locators.
|
||||||
|
- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors.
|
||||||
|
- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes.
|
||||||
|
- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead.
|
||||||
|
- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires.
|
||||||
|
- Register event and network waits before the action that triggers them.
|
||||||
|
- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
|
||||||
|
- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
|
||||||
|
- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.
|
||||||
@@ -56,6 +56,32 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
|
|||||||
|
|
||||||
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
|
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
|
||||||
|
|
||||||
|
## Desktop profiler
|
||||||
|
|
||||||
|
The desktop profiler launches the existing production build directly, creates isolated desktop state, chooses an available CDP port, and writes reports under the OS temporary directory by default.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run profile:desktop --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a private partial snapshot from the default local database and run Home once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run profile:desktop --partial-snapshot-out /tmp/opencode-perf.db \
|
||||||
|
--window-end 2026-08-04T06:14:26.878Z \
|
||||||
|
--scenarios home,calibration --skip-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat against the immutable partial snapshot:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run profile:desktop --mode partial-snapshot --db /tmp/opencode-perf.db \
|
||||||
|
--window-end 2026-08-04T06:14:26.878Z \
|
||||||
|
--scenarios home,calibration --runs 3 --skip-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Partial snapshots contain private application data and must not be committed or shared. The profiler copies each partial snapshot to a per-run working database and remaps selected project paths to temporary workspaces, leaving the source snapshot unchanged. `PROFILE_SUMMARY` is the compact comparison output; `PROFILE_REPORT` points to the complete JSON report with the database hash, invocation parameters, raw runs, and attribution data.
|
||||||
|
|
||||||
## Chrome traces
|
## Chrome traces
|
||||||
|
|
||||||
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
|
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Database } from "bun:sqlite"
|
||||||
|
import { mkdir, rm } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { progress } from "./progress"
|
||||||
|
import type { Options, Target } from "./types"
|
||||||
|
|
||||||
|
export async function createPartialSnapshot(source: string, destination: string, options: Options, targets: Target[]) {
|
||||||
|
await mkdir(path.dirname(destination), { recursive: true })
|
||||||
|
await rm(destination, { force: true })
|
||||||
|
const input = new Database(source, { readonly: true })
|
||||||
|
const schema = input
|
||||||
|
.query(
|
||||||
|
`SELECT type, name, sql FROM sqlite_schema
|
||||||
|
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name`,
|
||||||
|
)
|
||||||
|
.all() as { type: string; name: string; sql: string }[]
|
||||||
|
input.close()
|
||||||
|
|
||||||
|
const output = new Database(destination, { create: true })
|
||||||
|
output.run("PRAGMA foreign_keys = OFF")
|
||||||
|
schema.filter((item) => item.type === "table").forEach((item) => output.run(item.sql))
|
||||||
|
output.run("ATTACH DATABASE ? AS source", source)
|
||||||
|
const selected = [...new Set(targets.map((target) => target.id))]
|
||||||
|
const placeholders = selected.map(() => "?").join(",")
|
||||||
|
|
||||||
|
for (const table of schema.filter((item) => item.type === "table").map((item) => item.name)) {
|
||||||
|
progress("copying partial snapshot table", { table })
|
||||||
|
if (table === "event") continue
|
||||||
|
if (table === "message") {
|
||||||
|
output.run(
|
||||||
|
`INSERT INTO main.message SELECT * FROM source.message
|
||||||
|
WHERE (time_created >= ? AND time_created < ? AND session_id IN (
|
||||||
|
SELECT id FROM source.session WHERE parent_id IS NULL
|
||||||
|
)) OR session_id IN (${placeholders})`,
|
||||||
|
options.windowStart,
|
||||||
|
options.windowEnd,
|
||||||
|
...selected,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (table === "part") {
|
||||||
|
output.run("INSERT INTO main.part SELECT * FROM source.part WHERE message_id IN (SELECT id FROM main.message)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (["session_context_epoch", "session_input", "session_message", "session_share", "todo"].includes(table)) {
|
||||||
|
output.run(
|
||||||
|
`INSERT INTO main."${table}" SELECT * FROM source."${table}" WHERE session_id IN (${placeholders})`,
|
||||||
|
...selected,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
output.run(`INSERT INTO main."${table}" SELECT * FROM source."${table}"`)
|
||||||
|
}
|
||||||
|
output.run("DETACH DATABASE source")
|
||||||
|
schema.filter((item) => item.type !== "table").forEach((item) => output.run(item.sql))
|
||||||
|
output.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fingerprint(file: string) {
|
||||||
|
const input = Bun.file(file)
|
||||||
|
const hasher = new Bun.CryptoHasher("sha256")
|
||||||
|
for await (const chunk of input.stream()) hasher.update(chunk)
|
||||||
|
return { bytes: input.size, sha256: hasher.digest("hex") }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCorpus(options: Options) {
|
||||||
|
const database = new Database(options.database, { readonly: true })
|
||||||
|
database.run("PRAGMA query_only = ON")
|
||||||
|
const sessions = database
|
||||||
|
.query(
|
||||||
|
`SELECT id, project_id AS projectID, directory, title
|
||||||
|
FROM session AS candidate
|
||||||
|
WHERE parent_id IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM message
|
||||||
|
WHERE session_id = candidate.id AND time_created >= ? AND time_created < ?
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
.all(options.windowStart, options.windowEnd) as { id: string; projectID: string; directory: string; title: string }[]
|
||||||
|
const messageRows = database.query(
|
||||||
|
`SELECT id, data FROM message
|
||||||
|
WHERE session_id = ? AND time_created >= ? AND time_created < ?
|
||||||
|
ORDER BY time_created, id`,
|
||||||
|
)
|
||||||
|
const partRows = database.query(`SELECT data FROM part WHERE message_id = ? ORDER BY id`)
|
||||||
|
const ranked = sessions
|
||||||
|
.map((session) => {
|
||||||
|
const messages = messageRows.all(session.id, options.windowStart, options.windowEnd) as {
|
||||||
|
id: string
|
||||||
|
data: string
|
||||||
|
}[]
|
||||||
|
const parts = messages.flatMap((message) => partRows.all(message.id) as { data: string }[])
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
bytes:
|
||||||
|
messages.reduce((sum, message) => sum + Buffer.byteLength(message.data), 0) +
|
||||||
|
parts.reduce((sum, part) => sum + Buffer.byteLength(part.data), 0),
|
||||||
|
messages: messages.length,
|
||||||
|
parts: parts.length,
|
||||||
|
userTurns: messages.filter((message) => JSON.parse(message.data).role === "user").length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((session) => session.messages > 0)
|
||||||
|
.sort((a, b) => a.bytes - b.bytes || a.id.localeCompare(b.id))
|
||||||
|
if (ranked.length === 0) throw new Error("No sessions found in the profile window")
|
||||||
|
const select = (label: Target["label"], percentile: number) => ({
|
||||||
|
label,
|
||||||
|
...ranked[Math.max(0, Math.ceil(ranked.length * percentile) - 1)]!,
|
||||||
|
})
|
||||||
|
const targets = [select("p50", 0.5), select("p95", 0.95), select("max", 1)] satisfies Target[]
|
||||||
|
const typingText = loadTypingText(database, partRows, messageRows, targets[2]!, options)
|
||||||
|
const projectIDs = [...new Set(ranked.map((session) => session.projectID))]
|
||||||
|
database.close()
|
||||||
|
return { targets, typingText, projectIDs }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTypingText(
|
||||||
|
database: Database,
|
||||||
|
partRows: ReturnType<Database["query"]>,
|
||||||
|
messageRows: ReturnType<Database["query"]>,
|
||||||
|
target: Target,
|
||||||
|
options: Options,
|
||||||
|
) {
|
||||||
|
const messages = messageRows.all(target.id, options.windowStart, options.windowEnd) as { id: string; data: string }[]
|
||||||
|
const text = messages
|
||||||
|
.filter((message) => JSON.parse(message.data).role === "user")
|
||||||
|
.flatMap((message) =>
|
||||||
|
(partRows.all(message.id) as { data: string }[]).flatMap((part) => {
|
||||||
|
const data = JSON.parse(part.data)
|
||||||
|
return data.type === "text" && typeof data.text === "string" ? [data.text] : []
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.length - a.length)[0]
|
||||||
|
if (!text) throw new Error("No real user prompt found for composer profiling")
|
||||||
|
return text
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Database } from "bun:sqlite"
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import type { Options } from "./types"
|
||||||
|
|
||||||
|
export async function prepareDesktopState(
|
||||||
|
options: Options,
|
||||||
|
databasePath: string,
|
||||||
|
userData: string,
|
||||||
|
run: number,
|
||||||
|
projectIDs: string[],
|
||||||
|
) {
|
||||||
|
const database = new Database(databasePath)
|
||||||
|
const projects = database.query("SELECT id, worktree, sandboxes FROM project ORDER BY id").all() as {
|
||||||
|
id: string
|
||||||
|
worktree: string
|
||||||
|
sandboxes: string
|
||||||
|
}[]
|
||||||
|
const selected = new Set(projectIDs)
|
||||||
|
const profileProjects = projects.filter((project) => selected.has(project.id))
|
||||||
|
const worktrees =
|
||||||
|
options.mode === "partial-snapshot"
|
||||||
|
? await remapDirectories(database, profileProjects, path.join(options.output, "workspaces", String(run)))
|
||||||
|
: profileProjects.map((project) => project.worktree)
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(userData, "opencode.settings"),
|
||||||
|
JSON.stringify({ firstLaunchOnboardingComplete: true, oldLayoutEligible: true, tauriMigrated: true }),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(userData, "opencode.global.dat"),
|
||||||
|
JSON.stringify({
|
||||||
|
server: JSON.stringify({
|
||||||
|
list: [],
|
||||||
|
projects: { local: worktrees.map((worktree) => ({ worktree, expanded: true })) },
|
||||||
|
lastProject: worktrees[0] ? { local: worktrees[0] } : {},
|
||||||
|
recentlyClosed: {},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remapDirectories(
|
||||||
|
database: Database,
|
||||||
|
projects: { id: string; worktree: string; sandboxes: string }[],
|
||||||
|
root: string,
|
||||||
|
) {
|
||||||
|
await mkdir(root, { recursive: true })
|
||||||
|
const mappings = new Map<string, string>()
|
||||||
|
const worktrees = await Promise.all(
|
||||||
|
projects.map(async (project, index) => {
|
||||||
|
const worktree = path.join(root, `project-${String(index + 1).padStart(3, "0")}`)
|
||||||
|
await mkdir(worktree, { recursive: true })
|
||||||
|
mappings.set(project.worktree, worktree)
|
||||||
|
const sandboxes = JSON.parse(project.sandboxes) as string[]
|
||||||
|
const nextSandboxes = await Promise.all(
|
||||||
|
sandboxes.map(async (sandbox, sandboxIndex) => {
|
||||||
|
const next = path.join(worktree, `sandbox-${sandboxIndex + 1}`)
|
||||||
|
await mkdir(next, { recursive: true })
|
||||||
|
mappings.set(sandbox, next)
|
||||||
|
return next
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
database.run("UPDATE project SET worktree = ?, sandboxes = ? WHERE id = ?", worktree, JSON.stringify(nextSandboxes), project.id)
|
||||||
|
return worktree
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const byProject = new Map(projects.map((project, index) => [project.id, worktrees[index]!]))
|
||||||
|
const sessions = database.query("SELECT id, project_id, directory FROM session").all() as {
|
||||||
|
id: string
|
||||||
|
project_id: string
|
||||||
|
directory: string
|
||||||
|
}[]
|
||||||
|
const directories = database.query("SELECT * FROM project_directory").all() as {
|
||||||
|
project_id: string
|
||||||
|
directory: string
|
||||||
|
type: string | null
|
||||||
|
strategy: string | null
|
||||||
|
time_created: number
|
||||||
|
}[]
|
||||||
|
const selected = new Set(projects.map((project) => project.id))
|
||||||
|
const nextDirectories = await Promise.all(
|
||||||
|
directories.filter((item) => selected.has(item.project_id)).map(async (item, index) => {
|
||||||
|
const directory =
|
||||||
|
mappings.get(item.directory) ?? path.join(byProject.get(item.project_id) ?? root, `directory-${index + 1}`)
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
return { ...item, directory }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
database.transaction(() => {
|
||||||
|
sessions.filter((session) => selected.has(session.project_id)).forEach((session) =>
|
||||||
|
database.run(
|
||||||
|
"UPDATE session SET directory = ? WHERE id = ?",
|
||||||
|
mappings.get(session.directory) ?? byProject.get(session.project_id) ?? worktrees[0]!,
|
||||||
|
session.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
database.run(
|
||||||
|
`DELETE FROM project_directory WHERE project_id IN (${projects.map(() => "?").join(",")})`,
|
||||||
|
...projects.map((project) => project.id),
|
||||||
|
)
|
||||||
|
nextDirectories.forEach((item) =>
|
||||||
|
database.run(
|
||||||
|
`INSERT INTO project_directory (project_id, directory, type, strategy, time_created)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
item.project_id,
|
||||||
|
item.directory,
|
||||||
|
item.type,
|
||||||
|
item.strategy,
|
||||||
|
item.time_created,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
return worktrees
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Database } from "bun:sqlite"
|
||||||
|
import { afterAll, expect, test } from "bun:test"
|
||||||
|
import { mkdir, rm } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { createPartialSnapshot, fingerprint } from "./corpus"
|
||||||
|
import { parseOptions } from "./options"
|
||||||
|
|
||||||
|
const directory = path.join(import.meta.dir, `.tmp-${process.pid}`)
|
||||||
|
const source = path.join(directory, "source.db")
|
||||||
|
const partialSnapshot = path.join(directory, "partial-snapshot.db")
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
const database = new Database(source, { create: true })
|
||||||
|
database.run("CREATE TABLE sample (value TEXT NOT NULL)")
|
||||||
|
database.run("INSERT INTO sample VALUES ('repeatable')")
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
afterAll(() => rm(directory, { recursive: true, force: true }))
|
||||||
|
|
||||||
|
test("parses a portable fixed-window partial snapshot invocation", () => {
|
||||||
|
const options = parseOptions([
|
||||||
|
"--mode",
|
||||||
|
"partial-snapshot",
|
||||||
|
"--db",
|
||||||
|
source,
|
||||||
|
"--window-end",
|
||||||
|
"2026-08-04T06:14:26.878Z",
|
||||||
|
"--window-hours",
|
||||||
|
"24",
|
||||||
|
"--scenarios",
|
||||||
|
"home,calibration",
|
||||||
|
"--runs",
|
||||||
|
"3",
|
||||||
|
"--skip-build",
|
||||||
|
])!
|
||||||
|
|
||||||
|
expect(options.database).toBe(source)
|
||||||
|
expect(options.windowEnd).toBe(1_785_824_066_878)
|
||||||
|
expect(options.windowStart).toBe(1_785_737_666_878)
|
||||||
|
expect(options.scenarios).toEqual(["home", "calibration"])
|
||||||
|
expect(options.runs).toBe(3)
|
||||||
|
expect(options.build).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates a consistent private partial database snapshot", async () => {
|
||||||
|
const options = parseOptions(["--db", source, "--window-end", "2026-08-04T06:14:26.878Z"])!
|
||||||
|
await createPartialSnapshot(source, partialSnapshot, options, [])
|
||||||
|
const copy = new Database(partialSnapshot, { readonly: true })
|
||||||
|
expect(copy.query("SELECT value FROM sample").get()).toEqual({ value: "repeatable" })
|
||||||
|
copy.close()
|
||||||
|
expect(await fingerprint(partialSnapshot)).toEqual({
|
||||||
|
bytes: expect.any(Number),
|
||||||
|
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { existsSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { scenarios, type Options, type Scenario } from "./types"
|
||||||
|
|
||||||
|
const help = `Desktop renderer profiler
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
bun run profile:desktop [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--mode local|partial-snapshot
|
||||||
|
Local corpus or fixed partial snapshot (default: local)
|
||||||
|
--db <path> SQLite database (default: opencode data directory)
|
||||||
|
--partial-snapshot-out <path>
|
||||||
|
Copy the benchmark corpus to a private partial snapshot
|
||||||
|
--output <directory> Report directory (default: OS temp directory)
|
||||||
|
--window-end <ISO|epoch> End of corpus window (default: now; required for partial snapshot)
|
||||||
|
--window-hours <hours> Corpus window size (default: 24)
|
||||||
|
--scenarios <names> Comma list: ${scenarios.join(",")} (default: all)
|
||||||
|
--runs <count> Restart Electron and repeat (default: 1)
|
||||||
|
--skip-build Use the existing desktop production build
|
||||||
|
--diagnostics Capture Chrome traces
|
||||||
|
--cpu Capture sampled CPU summaries
|
||||||
|
--response-urls Attribute Response.text durations by URL
|
||||||
|
--help Show this message
|
||||||
|
|
||||||
|
Partial snapshots contain private application data. Do not commit or share them.
|
||||||
|
`
|
||||||
|
|
||||||
|
export function parseOptions(args: string[], now = Date.now()): Options | undefined {
|
||||||
|
if (args.includes("--help")) {
|
||||||
|
console.log(help)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = (name: string) => {
|
||||||
|
const index = args.indexOf(name)
|
||||||
|
if (index === -1) return
|
||||||
|
const result = args[index + 1]
|
||||||
|
if (!result || result.startsWith("--")) throw new Error(`${name} requires a value`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const mode = value("--mode") ?? "local"
|
||||||
|
if (mode !== "local" && mode !== "partial-snapshot") throw new Error(`Unsupported mode: ${mode}`)
|
||||||
|
const endValue = value("--window-end")
|
||||||
|
if (mode === "partial-snapshot" && !endValue)
|
||||||
|
throw new Error("--window-end is required in partial-snapshot mode")
|
||||||
|
const windowEnd = endValue ? parseTime(endValue) : now
|
||||||
|
const windowHours = number(value("--window-hours") ?? "24", "--window-hours")
|
||||||
|
const selected = (value("--scenarios")?.split(",") ?? [...scenarios]).map((item) => item.trim())
|
||||||
|
if (selected.some((item) => !scenarios.includes(item as Scenario)))
|
||||||
|
throw new Error(`--scenarios must contain only: ${scenarios.join(", ")}`)
|
||||||
|
const database = path.resolve(value("--db") ?? path.join(Global.Path.data, "opencode.db"))
|
||||||
|
if (!existsSync(database)) throw new Error(`Database does not exist: ${database}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
database,
|
||||||
|
output: path.resolve(
|
||||||
|
value("--output") ?? path.join(tmpdir(), "opencode-performance", new Date(windowEnd).toISOString().replace(/[:.]/g, "-")),
|
||||||
|
),
|
||||||
|
windowStart: windowEnd - windowHours * 60 * 60 * 1_000,
|
||||||
|
windowEnd,
|
||||||
|
scenarios: selected as Scenario[],
|
||||||
|
runs: number(value("--runs") ?? "1", "--runs"),
|
||||||
|
build: !args.includes("--skip-build"),
|
||||||
|
diagnostics: args.includes("--diagnostics"),
|
||||||
|
cpu: args.includes("--cpu"),
|
||||||
|
responseURLs: args.includes("--response-urls"),
|
||||||
|
partialSnapshotOut: value("--partial-snapshot-out")
|
||||||
|
? path.resolve(value("--partial-snapshot-out")!)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTime(value: string) {
|
||||||
|
const result = /^\d+$/.test(value) ? Number(value) : Date.parse(value)
|
||||||
|
if (!Number.isFinite(result)) throw new Error(`Invalid --window-end: ${value}`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function number(value: string, option: string) {
|
||||||
|
const result = Number(value)
|
||||||
|
if (!Number.isFinite(result) || result <= 0) throw new Error(`${option} must be greater than zero`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import type { Options, ProbeResult } from "./types"
|
||||||
|
|
||||||
|
export async function installProbe(page: Page, options: Options) {
|
||||||
|
await page.addInitScript((attributeResponses) => {
|
||||||
|
const state = {
|
||||||
|
longTasks: [] as number[],
|
||||||
|
animationFrames: [] as ProbeResult["animationFrames"],
|
||||||
|
frameGaps: [] as number[],
|
||||||
|
responseText: [] as ProbeResult["responseText"],
|
||||||
|
}
|
||||||
|
;(window as Window & { __opencodeRendererProfile?: typeof state }).__opencodeRendererProfile = state
|
||||||
|
if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
|
||||||
|
new PerformanceObserver((list) =>
|
||||||
|
state.longTasks.push(...list.getEntries().map((entry) => entry.duration)),
|
||||||
|
).observe({ type: "longtask" })
|
||||||
|
}
|
||||||
|
if (PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")) {
|
||||||
|
new PerformanceObserver((list) =>
|
||||||
|
state.animationFrames.push(
|
||||||
|
...list.getEntries().map((entry) => {
|
||||||
|
const frame = entry as PerformanceEntry & {
|
||||||
|
blockingDuration: number
|
||||||
|
scripts?: {
|
||||||
|
duration: number
|
||||||
|
forcedStyleAndLayoutDuration?: number
|
||||||
|
sourceFunctionName?: string
|
||||||
|
sourceURL?: string
|
||||||
|
sourceCharPosition?: number
|
||||||
|
invoker?: string
|
||||||
|
invokerType?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
duration: frame.duration,
|
||||||
|
blockingDuration: frame.blockingDuration,
|
||||||
|
forcedStyleAndLayoutDuration:
|
||||||
|
frame.scripts?.reduce((sum, script) => sum + (script.forcedStyleAndLayoutDuration ?? 0), 0) ?? 0,
|
||||||
|
scripts:
|
||||||
|
frame.scripts?.map((script) => ({
|
||||||
|
function: script.sourceFunctionName || "(anonymous)",
|
||||||
|
source: script.sourceURL?.split("/").at(-1) || "(document)",
|
||||||
|
position: script.sourceCharPosition ?? -1,
|
||||||
|
invoker: script.invoker ?? "(unknown)",
|
||||||
|
invokerType: script.invokerType ?? "(unknown)",
|
||||||
|
duration: script.duration,
|
||||||
|
forcedStyleAndLayoutDuration: script.forcedStyleAndLayoutDuration ?? 0,
|
||||||
|
})) ?? [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).observe({ type: "long-animation-frame" })
|
||||||
|
}
|
||||||
|
let previous = performance.now()
|
||||||
|
const frame = (now: number) => {
|
||||||
|
const gap = now - previous
|
||||||
|
if (gap > 20) state.frameGaps.push(gap)
|
||||||
|
previous = now
|
||||||
|
requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame)
|
||||||
|
if (!attributeResponses) return
|
||||||
|
const responseText = Response.prototype.text
|
||||||
|
Response.prototype.text = function () {
|
||||||
|
const started = performance.now()
|
||||||
|
const url = this.url
|
||||||
|
return responseText.call(this).then((text) => {
|
||||||
|
state.responseText.push({ url, duration: performance.now() - started })
|
||||||
|
return text
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, options.responseURLs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetProbe(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const state = (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile
|
||||||
|
if (!state) return
|
||||||
|
state.longTasks.length = 0
|
||||||
|
state.animationFrames.length = 0
|
||||||
|
state.frameGaps.length = 0
|
||||||
|
state.responseText.length = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectProbe(page: Page) {
|
||||||
|
return page.evaluate(
|
||||||
|
() => (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeProbe(probe: ProbeResult) {
|
||||||
|
const scripts = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
function: string
|
||||||
|
source: string
|
||||||
|
position: number
|
||||||
|
invoker: string
|
||||||
|
invokerType: string
|
||||||
|
durationMs: number
|
||||||
|
forcedStyleAndLayoutMs: number
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
probe.animationFrames
|
||||||
|
.flatMap((frame) => frame.scripts)
|
||||||
|
.forEach((script) => {
|
||||||
|
const key = `${script.source}:${script.position}:${script.invoker}`
|
||||||
|
const current = scripts.get(key) ?? {
|
||||||
|
function: script.function,
|
||||||
|
source: script.source,
|
||||||
|
position: script.position,
|
||||||
|
invoker: script.invoker,
|
||||||
|
invokerType: script.invokerType,
|
||||||
|
durationMs: 0,
|
||||||
|
forcedStyleAndLayoutMs: 0,
|
||||||
|
}
|
||||||
|
current.durationMs += script.duration
|
||||||
|
current.forcedStyleAndLayoutMs += script.forcedStyleAndLayoutDuration
|
||||||
|
scripts.set(key, current)
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
longTasks: {
|
||||||
|
count: probe.longTasks.length,
|
||||||
|
totalMs: sum(probe.longTasks),
|
||||||
|
maxMs: Math.max(0, ...probe.longTasks),
|
||||||
|
},
|
||||||
|
longAnimationFrames: {
|
||||||
|
count: probe.animationFrames.length,
|
||||||
|
totalBlockingMs: sum(probe.animationFrames.map((frame) => frame.blockingDuration)),
|
||||||
|
maxDurationMs: Math.max(0, ...probe.animationFrames.map((frame) => frame.duration)),
|
||||||
|
forcedStyleAndLayoutMs: sum(probe.animationFrames.map((frame) => frame.forcedStyleAndLayoutDuration)),
|
||||||
|
scripts: [...scripts.values()].sort((a, b) => b.durationMs - a.durationMs).slice(0, 15),
|
||||||
|
},
|
||||||
|
frameGaps: {
|
||||||
|
count: probe.frameGaps.length,
|
||||||
|
maxMs: Math.max(0, ...probe.frameGaps),
|
||||||
|
},
|
||||||
|
responseText: probe.responseText
|
||||||
|
.map((item) => ({ path: responsePath(item.url), durationMs: item.duration }))
|
||||||
|
.sort((a, b) => b.durationMs - a.durationMs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startCPUProfile(page: Page, enabled: boolean) {
|
||||||
|
if (!enabled) return { stop: async () => [] }
|
||||||
|
const session = await page.context().newCDPSession(page)
|
||||||
|
await session.send("Profiler.enable")
|
||||||
|
await session.send("Profiler.setSamplingInterval", { interval: 1_000 })
|
||||||
|
await session.send("Profiler.start")
|
||||||
|
return {
|
||||||
|
async stop() {
|
||||||
|
const result = await session.send("Profiler.stop")
|
||||||
|
await session.detach()
|
||||||
|
const self = new Map<number, number>()
|
||||||
|
result.profile.samples?.forEach((id, index) => {
|
||||||
|
self.set(id, (self.get(id) ?? 0) + (result.profile.timeDeltas?.[index] ?? 0) / 1_000)
|
||||||
|
})
|
||||||
|
return result.profile.nodes
|
||||||
|
.map((node) => ({
|
||||||
|
function: node.callFrame.functionName || "(anonymous)",
|
||||||
|
source: sourceName(node.callFrame.url),
|
||||||
|
line: node.callFrame.lineNumber + 1,
|
||||||
|
selfMs: self.get(node.id) ?? 0,
|
||||||
|
}))
|
||||||
|
.filter((node) => node.selfMs >= 1)
|
||||||
|
.sort((a, b) => b.selfMs - a.selfMs)
|
||||||
|
.slice(0, 40)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function responsePath(value: string) {
|
||||||
|
try {
|
||||||
|
return new URL(value).pathname
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceName(value: string) {
|
||||||
|
if (!value) return "(native)"
|
||||||
|
try {
|
||||||
|
return new URL(value).pathname.split("/").at(-1) || "(document)"
|
||||||
|
} catch {
|
||||||
|
return value.split(/[\\/]/).at(-1) || value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sum(values: number[]) {
|
||||||
|
return values.reduce((total, value) => total + value, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const started = performance.now()
|
||||||
|
|
||||||
|
export function progress(message: string, details?: Record<string, unknown>) {
|
||||||
|
const elapsed = ((performance.now() - started) / 1_000).toFixed(1)
|
||||||
|
const suffix = details ? ` ${JSON.stringify(details)}` : ""
|
||||||
|
console.error(`[desktop-profile +${elapsed}s] ${message}${suffix}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { chromium, type Page } from "@playwright/test"
|
||||||
|
import { copyFile, mkdir, rm } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { prepareDesktopState } from "./desktop-state"
|
||||||
|
import { progress } from "./progress"
|
||||||
|
import type { Options } from "./types"
|
||||||
|
|
||||||
|
export async function withDesktop<T>(
|
||||||
|
options: Options,
|
||||||
|
desktop: string,
|
||||||
|
run: number,
|
||||||
|
projectIDs: string[],
|
||||||
|
use: (page: Page) => Promise<T>,
|
||||||
|
) {
|
||||||
|
const port = availablePort()
|
||||||
|
const endpoint = `http://127.0.0.1:${port}`
|
||||||
|
const userData = path.join(options.output, `user-data-${run}`)
|
||||||
|
const database =
|
||||||
|
options.mode === "partial-snapshot" ? path.join(options.output, `working-database-${run}.db`) : options.database
|
||||||
|
await rm(userData, { recursive: true, force: true })
|
||||||
|
await mkdir(userData, { recursive: true })
|
||||||
|
if (database !== options.database) await copyFile(options.database, database)
|
||||||
|
await prepareDesktopState(options, database, userData, run, projectIDs)
|
||||||
|
const electron = path.join(
|
||||||
|
desktop,
|
||||||
|
"node_modules",
|
||||||
|
"electron",
|
||||||
|
"dist",
|
||||||
|
(await Bun.file(path.join(desktop, "node_modules", "electron", "path.txt")).text()).trim(),
|
||||||
|
)
|
||||||
|
progress("launching Electron", { run, port })
|
||||||
|
const child = Bun.spawn([electron, "."], {
|
||||||
|
cwd: desktop,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
OPENCODE_DB: database,
|
||||||
|
OPENCODE_CHANNEL: "dev",
|
||||||
|
OPENCODE_PROFILE_LOAF: "1",
|
||||||
|
OPENCODE_PROFILE_CDP_PORT: String(port),
|
||||||
|
OPENCODE_PROFILE_USER_DATA: userData,
|
||||||
|
OPENCODE_PERFORMANCE_TRACE_DIR: options.diagnostics ? path.join(options.output, "traces", String(run)) : "",
|
||||||
|
OPENCODE_PERFORMANCE_RUN_ID: `desktop-${run}`,
|
||||||
|
},
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const stdout = drain(child.stdout, "stdout")
|
||||||
|
const stderr = drain(child.stderr, "stderr")
|
||||||
|
let browser: Awaited<ReturnType<typeof chromium.connectOverCDP>> | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
progress("waiting for CDP", { run })
|
||||||
|
await waitForCDP(endpoint, child, stdout, stderr)
|
||||||
|
progress("connecting Playwright", { run })
|
||||||
|
browser = await chromium.connectOverCDP(endpoint)
|
||||||
|
progress("waiting for renderer", { run })
|
||||||
|
const page = await waitForRenderer(browser)
|
||||||
|
progress("waiting for desktop API", { run })
|
||||||
|
await page.waitForFunction(() => typeof window.api === "object", undefined, { timeout: 60_000 })
|
||||||
|
progress("desktop ready", { run })
|
||||||
|
return await use(page)
|
||||||
|
} finally {
|
||||||
|
progress("stopping Electron", { run })
|
||||||
|
await browser?.close().catch(() => {})
|
||||||
|
await killTree(child.pid)
|
||||||
|
await Promise.allSettled([stdout, stderr])
|
||||||
|
if (database !== options.database) {
|
||||||
|
await Bun.sleep(500)
|
||||||
|
await rm(database, { force: true }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(command: string[], cwd: string, database: string) {
|
||||||
|
const child = Bun.spawn(command, {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, OPENCODE_DB: database, OPENCODE_CHANNEL: "dev" },
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
})
|
||||||
|
const code = await child.exited
|
||||||
|
if (code !== 0) throw new Error(`${command.join(" ")} exited with ${code}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function availablePort() {
|
||||||
|
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||||
|
const port = server.port
|
||||||
|
server.stop(true)
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCDP(
|
||||||
|
endpoint: string,
|
||||||
|
child: ReturnType<typeof Bun.spawn>,
|
||||||
|
stdout: Promise<string>,
|
||||||
|
stderr: Promise<string>,
|
||||||
|
) {
|
||||||
|
const timeout = Date.now() + 5 * 60_000
|
||||||
|
let heartbeat = Date.now() + 10_000
|
||||||
|
while (Date.now() < timeout) {
|
||||||
|
const ready = await fetch(`${endpoint}/json/version`)
|
||||||
|
.then((response) => response.ok)
|
||||||
|
.catch(() => false)
|
||||||
|
if (ready) return
|
||||||
|
if (child.exitCode !== null)
|
||||||
|
throw new Error(`Desktop exited before CDP was ready (${child.exitCode})\n${await stdout}\n${await stderr}`)
|
||||||
|
if (Date.now() >= heartbeat) {
|
||||||
|
progress("still waiting for CDP")
|
||||||
|
heartbeat = Date.now() + 10_000
|
||||||
|
}
|
||||||
|
await Bun.sleep(250)
|
||||||
|
}
|
||||||
|
throw new Error("Timed out waiting for desktop CDP")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRenderer(browser: Awaited<ReturnType<typeof chromium.connectOverCDP>>) {
|
||||||
|
const timeout = Date.now() + 60_000
|
||||||
|
let heartbeat = Date.now() + 10_000
|
||||||
|
while (Date.now() < timeout) {
|
||||||
|
const page = browser
|
||||||
|
.contexts()
|
||||||
|
.flatMap((context) => context.pages())
|
||||||
|
.find((candidate) => candidate.url().startsWith("oc://renderer"))
|
||||||
|
if (page) return page
|
||||||
|
if (Date.now() >= heartbeat) {
|
||||||
|
progress("still waiting for renderer")
|
||||||
|
heartbeat = Date.now() + 10_000
|
||||||
|
}
|
||||||
|
await Bun.sleep(100)
|
||||||
|
}
|
||||||
|
throw new Error("Desktop renderer target was not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain(stream: ReadableStream<Uint8Array>, label: string) {
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let output = ""
|
||||||
|
let pending = ""
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const text = decoder.decode(chunk, { stream: true })
|
||||||
|
output = (output + text).slice(-50_000)
|
||||||
|
const lines = (pending + text).split(/\r?\n/)
|
||||||
|
pending = lines.pop() ?? ""
|
||||||
|
lines.filter(Boolean).forEach((line) => progress(`Electron ${label}`, { line: line.slice(0, 500) }))
|
||||||
|
}
|
||||||
|
if (pending) progress(`Electron ${label}`, { line: pending.slice(0, 500) })
|
||||||
|
return output + decoder.decode()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function killTree(pid: number) {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
process.kill(pid, "SIGTERM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const child = Bun.spawn(["taskkill", "/pid", String(pid), "/T", "/F"], { stdout: "ignore", stderr: "ignore" })
|
||||||
|
await child.exited
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { progress } from "./progress"
|
||||||
|
|
||||||
|
export async function setDesktopRoute(page: Page, route: string) {
|
||||||
|
await page.evaluate(async (value) => {
|
||||||
|
const api = window.api as typeof window.api & { getWindowID?: () => Promise<string> }
|
||||||
|
const id = (await api.getWindowID?.()) ?? "browser"
|
||||||
|
localStorage.setItem(`opencode.desktop.window.${id}.last-active-url`, value)
|
||||||
|
}, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForQuietDOM(page: Page) {
|
||||||
|
progress("waiting for DOM to settle")
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
let timer = setTimeout(done, 750)
|
||||||
|
const deadline = setTimeout(done, 30_000)
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(done, 750)
|
||||||
|
})
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
||||||
|
function done() {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(deadline)
|
||||||
|
observer.disconnect()
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
progress("DOM settled")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForSelector(page: Page, selector: string, label: string) {
|
||||||
|
progress("waiting for UI", { label })
|
||||||
|
try {
|
||||||
|
await page.waitForSelector(selector, { timeout: 30_000 })
|
||||||
|
} catch (error) {
|
||||||
|
progress("UI wait failed", {
|
||||||
|
label,
|
||||||
|
url: page.url(),
|
||||||
|
body: (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ").slice(0, 500),
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
progress("UI ready", { label })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function domCounts(page: Page, review = false) {
|
||||||
|
return page.evaluate((review) => ({
|
||||||
|
elements: document.getElementsByTagName("*").length,
|
||||||
|
...(review
|
||||||
|
? {
|
||||||
|
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
|
||||||
|
diffLines: document.querySelectorAll("[data-line]").length,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
timelineRows: document.querySelectorAll("[data-timeline-row]").length,
|
||||||
|
messageRows: document.querySelectorAll("[data-message-id]").length,
|
||||||
|
markdownRoots: document.querySelectorAll('[data-component="markdown"]').length,
|
||||||
|
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
|
||||||
|
}),
|
||||||
|
}), review)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sum(values: number[]) {
|
||||||
|
return values.reduce((total, value) => total + value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function percentile(values: number[], quantile: number) {
|
||||||
|
return values.toSorted((a, b) => a - b)[Math.max(0, Math.ceil(values.length * quantile) - 1)] ?? 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { startChromeTrace } from "../chrome-trace"
|
||||||
|
import { collectProbe, resetProbe, startCPUProfile, summarizeProbe } from "./probe"
|
||||||
|
import { progress } from "./progress"
|
||||||
|
import { domCounts, percentile, setDesktopRoute, sum, waitForQuietDOM, waitForSelector } from "./scenario-utils"
|
||||||
|
import type { Options, Target } from "./types"
|
||||||
|
|
||||||
|
export async function runScenarios(page: Page, options: Options, targets: Target[], typingText: string) {
|
||||||
|
const results: unknown[] = []
|
||||||
|
if (options.scenarios.includes("home")) results.push(await profileHome(page, options))
|
||||||
|
if (options.scenarios.includes("calibration")) results.push(await profileCalibration(page))
|
||||||
|
if (options.scenarios.includes("session")) {
|
||||||
|
for (const target of targets) results.push(await profileSession(page, options, target))
|
||||||
|
}
|
||||||
|
if (options.scenarios.some((scenario) => ["composer", "history", "review"].includes(scenario))) {
|
||||||
|
await openSession(page, targets[2]!)
|
||||||
|
}
|
||||||
|
if (options.scenarios.includes("composer")) results.push(await profileComposer(page, options, typingText))
|
||||||
|
if (options.scenarios.includes("history")) results.push(await profileHistory(page, options, targets[2]!))
|
||||||
|
if (options.scenarios.includes("review")) {
|
||||||
|
const review = await profileReview(page, options)
|
||||||
|
if (review) results.push(review)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileHome(page: Page, options: Options) {
|
||||||
|
const measured = await measure(page, options, "home", async () => {
|
||||||
|
await setDesktopRoute(page, "/")
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
|
||||||
|
await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
})
|
||||||
|
return { ...measured, dom: await domCounts(page) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileCalibration(page: Page) {
|
||||||
|
await resetProbe(page)
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
setTimeout(function opencodeProfileCalibration() {
|
||||||
|
const end = performance.now() + 80
|
||||||
|
while (performance.now() < end) {
|
||||||
|
// Deliberate benchmark-only main-thread block.
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => setTimeout(resolve, 100))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { name: "attribution-calibration", ...summarizeProbe(await collectProbe(page)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileSession(page: Page, options: Options, target: Target) {
|
||||||
|
await prepareHome(page)
|
||||||
|
const measured = await measure(page, options, `session-${target.label}`, async () => {
|
||||||
|
await navigateSession(page, target)
|
||||||
|
await waitForSelector(page, '[data-component="prompt-input"]', "session composer")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
})
|
||||||
|
return { ...measured, context: targetContext(target), dom: await domCounts(page) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileComposer(page: Page, options: Options, typingText: string) {
|
||||||
|
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]').first()
|
||||||
|
await editor.click()
|
||||||
|
await page.keyboard.press("Control+A")
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
const printable = [...typingText].filter((character) => !["\r", "\n", "\t"].includes(character))
|
||||||
|
const measuredText = printable.slice(-120).join("")
|
||||||
|
const prefix = printable.slice(0, -measuredText.length).join("")
|
||||||
|
if (prefix) await page.keyboard.insertText(prefix)
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
const durations: number[] = []
|
||||||
|
const measured = await measure(page, options, "composer-typing", async () => {
|
||||||
|
for (const character of measuredText) {
|
||||||
|
const started = performance.now()
|
||||||
|
await page.keyboard.type(character)
|
||||||
|
durations.push(performance.now() - started)
|
||||||
|
}
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
})
|
||||||
|
await page.keyboard.press("Control+A")
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
return {
|
||||||
|
...measured,
|
||||||
|
context: { promptCharacters: printable.length, measuredCharacters: measuredText.length },
|
||||||
|
typing: {
|
||||||
|
totalMs: sum(durations),
|
||||||
|
meanMs: sum(durations) / durations.length,
|
||||||
|
p50Ms: percentile(durations, 0.5),
|
||||||
|
p95Ms: percentile(durations, 0.95),
|
||||||
|
maxMs: Math.max(...durations),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileHistory(page: Page, options: Options, target: Target) {
|
||||||
|
await waitForSelector(page, '[data-component="prompt-input"]', "history session composer")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
let requests = 0
|
||||||
|
const onResponse = (response: { url(): string }) => {
|
||||||
|
if (/\/session\/[^/]+\/message(?:\?|$)/.test(response.url())) requests++
|
||||||
|
}
|
||||||
|
page.on("response", onResponse)
|
||||||
|
const measured = await measure(page, options, "session-max-history-boundary", async () => {
|
||||||
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }).first()
|
||||||
|
await scroller.evaluate((element) => {
|
||||||
|
element.scrollTop = 0
|
||||||
|
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -10_000, bubbles: true }))
|
||||||
|
element.dispatchEvent(new Event("scroll", { bubbles: true }))
|
||||||
|
})
|
||||||
|
const timeout = Date.now() + 60_000
|
||||||
|
while (requests === 0 && Date.now() < timeout) await page.waitForTimeout(50)
|
||||||
|
if (requests === 0) throw new Error("History boundary did not request a page")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
})
|
||||||
|
page.off("response", onResponse)
|
||||||
|
return { ...measured, context: targetContext(target), messageRequests: requests }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileReview(page: Page, options: Options) {
|
||||||
|
const button = page.getByRole("button", { name: "Toggle review" })
|
||||||
|
if (!(await button.isVisible().catch(() => false))) return
|
||||||
|
const panel = page.locator("#review-panel")
|
||||||
|
if (await panel.isVisible().catch(() => false)) {
|
||||||
|
await button.click()
|
||||||
|
await panel.waitFor({ state: "hidden", timeout: 60_000 })
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
}
|
||||||
|
const measured = await measure(page, options, "review-open", async () => {
|
||||||
|
await button.click()
|
||||||
|
await panel.waitFor({ state: "visible", timeout: 60_000 })
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
})
|
||||||
|
return { ...measured, dom: await domCounts(page, true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measure(page: Page, options: Options, name: string, action: () => Promise<void>) {
|
||||||
|
progress("scenario started", { name })
|
||||||
|
await resetProbe(page)
|
||||||
|
const stopTrace = options.diagnostics ? await startChromeTrace(page, name) : undefined
|
||||||
|
const cpu = await startCPUProfile(page, options.cpu)
|
||||||
|
const started = performance.now()
|
||||||
|
await action()
|
||||||
|
const result = {
|
||||||
|
name,
|
||||||
|
elapsedMs: performance.now() - started,
|
||||||
|
...summarizeProbe(await collectProbe(page)),
|
||||||
|
cpu: await cpu.stop(),
|
||||||
|
trace: await stopTrace?.(),
|
||||||
|
}
|
||||||
|
progress("scenario completed", { name, elapsedMs: Math.round(result.elapsedMs), longTasks: result.longTasks.count })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSession(page: Page, target: Target) {
|
||||||
|
await navigateSession(page, target)
|
||||||
|
await waitForSelector(page, '[data-component="prompt-input"]', "session composer")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareHome(page: Page) {
|
||||||
|
await setDesktopRoute(page, "/")
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
|
||||||
|
await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateSession(page: Page, target: Target) {
|
||||||
|
await setDesktopRoute(page, `/server/${base64Encode("sidecar")}/session/${target.id}`)
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetContext(target: Target) {
|
||||||
|
return { serializedBytes: target.bytes, messages: target.messages, parts: target.parts, userTurns: target.userTurns }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export const scenarios = ["home", "calibration", "session", "composer", "history", "review"] as const
|
||||||
|
|
||||||
|
export type Scenario = (typeof scenarios)[number]
|
||||||
|
|
||||||
|
export type Options = {
|
||||||
|
mode: "local" | "partial-snapshot"
|
||||||
|
database: string
|
||||||
|
output: string
|
||||||
|
windowStart: number
|
||||||
|
windowEnd: number
|
||||||
|
scenarios: Scenario[]
|
||||||
|
runs: number
|
||||||
|
build: boolean
|
||||||
|
diagnostics: boolean
|
||||||
|
cpu: boolean
|
||||||
|
responseURLs: boolean
|
||||||
|
partialSnapshotOut?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Target = {
|
||||||
|
label: "p50" | "p95" | "max"
|
||||||
|
id: string
|
||||||
|
projectID: string
|
||||||
|
directory: string
|
||||||
|
title: string
|
||||||
|
bytes: number
|
||||||
|
messages: number
|
||||||
|
parts: number
|
||||||
|
userTurns: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProbeResult = {
|
||||||
|
longTasks: number[]
|
||||||
|
animationFrames: {
|
||||||
|
duration: number
|
||||||
|
blockingDuration: number
|
||||||
|
forcedStyleAndLayoutDuration: number
|
||||||
|
scripts: {
|
||||||
|
function: string
|
||||||
|
source: string
|
||||||
|
position: number
|
||||||
|
invoker: string
|
||||||
|
invokerType: string
|
||||||
|
duration: number
|
||||||
|
forcedStyleAndLayoutDuration: number
|
||||||
|
}[]
|
||||||
|
}[]
|
||||||
|
frameGaps: number[]
|
||||||
|
responseText: { url: string; duration: number }[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { createPartialSnapshot, fingerprint, loadCorpus } from "./desktop-profile/corpus"
|
||||||
|
import { parseOptions } from "./desktop-profile/options"
|
||||||
|
import { installProbe } from "./desktop-profile/probe"
|
||||||
|
import { progress } from "./desktop-profile/progress"
|
||||||
|
import { withDesktop, run } from "./desktop-profile/runtime"
|
||||||
|
import { runScenarios } from "./desktop-profile/scenarios"
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dir, "../../../..")
|
||||||
|
const desktop = path.join(root, "packages/desktop")
|
||||||
|
const options = parseOptions(process.argv.slice(2))
|
||||||
|
if (!options) process.exit(0)
|
||||||
|
|
||||||
|
await mkdir(options.output, { recursive: true })
|
||||||
|
progress("loading corpus", { mode: options.mode })
|
||||||
|
let corpus = loadCorpus(options)
|
||||||
|
if (options.partialSnapshotOut) {
|
||||||
|
progress("creating partial snapshot")
|
||||||
|
await createPartialSnapshot(options.database, options.partialSnapshotOut, options, corpus.targets)
|
||||||
|
options.database = options.partialSnapshotOut
|
||||||
|
options.mode = "partial-snapshot"
|
||||||
|
corpus = loadCorpus(options)
|
||||||
|
}
|
||||||
|
if (options.build) {
|
||||||
|
progress("building desktop production bundle")
|
||||||
|
await run(["bun", "run", "build"], desktop, options.database)
|
||||||
|
}
|
||||||
|
|
||||||
|
progress("corpus ready", { targets: corpus.targets.map((target) => target.label), runs: options.runs })
|
||||||
|
const runs = []
|
||||||
|
for (let index = 1; index <= options.runs; index++) {
|
||||||
|
runs.push(
|
||||||
|
await withDesktop(options, desktop, index, corpus.projectIDs, async (page) => {
|
||||||
|
await installProbe(page, options)
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({ ...settings, general: { ...settings.general, newLayoutDesigns: true } }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return runScenarios(page, options, corpus.targets, corpus.typingText)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
source: options.mode === "partial-snapshot" ? "partial-database-snapshot" : "local-opencode-db",
|
||||||
|
command: process.argv.slice(2),
|
||||||
|
diagnostics: options.diagnostics,
|
||||||
|
profileCPU: options.cpu,
|
||||||
|
database: await fingerprint(options.database),
|
||||||
|
window: {
|
||||||
|
start: new Date(options.windowStart).toISOString(),
|
||||||
|
end: new Date(options.windowEnd).toISOString(),
|
||||||
|
},
|
||||||
|
revision: (await Bun.$`git rev-parse HEAD`.cwd(root).text()).trim(),
|
||||||
|
targets: corpus.targets.map(({ id: _, projectID: __, directory: ___, title: ____, ...target }) => target),
|
||||||
|
summary: summarize(runs),
|
||||||
|
runs: runs.map((results, index) => ({ index: index + 1, results })),
|
||||||
|
}
|
||||||
|
const file = path.join(options.output, "renderer-profile.json")
|
||||||
|
await Bun.write(file, JSON.stringify(report, null, 2))
|
||||||
|
console.log(`PROFILE_REPORT ${file}`)
|
||||||
|
console.log(`PROFILE_SUMMARY ${JSON.stringify(report.summary)}`)
|
||||||
|
console.log(JSON.stringify(report, null, 2))
|
||||||
|
|
||||||
|
function summarize(runs: unknown[][]) {
|
||||||
|
type Result = {
|
||||||
|
name: string
|
||||||
|
elapsedMs?: number
|
||||||
|
longTasks: { count: number; totalMs: number; maxMs: number }
|
||||||
|
longAnimationFrames: { totalBlockingMs: number }
|
||||||
|
typing?: { p50Ms: number; p95Ms: number; maxMs: number }
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
[...Map.groupBy(runs.flat() as Result[], (result) => result.name)].map(([name, samples]) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
samples: samples.length,
|
||||||
|
elapsedMedianMs: median(samples.flatMap((sample) => sample.elapsedMs ?? [])),
|
||||||
|
longTasks: {
|
||||||
|
maxCount: Math.max(...samples.map((sample) => sample.longTasks.count)),
|
||||||
|
maxTotalMs: Math.max(...samples.map((sample) => sample.longTasks.totalMs)),
|
||||||
|
maxTaskMs: Math.max(...samples.map((sample) => sample.longTasks.maxMs)),
|
||||||
|
},
|
||||||
|
maxBlockingMs: Math.max(...samples.map((sample) => sample.longAnimationFrames.totalBlockingMs)),
|
||||||
|
...(samples[0]?.typing
|
||||||
|
? {
|
||||||
|
typingMedianMs: {
|
||||||
|
p50: median(samples.flatMap((sample) => sample.typing?.p50Ms ?? [])),
|
||||||
|
p95: median(samples.flatMap((sample) => sample.typing?.p95Ms ?? [])),
|
||||||
|
max: median(samples.flatMap((sample) => sample.typing?.maxMs ?? [])),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(values: number[]) {
|
||||||
|
if (values.length === 0) return
|
||||||
|
return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "./profile-desktop"
|
||||||
@@ -197,7 +197,9 @@ export async function setupTimeline(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
async waitForPart(partID: string) {
|
async waitForPart(partID: string) {
|
||||||
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
|
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
|
||||||
|
await expect(part).toHaveCount(1)
|
||||||
|
await expect(part).toBeVisible()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ test("opens the comment editor when code is clicked", async ({ page }) => {
|
|||||||
await line.click()
|
await line.click()
|
||||||
|
|
||||||
await expect(review.getByRole("textbox")).toBeVisible()
|
await expect(review.getByRole("textbox")).toBeVisible()
|
||||||
|
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("opens the comment editor when a line number is clicked", async ({ page }) => {
|
test("opens the comment editor when a line number is clicked", async ({ page }) => {
|
||||||
@@ -27,6 +28,7 @@ test("opens the comment editor when a line number is clicked", async ({ page })
|
|||||||
await lineNumber.click()
|
await lineNumber.click()
|
||||||
|
|
||||||
await expect(review.getByRole("textbox")).toBeVisible()
|
await expect(review.getByRole("textbox")).toBeVisible()
|
||||||
|
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("opens the comment editor for a line number range", async ({ page }) => {
|
test("opens the comment editor for a line number range", async ({ page }) => {
|
||||||
@@ -36,15 +38,10 @@ test("opens the comment editor for a line number range", async ({ page }) => {
|
|||||||
await expectAppVisible(start)
|
await expectAppVisible(start)
|
||||||
await expectAppVisible(end)
|
await expectAppVisible(end)
|
||||||
|
|
||||||
const from = await start.boundingBox()
|
await start.dragTo(end)
|
||||||
const to = await end.boundingBox()
|
|
||||||
if (!from || !to) throw new Error("Missing line number bounds")
|
|
||||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
|
|
||||||
await page.mouse.down()
|
|
||||||
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
|
|
||||||
await page.mouse.up()
|
|
||||||
|
|
||||||
await expect(review.getByRole("textbox")).toBeVisible()
|
await expect(review.getByRole("textbox")).toBeVisible()
|
||||||
|
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
||||||
@@ -54,31 +51,38 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
|
|||||||
|
|
||||||
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
await page.mouse.move(0, 0)
|
|
||||||
await lineNumber.hover()
|
await lineNumber.hover()
|
||||||
await expect(comment).toBeVisible({ timeout: 500 })
|
await expect(lineNumber).toHaveAttribute("data-hovered", "")
|
||||||
await comment.click({ timeout: 500 })
|
await expect(comment).toHaveCount(1)
|
||||||
}).toPass()
|
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||||
|
await comment.focus()
|
||||||
|
await expect(comment).toBeFocused()
|
||||||
|
}).toPass({ timeout: 10_000 })
|
||||||
|
await comment.press("Enter")
|
||||||
await expect(review.getByRole("textbox")).toBeVisible()
|
await expect(review.getByRole("textbox")).toBeVisible()
|
||||||
|
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("stages a submitted line comment in the prompt context", async ({ page }) => {
|
test("stages a submitted line comment in the prompt context", async ({ page }) => {
|
||||||
const requests: string[] = []
|
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
|
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
|
||||||
})
|
})
|
||||||
|
|
||||||
const review = page.locator('[data-component="session-review"]')
|
const review = page.locator('[data-component="session-review"]')
|
||||||
await review.getByText("export const value = 'after'", { exact: true }).click()
|
await review.getByText("export const value = 'after'", { exact: true }).click()
|
||||||
await review.getByRole("textbox").fill("Use the existing value instead")
|
const textbox = review.getByRole("textbox")
|
||||||
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
|
await expect(textbox).toBeVisible()
|
||||||
|
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
|
||||||
|
await textbox.fill("Use the existing value instead")
|
||||||
|
const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]')
|
||||||
|
await expect(submit).toBeEnabled()
|
||||||
|
await submit.click()
|
||||||
|
|
||||||
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
|
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
|
||||||
await page.getByRole("tab", { name: "Session" }).click()
|
await page.getByRole("tab", { name: "Session" }).click()
|
||||||
const context = page.getByText("Use the existing value instead", { exact: true }).last()
|
const context = page.getByText("Use the existing value instead", { exact: true }).last()
|
||||||
await expect(context).toBeVisible()
|
await expect(context).toBeVisible()
|
||||||
await expect(context.locator("..")).toContainText("review.ts:2")
|
await expect(context.locator("..")).toContainText("review.ts:2")
|
||||||
expect(requests).toEqual([])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function openReview(page: Page) {
|
async function openReview(page: Page) {
|
||||||
@@ -144,15 +148,22 @@ async function openReview(page: Page) {
|
|||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
|
const changes = page.getByRole("tab", { name: "Changes" })
|
||||||
await page.getByRole("tab", { name: "Changes" }).click()
|
const diffResponse = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff",
|
||||||
|
)
|
||||||
|
await changes.click()
|
||||||
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||||
|
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
|
||||||
|
|
||||||
const review = page.locator('[data-component="session-review"]')
|
const review = page.locator('[data-component="session-review"]')
|
||||||
await expectAppVisible(review)
|
await expectAppVisible(review)
|
||||||
await review
|
const file = review.locator('[data-file="src/review.ts"]')
|
||||||
.getByRole("heading", { name: /review\.ts/ })
|
await expectAppVisible(file)
|
||||||
.getByRole("button")
|
const trigger = file.getByRole("button", { expanded: false })
|
||||||
.first()
|
await expect(trigger).toHaveCount(1)
|
||||||
.click()
|
await trigger.click()
|
||||||
|
await expect(file.getByRole("button", { expanded: true })).toBeVisible()
|
||||||
|
await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
})
|
})
|
||||||
await mockServer(page, { questions: [] })
|
await mockServer(page, { questions: [] })
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await transport.waitForConnection()
|
await transport.waitForConnection({ path: "/api/event" })
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||||
@@ -132,7 +132,8 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.toBe(cursor)
|
.toBe(cursor)
|
||||||
await transport.send({
|
await transport.send(
|
||||||
|
{
|
||||||
directory,
|
directory,
|
||||||
payload: {
|
payload: {
|
||||||
type: "question.asked",
|
type: "question.asked",
|
||||||
@@ -149,15 +150,22 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
tool: { messageID: "message-caret", callID: "call-caret" },
|
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
undefined,
|
||||||
|
"/api/event",
|
||||||
|
)
|
||||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||||
await expect(question).toBeVisible()
|
await expect(question).toBeVisible()
|
||||||
await expect(editor).toHaveCount(0)
|
await expect(editor).toHaveCount(0)
|
||||||
|
|
||||||
await transport.send({
|
await transport.send(
|
||||||
|
{
|
||||||
directory,
|
directory,
|
||||||
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||||
})
|
},
|
||||||
|
undefined,
|
||||||
|
"/api/event",
|
||||||
|
)
|
||||||
await expect(question).toHaveCount(0)
|
await expect(question).toHaveCount(0)
|
||||||
await expect(editor).toBeVisible()
|
await expect(editor).toBeVisible()
|
||||||
await page.keyboard.press("x")
|
await page.keyboard.press("x")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { installSseTransport } from "../utils/sse-transport"
|
|||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const initialPageSize = 20
|
const initialPageSize = 20
|
||||||
const historyPageSize = 200
|
const historyPageSize = 50
|
||||||
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
||||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
|
|||||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||||
|
|
||||||
for (const profile of [
|
for (const profile of [
|
||||||
{ locale: "de", label: "Erkundet" },
|
{ locale: "de", label: "Erkundung abgeschlossen" },
|
||||||
{ locale: "ar", label: "تم الاستكشاف" },
|
{ locale: "ar", label: "تم الاستكشاف" },
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
|
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import {
|
import {
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
status,
|
|
||||||
textPart,
|
textPart,
|
||||||
userMessage,
|
userMessage,
|
||||||
} from "../performance/timeline-stability/fixture"
|
} from "../performance/timeline-stability/fixture"
|
||||||
@@ -17,7 +16,7 @@ test("keeps one connection open while delivering multiple events", async ({ page
|
|||||||
await timeline.waitForPart("prt_transport_first")
|
await timeline.waitForPart("prt_transport_first")
|
||||||
await timeline.waitForPart("prt_transport_second")
|
await timeline.waitForPart("prt_transport_second")
|
||||||
expect(first.connectionID).toBe(second.connectionID)
|
expect(first.connectionID).toBe(second.connectionID)
|
||||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||||
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
|
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,20 +50,28 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
||||||
|
const sentinelID = "prt_transport_heartbeat_sentinel"
|
||||||
const timeline = await setupTimeline(page, {
|
const timeline = await setupTimeline(page, {
|
||||||
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
|
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
|
||||||
})
|
})
|
||||||
const before = await page.locator("[data-timeline-row]").allTextContents()
|
await timeline.waitForPart("prt_transport_steady")
|
||||||
|
const before = await stableTimelineRows(page)
|
||||||
|
|
||||||
await timeline.transport.heartbeat()
|
await timeline.transport.writeRaw(": heartbeat\n\n")
|
||||||
await timeline.settle()
|
await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed")))
|
||||||
|
await timeline.waitForPart(sentinelID)
|
||||||
|
|
||||||
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
|
await expect
|
||||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
.poll(async () => {
|
||||||
|
const rows = await timelineRows(page)
|
||||||
|
return rows.filter((row) => before.some((item) => item.key === row.key))
|
||||||
|
})
|
||||||
|
.toEqual(before)
|
||||||
|
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reconnects after a clean close", async ({ page }) => {
|
test("reconnects after a clean close", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
const timeline = await setupTimeline(page)
|
||||||
const first = await timeline.transport.waitForConnection()
|
const first = await timeline.transport.waitForConnection()
|
||||||
|
|
||||||
await timeline.transport.close()
|
await timeline.transport.close()
|
||||||
@@ -77,27 +84,30 @@ test("reconnects after a clean close", async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("reconnects after a stream error", async ({ page }) => {
|
test("reconnects after a stream error", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
const timeline = await setupTimeline(page)
|
||||||
const first = await timeline.transport.waitForConnection()
|
const first = await timeline.transport.waitForConnection()
|
||||||
|
|
||||||
await timeline.transport.error("contract failure")
|
await timeline.transport.error("contract failure")
|
||||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||||
await timeline.transport.send(status("busy"))
|
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
|
||||||
|
|
||||||
|
await timeline.waitForPart("prt_transport_error")
|
||||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
|
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
|
||||||
expect(second.id).toBeGreaterThan(first.id)
|
expect(second.id).toBeGreaterThan(first.id)
|
||||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
const timeline = await setupTimeline(page, { protocol: "v2" })
|
||||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
const first = await timeline.transport.send(
|
||||||
id: "timeline-event-7",
|
partUpdated(textPart("prt_transport_id", "event with id")),
|
||||||
})
|
{ id: "timeline-event-7" },
|
||||||
|
"/api/event",
|
||||||
|
)
|
||||||
await timeline.waitForPart("prt_transport_id")
|
await timeline.waitForPart("prt_transport_id")
|
||||||
|
|
||||||
await timeline.transport.error("retry with event id")
|
await timeline.transport.error("retry with event id", "/api/event")
|
||||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" })
|
||||||
|
|
||||||
expect(first.eventID).toBe("timeline-event-7")
|
expect(first.eventID).toBe("timeline-event-7")
|
||||||
expect(connection.headers["last-event-id"]).toBeUndefined()
|
expect(connection.headers["last-event-id"]).toBeUndefined()
|
||||||
@@ -112,5 +122,35 @@ test("passes through non-event fetches", async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(health).toEqual({ healthy: true })
|
expect(health).toEqual({ healthy: true })
|
||||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function stableTimelineRows(page: Page) {
|
||||||
|
let previous: Awaited<ReturnType<typeof timelineRows>> | undefined
|
||||||
|
let stable = 0
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const next = await timelineRows(page)
|
||||||
|
stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0
|
||||||
|
previous = next
|
||||||
|
return stable
|
||||||
|
},
|
||||||
|
{ intervals: [50, 50, 100] },
|
||||||
|
)
|
||||||
|
.toBeGreaterThanOrEqual(2)
|
||||||
|
return previous!
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineRows(page: Page) {
|
||||||
|
return page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
||||||
|
elements.map((element) => ({
|
||||||
|
key: element.getAttribute("data-timeline-key"),
|
||||||
|
row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"),
|
||||||
|
parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) =>
|
||||||
|
part.getAttribute("data-timeline-part-id"),
|
||||||
|
),
|
||||||
|
text: element.textContent,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
const root = "C:/OpenCode/WorkspaceProject"
|
||||||
|
const workspace = "C:/OpenCode/worktree/project/feature"
|
||||||
|
const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix"
|
||||||
|
const project = {
|
||||||
|
id: "proj_workspaces",
|
||||||
|
worktree: root,
|
||||||
|
vcs: "git" as const,
|
||||||
|
name: "workspace-project",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
sandboxes: [workspace],
|
||||||
|
}
|
||||||
|
const provider = {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
}
|
||||||
|
const diff = {
|
||||||
|
file: "src/workspace.ts",
|
||||||
|
additions: 3,
|
||||||
|
deletions: 1,
|
||||||
|
patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true",
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(sessionID: string, id: string, text: string, withDiff = false) {
|
||||||
|
return {
|
||||||
|
info: {
|
||||||
|
id,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "opencode", modelID: "test" },
|
||||||
|
...(withDiff ? { summary: { diffs: [diff] } } : {}),
|
||||||
|
},
|
||||||
|
parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init(page: Page, tab: Record<string, unknown>) {
|
||||||
|
await page.addInitScript(
|
||||||
|
({ root, server, tab }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }]))
|
||||||
|
},
|
||||||
|
{ root, server, tab },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("selects an existing workspace from the start menu", async ({ page }) => {
|
||||||
|
const draftID = "draft_workspaces"
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory: root,
|
||||||
|
project,
|
||||||
|
provider,
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await init(page, { type: "draft", draftID, directory: root })
|
||||||
|
|
||||||
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /^local$/i }).click()
|
||||||
|
await page.getByRole("menuitem", { name: /Workspace/ }).hover()
|
||||||
|
await page.getByRole("menuitem", { name: "feature" }).click()
|
||||||
|
await expect(page.getByRole("button", { name: /feature/ })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("lists and manually deletes workspaces from settings", async ({ page }) => {
|
||||||
|
const draftID = "draft_workspace_settings"
|
||||||
|
const cleanWorkspace = `${workspace}-clean`
|
||||||
|
const inventory = { ...project, sandboxes: [cleanWorkspace] }
|
||||||
|
|
||||||
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory: root,
|
||||||
|
project: inventory,
|
||||||
|
provider,
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
let releaseSessions = () => {}
|
||||||
|
const sessionsReady = new Promise<void>((resolve) => {
|
||||||
|
releaseSessions = resolve
|
||||||
|
})
|
||||||
|
await page.route("**/session?**", async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
if (route.request().method() !== "GET" || url.pathname !== "/session" || url.searchParams.get("limit") !== "1000")
|
||||||
|
return route.fallback()
|
||||||
|
await sessionsReady
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: "[]",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route("**/experimental/worktree**", async (route) => {
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "DELETE" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (route.request().method() !== "DELETE") return route.fallback()
|
||||||
|
await transport.send({
|
||||||
|
directory: "global",
|
||||||
|
payload: {
|
||||||
|
id: "evt_workspace_deleted_settings",
|
||||||
|
type: "project.updated",
|
||||||
|
properties: { ...inventory, sandboxes: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: "true",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await init(page, { type: "draft", draftID, directory: root })
|
||||||
|
|
||||||
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||||
|
|
||||||
|
const app = page.locator('[data-component="session-new-design"]')
|
||||||
|
const appNode = await app.elementHandle()
|
||||||
|
await page.getByRole("button", { name: /local|new workspace/i }).click()
|
||||||
|
const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
|
||||||
|
if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
|
||||||
|
await page.getByRole("menuitem", { name: "View all" }).click()
|
||||||
|
|
||||||
|
const settings = page.locator(".settings-v2-dialog")
|
||||||
|
await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected")
|
||||||
|
expect(await appNode?.evaluate((node) => node.isConnected)).toBe(true)
|
||||||
|
releaseSessions()
|
||||||
|
await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
|
||||||
|
const confirmation = page
|
||||||
|
.locator('[data-component="dialog-v2"]')
|
||||||
|
.filter({ hasText: 'Delete workspace "feature-clean"?' })
|
||||||
|
const removed = page.waitForRequest(
|
||||||
|
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/experimental/worktree",
|
||||||
|
)
|
||||||
|
await confirmation.getByRole("button", { name: "Delete workspace" }).click()
|
||||||
|
const request = await removed
|
||||||
|
expect(new URL(request.url()).searchParams.get("directory")).toBe(root)
|
||||||
|
expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace })
|
||||||
|
await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => {
|
||||||
|
const draftID = "draft_workspace_submit"
|
||||||
|
const sessionID = "ses_workspace_submit"
|
||||||
|
const session = {
|
||||||
|
id: sessionID,
|
||||||
|
slug: "workspace-submit",
|
||||||
|
projectID: project.id,
|
||||||
|
directory: createdWorkspace,
|
||||||
|
title: "New session",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
}
|
||||||
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory: root,
|
||||||
|
project,
|
||||||
|
provider,
|
||||||
|
sessions: [session],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.route("**/experimental/worktree**", async (route) => {
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (route.request().method() !== "POST") return route.fallback()
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route("**/session**", async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
if (url.pathname !== "/session") return route.fallback()
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (route.request().method() !== "POST") return route.fallback()
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify(session),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route(`**/session/${sessionID}/prompt_async**`, async (route) => {
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await init(page, { type: "draft", draftID, directory: root })
|
||||||
|
|
||||||
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
await page.getByRole("button", { name: /^local$/i }).click()
|
||||||
|
await page.getByRole("menuitem", { name: "New workspace" }).click()
|
||||||
|
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||||
|
await editor.fill("Build workspace support")
|
||||||
|
await page.locator('[data-action="prompt-submit"]').click()
|
||||||
|
|
||||||
|
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
|
||||||
|
await expect(lifecycle).toContainText("Creating workspace")
|
||||||
|
const sent = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "POST" && new URL(request.url()).pathname === `/session/${sessionID}/prompt_async`,
|
||||||
|
)
|
||||||
|
await transport.send({
|
||||||
|
directory: createdWorkspace,
|
||||||
|
payload: {
|
||||||
|
id: "evt_submit_ready",
|
||||||
|
type: "worktree.ready",
|
||||||
|
properties: { name: "quick-contrast-fix" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await sent
|
||||||
|
await expect(lifecycle).toContainText("Workspace created")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sessionID = "ses_workspace_move_new"
|
||||||
|
const messageID = "msg_workspace_move_new"
|
||||||
|
const session = {
|
||||||
|
id: sessionID,
|
||||||
|
slug: "workspace-move-new",
|
||||||
|
projectID: project.id,
|
||||||
|
directory: root,
|
||||||
|
title: "Create a workspace",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
}
|
||||||
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory: root,
|
||||||
|
project,
|
||||||
|
provider,
|
||||||
|
sessions: [session],
|
||||||
|
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Create isolated workspace", true)] }),
|
||||||
|
vcsDiff: [diff],
|
||||||
|
})
|
||||||
|
await page.route("**/experimental/worktree**", async (route) => {
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (route.request().method() !== "POST") return route.fallback()
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route("**/experimental/control-plane/move-session", async (route) => {
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.directory = createdWorkspace
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: "null",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await init(page, { type: "session", sessionId: sessionID })
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
await page.locator("[data-session-title]").getByRole("button", { name: "Session details" }).click()
|
||||||
|
const panel = page.locator('[data-component="session-summary-panel"]')
|
||||||
|
await panel.getByRole("button", { name: "Local repository" }).click()
|
||||||
|
await expect(page.getByRole("menuitem", { name: "New workspace" })).toBeVisible()
|
||||||
|
await page.getByRole("menuitem", { name: "New workspace" }).click()
|
||||||
|
|
||||||
|
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
|
||||||
|
await expect(lifecycle).toContainText("Creating workspace")
|
||||||
|
const moved = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "POST" && new URL(request.url()).pathname === "/experimental/control-plane/move-session",
|
||||||
|
)
|
||||||
|
await transport.send({
|
||||||
|
directory: createdWorkspace,
|
||||||
|
payload: {
|
||||||
|
id: "evt_worktree_ready",
|
||||||
|
type: "worktree.ready",
|
||||||
|
properties: { name: "quick-contrast-fix" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect((await moved).postDataJSON()).toEqual({
|
||||||
|
sessionID,
|
||||||
|
destination: { directory: createdWorkspace },
|
||||||
|
moveChanges: true,
|
||||||
|
})
|
||||||
|
await transport.send({
|
||||||
|
directory: createdWorkspace,
|
||||||
|
payload: {
|
||||||
|
id: "evt_workspace_created",
|
||||||
|
type: "session.next.moved",
|
||||||
|
properties: {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
sessionID,
|
||||||
|
location: { directory: createdWorkspace },
|
||||||
|
subdirectory: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await expect(lifecycle).toContainText("Workspace created")
|
||||||
|
})
|
||||||
@@ -29,23 +29,38 @@ export type SseEventOptions = {
|
|||||||
|
|
||||||
export type SseTransport<T> = {
|
export type SseTransport<T> = {
|
||||||
server: string
|
server: string
|
||||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
waitForConnection(options?: {
|
||||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
after?: number
|
||||||
|
timeout?: number
|
||||||
|
path?: SseConnectionRecord["path"]
|
||||||
|
}): Promise<SseConnectionRecord>
|
||||||
|
send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise<SseDeliveryAcknowledgement>
|
||||||
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
||||||
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||||
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||||
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
||||||
close(): Promise<void>
|
close(): Promise<void>
|
||||||
disconnect(message?: string): Promise<void>
|
disconnect(message?: string): Promise<void>
|
||||||
error(message?: string): Promise<void>
|
error(message?: string, path?: SseConnectionRecord["path"]): Promise<void>
|
||||||
connections(): Promise<SseConnectionRecord[]>
|
connections(): Promise<SseConnectionRecord[]>
|
||||||
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrowserCommand<T> =
|
type BrowserCommand<T> =
|
||||||
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
|
| {
|
||||||
|
type: "send"
|
||||||
|
deliveries: { payload: T; options?: SseEventOptions }[]
|
||||||
|
burst: boolean
|
||||||
|
cuts?: number[]
|
||||||
|
path?: SseConnectionRecord["path"]
|
||||||
|
}
|
||||||
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
||||||
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
|
| {
|
||||||
|
type: "end"
|
||||||
|
mode: "close" | "disconnect" | "error"
|
||||||
|
message?: string
|
||||||
|
path?: SseConnectionRecord["path"]
|
||||||
|
}
|
||||||
| { type: "connections" }
|
| { type: "connections" }
|
||||||
| { type: "acknowledgements" }
|
| { type: "acknowledgements" }
|
||||||
|
|
||||||
@@ -73,7 +88,8 @@ export async function installSseTransport<T>(
|
|||||||
let nextConnectionID = 0
|
let nextConnectionID = 0
|
||||||
let nextDeliveryID = 0
|
let nextDeliveryID = 0
|
||||||
|
|
||||||
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
|
const current = (path?: SseConnectionRecord["path"]) =>
|
||||||
|
connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
|
||||||
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
||||||
const boundaries = [...new Set(cuts ?? [])]
|
const boundaries = [...new Set(cuts ?? [])]
|
||||||
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
||||||
@@ -125,8 +141,8 @@ export async function installSseTransport<T>(
|
|||||||
acknowledgements.push(acknowledgement)
|
acknowledgements.push(acknowledgement)
|
||||||
return acknowledgement
|
return acknowledgement
|
||||||
}
|
}
|
||||||
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
|
const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => {
|
||||||
const connection = current()
|
const connection = current(path)
|
||||||
if (!connection) throw new Error("SSE transport has no active connection")
|
if (!connection) throw new Error("SSE transport has no active connection")
|
||||||
connection.endedAt = performance.now()
|
connection.endedAt = performance.now()
|
||||||
connection.endedBy = mode
|
connection.endedBy = mode
|
||||||
@@ -146,8 +162,8 @@ export async function installSseTransport<T>(
|
|||||||
if (input.type === "connections")
|
if (input.type === "connections")
|
||||||
return connections.map(({ controller: _controller, ...connection }) => connection)
|
return connections.map(({ controller: _controller, ...connection }) => connection)
|
||||||
if (input.type === "acknowledgements") return acknowledgements
|
if (input.type === "acknowledgements") return acknowledgements
|
||||||
if (input.type === "end") return end(input.mode, input.message)
|
if (input.type === "end") return end(input.mode, input.message, input.path)
|
||||||
const connection = current()
|
const connection = current(input.type === "send" ? input.path : undefined)
|
||||||
if (!connection) throw new Error("SSE transport has no active connection")
|
if (!connection) throw new Error("SSE transport has no active connection")
|
||||||
if (input.type === "raw") {
|
if (input.type === "raw") {
|
||||||
marker(input.marker)
|
marker(input.marker)
|
||||||
@@ -247,21 +263,29 @@ export async function installSseTransport<T>(
|
|||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
async waitForConnection(input = {}) {
|
async waitForConnection(input = {}) {
|
||||||
await page.waitForFunction(
|
const connection = await page.waitForFunction(
|
||||||
(after) => {
|
({ after, path }) => {
|
||||||
const transport = (window as BrowserTransport).__testSseTransport
|
const transport = (window as BrowserTransport).__testSseTransport
|
||||||
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
||||||
return connections?.some((connection) => connection.id > after)
|
return connections?.findLast(
|
||||||
|
(connection) =>
|
||||||
|
connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
input.after ?? 0,
|
{ after: input.after ?? 0, path: input.path },
|
||||||
{ timeout: input.timeout },
|
{ timeout: input.timeout },
|
||||||
)
|
)
|
||||||
return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
|
let result: SseConnectionRecord | undefined
|
||||||
(connection) => connection.id > (input.after ?? 0),
|
try {
|
||||||
)!
|
result = await connection.jsonValue()
|
||||||
|
} finally {
|
||||||
|
await connection.dispose()
|
||||||
|
}
|
||||||
|
if (!result) throw new Error("SSE transport connection disappeared while waiting")
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
send(payload, eventOptions) {
|
send(payload, eventOptions, path) {
|
||||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
|
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path })
|
||||||
},
|
},
|
||||||
burst(payloads, eventOptions = []) {
|
burst(payloads, eventOptions = []) {
|
||||||
return command({
|
return command({
|
||||||
@@ -299,8 +323,8 @@ export async function installSseTransport<T>(
|
|||||||
disconnect(message) {
|
disconnect(message) {
|
||||||
return command({ type: "end", mode: "disconnect", message })
|
return command({ type: "end", mode: "disconnect", message })
|
||||||
},
|
},
|
||||||
error(message) {
|
error(message, path) {
|
||||||
return command({ type: "end", mode: "error", message })
|
return command({ type: "end", mode: "error", message, path })
|
||||||
},
|
},
|
||||||
connections() {
|
connections() {
|
||||||
return command({ type: "connections" })
|
return command({ type: "connections" })
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.12",
|
"version": "1.18.14",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./desktop-menu": "./src/desktop-menu.ts",
|
"./desktop-menu": "./src/desktop-menu.ts",
|
||||||
|
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
|
||||||
"./updater": "./src/updater.ts",
|
"./updater": "./src/updater.ts",
|
||||||
"./wsl/types": "./src/wsl/types.ts",
|
"./wsl/types": "./src/wsl/types.ts",
|
||||||
"./vite": "./vite.js",
|
"./vite": "./vite.js",
|
||||||
@@ -27,7 +28,8 @@
|
|||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||||
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
||||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
|
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
|
||||||
|
"profile:desktop": "bun run e2e/performance/profile-desktop.ts"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+34
-14
@@ -1,10 +1,9 @@
|
|||||||
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 { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
|
||||||
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 { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
@@ -59,18 +58,24 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
|||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { WslServersProvider } from "@/wsl/context"
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import LegacyLayout from "@/pages/layout"
|
|
||||||
import NewLayout from "@/pages/layout-new"
|
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||||
|
|
||||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
|
||||||
import { NewHome } from "@/pages/home"
|
|
||||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
const NewLayout = lazy(() => import("@/pages/layout-new"))
|
||||||
|
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
|
||||||
|
const LegacyLayout = lazy(() => import("@/pages/layout"))
|
||||||
|
const LegacyHome = lazy(() => import("@/pages/home/legacy-home").then((module) => ({ default: module.LegacyHome })))
|
||||||
|
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
|
||||||
|
const SessionPage = lazy(() => import("@/pages/session").then((module) => ({ default: module.SessionPage })))
|
||||||
|
const SessionRouteErrorBoundary = lazy(() =>
|
||||||
|
import("@/pages/session").then((module) => ({ default: module.SessionRouteErrorBoundary })),
|
||||||
|
)
|
||||||
|
const TargetSessionRouteContent = lazy(() =>
|
||||||
|
import("@/pages/session").then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||||
|
)
|
||||||
|
|
||||||
const SessionRoute = () => {
|
const SessionRoute = () => {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -234,7 +239,19 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
|
|
||||||
function UiI18nBridge(props: ParentProps) {
|
function UiI18nBridge(props: ParentProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return (
|
||||||
|
<I18nProvider
|
||||||
|
value={{
|
||||||
|
locale: language.intl,
|
||||||
|
layoutLocale: language.layoutLocale,
|
||||||
|
t: language.t as UiI18n["t"],
|
||||||
|
plural: language.plural,
|
||||||
|
pluralForm: language.pluralForm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LayoutCompatibility(props: ParentProps) {
|
function LayoutCompatibility(props: ParentProps) {
|
||||||
@@ -327,7 +344,7 @@ function DesktopCommands() {
|
|||||||
if (platform.platform === "desktop" && platform.exportDebugLogs) {
|
if (platform.platform === "desktop" && platform.exportDebugLogs) {
|
||||||
commands.push({
|
commands.push({
|
||||||
id: "logs.export",
|
id: "logs.export",
|
||||||
title: "Export logs",
|
title: language.t("command.logs.export"),
|
||||||
category: language.t("command.category.settings"),
|
category: language.t("command.category.settings"),
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
void platform.exportDebugLogs?.()
|
void platform.exportDebugLogs?.()
|
||||||
@@ -385,7 +402,12 @@ function DraftProviders(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
export function AppBaseProviders(
|
||||||
|
props: ParentProps<{
|
||||||
|
locale?: Locale
|
||||||
|
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<Font />
|
<Font />
|
||||||
@@ -394,7 +416,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
|||||||
void window.api?.setTitlebar?.({ mode, scheme })
|
void window.api?.setTitlebar?.({ mode, scheme })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LanguageProvider locale={props.locale}>
|
<LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
|
||||||
<UiI18nBridge>
|
<UiI18nBridge>
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={(error) => {
|
fallback={(error) => {
|
||||||
@@ -405,9 +427,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
|||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<WslServersProvider>
|
<WslServersProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<MarkedProvider>
|
|
||||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||||
</MarkedProvider>
|
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</WslServersProvider>
|
</WslServersProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function Cell(props: {
|
|||||||
label: string
|
label: string
|
||||||
tip: string
|
tip: string
|
||||||
value: string
|
value: string
|
||||||
wide?: boolean
|
span?: 2 | 3
|
||||||
}) {
|
}) {
|
||||||
const content = () => (
|
const content = () => (
|
||||||
<div
|
<div
|
||||||
@@ -69,7 +69,8 @@ function Cell(props: {
|
|||||||
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
|
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
|
||||||
"justify-center text-center": !props.inline,
|
"justify-center text-center": !props.inline,
|
||||||
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
||||||
"col-span-2": !!props.wide && !props.inline,
|
"col-span-2": props.span === 2 && !props.inline,
|
||||||
|
"col-span-3": props.span === 3 && !props.inline,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -116,11 +117,18 @@ function Cell(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
|
function ToggleCell(props: {
|
||||||
|
active: boolean
|
||||||
|
inline?: boolean
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
tip: string
|
||||||
|
value: string
|
||||||
|
}) {
|
||||||
const content = () => (
|
const content = () => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Force focus styles on all interactive elements"
|
aria-label={`${props.label}: ${props.value}`}
|
||||||
aria-pressed={props.active}
|
aria-pressed={props.active}
|
||||||
classList={{
|
classList={{
|
||||||
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
||||||
@@ -137,26 +145,22 @@ function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => vo
|
|||||||
"flex-col items-center": !props.inline,
|
"flex-col items-center": !props.inline,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
|
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
|
||||||
<span
|
<span class="text-[11px] leading-none font-bold">{props.value}</span>
|
||||||
classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}
|
|
||||||
>
|
|
||||||
{props.active ? "ON" : "OFF"}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (props.inline) {
|
if (props.inline) {
|
||||||
return (
|
return (
|
||||||
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
|
<TooltipV2 value={props.tip} placement="top">
|
||||||
{content()}
|
{content()}
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip value="Force focus styles on all interactive elements" placement="top">
|
<Tooltip value={props.tip} placement="top">
|
||||||
{content()}
|
{content()}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
@@ -475,7 +479,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
"gap-[9px]": !!props.inline,
|
"gap-[9px]": !!props.inline,
|
||||||
"gap-px": !props.inline,
|
"gap-px": !props.inline,
|
||||||
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
|
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
|
||||||
"grid-cols-5": !props.inline,
|
"grid-cols-4": !props.inline,
|
||||||
grid: !props.inline,
|
grid: !props.inline,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -557,10 +561,25 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
bad={bad(heap(), 0.8)}
|
bad={bad(heap(), 0.8)}
|
||||||
dim={state.heap.used === undefined}
|
dim={state.heap.used === undefined}
|
||||||
inline={props.inline}
|
inline={props.inline}
|
||||||
wide={!platform.setForceFocus}
|
span={platform.setForceFocus ? 2 : 3}
|
||||||
|
/>
|
||||||
|
<ToggleCell
|
||||||
|
active={language.direction() === "rtl"}
|
||||||
|
inline={props.inline}
|
||||||
|
label={language.t("debugBar.direction.label")}
|
||||||
|
tip={language.t("debugBar.direction.tip")}
|
||||||
|
value={language.t(`debugBar.direction.${language.direction()}`)}
|
||||||
|
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
||||||
/>
|
/>
|
||||||
{platform.setForceFocus && (
|
{platform.setForceFocus && (
|
||||||
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
|
<ToggleCell
|
||||||
|
active={state.focus}
|
||||||
|
inline={props.inline}
|
||||||
|
label={language.t("debugBar.focus.label")}
|
||||||
|
tip={language.t("debugBar.focus.tip")}
|
||||||
|
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
||||||
|
onClick={() => void toggleFocus()}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -509,7 +509,12 @@ function ProviderConnection(props: {
|
|||||||
const hint = suffix?.[1]
|
const hint = suffix?.[1]
|
||||||
return {
|
return {
|
||||||
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
||||||
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
|
hint:
|
||||||
|
hint?.toLowerCase() === "headless"
|
||||||
|
? language.t("provider.connect.method.headless")
|
||||||
|
: hint?.toLowerCase() === "browser" || (!hint && value?.type === "key")
|
||||||
|
? language.t("provider.connect.method.browser")
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
if ((await serverSDK().protocol) !== "v1") throw new Error(language.t("provider.custom.unavailable"))
|
||||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={6}
|
gutter={6}
|
||||||
openDelay={0}
|
delay="intent"
|
||||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||||
value={
|
value={
|
||||||
<ModelTooltip
|
<ModelTooltip
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={6}
|
gutter={6}
|
||||||
openDelay={0}
|
delay="intent"
|
||||||
value={
|
value={
|
||||||
<ModelTooltip
|
<ModelTooltip
|
||||||
model={item}
|
model={item}
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ export type { Kind } from "@/components/file-tree"
|
|||||||
|
|
||||||
const INDENT_STEP = 16
|
const INDENT_STEP = 16
|
||||||
|
|
||||||
function rowPaddingLeft(level: number, type: FileNode["type"]) {
|
function rowPaddingStart(level: number, type: FileNode["type"]) {
|
||||||
if (type === "directory") return 8 + level * INDENT_STEP
|
if (type === "directory") return 8 + level * INDENT_STEP
|
||||||
if (level === 0) return 8
|
if (level === 0) return 8
|
||||||
return 8 + level * INDENT_STEP - INDENT_STEP
|
return 8 + level * INDENT_STEP - INDENT_STEP
|
||||||
}
|
}
|
||||||
|
|
||||||
function guideLineLeft(level: number) {
|
function guideLineStart(level: number) {
|
||||||
return rowPaddingLeft(level, "directory") + 8
|
return rowPaddingStart(level, "directory") + 8
|
||||||
}
|
}
|
||||||
|
|
||||||
export const kindLabel = (kind: Kind) => {
|
export const kindLabel = (kind: Kind) => {
|
||||||
@@ -87,7 +87,7 @@ const FileTreeNodeV2 = (
|
|||||||
...local.classList,
|
...local.classList,
|
||||||
[local.class ?? ""]: !!local.class,
|
[local.class ?? ""]: !!local.class,
|
||||||
}}
|
}}
|
||||||
style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
|
style={`padding-inline-start: ${rowPaddingStart(local.level, local.node.type)}px`}
|
||||||
draggable={local.draggable}
|
draggable={local.draggable}
|
||||||
onDragStart={(event: DragEvent) => {
|
onDragStart={(event: DragEvent) => {
|
||||||
if (!local.draggable) return
|
if (!local.draggable) return
|
||||||
@@ -99,7 +99,9 @@ const FileTreeNodeV2 = (
|
|||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{local.children}
|
{local.children}
|
||||||
<span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
|
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
|
||||||
|
<bdi dir="auto">{local.node.name}</bdi>
|
||||||
|
</span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const value = kind()
|
const value = kind()
|
||||||
if (!value || local.node.type !== "file") return null
|
if (!value || local.node.type !== "file") return null
|
||||||
@@ -116,7 +118,7 @@ const FileTreeNodeV2 = (
|
|||||||
function GuideLines(props: { level: number }) {
|
function GuideLines(props: { level: number }) {
|
||||||
return (
|
return (
|
||||||
<For each={Array.from({ length: props.level })}>
|
<For each={Array.from({ length: props.level })}>
|
||||||
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
|
{(_, index) => <div data-slot="file-tree-v2-guide" style={`inset-inline-start: ${guideLineStart(index())}px`} />}
|
||||||
</For>
|
</For>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -227,7 +229,7 @@ export default function FileTreeV2(props: {
|
|||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "0",
|
top: "0",
|
||||||
left: "0",
|
"inset-inline-start": "0",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: `${item().size}px`,
|
height: `${item().size}px`,
|
||||||
transform: `translateY(${item().start}px)`,
|
transform: `translateY(${item().start}px)`,
|
||||||
|
|||||||
@@ -146,13 +146,13 @@ const FileTreeNode = (
|
|||||||
<Dynamic
|
<Dynamic
|
||||||
component={local.as ?? "div"}
|
component={local.as ?? "div"}
|
||||||
classList={{
|
classList={{
|
||||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-start hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||||
"bg-surface-base-active": local.node.path === local.active,
|
"bg-surface-base-active": local.node.path === local.active,
|
||||||
...local.classList,
|
...local.classList,
|
||||||
[local.class ?? ""]: !!local.class,
|
[local.class ?? ""]: !!local.class,
|
||||||
[local.nodeClass ?? ""]: !!local.nodeClass,
|
[local.nodeClass ?? ""]: !!local.nodeClass,
|
||||||
}}
|
}}
|
||||||
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
style={`padding-inline-start: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
||||||
draggable={local.draggable}
|
draggable={local.draggable}
|
||||||
onDragStart={(event: DragEvent) => {
|
onDragStart={(event: DragEvent) => {
|
||||||
if (!local.draggable) return
|
if (!local.draggable) return
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { isRTL } from "@kobalte/core/i18n"
|
||||||
import { createSignal, Show } from "solid-js"
|
import { createSignal, Show } from "solid-js"
|
||||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||||
import homeImage from "@/assets/help/home.png"
|
import homeImage from "@/assets/help/home.png"
|
||||||
import tabsImage from "@/assets/help/tabs.png"
|
import tabsImage from "@/assets/help/tabs.png"
|
||||||
|
|
||||||
// TODO: wire to changelog / seen-state when available
|
|
||||||
const showPopover = () => true
|
|
||||||
|
|
||||||
// can remove this after the tabs rollout has been out for a while
|
// can remove this after the tabs rollout has been out for a while
|
||||||
export function TabsInfoPopup() {
|
export function TabsInfoPopup() {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const language = useLanguage()
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||||
|
const rtl = () => isRTL(language.intl())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side={rtl() ? "left" : "right"}>
|
||||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||||
<div
|
<div
|
||||||
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
class="fixed bottom-5 end-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
aria-label={language.t("help.tabs.toast.ariaLabel")}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Dismiss Tabs information"
|
aria-label={language.t("help.tabs.toast.dismiss")}
|
||||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
class="absolute top-3 end-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||||
onClick={settings.general.dismissTabsToast}
|
onClick={settings.general.dismissTabsToast}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -44,7 +45,7 @@ export function TabsInfoPopup() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
settings.general.dismissTabsToast()
|
settings.general.dismissTabsToast()
|
||||||
setDrawerOpen(true)
|
setDrawerOpen(true)
|
||||||
@@ -62,10 +63,10 @@ export function TabsInfoPopup() {
|
|||||||
/>
|
/>
|
||||||
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
||||||
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
||||||
Introducing Tabs
|
{language.t("help.tabs.title")}
|
||||||
</p>
|
</p>
|
||||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||||
Organize your work and active sessions with tabs
|
{language.t("help.tabs.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -75,7 +76,10 @@ export function TabsInfoPopup() {
|
|||||||
style={
|
style={
|
||||||
windows()
|
windows()
|
||||||
? {
|
? {
|
||||||
inset: "0 0 0 auto",
|
top: "0",
|
||||||
|
bottom: "0",
|
||||||
|
"inset-inline-end": "0",
|
||||||
|
"inset-inline-start": "auto",
|
||||||
"max-height": "100vh",
|
"max-height": "100vh",
|
||||||
"max-width": "100vw",
|
"max-width": "100vw",
|
||||||
"border-radius": "0",
|
"border-radius": "0",
|
||||||
@@ -89,9 +93,9 @@ export function TabsInfoPopup() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="small"
|
size="small"
|
||||||
variant="neutral"
|
variant="neutral"
|
||||||
aria-label="Close"
|
aria-label={language.t("common.close")}
|
||||||
icon={<IconV2 name="xmark-small" />}
|
icon={<IconV2 name="xmark-small" />}
|
||||||
class="absolute top-[10px] left-[-36px]"
|
class="absolute top-[10px] start-[-36px]"
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
@@ -102,7 +106,7 @@ export function TabsInfoPopup() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||||
July 14
|
{language.t("help.tabs.date")}
|
||||||
</p>
|
</p>
|
||||||
<Show when={!windows()}>
|
<Show when={!windows()}>
|
||||||
<DrawerClose
|
<DrawerClose
|
||||||
@@ -110,36 +114,23 @@ export function TabsInfoPopup() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
aria-label="Close"
|
aria-label={language.t("common.close")}
|
||||||
icon={<IconV2 name="xmark-small" />}
|
icon={<IconV2 name="xmark-small" />}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
||||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||||
Introducing Tabs
|
{language.t("help.tabs.title")}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||||
<p>OpenCode Desktop is now built around tabs.</p>
|
<p>{language.t("help.tabs.introduction")}</p>
|
||||||
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||||
<p>
|
<p>{language.t("help.tabs.sessions")}</p>
|
||||||
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
<p>{language.t("help.tabs.organize")}</p>
|
||||||
you're starting something new, and close it when you're done.
|
<p>{language.t("help.tabs.home")}</p>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
|
||||||
memorable if you plan to keep them around.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
|
||||||
</p>
|
|
||||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||||
<p>When you reopen the app, your tabs are still open.</p>
|
<p>{language.t("help.tabs.persistence")}</p>
|
||||||
<p>
|
|
||||||
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
|
||||||
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
|
||||||
will become permanent in a few weeks.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -137,7 +139,10 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
|
const designPlaceholder = () =>
|
||||||
|
promptDesignPlaceholder(mode(), placeholder(), (key, params) =>
|
||||||
|
language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||||
|
)
|
||||||
|
|
||||||
const historyComments = () => {
|
const historyComments = () => {
|
||||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/
|
|||||||
import { PromptContextItems } from "./prompt-input/context-items"
|
import { PromptContextItems } from "./prompt-input/context-items"
|
||||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||||
import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder"
|
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
@@ -1493,6 +1493,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
onRemove={removeAttachment}
|
onRemove={removeAttachment}
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
removeLabel={language.t("prompt.attachment.remove")}
|
||||||
|
fileLabel={language.t("ui.common.file")}
|
||||||
newLayoutDesigns={false}
|
newLayoutDesigns={false}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
placement="top"
|
placement="top"
|
||||||
openDelay={800}
|
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type PromptImageAttachmentsProps = {
|
|||||||
onOpen: (attachment: ImageAttachmentPart) => void
|
onOpen: (attachment: ImageAttachmentPart) => void
|
||||||
onRemove: (id: string) => void
|
onRemove: (id: string) => void
|
||||||
removeLabel: string
|
removeLabel: string
|
||||||
|
fileLabel: string
|
||||||
newLayoutDesigns: boolean
|
newLayoutDesigns: boolean
|
||||||
comments?: PromptCommentItem[]
|
comments?: PromptCommentItem[]
|
||||||
commentActive?: (item: PromptCommentItem) => boolean
|
commentActive?: (item: PromptCommentItem) => boolean
|
||||||
@@ -52,12 +53,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<For each={props.comments ?? []}>
|
<For each={props.comments ?? []}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div class="relative group shrink-0">
|
<div class="relative group shrink-0">
|
||||||
<TooltipV2
|
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||||
value={item.comment}
|
|
||||||
placement="top"
|
|
||||||
openDelay={800}
|
|
||||||
contentClass="max-w-[300px] break-words"
|
|
||||||
>
|
|
||||||
<CommentCardV2
|
<CommentCardV2
|
||||||
comment={item.comment ?? ""}
|
comment={item.comment ?? ""}
|
||||||
path={item.path}
|
path={item.path}
|
||||||
@@ -94,7 +90,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AttachmentCardV2 title={attachment.filename}>
|
<AttachmentCardV2 title={attachment.filename}>
|
||||||
{typeLabel(attachment.filename, attachment.mime)}
|
{typeLabel(attachment.filename, attachment.mime, props.fileLabel)}
|
||||||
</AttachmentCardV2>
|
</AttachmentCardV2>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { promptPlaceholder } from "./placeholder"
|
import { promptDesignPlaceholder, promptPlaceholder } from "./placeholder"
|
||||||
|
|
||||||
describe("promptPlaceholder", () => {
|
describe("promptPlaceholder", () => {
|
||||||
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
|
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
|
||||||
@@ -46,3 +46,18 @@ describe("promptPlaceholder", () => {
|
|||||||
expect(value).toBe("prompt.placeholder.simple")
|
expect(value).toBe("prompt.placeholder.simple")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("promptDesignPlaceholder", () => {
|
||||||
|
const t = (key: string, params?: Record<string, string>) => {
|
||||||
|
if (key !== "ui.promptInput.placeholder.normal") return key
|
||||||
|
return `Ask anything, ${params?.slash} for commands, ${params?.at} for context...`
|
||||||
|
}
|
||||||
|
|
||||||
|
test("composes the design placeholder from localized fragments", () => {
|
||||||
|
expect(promptDesignPlaceholder("normal", "fallback", t)).toBe("Ask anything, / for commands, @ for context...")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves the shell placeholder", () => {
|
||||||
|
expect(promptDesignPlaceholder("shell", "Enter shell command...", t)).toBe("Enter shell command...")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
|
|||||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
|
export function promptDesignPlaceholder(
|
||||||
|
mode: PromptPlaceholderInput["mode"],
|
||||||
|
placeholder: string,
|
||||||
|
t: PromptPlaceholderInput["t"],
|
||||||
|
) {
|
||||||
if (mode === "shell") return placeholder
|
if (mode === "shell") return placeholder
|
||||||
return "Ask anything, / for commands, @ for context..."
|
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
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 { Worktree } from "@/utils/worktree"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
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,24 +22,35 @@ 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[] = []
|
||||||
const sentCommands: unknown[] = []
|
const sentCommands: unknown[] = []
|
||||||
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 worktreeHung = false
|
||||||
|
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> = {}
|
||||||
|
|
||||||
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>({
|
||||||
@@ -67,19 +78,21 @@ 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)
|
|
||||||
return {
|
return {
|
||||||
id: `session-${createdSessions.length}`,
|
id: `session-${createdSessions.length}`,
|
||||||
projectID: "project",
|
projectID: "project",
|
||||||
@@ -102,6 +115,7 @@ 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(directory)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -110,7 +124,16 @@ const clientFor = (directory: string) => {
|
|||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
worktree: {
|
worktree: {
|
||||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
create: async (_input: unknown, options?: { signal?: AbortSignal }) => {
|
||||||
|
worktreeCreates++
|
||||||
|
if (worktreeHung)
|
||||||
|
return new Promise<never>((_, reject) => {
|
||||||
|
options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true })
|
||||||
|
})
|
||||||
|
await createWorktreeGate
|
||||||
|
if (worktreeFailure) throw worktreeFailure
|
||||||
|
return { data: { directory: worktreeDirectory } }
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,13 +150,13 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||||
createOpencodeClient: (input: { directory: string }) => {
|
createOpencodeClient: (input: { directory: string }) => {
|
||||||
createdClients.push(input.directory)
|
|
||||||
return clientFor(input.directory)
|
return clientFor(input.directory)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -151,20 +174,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", () => ({
|
||||||
@@ -173,7 +189,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 })
|
||||||
},
|
},
|
||||||
@@ -194,22 +213,24 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
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",
|
||||||
client: rootClient,
|
client: rootClient,
|
||||||
api: rootClient.api,
|
api: rootClient.api,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
|
createApi: (directory: string) => clientFor(directory).api,
|
||||||
createClient(opts: any) {
|
createClient(opts: any) {
|
||||||
return clientFor(opts.directory)
|
return clientFor(opts.directory)
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
return () => sdk
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => ({
|
useSync: () => () => {
|
||||||
|
const server = activeDirectorySync
|
||||||
|
return {
|
||||||
data: { command: commands },
|
data: { command: commands },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
@@ -218,22 +239,22 @@ beforeAll(async () => {
|
|||||||
sessionID?: string
|
sessionID?: string
|
||||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||||
}) => {
|
}) => {
|
||||||
|
optimisticServers.push(server)
|
||||||
optimistic.push(value)
|
optimistic.push(value)
|
||||||
optimisticSeeded.push(
|
|
||||||
!!value.directory &&
|
|
||||||
!!value.sessionID &&
|
|
||||||
!!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: () => () => {
|
||||||
|
const server = activeServerSync
|
||||||
|
return {
|
||||||
session: {
|
session: {
|
||||||
remember: () => undefined,
|
remember: () => undefined,
|
||||||
set: () => undefined,
|
set: () => undefined,
|
||||||
@@ -242,7 +263,7 @@ beforeAll(async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
child: (directory: string) => {
|
child: (directory: string) => {
|
||||||
syncedDirectories.push(directory)
|
syncedServers.push(server)
|
||||||
storedSessions[directory] ??= []
|
storedSessions[directory] ??= []
|
||||||
return [
|
return [
|
||||||
{ session: storedSessions[directory] },
|
{ session: storedSessions[directory] },
|
||||||
@@ -259,7 +280,8 @@ beforeAll(async () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}),
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/platform", () => ({
|
mock.module("@/context/platform", () => ({
|
||||||
@@ -279,40 +301,47 @@ 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
|
syncedServers.length = 0
|
||||||
commands.length = 0
|
optimisticServers.length = 0
|
||||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
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
|
||||||
|
worktreeHung = false
|
||||||
|
worktreeCreates = 0
|
||||||
|
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
test("reads the latest worktree accessor value per submit", async () => {
|
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
|
||||||
const submit = createPromptSubmit({
|
createPromptSubmit({
|
||||||
prompt,
|
prompt,
|
||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
autoAccept: () => false,
|
autoAccept: () => false,
|
||||||
mode: () => "shell",
|
mode: () => "normal",
|
||||||
working: () => false,
|
working: () => false,
|
||||||
editor: () => undefined,
|
editor: () => undefined,
|
||||||
queueScroll: () => undefined,
|
queueScroll: () => undefined,
|
||||||
@@ -324,153 +353,101 @@ describe("prompt submit worktree selection", () => {
|
|||||||
newSessionWorktree: () => selected,
|
newSessionWorktree: () => selected,
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
onSubmit: () => undefined,
|
onSubmit: () => undefined,
|
||||||
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
describe("prompt submit worktree selection", () => {
|
||||||
|
test("admits only one concurrent new-workspace submission", async () => {
|
||||||
|
selected = "create"
|
||||||
|
let release = () => {}
|
||||||
|
createWorktreeGate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
const submit = makeSubmit()
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
const first = submit.handleSubmit(event)
|
||||||
selected = "/repo/worktree-b"
|
const duplicate = submit.handleSubmit(event)
|
||||||
await submit.handleSubmit(event)
|
expect(worktreeCreates).toBe(1)
|
||||||
|
|
||||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
release()
|
||||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
await Promise.all([first, duplicate])
|
||||||
expect(sessionCreateInputs).toEqual([
|
expect(createdSessions).toEqual([worktreeDirectory])
|
||||||
{
|
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||||
agent: "agent",
|
await settle()
|
||||||
model: { id: "model", providerID: "provider", variant: undefined },
|
|
||||||
location: { directory: "/repo/worktree-a" },
|
expect(worktreeCreates).toBe(1)
|
||||||
},
|
expect(createdSessions).toHaveLength(1)
|
||||||
{
|
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||||
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("aborts a hung new-workspace request and allows retry", async () => {
|
||||||
const submit = createPromptSubmit({
|
selected = "create"
|
||||||
prompt,
|
worktreeHung = true
|
||||||
info: () => undefined,
|
let resets = 0
|
||||||
imageAttachments: () => [],
|
const submit = makeSubmit({
|
||||||
commentCount: () => 0,
|
onNewSessionWorktreeReset: () => resets++,
|
||||||
autoAccept: () => true,
|
worktreeRequestTimeoutMs: 1,
|
||||||
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 submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(worktreeCreates).toBe(1)
|
||||||
|
expect(createdSessions).toEqual([])
|
||||||
|
expect(selected).toBe("create")
|
||||||
|
expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }])
|
||||||
|
expect(resets).toBe(0)
|
||||||
|
|
||||||
|
worktreeHung = false
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(worktreeCreates).toBe(2)
|
||||||
|
expect(createdSessions).toEqual([worktreeDirectory])
|
||||||
|
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||||
|
expect(resets).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
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(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||||
prompt,
|
expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined()
|
||||||
info: () => undefined,
|
expect(submitted).toBe(0)
|
||||||
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("includes the selected variant on optimistic prompts", async () => {
|
test("includes the selected variant on optimistic prompts", async () => {
|
||||||
params = { id: "session-1" }
|
params.id = "session-1"
|
||||||
variant = "high"
|
variant = "high"
|
||||||
|
|
||||||
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,
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -500,24 +477,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([
|
||||||
{
|
{
|
||||||
@@ -533,66 +498,43 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(serverSessionSyncs).toBe(0)
|
expect(serverSessionSyncs).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses an injected model selection", async () => {
|
test("waits for a new workspace before sending an initial shell", 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)
|
||||||
|
expect(sentShell).toEqual([])
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
|
||||||
|
|
||||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
await settle()
|
||||||
expect(optimisticSeeded).toEqual([true])
|
|
||||||
|
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
||||||
|
expect(sentShell[0]).toMatchObject({
|
||||||
|
sessionID: "session-1",
|
||||||
|
command: "ls",
|
||||||
|
agent: "agent",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
})
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("settles a pending workspace operation when the initial prompt is aborted", async () => {
|
||||||
|
selected = "create"
|
||||||
|
const submit = makeSubmit()
|
||||||
|
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
|
||||||
|
params = { id: "session-1" }
|
||||||
|
|
||||||
|
await submit.abort()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(sentPrompts).toEqual([])
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
|
||||||
|
status: "failed",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ 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 { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
@@ -27,9 +29,13 @@ import { blobDataUrl } from "@/utils/draft-store"
|
|||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
cleanup: VoidFunction
|
cleanup: VoidFunction
|
||||||
|
scope: ServerScope
|
||||||
|
sessionID: string
|
||||||
|
serverSync: ServerSync
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = new Map<string, PendingPrompt>()
|
const pending = new Map<string, PendingPrompt>()
|
||||||
|
const submitting = new Set<string>()
|
||||||
|
|
||||||
export type FollowupDraft = {
|
export type FollowupDraft = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -43,6 +49,7 @@ export type FollowupDraft = {
|
|||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
api: DirectorySDK["api"]["session"]
|
api: DirectorySDK["api"]["session"]
|
||||||
|
scope: ServerScope
|
||||||
serverSync: ServerSync
|
serverSync: ServerSync
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
@@ -56,6 +63,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
|
|||||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||||
|
|
||||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
|
const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID)
|
||||||
|
if (operation?.status === "pending" && operation.messageID !== input.messageID) return false
|
||||||
const text = draftText(input.draft.prompt)
|
const text = draftText(input.draft.prompt)
|
||||||
const images = draftImages(input.draft.prompt)
|
const images = draftImages(input.draft.prompt)
|
||||||
const setBusy = () => {
|
const setBusy = () => {
|
||||||
@@ -229,6 +238,7 @@ type PromptSubmitInput = {
|
|||||||
onAbort?: () => void
|
onAbort?: () => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
model?: ModelSelection
|
model?: ModelSelection
|
||||||
|
worktreeRequestTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
@@ -244,7 +254,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
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 pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||||
|
let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined
|
||||||
|
|
||||||
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
|
||||||
@@ -257,18 +268,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const abort = async () => {
|
const abort = async () => {
|
||||||
const sessionID = params.id
|
const routeSessionID = params.id
|
||||||
|
const owned =
|
||||||
|
pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID)
|
||||||
|
? pending.get(pendingSubmission.key)
|
||||||
|
: undefined
|
||||||
|
const sessionID = routeSessionID ?? owned?.sessionID
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, [])
|
||||||
serverSync().session.set("todo", sessionID, [])
|
|
||||||
|
|
||||||
input.onAbort?.()
|
input.onAbort?.()
|
||||||
|
|
||||||
const key = pendingKey(sessionID)
|
const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID)
|
||||||
const queued = pending.get(key)
|
const queued = owned ?? pending.get(key)
|
||||||
if (queued) {
|
if (queued) {
|
||||||
queued.abort.abort()
|
queued.abort.abort()
|
||||||
queued.cleanup()
|
queued.cleanup()
|
||||||
|
WorkspaceOperation.fail(queued.scope, queued.sessionID)
|
||||||
pending.delete(key)
|
pending.delete(key)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
@@ -300,9 +316,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = (dir: string, info: Session) => {
|
const seed = (target: ServerSync, dir: string, info: Session) => {
|
||||||
serverSync().session.remember(info)
|
target.session.remember(info)
|
||||||
const [, setStore] = serverSync().child(dir)
|
const [, setStore] = target.child(dir)
|
||||||
setStore("session", (list: Session[]) => {
|
setStore("session", (list: Session[]) => {
|
||||||
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]
|
||||||
@@ -334,6 +350,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (input.working()) void abort()
|
if (input.working()) void abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return
|
||||||
|
|
||||||
const modelSelection = input.model ?? local.model
|
const modelSelection = input.model ?? local.model
|
||||||
const currentModel = modelSelection.current()
|
const currentModel = modelSelection.current()
|
||||||
@@ -347,22 +364,47 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submissionSDK = sdk()
|
||||||
|
const submissionSync = sync()
|
||||||
|
const submissionServerSync = serverSync()
|
||||||
|
const submissionScope = submissionSDK.scope
|
||||||
|
const projectDirectory = submissionSDK.directory
|
||||||
|
const projectRoot = submissionSync.project?.worktree ?? projectDirectory
|
||||||
|
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 handoff = layout.handoff
|
||||||
|
const resetWorktree = input.onNewSessionWorktreeReset
|
||||||
|
const onSubmit = input.onSubmit
|
||||||
|
const permissionState = permission.currentServerState()
|
||||||
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
|
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)
|
||||||
|
|
||||||
|
try {
|
||||||
input.addToHistory(currentPrompt, mode)
|
input.addToHistory(currentPrompt, mode)
|
||||||
input.resetHistoryNavigation()
|
input.resetHistoryNavigation()
|
||||||
|
|
||||||
const projectDirectory = sdk().directory
|
|
||||||
const permissionState = permission.currentServerState()
|
|
||||||
const isNewSession = !params.id
|
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
let sessionDirectory = projectDirectory
|
||||||
let client = sdk().client
|
let client = submissionSDK.client
|
||||||
|
let api = submissionSDK.api.session
|
||||||
|
|
||||||
if (isNewSession) {
|
if (isNewSession) {
|
||||||
if (worktreeSelection === "create") {
|
if (worktreeSelection === "create") {
|
||||||
const createdWorktree = await client.worktree
|
const createdWorktree = await workspaceRequestWithTimeout(
|
||||||
.create({ directory: projectDirectory })
|
(signal) => client.worktree.create({ directory: projectDirectory }, { signal }),
|
||||||
|
language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
)
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -379,7 +421,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
WorktreeState.pending(submissionScope, createdWorktree.directory)
|
||||||
sessionDirectory = createdWorktree.directory
|
sessionDirectory = createdWorktree.directory
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,20 +430,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionDirectory !== projectDirectory) {
|
if (sessionDirectory !== projectDirectory) {
|
||||||
client = sdk().createClient({
|
client = submissionSDK.createClient({
|
||||||
directory: sessionDirectory,
|
directory: sessionDirectory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
serverSync().child(sessionDirectory)
|
api = submissionSDK.createApi(sessionDirectory).session
|
||||||
|
submissionServerSync.child(sessionDirectory)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input.onNewSessionWorktreeReset?.()
|
let session = currentSession
|
||||||
}
|
|
||||||
|
|
||||||
let session = input.info()
|
|
||||||
if (!session && isNewSession) {
|
if (!session && isNewSession) {
|
||||||
const created = await sdk()
|
const created = await submissionSDK.api.session
|
||||||
.api.session.create({
|
.create({
|
||||||
agent: currentAgent.name,
|
agent: currentAgent.name,
|
||||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||||
location: { directory: sessionDirectory },
|
location: { directory: sessionDirectory },
|
||||||
@@ -415,21 +456,27 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
if (created) {
|
if (created) {
|
||||||
seed(sessionDirectory, created)
|
seed(submissionServerSync, sessionDirectory, created)
|
||||||
session = created
|
session = created
|
||||||
await startTransition(() => {
|
await startTransition(() => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
||||||
|
if (!draftID) resetWorktree?.()
|
||||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||||
local.session.promote(sessionDirectory, session.id, {
|
localSession.promote(sessionDirectory, session.id, {
|
||||||
agent: currentAgent.name,
|
agent: currentAgent.name,
|
||||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||||
variant: variant ?? null,
|
variant: variant ?? null,
|
||||||
})
|
})
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
const draftID = search.draftId
|
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
|
||||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
submission.retarget(
|
||||||
|
capturePrompt(
|
||||||
|
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||||
|
{ server: draftServer, scope: submissionScope },
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,96 +533,45 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.onSubmit?.()
|
const startWorkspaceOperation = (messageID: string) => {
|
||||||
|
if (!isNewSession) return
|
||||||
if (mode === "shell") {
|
if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) {
|
||||||
clearInput()
|
WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID)
|
||||||
const eventID = Event.ID.create()
|
WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
sdk()
|
}
|
||||||
.api.session.shell({
|
if (worktreeSelection !== "create") return
|
||||||
sessionID: session.id,
|
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||||
id: eventID,
|
WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID)
|
||||||
command: text,
|
if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
agent,
|
if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id)
|
||||||
model,
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
restoreInput()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.startsWith("/")) {
|
const waitForWorktree = async (cleanup: VoidFunction) => {
|
||||||
const [cmdName, ...args] = text.split(" ")
|
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||||
const commandName = cmdName.slice(1)
|
if (!worktree) return true
|
||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
if (worktree.status === "ready") {
|
||||||
if (customCommand) {
|
WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
clearInput()
|
return true
|
||||||
const messageID = Identifier.ascending("message")
|
|
||||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
|
||||||
sdk()
|
|
||||||
.api.session.command({
|
|
||||||
sessionID: session.id,
|
|
||||||
id: messageID,
|
|
||||||
command: commandName,
|
|
||||||
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) => {
|
|
||||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
|
||||||
})
|
|
||||||
restoreInput()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if (worktree.status === "failed") {
|
||||||
|
WorkspaceOperation.fail(submissionScope, session.id)
|
||||||
|
throw new Error(worktree.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
|
||||||
const messageID = Identifier.ascending("message")
|
|
||||||
|
|
||||||
const removeOptimisticMessage = () => {
|
|
||||||
sync().session.optimistic.remove({
|
|
||||||
directory: sessionDirectory,
|
|
||||||
sessionID: session.id,
|
|
||||||
messageID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
|
||||||
clearInput()
|
|
||||||
|
|
||||||
const waitForWorktree = async () => {
|
|
||||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
|
||||||
if (!worktree || worktree.status !== "pending") return true
|
|
||||||
|
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync().set("session_status", session.id, { type: "busy" })
|
submissionSync.set("session_status", session.id, { type: "busy" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const cleanup = () => {
|
const key = pendingKey(submissionScope, session.id)
|
||||||
if (sessionDirectory === projectDirectory) {
|
pendingSubmission = { key, scope: submissionScope, sessionID: session.id }
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
pending.set(key, {
|
||||||
}
|
abort: controller,
|
||||||
removeOptimisticMessage()
|
cleanup,
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
scope: submissionScope,
|
||||||
}
|
sessionID: session.id,
|
||||||
|
serverSync: submissionServerSync,
|
||||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
})
|
||||||
|
|
||||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
@@ -603,31 +599,131 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const result = await Promise.race([
|
const result = await Promise.race([
|
||||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
WorktreeState.wait(submissionScope, sessionDirectory),
|
||||||
abortWait,
|
abortWait,
|
||||||
timeout,
|
timeout,
|
||||||
]).finally(() => {
|
]).finally(() => {
|
||||||
|
pending.delete(key)
|
||||||
|
if (pendingSubmission?.key === key) pendingSubmission = undefined
|
||||||
if (timer.id === undefined) return
|
if (timer.id === undefined) return
|
||||||
clearTimeout(timer.id)
|
clearTimeout(timer.id)
|
||||||
})
|
})
|
||||||
pending.delete(pendingKey(session.id))
|
|
||||||
if (controller.signal.aborted) return false
|
if (controller.signal.aborted) return false
|
||||||
if (result.status === "failed") throw new Error(result.message)
|
if (result.status === "failed") {
|
||||||
|
WorkspaceOperation.fail(submissionScope, session.id)
|
||||||
|
throw new Error(result.message)
|
||||||
|
}
|
||||||
|
WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!draftID || search.draftId === draftID) onSubmit?.()
|
||||||
|
|
||||||
|
if (mode === "shell") {
|
||||||
|
clearInput()
|
||||||
|
const eventID = Event.ID.create()
|
||||||
|
startWorkspaceOperation(eventID)
|
||||||
|
void waitForWorktree(() => {
|
||||||
|
restoreInput()
|
||||||
|
})
|
||||||
|
.then((ready) => {
|
||||||
|
if (!ready) return
|
||||||
|
return api.shell({
|
||||||
|
sessionID: session.id,
|
||||||
|
id: eventID,
|
||||||
|
command: text,
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
restoreInput()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.startsWith("/")) {
|
||||||
|
const [cmdName, ...args] = text.split(" ")
|
||||||
|
const commandName = cmdName.slice(1)
|
||||||
|
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||||
|
if (customCommand) {
|
||||||
|
clearInput()
|
||||||
|
const messageID = Identifier.ascending("message")
|
||||||
|
startWorkspaceOperation(messageID)
|
||||||
|
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||||
|
void waitForWorktree(() => {
|
||||||
|
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||||
|
restoreInput()
|
||||||
|
})
|
||||||
|
.then(async (ready) => {
|
||||||
|
if (!ready) return
|
||||||
|
return api.command({
|
||||||
|
sessionID: session.id,
|
||||||
|
id: messageID,
|
||||||
|
command: commandName,
|
||||||
|
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) => {
|
||||||
|
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
|
})
|
||||||
|
restoreInput()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||||
|
const messageID = Identifier.ascending("message")
|
||||||
|
startWorkspaceOperation(messageID)
|
||||||
|
|
||||||
|
const removeOptimisticMessage = () => {
|
||||||
|
submissionSync.session.optimistic.remove({
|
||||||
|
directory: sessionDirectory,
|
||||||
|
sessionID: session.id,
|
||||||
|
messageID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||||
|
clearInput()
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (sessionDirectory === projectDirectory) {
|
||||||
|
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||||
|
}
|
||||||
|
removeOptimisticMessage()
|
||||||
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
|
}
|
||||||
|
|
||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
api: sdk().api.session,
|
api,
|
||||||
sync: sync(),
|
scope: submissionScope,
|
||||||
serverSync: serverSync(),
|
sync: submissionSync,
|
||||||
|
serverSync: submissionServerSync,
|
||||||
draft,
|
draft,
|
||||||
messageID,
|
messageID,
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
before: waitForWorktree,
|
before: () => waitForWorktree(cleanup),
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
pending.delete(pendingKey(session.id))
|
pending.delete(pendingKey(submissionScope, session.id))
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
@@ -636,6 +732,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
removeOptimisticMessage()
|
removeOptimisticMessage()
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
submitting.delete(submissionKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
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"
|
||||||
|
|
||||||
@@ -11,25 +10,42 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
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("")
|
||||||
|
let searchInput: HTMLInputElement | undefined
|
||||||
|
let focusSearch = false
|
||||||
|
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
|
||||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
const selected = () => (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,44 +57,163 @@ 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>
|
||||||
|
<TooltipV2
|
||||||
|
placement="top"
|
||||||
|
delay="standard"
|
||||||
|
value={
|
||||||
|
props.onboarding ? (
|
||||||
|
<div class="flex flex-col gap-1 text-left">
|
||||||
|
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||||
|
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||||
|
<span>{language.t("workspace.onboarding.title")}</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t("workspace.onboarding.description")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
language.t("session.new.workspace.trigger.tooltip")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||||
|
class="min-w-0"
|
||||||
|
>
|
||||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||||
<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">
|
<MenuV2.Trigger
|
||||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
<span class="min-w-0 truncate">{label()}</span>
|
<span class="min-w-0 truncate">{label()}</span>
|
||||||
|
<Show when={props.onboarding}>
|
||||||
|
<span
|
||||||
|
data-slot="workspace-onboarding-dot"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
</MenuV2.Trigger>
|
</MenuV2.Trigger>
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content class="w-[180px]">
|
<MenuV2.Content class="w-[200px]">
|
||||||
<MenuV2.Group>
|
<MenuV2.Group>
|
||||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||||
<MenuV2.Item onSelect={() => select("main")}>
|
<MenuV2.Item onSelect={() => select("main")}>
|
||||||
<IconV2 name="monitor" />
|
<Icon name="monitor" />
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
<TooltipV2
|
||||||
|
placement="right"
|
||||||
|
delay="standard"
|
||||||
|
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"}>
|
<Show when={selected() === "main"}>
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
</Show>
|
</Show>
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
<MenuV2.Item onSelect={() => select("create")}>
|
<MenuV2.Item onSelect={() => select("create")}>
|
||||||
<IconV2 name="workspace-new" />
|
<Icon name="workspace-new" />
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
<TooltipV2
|
||||||
|
placement="right"
|
||||||
|
delay="standard"
|
||||||
|
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"}>
|
<Show when={selected() === "create"}>
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
</Show>
|
</Show>
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</MenuV2.Group>
|
</MenuV2.Group>
|
||||||
<Show when={props.workspaces.length > 0}>
|
<Show
|
||||||
<MenuV2.Separator />
|
when={props.workspaces.length > 0}
|
||||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
fallback={
|
||||||
<MenuV2.SubTrigger>
|
<>
|
||||||
<IconV2 name="workspace" />
|
<MenuV2.Separator class="h-[0.5px]" />
|
||||||
{language.t("session.new.workspace.existing")}
|
<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.SubTrigger>
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.SubContent class="max-w-[200px]">
|
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||||
<For each={props.workspaces}>
|
<Show when={props.workspaces.length >= 10}>
|
||||||
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-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) => (
|
{(workspace) => (
|
||||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||||
<IconV2 name="workspace-isolated" />
|
<Icon name="workspace-isolated" />
|
||||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||||
<Show when={selected() === workspace}>
|
<Show when={selected() === workspace}>
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
@@ -86,6 +221,10 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
)}
|
)}
|
||||||
</For>
|
</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.SubContent>
|
||||||
</MenuV2.Portal>
|
</MenuV2.Portal>
|
||||||
</MenuV2.Sub>
|
</MenuV2.Sub>
|
||||||
@@ -93,35 +232,45 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
</MenuV2.Content>
|
</MenuV2.Content>
|
||||||
</MenuV2.Portal>
|
</MenuV2.Portal>
|
||||||
</MenuV2>
|
</MenuV2>
|
||||||
<PromptGitStatus branch={props.branch} />
|
</TooltipV2>
|
||||||
|
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ml-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) => (
|
||||||
<>
|
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
placement="top"
|
placement="top"
|
||||||
value={value()}
|
value={value()}
|
||||||
class="min-w-0 max-w-[220px]"
|
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
>
|
>
|
||||||
<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]">
|
<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="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
<Icon
|
||||||
|
name={icon()}
|
||||||
|
size="small"
|
||||||
|
class="shrink-0 text-v2-icon-icon-muted"
|
||||||
|
/>
|
||||||
<span class="min-w-0 truncate">{value()}</span>
|
<span class="min-w-0 truncate">{value()}</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,11 +78,10 @@ export const ServerRowMenuView: Component<{
|
|||||||
>
|
>
|
||||||
{props.labels.edit}
|
{props.labels.edit}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
<Show when={props.canDefault && !props.isDefault}>
|
<Show when={props.canDefault}>
|
||||||
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
|
<MenuV2.Item disabled={props.isDefault} onSelect={props.onSetDefault}>
|
||||||
</Show>
|
{props.labels.default}
|
||||||
<Show when={props.canDefault && props.isDefault}>
|
</MenuV2.Item>
|
||||||
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
|
||||||
</Show>
|
</Show>
|
||||||
<MenuV2.Separator />
|
<MenuV2.Separator />
|
||||||
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||||
|
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 { Worktree } from "@/utils/worktree"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { workspaceDirectories } from "@/utils/workspace"
|
||||||
|
import {
|
||||||
|
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||||
|
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
workspaceRequestWithTimeout,
|
||||||
|
} from "@/utils/workspace-request"
|
||||||
|
|
||||||
|
export function SessionWorkspaceMenu(props: {
|
||||||
|
eligible?: boolean
|
||||||
|
sessionID: string
|
||||||
|
project: Project
|
||||||
|
directory: string
|
||||||
|
messageID?: 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 operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
|
||||||
|
const blocked = () =>
|
||||||
|
props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID)
|
||||||
|
const workspaces = () =>
|
||||||
|
workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
|
||||||
|
|
||||||
|
const fail = (scope: ServerScope, sessionID: string, message: string) => {
|
||||||
|
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
|
||||||
|
WorkspaceOperation.fail(scope, sessionID)
|
||||||
|
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
|
||||||
|
}
|
||||||
|
const move = async (selection: "create" | string) => {
|
||||||
|
if (store.selected || blocked()) return
|
||||||
|
const sdk = serverSDK()
|
||||||
|
const sync = serverSync()
|
||||||
|
const scope = sdk.scope
|
||||||
|
const sessionID = props.sessionID
|
||||||
|
const messageID = props.messageID
|
||||||
|
const root = props.project.worktree
|
||||||
|
const source = props.directory
|
||||||
|
setStore("selected", selection)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const destination =
|
||||||
|
selection === "create"
|
||||||
|
? await createWorkspace(root, sessionID, messageID, sdk, (message) => fail(scope, sessionID, message), {
|
||||||
|
createFailed: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
stillPreparing: language.t("workspace.error.stillPreparing"),
|
||||||
|
})
|
||||||
|
: selection
|
||||||
|
if (!destination) return
|
||||||
|
|
||||||
|
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
|
||||||
|
if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed"))
|
||||||
|
await workspaceRequestWithTimeout(
|
||||||
|
(signal) =>
|
||||||
|
sdk.client.experimental.controlPlane.moveSession(
|
||||||
|
{
|
||||||
|
sessionID,
|
||||||
|
destination: { directory: destination },
|
||||||
|
moveChanges: true,
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
language.t("workspace.move.failed"),
|
||||||
|
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
const session = await workspaceRequestWithTimeout(
|
||||||
|
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
|
||||||
|
language.t("workspace.move.failed"),
|
||||||
|
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
if (!session || pathKey(session.directory) !== pathKey(destination))
|
||||||
|
throw new Error(language.t("workspace.move.failed"))
|
||||||
|
WorkspaceOperation.complete(scope, sessionID, destination)
|
||||||
|
sync.reindexSession(sessionID, source)
|
||||||
|
} catch (error) {
|
||||||
|
fail(scope, sessionID, 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={props.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(
|
||||||
|
root: string,
|
||||||
|
sessionID: string,
|
||||||
|
messageID: string | undefined,
|
||||||
|
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
|
||||||
|
fail: (message: string) => void,
|
||||||
|
messages: { createFailed: string; stillPreparing: string },
|
||||||
|
) {
|
||||||
|
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", root, messageID)
|
||||||
|
const created = await workspaceRequestWithTimeout(
|
||||||
|
(signal) => serverSDK.client.worktree.create({ directory: root }, { signal }),
|
||||||
|
messages.createFailed,
|
||||||
|
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
.then((result) => result.data)
|
||||||
|
.catch((error) => {
|
||||||
|
fail(error instanceof Error ? error.message : messages.createFailed)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!created?.directory) return
|
||||||
|
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID)
|
||||||
|
Worktree.pending(serverSDK.scope, created.directory)
|
||||||
|
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
|
const timeout = new Promise<ReturnType<typeof Worktree.get>>((resolve) => {
|
||||||
|
timer.id = setTimeout(
|
||||||
|
() => resolve({ status: "failed", message: messages.stillPreparing }),
|
||||||
|
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const ready = await Promise.race([Worktree.wait(serverSDK.scope, created.directory), timeout]).finally(() => {
|
||||||
|
if (timer.id) clearTimeout(timer.id)
|
||||||
|
})
|
||||||
|
if (!ready || ready.status === "failed") {
|
||||||
|
fail(ready?.message ?? messages.createFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return created.directory
|
||||||
|
}
|
||||||
@@ -5,12 +5,15 @@ import { checksum } from "@opencode-ai/core/util/encode"
|
|||||||
import { findLast } from "@opencode-ai/core/util/array"
|
import { findLast } from "@opencode-ai/core/util/array"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
import { File } from "@opencode-ai/session-ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
@@ -220,6 +223,31 @@ export function SessionContextTab() {
|
|||||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||||
] satisfies { label: string; value: () => JSX.Element }[]
|
] satisfies { label: string; value: () => JSX.Element }[]
|
||||||
|
|
||||||
|
const exportSession = async () => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
try {
|
||||||
|
const data = await fetchSessionExport({
|
||||||
|
sessionID,
|
||||||
|
client: sdk().client,
|
||||||
|
})
|
||||||
|
const filename = sessionExportFilename(data.info)
|
||||||
|
downloadSessionExport(filename, data)
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("toast.session.export.success.title"),
|
||||||
|
description: language.t("toast.session.export.success.description", { filename }),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("toast.session.export.failed.title"),
|
||||||
|
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
@@ -328,7 +356,18 @@ export function SessionContextTab() {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
|
||||||
|
onClick={exportSession}
|
||||||
|
>
|
||||||
|
<Icon name="download" size="small" />
|
||||||
|
<span>{language.t("context.export.session")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Accordion multiple>
|
<Accordion multiple>
|
||||||
<For each={messages()}>
|
<For each={messages()}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { type Component, For, Show } from "solid-js"
|
import { type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||||
@@ -43,6 +44,7 @@ export const SettingsModels: Component = () => {
|
|||||||
const SettingsModelsContent: Component = () => {
|
const SettingsModelsContent: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
useServerSync()().loadProviders()
|
||||||
|
|
||||||
const list = useFilteredList<ModelItem>({
|
const list = useFilteredList<ModelItem>({
|
||||||
items: (_filter) => models.list(),
|
items: (_filter) => models.list(),
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Component, Show, createMemo, createResource } from "solid-js"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { ExternalLink } from "../external-link"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
import {
|
||||||
|
createAppearanceSettingsController,
|
||||||
|
type AppearanceSettingsController,
|
||||||
|
} from "./general-controllers"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||||
|
const fontSettings = {
|
||||||
|
ui: {
|
||||||
|
action: "settings-ui-font",
|
||||||
|
title: "settings.general.row.uiFont.title",
|
||||||
|
description: "settings.general.row.uiFont.description",
|
||||||
|
font: "ui",
|
||||||
|
input: "setUI",
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
action: "settings-code-font",
|
||||||
|
title: "settings.general.row.font.title",
|
||||||
|
description: "settings.general.row.font.description",
|
||||||
|
font: "code",
|
||||||
|
input: "setCode",
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
action: "settings-terminal-font",
|
||||||
|
title: "settings.general.row.terminalFont.title",
|
||||||
|
description: "settings.general.row.terminalFont.description",
|
||||||
|
font: "terminal",
|
||||||
|
input: "setTerminal",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const FontSetting: Component<{
|
||||||
|
kind: "ui" | "code" | "terminal"
|
||||||
|
fonts: AppearanceSettingsController["fonts"]
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const config = () => fontSettings[props.kind]
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||||
|
<div class="w-full sm:w-[220px]">
|
||||||
|
<TextInputV2
|
||||||
|
data-action={config().action}
|
||||||
|
type="text"
|
||||||
|
appearance="base"
|
||||||
|
value={props.fonts[config().font]().value}
|
||||||
|
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
||||||
|
placeholder={props.fonts[config().font]().placeholder}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={language.t(config().title)}
|
||||||
|
style={{ "font-family": props.fonts[config().font]().family }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsAppearanceV2: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const appearance = createAppearanceSettingsController()
|
||||||
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
|
() => desktop() && "getPinchZoomEnabled" in platform,
|
||||||
|
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||||
|
{ initialValue: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPinchZoomChange = (checked: boolean) => {
|
||||||
|
setPinchZoom(checked)
|
||||||
|
const update = platform.setPinchZoomEnabled?.(checked)
|
||||||
|
if (!update) return
|
||||||
|
void update.catch(() => setPinchZoom(!checked))
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreDefaults = () => {
|
||||||
|
appearance.scheme.select("system")
|
||||||
|
appearance.fonts.setUI("")
|
||||||
|
appearance.fonts.setCode("")
|
||||||
|
appearance.fonts.setTerminal("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.appearance")}</h2>
|
||||||
|
<ButtonV2 size="small" variant="ghost-muted" onClick={restoreDefaults}>
|
||||||
|
{language.t("common.reset")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.colorScheme.title")}
|
||||||
|
description={language.t("settings.general.row.colorScheme.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-color-scheme"
|
||||||
|
options={schemeOptions}
|
||||||
|
current={schemeOptions.find((option) => option === appearance.scheme.current())}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
label={(option) => {
|
||||||
|
if (option === "system") return language.t("theme.scheme.system")
|
||||||
|
if (option === "light") return language.t("theme.scheme.light")
|
||||||
|
return language.t("theme.scheme.dark")
|
||||||
|
}}
|
||||||
|
onSelect={(option) => option && appearance.scheme.select(option)}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.theme.title")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("settings.general.row.theme.description")}{" "}
|
||||||
|
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||||
|
{language.t("common.learnMore")}
|
||||||
|
</ExternalLink>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-theme"
|
||||||
|
options={appearance.theme.options()}
|
||||||
|
current={appearance.theme.current()}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => option.name}
|
||||||
|
onSelect={appearance.theme.select}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<FontSetting kind="ui" fonts={appearance.fonts} />
|
||||||
|
<FontSetting kind="code" fonts={appearance.fonts} />
|
||||||
|
<FontSetting kind="terminal" fonts={appearance.fonts} />
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={desktop()}>
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.display")}</h3>
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.pinchZoom.title")}
|
||||||
|
description={language.t("settings.general.row.pinchZoom.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-pinch-zoom">
|
||||||
|
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,15 +5,19 @@ 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 { SettingsGeneralV2 } from "./general"
|
||||||
|
import { SettingsAppearanceV2 } from "./appearance"
|
||||||
import { SettingsKeybinds } from "../settings-keybinds"
|
import { SettingsKeybinds } from "../settings-keybinds"
|
||||||
|
import { SettingsNotificationsV2 } from "./notifications"
|
||||||
import { SettingsProvidersV2 } from "./providers"
|
import { SettingsProvidersV2 } from "./providers"
|
||||||
import { SettingsModelsV2 } from "./models"
|
import { SettingsModelsV2 } from "./models"
|
||||||
import "./settings-v2.css"
|
|
||||||
import { SettingsServersV2 } from "./servers"
|
import { SettingsServersV2 } from "./servers"
|
||||||
|
import { SettingsWorkspacesV2 } from "./workspaces"
|
||||||
|
import { SettingsProjectsV2 } from "./projects"
|
||||||
|
import { SettingsExtensionsV2 } from "./extensions"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
export const DialogSettings: Component<{
|
export const DialogSettings: Component<{
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -22,18 +26,16 @@ export const DialogSettings: Component<{
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const layout = useLayout()
|
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||||
const directory = createMemo(() => {
|
const directory = createMemo(() => {
|
||||||
const route = layout.route()
|
const active = tabs.active()
|
||||||
if (route.type === "dir-new-sesssion") return route.dir
|
if (active?.type === "draft") return active.directory
|
||||||
if (route.type === "draft") {
|
if (active?.type === "session") {
|
||||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
const session = serverSync().session.get(active.sessionId)
|
||||||
return draft?.type === "draft" ? draft.directory : undefined
|
if (session) return session.directory
|
||||||
}
|
}
|
||||||
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
|
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -52,29 +54,42 @@ export const DialogSettings: Component<{
|
|||||||
>
|
>
|
||||||
<TabsV2.List>
|
<TabsV2.List>
|
||||||
<div class="flex flex-col justify-between h-full w-full">
|
<div class="flex flex-col justify-between h-full w-full">
|
||||||
<div class="flex flex-col gap-3 w-full">
|
<div class="flex flex-col gap-4 w-full">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-1 w-full">
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
|
|
||||||
<div class="flex flex-col gap-1.5 w-full">
|
|
||||||
<TabsV2.Trigger value="general">
|
<TabsV2.Trigger value="general">
|
||||||
<Icon name="sliders" />
|
<Icon name="sliders" />
|
||||||
{language.t("settings.tab.general")}
|
{language.t("settings.tab.preferences")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="appearance">
|
||||||
|
<Icon name="appearance" />
|
||||||
|
{language.t("settings.general.section.appearance")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="notifications">
|
||||||
|
<Icon name="notifications" />
|
||||||
|
{language.t("settings.general.section.notifications")}
|
||||||
</TabsV2.Trigger>
|
</TabsV2.Trigger>
|
||||||
<TabsV2.Trigger value="shortcuts">
|
<TabsV2.Trigger value="shortcuts">
|
||||||
<Icon name="keyboard" />
|
<Icon name="keyboard" />
|
||||||
{language.t("settings.tab.shortcuts")}
|
{language.t("settings.tab.shortcuts")}
|
||||||
</TabsV2.Trigger>
|
</TabsV2.Trigger>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1 w-full">
|
||||||
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
<TabsV2.Trigger value="workspaces">
|
||||||
<div class="flex flex-col gap-1.5 w-full">
|
<Icon name="workspace-isolated" />
|
||||||
|
{language.t("settings.tab.workspaces")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
<TabsV2.Trigger value="servers">
|
<TabsV2.Trigger value="servers">
|
||||||
<Icon name="server" />
|
<Icon name="server" />
|
||||||
{language.t("status.popover.tab.servers")}
|
{language.t("status.popover.tab.servers")}
|
||||||
</TabsV2.Trigger>
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="projects">
|
||||||
|
<Icon name="folder" />
|
||||||
|
{language.t("settings.tab.projects")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 w-full">
|
||||||
<TabsV2.Trigger value="providers">
|
<TabsV2.Trigger value="providers">
|
||||||
<Icon name="providers" />
|
<Icon name="providers" />
|
||||||
{language.t("settings.providers.title")}
|
{language.t("settings.providers.title")}
|
||||||
@@ -83,31 +98,50 @@ export const DialogSettings: Component<{
|
|||||||
<Icon name="models" />
|
<Icon name="models" />
|
||||||
{language.t("settings.models.title")}
|
{language.t("settings.models.title")}
|
||||||
</TabsV2.Trigger>
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="extensions">
|
||||||
|
<Icon name="extensions" />
|
||||||
|
{language.t("settings.tab.extensions")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-v2-nav-footer">
|
<div class="settings-v2-nav-footer">
|
||||||
<span>{language.t("app.name.desktop")}</span>
|
<span>{language.t("app.name.desktop")}</span>
|
||||||
<span>v{platform.version}</span>
|
<span>v{platform.version}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsV2.List>
|
</TabsV2.List>
|
||||||
|
|
||||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||||
<SettingsGeneralV2 sessionID={props.sessionID} />
|
<SettingsGeneralV2 sessionID={props.sessionID} />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="appearance" class="settings-v2-panel">
|
||||||
|
<SettingsAppearanceV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="notifications" class="settings-v2-panel">
|
||||||
|
<SettingsNotificationsV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||||
<SettingsKeybinds v2 />
|
<SettingsKeybinds v2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="workspaces" class="settings-v2-panel">
|
||||||
|
<SettingsWorkspacesV2 activeDirectory={directory()} />
|
||||||
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="servers" class="settings-v2-panel">
|
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||||
<SettingsServersV2 />
|
<SettingsServersV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="projects" class="settings-v2-panel">
|
||||||
|
<SettingsProjectsV2 />
|
||||||
|
</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>
|
||||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||||
<SettingsModelsV2 />
|
<SettingsModelsV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="extensions" class="settings-v2-panel">
|
||||||
|
<SettingsExtensionsV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
</TabsV2>
|
</TabsV2>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { ExternalLink } from "../external-link"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
type ExtensionSubTab = "mcps" | "plugins" | "skills"
|
||||||
|
|
||||||
|
interface McpRowItem {
|
||||||
|
name: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginRowItem {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillRowItem {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsExtensionsV2: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const [selectedServer, setSelectedServer] = createSignal<ServerConnection.Key | "all">(server.key)
|
||||||
|
const [activeSubTab, setActiveSubTab] = createSignal<ExtensionSubTab>("mcps")
|
||||||
|
|
||||||
|
const [mcpOverrides, setMcpOverrides] = createSignal<Record<string, boolean>>({})
|
||||||
|
const mcps = createMemo<McpRowItem[]>(() => {
|
||||||
|
const configMcp = (serverSync().data.config as { mcp?: Record<string, { disabled?: boolean }> })?.mcp ?? {}
|
||||||
|
const configEntries = Object.entries(configMcp).map(([name, conf]) => ({
|
||||||
|
name,
|
||||||
|
status: conf.disabled ? "disabled" : "connected",
|
||||||
|
}))
|
||||||
|
|
||||||
|
const firstProject = server.projects.list()[0]?.worktree
|
||||||
|
const childMcp = firstProject ? serverSync().child(firstProject, { mcp: true })[0].mcp : {}
|
||||||
|
const childEntries = Object.entries(childMcp ?? {}).map(([name, stat]) => ({
|
||||||
|
name,
|
||||||
|
status: stat?.status,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const map = new Map<string, McpRowItem>()
|
||||||
|
for (const item of configEntries) map.set(item.name, item)
|
||||||
|
for (const item of childEntries) {
|
||||||
|
const existing = map.get(item.name)
|
||||||
|
map.set(item.name, { ...existing, ...item })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMcpEnabled = (item: McpRowItem) => {
|
||||||
|
if (mcpOverrides()[item.name] !== undefined) return mcpOverrides()[item.name]
|
||||||
|
return item.status === "connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
||||||
|
setMcpOverrides((prev) => ({ ...prev, [item.name]: checked }))
|
||||||
|
const firstProject = server.projects.list()[0]?.worktree
|
||||||
|
if (firstProject) {
|
||||||
|
void serverSync().mcp.toggle(firstProject, item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = createMemo<PluginRowItem[]>(() => {
|
||||||
|
const raw = serverSync().data.config.plugin ?? []
|
||||||
|
return raw.map((item) => {
|
||||||
|
const name = typeof item === "string" ? item : item[0]
|
||||||
|
return { name }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const skills = createMemo<SkillRowItem[]>(() => {
|
||||||
|
const configSkills = (serverSync().data.config as { skills?: string[] })?.skills ?? []
|
||||||
|
return configSkills.map((name) => ({ name }))
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.extensions")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.mcps.description")}</span>
|
||||||
|
</div>
|
||||||
|
<InlineServerSelect value={selectedServer()} onChange={setSelectedServer} includeAll />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
{/* Sub-Tabs Pill Strip */}
|
||||||
|
<div class="settings-v2-extensions-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeSubTab() === "mcps"}
|
||||||
|
data-selected={activeSubTab() === "mcps" ? "" : undefined}
|
||||||
|
onClick={() => setActiveSubTab("mcps")}
|
||||||
|
>
|
||||||
|
{language.t("status.popover.tab.mcp")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeSubTab() === "plugins"}
|
||||||
|
data-selected={activeSubTab() === "plugins" ? "" : undefined}
|
||||||
|
onClick={() => setActiveSubTab("plugins")}
|
||||||
|
>
|
||||||
|
{language.t("status.popover.tab.plugins")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeSubTab() === "skills"}
|
||||||
|
data-selected={activeSubTab() === "skills" ? "" : undefined}
|
||||||
|
onClick={() => setActiveSubTab("skills")}
|
||||||
|
>
|
||||||
|
{language.t("settings.permissions.tool.skill.title")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MCPs Sub-Tab */}
|
||||||
|
<Show when={activeSubTab() === "mcps"}>
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-13-medium text-v2-text-text-base">
|
||||||
|
{language.t("settings.extensions.availableAll")}
|
||||||
|
</span>
|
||||||
|
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||||
|
<For each={mcps()}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isMcpEnabled(item)}
|
||||||
|
onChange={(checked) => handleMcpToggle(item, checked)}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Plugins Sub-Tab */}
|
||||||
|
<Show when={activeSubTab() === "plugins"}>
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-13-medium text-v2-text-text-base">
|
||||||
|
{language.t("settings.extensions.availableAll")}
|
||||||
|
</span>
|
||||||
|
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||||
|
<For each={plugins()}>
|
||||||
|
{(plugin) => (
|
||||||
|
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Skills Sub-Tab */}
|
||||||
|
<Show when={activeSubTab() === "skills"}>
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-13-medium text-v2-text-text-base">
|
||||||
|
{language.t("settings.extensions.availableAll")}
|
||||||
|
</span>
|
||||||
|
<ExternalLink
|
||||||
|
class="text-13-regular text-v2-text-accent hover:underline"
|
||||||
|
href="https://opencode.ai/docs/skills/"
|
||||||
|
>
|
||||||
|
{language.t("settings.extensions.addSkills")}
|
||||||
|
</ExternalLink>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||||
|
<For each={skills()}>
|
||||||
|
{(skill) => (
|
||||||
|
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,74 +1,25 @@
|
|||||||
import { Component, Show, createMemo, createResource } from "solid-js"
|
import { Component, Show, createMemo } from "solid-js"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
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 { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||||
import {
|
import {
|
||||||
createAppearanceSettingsController,
|
|
||||||
createPermissionScopeController,
|
createPermissionScopeController,
|
||||||
createShellOptions,
|
createShellOptions,
|
||||||
createShellSettingsController,
|
createShellSettingsController,
|
||||||
createSoundSettingsController,
|
|
||||||
soundOptions,
|
|
||||||
type AppearanceSettingsController,
|
|
||||||
type PermissionScopeController,
|
type PermissionScopeController,
|
||||||
type ShellSettingsController,
|
type ShellSettingsController,
|
||||||
type SoundSettingsController,
|
|
||||||
} from "./general-controllers"
|
} from "./general-controllers"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
|
||||||
const fontSettings = {
|
|
||||||
ui: {
|
|
||||||
action: "settings-ui-font",
|
|
||||||
title: "settings.general.row.uiFont.title",
|
|
||||||
description: "settings.general.row.uiFont.description",
|
|
||||||
font: "ui",
|
|
||||||
input: "setUI",
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
action: "settings-code-font",
|
|
||||||
title: "settings.general.row.font.title",
|
|
||||||
description: "settings.general.row.font.description",
|
|
||||||
font: "code",
|
|
||||||
input: "setCode",
|
|
||||||
},
|
|
||||||
terminal: {
|
|
||||||
action: "settings-terminal-font",
|
|
||||||
title: "settings.general.row.terminalFont.title",
|
|
||||||
description: "settings.general.row.terminalFont.description",
|
|
||||||
font: "terminal",
|
|
||||||
input: "setTerminal",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
const soundSettings = {
|
|
||||||
agent: {
|
|
||||||
action: "settings-sounds-agent",
|
|
||||||
title: "settings.general.sounds.agent.title",
|
|
||||||
description: "settings.general.sounds.agent.description",
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
action: "settings-sounds-permissions",
|
|
||||||
title: "settings.general.sounds.permissions.title",
|
|
||||||
description: "settings.general.sounds.permissions.description",
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
action: "settings-sounds-errors",
|
|
||||||
title: "settings.general.sounds.errors.title",
|
|
||||||
description: "settings.general.sounds.errors.description",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
|
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return (
|
return (
|
||||||
@@ -87,6 +38,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(() =>
|
||||||
@@ -119,130 +98,6 @@ const ShellSetting: Component<{ controller: ShellSettingsController }> = (props)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
return (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.colorScheme.title")}
|
|
||||||
description={language.t("settings.general.row.colorScheme.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-color-scheme"
|
|
||||||
options={schemeOptions}
|
|
||||||
current={schemeOptions.find((option) => option === props.controller.scheme.current())}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
label={(option) => {
|
|
||||||
if (option === "system") return language.t("theme.scheme.system")
|
|
||||||
if (option === "light") return language.t("theme.scheme.light")
|
|
||||||
return language.t("theme.scheme.dark")
|
|
||||||
}}
|
|
||||||
onSelect={(option) => option && props.controller.scheme.select(option)}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.theme.title")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
|
||||||
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
|
||||||
{language.t("common.learnMore")}
|
|
||||||
</ExternalLink>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-theme"
|
|
||||||
options={props.controller.theme.options()}
|
|
||||||
current={props.controller.theme.current()}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
value={(option) => option.id}
|
|
||||||
label={(option) => option.name}
|
|
||||||
onSelect={props.controller.theme.select}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<FontSetting kind="ui" fonts={props.controller.fonts} />
|
|
||||||
<FontSetting kind="code" fonts={props.controller.fonts} />
|
|
||||||
<FontSetting kind="terminal" fonts={props.controller.fonts} />
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FontSetting: Component<{
|
|
||||||
kind: "ui" | "code" | "terminal"
|
|
||||||
fonts: AppearanceSettingsController["fonts"]
|
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const config = () => fontSettings[props.kind]
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextInputV2
|
|
||||||
data-action={config().action}
|
|
||||||
type="text"
|
|
||||||
appearance="base"
|
|
||||||
value={props.fonts[config().font]().value}
|
|
||||||
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
|
||||||
placeholder={props.fonts[config().font]().placeholder}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
aria-label={language.t(config().title)}
|
|
||||||
style={{ "font-family": props.fonts[config().font]().family }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
return (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
|
||||||
<SettingsListV2>
|
|
||||||
<SoundSetting kind="agent" channel={props.controller.agent} />
|
|
||||||
<SoundSetting kind="permissions" channel={props.controller.permissions} />
|
|
||||||
<SoundSetting kind="errors" channel={props.controller.errors} />
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SoundSetting: Component<{
|
|
||||||
kind: "agent" | "permissions" | "errors"
|
|
||||||
channel: SoundSettingsController["agent"]
|
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const config = () => soundSettings[props.kind]
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action={config().action}
|
|
||||||
options={soundOptions}
|
|
||||||
current={props.channel.current()}
|
|
||||||
value={(option) => option.id}
|
|
||||||
label={(option) => language.t(option.label)}
|
|
||||||
onHighlight={props.channel.highlight}
|
|
||||||
onSelect={props.channel.select}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LanguageSetting = () => {
|
const LanguageSetting = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const options = createMemo(() =>
|
const options = createMemo(() =>
|
||||||
@@ -282,21 +137,17 @@ export const SettingsGeneralV2: Component<{
|
|||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||||
const shell = createShellSettingsController()
|
const shell = createShellSettingsController()
|
||||||
const appearance = createAppearanceSettingsController()
|
|
||||||
const sounds = createSoundSettingsController()
|
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
const restoreDefaults = () => {
|
||||||
() => desktop() && "getPinchZoomEnabled" in platform,
|
settings.general.setAutoSave(true)
|
||||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
settings.general.setShowReasoningSummaries(true)
|
||||||
{ initialValue: false },
|
settings.general.setShellToolPartsExpanded(false)
|
||||||
)
|
settings.general.setEditToolPartsExpanded(false)
|
||||||
|
settings.general.setShowFileTree(false)
|
||||||
const onPinchZoomChange = (checked: boolean) => {
|
settings.general.setShowSearch(false)
|
||||||
setPinchZoom(checked)
|
settings.general.setShowStatus(false)
|
||||||
const update = platform.setPinchZoomEnabled?.(checked)
|
settings.general.setShowCustomAgents(false)
|
||||||
if (!update) return
|
|
||||||
void update.catch(() => setPinchZoom(!checked))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const InterfaceSection = () => (
|
const InterfaceSection = () => (
|
||||||
@@ -329,6 +180,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<LanguageSetting />
|
<LanguageSetting />
|
||||||
|
|
||||||
|
<WorkspaceDestinationSetting />
|
||||||
<PermissionScopeSetting controller={permissionScope} />
|
<PermissionScopeSetting controller={permissionScope} />
|
||||||
|
|
||||||
<ShellSetting controller={shell} />
|
<ShellSetting controller={shell} />
|
||||||
@@ -391,18 +243,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")}
|
||||||
@@ -438,50 +278,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const NotificationsSection = () => (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
|
||||||
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.agent.title")}
|
|
||||||
description={language.t("settings.general.notifications.agent.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-agent">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.agent()}
|
|
||||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.permissions.title")}
|
|
||||||
description={language.t("settings.general.notifications.permissions.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-permissions">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.permissions()}
|
|
||||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.errors.title")}
|
|
||||||
description={language.t("settings.general.notifications.errors.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-errors">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.errors()}
|
|
||||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -515,30 +312,15 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// We can probably remove this, right?
|
|
||||||
const DisplaySection = () => (
|
|
||||||
<Show when={desktop()}>
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.display")}</h3>
|
|
||||||
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.pinchZoom.title")}
|
|
||||||
description={language.t("settings.general.row.pinchZoom.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-pinch-zoom">
|
|
||||||
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header">
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
||||||
|
<ButtonV2 size="small" variant="ghost-muted" onClick={restoreDefaults}>
|
||||||
|
{language.t("common.reset")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
@@ -552,18 +334,10 @@ export const SettingsGeneralV2: Component<{
|
|||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection controller={appearance} />
|
|
||||||
|
|
||||||
<NotificationsSection />
|
|
||||||
|
|
||||||
<SoundsSection controller={sounds} />
|
|
||||||
|
|
||||||
<Show when={desktop()}>
|
<Show when={desktop()}>
|
||||||
<UpdatesSection />
|
<UpdatesSection />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<DisplaySection />
|
|
||||||
|
|
||||||
<AdvancedSection />
|
<AdvancedSection />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
export { DialogSettings } from "./dialog-settings-v2"
|
export { DialogSettings } from "./dialog-settings-v2"
|
||||||
|
export { SettingsGeneralV2 } from "./general"
|
||||||
|
export { SettingsAppearanceV2 } from "./appearance"
|
||||||
|
export { SettingsNotificationsV2 } from "./notifications"
|
||||||
|
export { SettingsProvidersV2 } from "./providers"
|
||||||
|
export { SettingsModelsV2 } from "./models"
|
||||||
|
export { SettingsServersV2 } from "./servers"
|
||||||
|
export { SettingsProjectsV2 } from "./projects"
|
||||||
|
export { SettingsExtensionsV2 } from "./extensions"
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
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"
|
||||||
import { type Component, For, Show } from "solid-js"
|
import { type Component, For, Show, createSignal } 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 { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -22,7 +25,10 @@ const PROVIDER_ICON_SIZE = 16
|
|||||||
export const SettingsModelsV2: Component = () => {
|
export const SettingsModelsV2: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
const server = useServer()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
useServerSync()().loadProviders()
|
||||||
|
const [selectedServer, setSelectedServer] = createSignal<ServerConnection.Key | "all">(server.key)
|
||||||
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> }),
|
||||||
@@ -53,7 +59,14 @@ export const SettingsModelsV2: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||||
|
<InlineServerSelect
|
||||||
|
value={selectedServer()}
|
||||||
|
onChange={setSelectedServer}
|
||||||
|
includeAll
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="settings-v2-tab-search">
|
<div class="settings-v2-tab-search">
|
||||||
<TextInputV2
|
<TextInputV2
|
||||||
type="search"
|
type="search"
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Component } from "solid-js"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
import {
|
||||||
|
createSoundSettingsController,
|
||||||
|
soundOptions,
|
||||||
|
type SoundSettingsController,
|
||||||
|
} from "./general-controllers"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
const soundSettings = {
|
||||||
|
agent: {
|
||||||
|
action: "settings-sounds-agent",
|
||||||
|
title: "settings.general.sounds.agent.title",
|
||||||
|
description: "settings.general.sounds.agent.description",
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
action: "settings-sounds-permissions",
|
||||||
|
title: "settings.general.sounds.permissions.title",
|
||||||
|
description: "settings.general.sounds.permissions.description",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
action: "settings-sounds-errors",
|
||||||
|
title: "settings.general.sounds.errors.title",
|
||||||
|
description: "settings.general.sounds.errors.description",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const SoundSetting: Component<{
|
||||||
|
kind: "agent" | "permissions" | "errors"
|
||||||
|
channel: SoundSettingsController["agent"]
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const config = () => soundSettings[props.kind]
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action={config().action}
|
||||||
|
options={soundOptions}
|
||||||
|
current={props.channel.current()}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => language.t(option.label)}
|
||||||
|
onHighlight={props.channel.highlight}
|
||||||
|
onSelect={props.channel.select}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsNotificationsV2: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const sounds = createSoundSettingsController()
|
||||||
|
|
||||||
|
const restoreDefaults = () => {
|
||||||
|
settings.notifications.setAgent(true)
|
||||||
|
settings.notifications.setPermissions(true)
|
||||||
|
settings.notifications.setErrors(true)
|
||||||
|
settings.sounds.setAgent("bip-bop-01")
|
||||||
|
settings.sounds.setPermissions("alert-01")
|
||||||
|
settings.sounds.setErrors("nope-01")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.notifications")}</h2>
|
||||||
|
<ButtonV2 size="small" variant="ghost-muted" onClick={restoreDefaults}>
|
||||||
|
{language.t("common.reset")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.notifications.agent.title")}
|
||||||
|
description={language.t("settings.general.notifications.agent.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-agent">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.agent()}
|
||||||
|
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.notifications.permissions.title")}
|
||||||
|
description={language.t("settings.general.notifications.permissions.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-permissions">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.permissions()}
|
||||||
|
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.notifications.errors.title")}
|
||||||
|
description={language.t("settings.general.notifications.errors.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-errors">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.errors()}
|
||||||
|
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||||
|
<SettingsListV2>
|
||||||
|
<SoundSetting kind="agent" channel={sounds.agent} />
|
||||||
|
<SoundSetting kind="permissions" channel={sounds.permissions} />
|
||||||
|
<SoundSetting kind="errors" channel={sounds.errors} />
|
||||||
|
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Show, createMemo, type Component } from "solid-js"
|
||||||
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||||
|
|
||||||
|
export interface ServerSelectOption {
|
||||||
|
key: ServerConnection.Key | "all"
|
||||||
|
label: string
|
||||||
|
isDefault?: boolean
|
||||||
|
isAll?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InlineServerSelect: Component<{
|
||||||
|
value: ServerConnection.Key | "all"
|
||||||
|
onChange: (key: ServerConnection.Key | "all") => void
|
||||||
|
includeAll?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const global = useGlobal()
|
||||||
|
const server = useServer()
|
||||||
|
|
||||||
|
const hasMultipleServers = createMemo(() => global.servers.list().length > 1)
|
||||||
|
|
||||||
|
const options = createMemo<ServerSelectOption[]>(() => {
|
||||||
|
const list: ServerSelectOption[] = []
|
||||||
|
if (props.includeAll) {
|
||||||
|
list.push({
|
||||||
|
key: "all",
|
||||||
|
label: language.t("settings.server.all"),
|
||||||
|
isAll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = global.servers.list()
|
||||||
|
for (const item of servers) {
|
||||||
|
const key = ServerConnection.key(item)
|
||||||
|
const isDefault = key === server.key
|
||||||
|
list.push({
|
||||||
|
key,
|
||||||
|
label: serverName(item) || key,
|
||||||
|
isDefault,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentOption = createMemo(() => {
|
||||||
|
return options().find((opt) => opt.key === props.value) ?? options()[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={hasMultipleServers()}>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-server-select"
|
||||||
|
options={options()}
|
||||||
|
current={currentOption()}
|
||||||
|
value={(opt) => opt.key}
|
||||||
|
label={(opt) => opt.label}
|
||||||
|
disabled={props.disabled}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
onSelect={(opt) => {
|
||||||
|
if (opt) props.onChange(opt.key)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLayout, getProjectAvatarVariant } from "@/context/layout"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
|
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
export const SettingsProjectsV2: Component = () => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
|
const global = useGlobal()
|
||||||
|
const layout = useLayout()
|
||||||
|
const [selectedServer, setSelectedServer] = createSignal<ServerConnection.Key | "all">(server.key)
|
||||||
|
|
||||||
|
const projects = createMemo(() => {
|
||||||
|
return layout.projects.list()
|
||||||
|
})
|
||||||
|
|
||||||
|
const openProjectSettings = (project: ReturnType<typeof layout.projects.list>[number]) => {
|
||||||
|
const currentServer = server.current ?? global.servers.list()[0]
|
||||||
|
if (!currentServer) return
|
||||||
|
dialog.push(() => <DialogEditProjectV2 project={project} server={currentServer} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.projects.title")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
|
||||||
|
</div>
|
||||||
|
<InlineServerSelect
|
||||||
|
value={selectedServer()}
|
||||||
|
onChange={setSelectedServer}
|
||||||
|
includeAll
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<div class="flex flex-col gap-2 w-full">
|
||||||
|
<Show
|
||||||
|
when={projects().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||||
|
{language.t("settings.projects.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={projects()}>
|
||||||
|
{(project) => {
|
||||||
|
const name = () => project.name || project.worktree.split(/[/\\]/).pop() || project.worktree
|
||||||
|
const color = () => getProjectAvatarVariant(project.icon?.color)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
|
||||||
|
onClick={() => openProjectSettings(project)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={name()}
|
||||||
|
variant={color()}
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
icon={<IconV2 name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
|
||||||
|
onClick={(e: MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openProjectSettings(project)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
import { createMemo, createSignal, type Accessor, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
@@ -41,6 +43,9 @@ export const SettingsProvidersV2: Component<{
|
|||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(props.directory)
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
|
||||||
|
const server = useServer()
|
||||||
|
const [selectedServer, setSelectedServer] = createSignal<ServerConnection.Key | "all">(server.key)
|
||||||
|
|
||||||
const connect = (provider?: string) => {
|
const connect = (provider?: string) => {
|
||||||
providerConnect.select(provider)
|
providerConnect.select(provider)
|
||||||
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||||
@@ -145,7 +150,14 @@ export const SettingsProvidersV2: Component<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header">
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||||
|
<InlineServerSelect
|
||||||
|
value={selectedServer()}
|
||||||
|
onChange={setSelectedServer}
|
||||||
|
includeAll
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body settings-v2-providers">
|
<div class="settings-v2-tab-body settings-v2-providers">
|
||||||
|
|||||||
@@ -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, useServer } from "@/context/server"
|
||||||
import { useServerManagementController } from "../dialog-select-server"
|
import { useServerManagementController } from "../dialog-select-server"
|
||||||
import { DialogServerV2 } from "./dialog-server-v2"
|
import { DialogServerV2 } from "./dialog-server-v2"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
@@ -19,6 +19,7 @@ import "./settings-v2.css"
|
|||||||
export const SettingsServersV2: Component = () => {
|
export const SettingsServersV2: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
const controller = useServerManagementController()
|
const controller = useServerManagementController()
|
||||||
const [store, setStore] = createStore({ filter: "" })
|
const [store, setStore] = createStore({ filter: "" })
|
||||||
const wslServers = useFilteredWslServers(() => store.filter)
|
const wslServers = useFilteredWslServers(() => store.filter)
|
||||||
@@ -27,15 +28,38 @@ export const SettingsServersV2: Component = () => {
|
|||||||
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isLocal = (item: ServerConnection.Any) => ServerConnection.local(item)
|
||||||
|
|
||||||
|
const isDefaultServer = (key: ServerConnection.Key, item: ServerConnection.Any) => {
|
||||||
|
const def = controller.defaultKey()
|
||||||
|
if (!def) return isLocal(item)
|
||||||
|
return def === key
|
||||||
|
}
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
||||||
const query = store.filter.trim()
|
const query = store.filter.trim()
|
||||||
if (!query) return items
|
if (!query) {
|
||||||
|
return items.slice().sort((a, b) => {
|
||||||
|
const aLocal = isLocal(a)
|
||||||
|
const bLocal = isLocal(b)
|
||||||
|
if (aLocal && !bLocal) return -1
|
||||||
|
if (!aLocal && bLocal) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
return fuzzysort
|
return fuzzysort
|
||||||
.go(query, items, {
|
.go(query, items, {
|
||||||
keys: [(item) => serverName(item), (item) => item.http.url],
|
keys: [(item) => serverName(item), (item) => item.http.url],
|
||||||
})
|
})
|
||||||
.map((result) => result.obj)
|
.map((result) => result.obj)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aLocal = isLocal(a)
|
||||||
|
const bLocal = isLocal(b)
|
||||||
|
if (aLocal && !bLocal) return -1
|
||||||
|
if (!aLocal && bLocal) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
@@ -97,12 +121,11 @@ export const SettingsServersV2: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<WslServerSettings controller={controller} servers={wslServers} />
|
|
||||||
<For each={filtered()}>
|
<For each={filtered()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const key = ServerConnection.key(item)
|
const key = ServerConnection.key(item)
|
||||||
const health = () => controller.status()[key]
|
const health = () => controller.status()[key]
|
||||||
const isDefault = () => controller.defaultKey() === key
|
const isDefault = () => isDefaultServer(key, item)
|
||||||
return (
|
return (
|
||||||
<div class="settings-v2-servers-row">
|
<div class="settings-v2-servers-row">
|
||||||
<div class="settings-v2-servers-lead">
|
<div class="settings-v2-servers-lead">
|
||||||
@@ -131,6 +154,7 @@ export const SettingsServersV2: Component = () => {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
<WslServerSettings controller={controller} servers={wslServers} />
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-v2-tab-title {
|
.settings-v2-tab-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 640;
|
font-weight: 640;
|
||||||
@@ -287,7 +295,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-provider-env-hint {
|
.settings-v2-provider-env-hint {
|
||||||
padding-right: 12px;
|
padding-inline-end: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 440;
|
font-weight: 440;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -311,7 +319,7 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--v2-text-text-accent);
|
color: var(--v2-text-text-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-providers-view-all:hover {
|
.settings-v2-providers-view-all:hover {
|
||||||
@@ -361,13 +369,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-tab-search [data-slot="text-input-v2-input"] {
|
.settings-v2-tab-search [data-slot="text-input-v2-input"] {
|
||||||
padding-right: 28px;
|
padding-inline-end: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-tab-search-clear {
|
.settings-v2-tab-search-clear {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
right: 6px;
|
inset-inline-end: 6px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
@@ -684,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);
|
||||||
@@ -727,3 +952,40 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--v2-state-fg-danger);
|
color: var(--v2-state-fg-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
width: 280px;
|
||||||
|
height: 32px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs > button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 0.5px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs > button[data-selected] {
|
||||||
|
border-color: var(--v2-border-border-muted);
|
||||||
|
background: var(--v2-background-bg-layer-03);
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs > button:focus-visible {
|
||||||
|
outline: 2px solid var(--v2-border-border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import type { Component } from "solid-js"
|
||||||
|
import { For, Show, createMemo } from "solid-js"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import type { Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
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 { SelectV2 } from "@opencode-ai/ui/v2/select-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 { useTabs } from "@/context/tabs"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import {
|
||||||
|
containsDirectory,
|
||||||
|
filterWorkspaceInventory,
|
||||||
|
inspectWorkspaceDeletion,
|
||||||
|
mergeWorkspaceSessionInventory,
|
||||||
|
removeWorkspacesSequentially,
|
||||||
|
sessionsForWorkspace,
|
||||||
|
type WorkspaceDeleteInspection,
|
||||||
|
workspaceInventory,
|
||||||
|
} from "@/utils/workspace"
|
||||||
|
import { listAllSessions, normalizeSessionInfo } 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) => project.sandboxes?.length))
|
||||||
|
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 protocol = await context.sdk.protocol
|
||||||
|
const fetched =
|
||||||
|
protocol === "v1"
|
||||||
|
? await context.sdk.api.session.list({ limit: 1000, order: "desc" }).then((response) => {
|
||||||
|
if (response.data.length >= 1000) throw new Error("Incomplete legacy session inventory")
|
||||||
|
return response.data.map(normalizeSessionInfo)
|
||||||
|
})
|
||||||
|
: await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||||
|
return mergeWorkspaceSessionInventory(
|
||||||
|
fetched,
|
||||||
|
Object.values(context.sync.session.data.info).filter((session): session is Session => !!session),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const sessionQuery = useQuery(() => ({
|
||||||
|
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
|
||||||
|
queryFn: () => loadSessions(),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
}))
|
||||||
|
const workspaceSessions = (workspace: Workspace) => {
|
||||||
|
if (!sessionQuery.isSuccess) return []
|
||||||
|
return sessionsForWorkspace(sessionQuery.data ?? [], 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: Session) => {
|
||||||
|
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 inspectionMessage = (result: WorkspaceDeleteInspection) => {
|
||||||
|
if (result === "active") return language.t("settings.workspaces.delete.blocked.active")
|
||||||
|
if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked")
|
||||||
|
if (result === "dirty") return language.t("workspace.status.dirty")
|
||||||
|
return language.t("workspace.status.clean")
|
||||||
|
}
|
||||||
|
const blocked = (result: WorkspaceDeleteInspection) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("workspace.delete.failed.title"),
|
||||||
|
description: inspectionMessage(result),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => {
|
||||||
|
const preflight = await inspect(workspace, context)
|
||||||
|
if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) {
|
||||||
|
blocked(preflight.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const removed = await context.sdk.client.worktree
|
||||||
|
.remove({
|
||||||
|
directory: workspace.project.worktree,
|
||||||
|
worktreeRemoveInput: { directory: workspace.directory },
|
||||||
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
|
.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),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}
|
||||||
|
inspectionMessage={inspectionMessage}
|
||||||
|
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">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||||
|
</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}>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
options={projectOptions()}
|
||||||
|
current={projectOptions().find((option) => option.id === selectedProject())}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => option.label}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
onSelect={(option) => option && setStore("project", option.id)}
|
||||||
|
/>
|
||||||
|
</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} 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: Session[] }>
|
||||||
|
inspectionMessage: (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 description = () => {
|
||||||
|
if (status.isPending) return language.t("workspace.status.checking")
|
||||||
|
if (status.isError) return language.t("workspace.status.error")
|
||||||
|
return props.inspectionMessage(status.data?.result ?? "unknown")
|
||||||
|
}
|
||||||
|
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")}
|
||||||
|
<br />
|
||||||
|
{description()}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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 !== "safe" && status.data?.result !== "dirty")
|
||||||
|
}
|
||||||
|
onClick={remove}
|
||||||
|
>
|
||||||
|
{language.t("workspace.delete.button")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -573,9 +573,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
if (!result) return
|
if (!result) return
|
||||||
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
||||||
if (result.response.status === 404 || result.response.status === 405) return
|
if (result.response.status === 404 || result.response.status === 405) return
|
||||||
if (result.response.status === 403)
|
if (result.response.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError"))
|
||||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
throw new Error(language.t("terminal.connectTicket.statusError", { status: result.response.status }))
|
||||||
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
|
||||||
}
|
}
|
||||||
// return sdk()
|
// return sdk()
|
||||||
// .api.pty.connectToken({
|
// .api.pty.connectToken({
|
||||||
@@ -742,6 +741,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
<div
|
<div
|
||||||
ref={container}
|
ref={container}
|
||||||
data-component="terminal"
|
data-component="terminal"
|
||||||
|
dir="ltr"
|
||||||
data-prevent-autofocus
|
data-prevent-autofocus
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={{ "background-color": terminalColors().background }}
|
style={{ "background-color": terminalColors().background }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[data-titlebar-tab] [data-slot="tab-close"] {
|
[data-titlebar-tab] [data-slot="tab-close"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
right: 4px;
|
inset-inline-end: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
left: -3.75px;
|
inset-inline-start: -3.75px;
|
||||||
width: 1.5px;
|
width: 1.5px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
@@ -86,6 +86,23 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to left,
|
||||||
|
black 0,
|
||||||
|
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
||||||
|
transparent calc(100% - var(--tab-title-fade-offset)),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to left,
|
||||||
|
black 0,
|
||||||
|
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
||||||
|
transparent calc(100% - var(--tab-title-fade-offset)),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
|
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
|
||||||
[data-slot="tab-link"] {
|
[data-slot="tab-link"] {
|
||||||
--tab-title-fade-offset: 24px;
|
--tab-title-fade-offset: 24px;
|
||||||
@@ -93,7 +110,7 @@
|
|||||||
|
|
||||||
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
|
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
|
||||||
[data-slot="tab-link"] {
|
[data-slot="tab-link"] {
|
||||||
padding-right: 0;
|
padding-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab] [data-slot="tab-title"] {
|
[data-titlebar-tab] [data-slot="tab-title"] {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 } from "@/context/global"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
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"
|
||||||
@@ -323,6 +324,7 @@ export function DraftTabItem(props: {
|
|||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
const closeTab = (event: MouseEvent) => {
|
const closeTab = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -397,7 +399,7 @@ export function DraftTabItem(props: {
|
|||||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||||
onClick={closeTab}
|
onClick={closeTab}
|
||||||
icon={<IconV2 name="xmark-small" />}
|
icon={<IconV2 name="xmark-small" />}
|
||||||
aria-label="Close tab"
|
aria-label={language.t("common.closeTab")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
|
import {
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
createSignal,
|
||||||
|
Match,
|
||||||
|
on,
|
||||||
|
onMount,
|
||||||
|
Show,
|
||||||
|
Switch,
|
||||||
|
untrack,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -42,8 +53,11 @@ export type TitlebarUpdate = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useTitlebarRightMount() {
|
export function useTitlebarRightMount() {
|
||||||
|
const language = useLanguage()
|
||||||
const [mount, setMount] = createSignal<HTMLElement | null>(null)
|
const [mount, setMount] = createSignal<HTMLElement | null>(null)
|
||||||
onMount(() => setMount(document.getElementById("opencode-titlebar-right")))
|
const sync = () => setMount(document.getElementById("opencode-titlebar-right"))
|
||||||
|
onMount(sync)
|
||||||
|
createEffect(on(language.direction, sync, { defer: true }))
|
||||||
return mount
|
return mount
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +127,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
return {
|
return {
|
||||||
visible: version !== undefined || installing,
|
visible: version !== undefined || installing,
|
||||||
installing,
|
installing,
|
||||||
label: "Update",
|
label: language.t("titlebar.update"),
|
||||||
ariaLabel: language.t("toast.update.action.installRestart"),
|
ariaLabel: language.t("toast.update.action.installRestart"),
|
||||||
title: version ? `Update ${version}` : undefined,
|
title: version ? language.t("titlebar.updateVersion", { version }) : undefined,
|
||||||
onInstall: () => props.update?.install(),
|
onInstall: () => props.update?.install(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -169,7 +183,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
|
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
|
||||||
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||||
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||||
"align-self": windows() ? "flex-start" : undefined,
|
// Native Windows caption controls remain on the physical right in both writing directions.
|
||||||
|
"margin-right": windows() ? "auto" : undefined,
|
||||||
}}
|
}}
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
>
|
>
|
||||||
@@ -515,7 +530,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
<div
|
<div
|
||||||
class="flex items-center shrink-0"
|
class="flex items-center shrink-0"
|
||||||
classList={{
|
classList={{
|
||||||
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
"ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
||||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||||
"duration-180 ease-in": layout.sidebar.opened(),
|
"duration-180 ease-in": layout.sidebar.opened(),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T,
|
|||||||
<DrawerPrimitive.Content
|
<DrawerPrimitive.Content
|
||||||
class={props.class}
|
class={props.class}
|
||||||
classList={{
|
classList={{
|
||||||
"group/drawer-content fixed inset-y-[6px] right-[6px] left-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
|
"group/drawer-content fixed inset-y-[6px] end-[6px] start-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
|
||||||
}}
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
export function WindowsAppMenu(props: {
|
export function WindowsAppMenu(props: {
|
||||||
command: ReturnType<typeof useCommand>
|
command: ReturnType<typeof useCommand>
|
||||||
@@ -15,6 +16,7 @@ export function WindowsAppMenu(props: {
|
|||||||
variant?: "legacy" | "v2"
|
variant?: "legacy" | "v2"
|
||||||
}) {
|
}) {
|
||||||
let lastFocused: HTMLElement | undefined
|
let lastFocused: HTMLElement | undefined
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
const rememberFocus = () => {
|
const rememberFocus = () => {
|
||||||
const active = document.activeElement
|
const active = document.activeElement
|
||||||
@@ -58,7 +60,7 @@ export function WindowsAppMenu(props: {
|
|||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
size="large"
|
size="large"
|
||||||
icon={<IconV2 name="menu" />}
|
icon={<IconV2 name="menu" />}
|
||||||
aria-label="OpenCode menu"
|
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||||
onPointerDown={rememberFocus}
|
onPointerDown={rememberFocus}
|
||||||
onKeyDown={rememberFocus}
|
onKeyDown={rememberFocus}
|
||||||
/>
|
/>
|
||||||
@@ -69,7 +71,7 @@ export function WindowsAppMenu(props: {
|
|||||||
icon="menu"
|
icon="menu"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="titlebar-icon rounded-md shrink-0"
|
class="titlebar-icon rounded-md shrink-0"
|
||||||
aria-label="OpenCode menu"
|
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||||
onPointerDown={rememberFocus}
|
onPointerDown={rememberFocus}
|
||||||
onKeyDown={rememberFocus}
|
onKeyDown={rememberFocus}
|
||||||
/>
|
/>
|
||||||
@@ -79,7 +81,7 @@ export function WindowsAppMenu(props: {
|
|||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
||||||
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
|
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
|
||||||
<DesktopMenuSubmenu label={menu.label}>
|
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
||||||
{menu.items
|
{menu.items
|
||||||
?.filter((entry) => desktopMenuVisible(entry, "windows"))
|
?.filter((entry) => desktopMenuVisible(entry, "windows"))
|
||||||
.map((entry) =>
|
.map((entry) =>
|
||||||
@@ -87,7 +89,7 @@ export function WindowsAppMenu(props: {
|
|||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
) : (
|
) : (
|
||||||
<DesktopMenuItem
|
<DesktopMenuItem
|
||||||
label={entry.label ?? ""}
|
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||||
onSelect={() => runEntry(entry)}
|
onSelect={() => runEntry(entry)}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const ACCEPTED_FILE_EXTENSIONS = Array.from(
|
|||||||
),
|
),
|
||||||
).sort()
|
).sort()
|
||||||
|
|
||||||
export function filePickerFilters(ext?: string[]) {
|
export function filePickerFilters(name: string, ext?: string[]) {
|
||||||
if (!ext || ext.length === 0) return undefined
|
if (!ext || ext.length === 0) return undefined
|
||||||
return [{ name: "Files", extensions: ext }]
|
return [{ name, extensions: ext }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,12 +152,6 @@ export async function bootstrapGlobal(input: {
|
|||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)),
|
|
||||||
() =>
|
|
||||||
input.queryClient.fetchQuery(
|
|
||||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
|
||||||
),
|
|
||||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
|
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||||
@@ -524,17 +518,6 @@ export async function bootstrapDirectory(input: {
|
|||||||
input.queryClient.fetchQuery(
|
input.queryClient.fetchQuery(
|
||||||
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||||
)),
|
)),
|
||||||
() =>
|
|
||||||
input.queryClient
|
|
||||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
|
||||||
.catch((err) => {
|
|
||||||
const project = getFilename(input.directory)
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
|
||||||
description: formatServerError(err, input.translate),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
].filter(Boolean) as (() => Promise<any>)[]
|
].filter(Boolean) as (() => Promise<any>)[]
|
||||||
|
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
|
|||||||
@@ -263,10 +263,13 @@ describe("createChildStoreManager", () => {
|
|||||||
manager.child("/project")
|
manager.child("/project")
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
expect(queries[3]?.().enabled).toBe(true)
|
expect(queries[3]?.().enabled).toBe(true)
|
||||||
expect(queries[4]?.().enabled).toBe(true)
|
expect(queries[4]?.().enabled).toBe(false)
|
||||||
expect(queries[5]?.().enabled).toBe(true)
|
expect(queries[5]?.().enabled).toBe(true)
|
||||||
expect(bootstraps).toEqual(["/project"])
|
expect(bootstraps).toEqual(["/project"])
|
||||||
|
|
||||||
|
manager.enableProviders("/project")
|
||||||
|
expect(queries[4]?.().enabled).toBe(true)
|
||||||
|
|
||||||
manager.child("/project", { bootstrap: false })
|
manager.child("/project", { bootstrap: false })
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function createChildStoreManager(input: {
|
|||||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
const activeDirectories = new Set<string>()
|
const activeDirectories = new Set<string>()
|
||||||
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
const providerToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
|
||||||
const markKey = (key: DirectoryKey) => {
|
const markKey = (key: DirectoryKey) => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
@@ -122,6 +123,7 @@ export function createChildStoreManager(input: {
|
|||||||
mcpToggles.delete(key)
|
mcpToggles.delete(key)
|
||||||
activeDirectories.delete(key)
|
activeDirectories.delete(key)
|
||||||
activationToggles.delete(key)
|
activationToggles.delete(key)
|
||||||
|
providerToggles.delete(key)
|
||||||
const dispose = disposers.get(key)
|
const dispose = disposers.get(key)
|
||||||
if (dispose) {
|
if (dispose) {
|
||||||
dispose()
|
dispose()
|
||||||
@@ -187,6 +189,7 @@ export function createChildStoreManager(input: {
|
|||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||||
|
const [providerEnabled, setProviderEnabled] = createSignal(false)
|
||||||
|
|
||||||
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||||
@@ -194,7 +197,7 @@ export function createChildStoreManager(input: {
|
|||||||
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||||
const providerQuery = useQuery(() => ({
|
const providerQuery = useQuery(() => ({
|
||||||
...input.queryOptions.providers(key),
|
...input.queryOptions.providers(key),
|
||||||
enabled: instanceQueriesEnabled(),
|
enabled: providerEnabled(),
|
||||||
}))
|
}))
|
||||||
const referenceQuery = useQuery(() => ({
|
const referenceQuery = useQuery(() => ({
|
||||||
...input.queryOptions.references(key),
|
...input.queryOptions.references(key),
|
||||||
@@ -206,7 +209,7 @@ export function createChildStoreManager(input: {
|
|||||||
projectMeta: initialMeta,
|
projectMeta: initialMeta,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
get provider_ready() {
|
get provider_ready() {
|
||||||
return instanceQueriesEnabled() && !providerQuery.isLoading
|
return providerEnabled() && !providerQuery.isLoading
|
||||||
},
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||||
@@ -263,6 +266,7 @@ export function createChildStoreManager(input: {
|
|||||||
disposers.set(key, dispose)
|
disposers.set(key, dispose)
|
||||||
mcpToggles.set(key, setMcpEnabled)
|
mcpToggles.set(key, setMcpEnabled)
|
||||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||||
|
providerToggles.set(key, setProviderEnabled)
|
||||||
|
|
||||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
if (!(init instanceof Promise)) return
|
if (!(init instanceof Promise)) return
|
||||||
@@ -329,6 +333,12 @@ export function createChildStoreManager(input: {
|
|||||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enableProviders(directory: string) {
|
||||||
|
const key = directoryKey(directory)
|
||||||
|
ensureChild(directory)
|
||||||
|
providerToggles.get(key)?.(true)
|
||||||
|
}
|
||||||
|
|
||||||
// Passive Home/project metadata reads must not initialize the directory.
|
// Passive Home/project metadata reads must not initialize the directory.
|
||||||
// A real directory access enables these queries once for the store lifetime.
|
// A real directory access enables these queries once for the store lifetime.
|
||||||
// TODO(v2): After Home switches to v2.project.list and root-filtered,
|
// TODO(v2): After Home switches to v2.project.list and root-filtered,
|
||||||
@@ -387,6 +397,7 @@ export function createChildStoreManager(input: {
|
|||||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||||
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
|
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
|
||||||
disableMcp,
|
disableMcp,
|
||||||
|
enableProviders,
|
||||||
disposeDirectory,
|
disposeDirectory,
|
||||||
runEviction,
|
runEviction,
|
||||||
vcsCache,
|
vcsCache,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const homeSessionIndexKey = (server: string) => ["home", "session-index",
|
|||||||
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||||
|
|
||||||
type HomeSessionPage = { data?: V2SessionListResponse }
|
type HomeSessionPage = { data?: V2SessionListResponse }
|
||||||
|
type ProjectedHomeSessionPage = { data?: { data: Session[]; cursor: { next?: string } } }
|
||||||
|
|
||||||
export async function loadHomeSessionIndex(
|
export async function loadHomeSessionIndex(
|
||||||
list: (
|
list: (
|
||||||
@@ -31,7 +32,30 @@ export async function loadHomeSessionIndex(
|
|||||||
eventSequence = 0,
|
eventSequence = 0,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const data: SessionV2Info[] = []
|
return loadHomeSessionPages(list, parseHomeSessionIndex, eventSequence, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadProjectedHomeSessionIndex(
|
||||||
|
list: (
|
||||||
|
input: { limit: number; order: "desc"; cursor?: string },
|
||||||
|
options: { signal?: AbortSignal },
|
||||||
|
) => Promise<ProjectedHomeSessionPage>,
|
||||||
|
eventSequence = 0,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
return loadHomeSessionPages(list, (sessions) => sessions, eventSequence, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHomeSessionPages<T>(
|
||||||
|
list: (
|
||||||
|
input: { limit: number; order: "desc"; cursor?: string },
|
||||||
|
options: { signal?: AbortSignal },
|
||||||
|
) => Promise<{ data?: { data: T[]; cursor: { next?: string } } }>,
|
||||||
|
project: (sessions: T[]) => Session[],
|
||||||
|
eventSequence: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const data: T[] = []
|
||||||
let cursor: string | undefined
|
let cursor: string | undefined
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -46,7 +70,7 @@ export async function loadHomeSessionIndex(
|
|||||||
const page = response.data!
|
const page = response.data!
|
||||||
data.push(...page.data)
|
data.push(...page.data)
|
||||||
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||||
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
return { sessions: project(data), eventSequence }
|
||||||
cursor = page.cursor.next
|
cursor = page.cursor.next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,58 +2,48 @@ 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 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"
|
||||||
|
import {
|
||||||
|
createDesktopNativeBundle,
|
||||||
|
DESKTOP_NATIVE_ENGLISH,
|
||||||
|
DESKTOP_NATIVE_LOCALES,
|
||||||
|
type DesktopNativeBundle,
|
||||||
|
type DesktopNativeLocale,
|
||||||
|
} from "@/i18n/desktop-native"
|
||||||
|
|
||||||
export type Locale =
|
export type Locale = DesktopNativeLocale
|
||||||
| "en"
|
export type Direction = "ltr" | "rtl"
|
||||||
| "zh"
|
|
||||||
| "zht"
|
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa"])
|
||||||
| "ko"
|
|
||||||
| "de"
|
function localeDirection(locale: Locale): Direction {
|
||||||
| "es"
|
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
|
||||||
| "fr"
|
}
|
||||||
| "da"
|
|
||||||
| "ja"
|
|
||||||
| "pl"
|
|
||||||
| "ru"
|
|
||||||
| "uk"
|
|
||||||
| "ar"
|
|
||||||
| "no"
|
|
||||||
| "br"
|
|
||||||
| "th"
|
|
||||||
| "bs"
|
|
||||||
| "tr"
|
|
||||||
|
|
||||||
type RawDictionary = typeof en & typeof uiEn
|
type RawDictionary = typeof en & typeof uiEn
|
||||||
type Dictionary = i18n.Flatten<RawDictionary>
|
type Dictionary = i18n.Flatten<RawDictionary>
|
||||||
|
type AppI18nKey = Extract<keyof typeof en, string>
|
||||||
|
type AppI18nPluralKey = {
|
||||||
|
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
|
||||||
|
}[AppI18nKey]
|
||||||
|
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
|
||||||
|
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
|
||||||
|
type TranslationKey<Key extends 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) {
|
||||||
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCALES: readonly Locale[] = [
|
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
|
||||||
"en",
|
|
||||||
"zh",
|
|
||||||
"zht",
|
|
||||||
"ko",
|
|
||||||
"de",
|
|
||||||
"es",
|
|
||||||
"fr",
|
|
||||||
"da",
|
|
||||||
"ja",
|
|
||||||
"pl",
|
|
||||||
"ru",
|
|
||||||
"uk",
|
|
||||||
"bs",
|
|
||||||
"ar",
|
|
||||||
"no",
|
|
||||||
"br",
|
|
||||||
"th",
|
|
||||||
"tr",
|
|
||||||
]
|
|
||||||
|
|
||||||
const INTL: Record<Locale, string> = {
|
const INTL: Record<Locale, string> = {
|
||||||
en: "en",
|
en: "en",
|
||||||
@@ -74,9 +64,19 @@ const INTL: Record<Locale, string> = {
|
|||||||
th: "th",
|
th: "th",
|
||||||
bs: "bs",
|
bs: "bs",
|
||||||
tr: "tr",
|
tr: "tr",
|
||||||
|
hi: "hi-IN",
|
||||||
|
nl: "nl-NL",
|
||||||
|
id: "id-ID",
|
||||||
|
vi: "vi-VN",
|
||||||
|
it: "it-IT",
|
||||||
|
ur: "ur-PK",
|
||||||
|
pa: "pa-Arab-PK",
|
||||||
|
az: "az-Latn-AZ",
|
||||||
|
fi: "fi-FI",
|
||||||
|
sv: "sv-SE",
|
||||||
}
|
}
|
||||||
|
|
||||||
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
const LABEL_KEY = {
|
||||||
en: "language.en",
|
en: "language.en",
|
||||||
zh: "language.zh",
|
zh: "language.zh",
|
||||||
zht: "language.zht",
|
zht: "language.zht",
|
||||||
@@ -95,6 +95,19 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
|||||||
th: "language.th",
|
th: "language.th",
|
||||||
bs: "language.bs",
|
bs: "language.bs",
|
||||||
tr: "language.tr",
|
tr: "language.tr",
|
||||||
|
} as const satisfies Partial<Record<Locale, AppI18nKey>>
|
||||||
|
|
||||||
|
const LABEL: Partial<Record<Locale, string>> = {
|
||||||
|
hi: "हिन्दी",
|
||||||
|
nl: "Nederlands",
|
||||||
|
id: "Bahasa Indonesia",
|
||||||
|
vi: "Tiếng Việt",
|
||||||
|
it: "Italiano",
|
||||||
|
ur: "اردو",
|
||||||
|
pa: "پنجابی",
|
||||||
|
az: "Azərbaycanca",
|
||||||
|
fi: "Suomi",
|
||||||
|
sv: "Svenska",
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = i18n.flatten({ ...en, ...uiEn })
|
const base = i18n.flatten({ ...en, ...uiEn })
|
||||||
@@ -121,6 +134,16 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
|||||||
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
||||||
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
|
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
|
||||||
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
|
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
|
||||||
|
hi: () => merge(import("@/i18n/hi"), import("@opencode-ai/ui/i18n/hi")),
|
||||||
|
nl: () => merge(import("@/i18n/nl"), import("@opencode-ai/ui/i18n/nl")),
|
||||||
|
id: () => merge(import("@/i18n/id"), import("@opencode-ai/ui/i18n/id")),
|
||||||
|
vi: () => merge(import("@/i18n/vi"), import("@opencode-ai/ui/i18n/vi")),
|
||||||
|
it: () => merge(import("@/i18n/it"), import("@opencode-ai/ui/i18n/it")),
|
||||||
|
ur: () => merge(import("@/i18n/ur"), import("@opencode-ai/ui/i18n/ur")),
|
||||||
|
pa: () => merge(import("@/i18n/pa"), import("@opencode-ai/ui/i18n/pa")),
|
||||||
|
az: () => merge(import("@/i18n/az"), import("@opencode-ai/ui/i18n/az")),
|
||||||
|
fi: () => merge(import("@/i18n/fi"), import("@opencode-ai/ui/i18n/fi")),
|
||||||
|
sv: () => merge(import("@/i18n/sv"), import("@opencode-ai/ui/i18n/sv")),
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadDict(locale: Locale) {
|
function loadDict(locale: Locale) {
|
||||||
@@ -140,7 +163,12 @@ export function loadLocaleDict(locale: Locale) {
|
|||||||
|
|
||||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||||
{ locale: "en", match: (language) => language.startsWith("en") },
|
{ locale: "en", match: (language) => language.startsWith("en") },
|
||||||
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
{
|
||||||
|
locale: "zht",
|
||||||
|
match: (language) =>
|
||||||
|
language.startsWith("zh") &&
|
||||||
|
(language.includes("hant") || language.includes("-tw") || language.includes("-hk") || language.includes("-mo")),
|
||||||
|
},
|
||||||
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
||||||
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
||||||
{ locale: "de", match: (language) => language.startsWith("de") },
|
{ locale: "de", match: (language) => language.startsWith("de") },
|
||||||
@@ -160,6 +188,22 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
|
|||||||
{ locale: "th", match: (language) => language.startsWith("th") },
|
{ locale: "th", match: (language) => language.startsWith("th") },
|
||||||
{ locale: "bs", match: (language) => language.startsWith("bs") },
|
{ locale: "bs", match: (language) => language.startsWith("bs") },
|
||||||
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
||||||
|
{ locale: "hi", match: (language) => language.startsWith("hi") },
|
||||||
|
{ locale: "nl", match: (language) => language.startsWith("nl") },
|
||||||
|
{ locale: "id", match: (language) => language.startsWith("id") },
|
||||||
|
{ locale: "vi", match: (language) => language.startsWith("vi") },
|
||||||
|
{ locale: "it", match: (language) => language.startsWith("it") },
|
||||||
|
{ locale: "ur", match: (language) => language.startsWith("ur") },
|
||||||
|
{
|
||||||
|
locale: "pa",
|
||||||
|
match: (language) => language.startsWith("pa") && (language.includes("arab") || language.includes("-pk")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: "az",
|
||||||
|
match: (language) => language.startsWith("az") && !language.includes("arab") && !language.includes("cyrl"),
|
||||||
|
},
|
||||||
|
{ locale: "fi", match: (language) => language.startsWith("fi") },
|
||||||
|
{ locale: "sv", match: (language) => language.startsWith("sv") },
|
||||||
]
|
]
|
||||||
|
|
||||||
function detectLocale(): Locale {
|
function detectLocale(): Locale {
|
||||||
@@ -199,7 +243,7 @@ if (warm !== "en") void loadDict(warm)
|
|||||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||||
name: "Language",
|
name: "Language",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: (props: { locale?: Locale }) => {
|
init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => {
|
||||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("language", ["language.v1"]),
|
Persist.global("language", ["language.v1"]),
|
||||||
@@ -210,34 +254,76 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
|
|
||||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||||
const intl = createMemo(() => INTL[locale()])
|
const intl = createMemo(() => INTL[locale()])
|
||||||
|
const [layout, setLayout] = createStore({ direction: undefined as Direction | undefined })
|
||||||
|
const direction = createMemo(() => layout.direction ?? localeDirection(locale()))
|
||||||
|
const layoutLocale = createMemo(() => {
|
||||||
|
if (!layout.direction) return intl()
|
||||||
|
// Kobalte derives menu direction from locale rather than accepting a direction override.
|
||||||
|
return layout.direction === "rtl" ? "ar" : "en"
|
||||||
|
})
|
||||||
|
|
||||||
const [dict] = createResource(locale, loadDict, {
|
const [dict] = createResource(locale, loadDict, {
|
||||||
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 extends string>(
|
||||||
key: keyof Dictionary,
|
key: TranslationKey<Key>,
|
||||||
params?: Record<string, string | number | boolean>,
|
params?: Record<string, string | number | boolean>,
|
||||||
) => string
|
) => string
|
||||||
|
|
||||||
const label = (value: Locale) => t(LABEL_KEY[value])
|
const pluralForm = (
|
||||||
|
key: PluralKey,
|
||||||
|
category: UiPluralCategory,
|
||||||
|
params?: Record<string, string | number | boolean>,
|
||||||
|
) => {
|
||||||
|
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
||||||
|
const candidate = `${key}.${category}`
|
||||||
|
const fallback = `${key}.other`
|
||||||
|
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) => {
|
||||||
|
const key = LABEL_KEY[value as keyof typeof LABEL_KEY]
|
||||||
|
if (key) return t(key)
|
||||||
|
return LABEL[value] ?? value
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document !== "object") return
|
if (typeof document !== "object") return
|
||||||
document.documentElement.lang = locale()
|
const value = locale()
|
||||||
document.cookie = cookie(locale())
|
document.documentElement.lang = value
|
||||||
|
document.documentElement.dir = direction()
|
||||||
|
document.cookie = cookie(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.onNativeTranslations || dict.loading) return
|
||||||
|
const current = dict()
|
||||||
|
if (!current) return
|
||||||
|
props.onNativeTranslations(
|
||||||
|
createDesktopNativeBundle(locale(), (key) => current[key] ?? DESKTOP_NATIVE_ENGLISH[key]),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
locale,
|
locale,
|
||||||
intl,
|
intl,
|
||||||
|
direction,
|
||||||
|
layoutLocale,
|
||||||
locales: LOCALES,
|
locales: LOCALES,
|
||||||
label,
|
label,
|
||||||
t,
|
t,
|
||||||
|
plural,
|
||||||
|
pluralForm,
|
||||||
setLocale(next: Locale) {
|
setLocale(next: Locale) {
|
||||||
setStore("locale", normalizeLocale(next))
|
setStore("locale", normalizeLocale(next))
|
||||||
},
|
},
|
||||||
|
setDirection(next: Direction) {
|
||||||
|
setLayout("direction", next === localeDirection(locale()) ? undefined : next)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { FileSelection } from "@/context/file"
|
|||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import type { BlobReference } from "@/utils/draft-store"
|
import type { BlobReference } from "@/utils/draft-store"
|
||||||
|
import type { Platform } from "@/context/platform"
|
||||||
|
|
||||||
interface PartBase {
|
interface PartBase {
|
||||||
content: string
|
content: string
|
||||||
@@ -236,13 +237,18 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
|
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt, platform?: Platform) {
|
||||||
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
|
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)), platform)
|
||||||
return { ready, ...createPromptStateValue(store, setStore) }
|
return { ready, ...createPromptStateValue(store, setStore) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
|
export function createPromptSession(
|
||||||
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
|
serverScope: ServerScope,
|
||||||
|
scope: PromptScope,
|
||||||
|
initial?: InitialPrompt,
|
||||||
|
platform?: Platform,
|
||||||
|
) {
|
||||||
|
return createPersistedPrompt(promptTarget(serverScope, scope), initial, platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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,
|
||||||
@@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
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()),
|
||||||
|
|||||||
@@ -1,7 +1,89 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
import {
|
||||||
|
adaptServerEvent,
|
||||||
|
adaptWorktreeCompatibilityEvent,
|
||||||
|
applyWorkspaceOperationEvent,
|
||||||
|
applyWorktreeEvent,
|
||||||
|
coalesceServerEvents,
|
||||||
|
enqueueServerEvent,
|
||||||
|
resumeStreamAfterPageShow,
|
||||||
|
} from "./server-sdk"
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { Worktree } from "@/utils/worktree"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
|
||||||
|
describe("applyWorktreeEvent", () => {
|
||||||
|
test("adapts global readiness with the created worktree directory", () => {
|
||||||
|
const directory = "/repo/worktree-compatible"
|
||||||
|
const event = adaptWorktreeCompatibilityEvent({
|
||||||
|
directory,
|
||||||
|
payload: { id: "ready", type: "worktree.ready", properties: { name: "compatible" } } as Event,
|
||||||
|
})
|
||||||
|
if (!event) throw new Error("expected worktree event")
|
||||||
|
|
||||||
|
Worktree.pending(ServerScope.local, directory)
|
||||||
|
applyWorktreeEvent(ServerScope.local, event, "failed")
|
||||||
|
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" })
|
||||||
|
expect(
|
||||||
|
adaptWorktreeCompatibilityEvent({
|
||||||
|
directory: "/repo",
|
||||||
|
payload: {
|
||||||
|
id: "status",
|
||||||
|
type: "session.status",
|
||||||
|
properties: { sessionID: "session", status: { type: "idle" } },
|
||||||
|
} as Event,
|
||||||
|
}),
|
||||||
|
).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves the server failure message", () => {
|
||||||
|
const directory = "/repo/worktree-failed"
|
||||||
|
Worktree.pending(ServerScope.local, directory)
|
||||||
|
|
||||||
|
applyWorktreeEvent(
|
||||||
|
ServerScope.local,
|
||||||
|
{
|
||||||
|
directory,
|
||||||
|
payload: {
|
||||||
|
id: "failed",
|
||||||
|
type: "worktree.failed",
|
||||||
|
properties: { name: "failed", message: "bootstrap failed" },
|
||||||
|
} as Event,
|
||||||
|
},
|
||||||
|
"fallback",
|
||||||
|
)
|
||||||
|
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "failed", message: "bootstrap failed" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("legacy and current moved events complete matching operations", () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: "legacy",
|
||||||
|
payload: {
|
||||||
|
id: "moved",
|
||||||
|
type: "session.next.moved",
|
||||||
|
properties: { timestamp: Date.now(), sessionID: "legacy", location: { directory: "/workspace" } },
|
||||||
|
} as Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "current",
|
||||||
|
payload: adaptServerEvent({
|
||||||
|
id: "moved-current",
|
||||||
|
created: Date.now(),
|
||||||
|
type: "session.moved",
|
||||||
|
data: { sessionID: "current", location: { directory: "/workspace" } },
|
||||||
|
} as OpenCodeEvent),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
events.forEach((event) => {
|
||||||
|
WorkspaceOperation.start(ServerScope.local, event.id, "move", "/workspace")
|
||||||
|
applyWorkspaceOperationEvent(ServerScope.local, { directory: "/workspace", payload: event.payload })
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, event.id)?.status).toBe("complete")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
test("restarts a stream only after a back-forward cache restore", () => {
|
test("restarts a stream only after a back-forward cache restore", () => {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { useGlobal } from "./global"
|
|||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
||||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
||||||
|
import { Worktree } from "@/utils/worktree"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
import { decodeVcsDiff } from "@/utils/vcs-diff-decoder"
|
||||||
|
import { decodeSessionList } from "./session-message-decoder"
|
||||||
|
|
||||||
const isAbortError = (error: unknown) =>
|
const isAbortError = (error: unknown) =>
|
||||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||||
@@ -56,6 +60,11 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
|||||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function adaptWorktreeCompatibilityEvent(event: { directory?: string; payload: Event }) {
|
||||||
|
if (event.payload.type !== "worktree.ready" && event.payload.type !== "worktree.failed") return
|
||||||
|
return { directory: event.directory ?? "global", payload: event.payload }
|
||||||
|
}
|
||||||
|
|
||||||
const coalescedKey = (event: QueuedServerEvent) => {
|
const coalescedKey = (event: QueuedServerEvent) => {
|
||||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||||
if (event.payload.type === "message.part.updated") {
|
if (event.payload.type === "message.part.updated") {
|
||||||
@@ -138,6 +147,31 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyWorktreeEvent(scope: ServerScope, event: QueuedServerEvent, fallback: string) {
|
||||||
|
if (event.payload.type === "worktree.ready") {
|
||||||
|
Worktree.ready(scope, event.directory)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event.payload.type !== "worktree.failed") return false
|
||||||
|
const message = event.payload.properties.message ?? fallback
|
||||||
|
Worktree.failed(scope, event.directory, message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) {
|
||||||
|
if (event.payload.current?.type === "session.moved") {
|
||||||
|
WorkspaceOperation.complete(
|
||||||
|
scope,
|
||||||
|
event.payload.current.data.sessionID,
|
||||||
|
event.payload.current.data.location.directory,
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event.payload.type !== "session.next.moved") return false
|
||||||
|
WorkspaceOperation.complete(scope, event.payload.properties.sessionID, event.payload.properties.location.directory)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||||
if (
|
if (
|
||||||
event?.type === "session.text.delta" ||
|
event?.type === "session.text.delta" ||
|
||||||
@@ -186,6 +220,7 @@ type ServerSDKBase = {
|
|||||||
|
|
||||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const language = useLanguage()
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
|
|
||||||
const eventFetch = (() => {
|
const eventFetch = (() => {
|
||||||
@@ -238,7 +273,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
last = Date.now()
|
last = Date.now()
|
||||||
const output = coalesceServerEvents(events)
|
const output = coalesceServerEvents(events)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
output.forEach((event) => {
|
||||||
|
applyWorktreeEvent(scope, event, language.t("common.requestFailed"))
|
||||||
|
applyWorkspaceOperationEvent(scope, event)
|
||||||
|
emitter.emit(event.directory, event.payload)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
buffer.length = 0
|
buffer.length = 0
|
||||||
@@ -251,12 +290,57 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
}
|
}
|
||||||
|
|
||||||
let streamErrorLogged = false
|
let streamErrorLogged = false
|
||||||
|
let worktreeStreamErrorLogged = false
|
||||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
let attempt: AbortController | undefined
|
let attempt: AbortController | undefined
|
||||||
|
let worktreeAttempt: AbortController | undefined
|
||||||
let run: Promise<void> | undefined
|
let run: Promise<void> | undefined
|
||||||
let started = false
|
let started = false
|
||||||
let generation = 0
|
let generation = 0
|
||||||
|
|
||||||
|
const consumeWorktreeEvents = async (active: number) => {
|
||||||
|
// Current worktree lifecycle events are still emitted only on the global compatibility stream.
|
||||||
|
while (!abort.signal.aborted && started && generation === active) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
worktreeAttempt = controller
|
||||||
|
const onAbort = () => controller.abort()
|
||||||
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
|
try {
|
||||||
|
const events = (await eventSdk.global.event({ signal: controller.signal })).stream
|
||||||
|
let yielded = Date.now()
|
||||||
|
for await (const event of events) {
|
||||||
|
const queued = adaptWorktreeCompatibilityEvent({
|
||||||
|
directory: event.directory,
|
||||||
|
payload: event.payload as Event,
|
||||||
|
})
|
||||||
|
if (queued) {
|
||||||
|
worktreeStreamErrorLogged = false
|
||||||
|
if (enqueueServerEvent(queue, queued)) schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
|
yielded = Date.now()
|
||||||
|
await wait(0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isStreamClosed(error, controller.signal) && !worktreeStreamErrorLogged) {
|
||||||
|
worktreeStreamErrorLogged = true
|
||||||
|
console.error("[global-sdk] worktree event stream failed", {
|
||||||
|
url: server.http.url,
|
||||||
|
fetch: eventFetch ? "platform" : "webview",
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abort.signal.removeEventListener("abort", onAbort)
|
||||||
|
if (worktreeAttempt === controller) worktreeAttempt = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abort.signal.aborted || !started || generation !== active) return
|
||||||
|
await wait(RECONNECT_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const start = () => {
|
const start = () => {
|
||||||
if (started) return run
|
if (started) return run
|
||||||
started = true
|
started = true
|
||||||
@@ -264,6 +348,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
const previous = run
|
const previous = run
|
||||||
const current = (async () => {
|
const current = (async () => {
|
||||||
if (previous) await previous
|
if (previous) await previous
|
||||||
|
const kind = await protocol
|
||||||
|
if (kind === "v2") void consumeWorktreeEvents(active)
|
||||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||||
while (!abort.signal.aborted && started && generation === active) {
|
while (!abort.signal.aborted && started && generation === active) {
|
||||||
attempt = new AbortController()
|
attempt = new AbortController()
|
||||||
@@ -272,7 +358,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
}
|
}
|
||||||
abort.signal.addEventListener("abort", onAbort)
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
try {
|
try {
|
||||||
const kind = await protocol
|
|
||||||
const events =
|
const events =
|
||||||
kind === "v1"
|
kind === "v1"
|
||||||
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
||||||
@@ -320,6 +405,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
started = false
|
started = false
|
||||||
generation++
|
generation++
|
||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
|
worktreeAttempt?.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -346,7 +432,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
directory,
|
directory,
|
||||||
})
|
})
|
||||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
|
const api = createCompatibleApi({ protocol, current: currentApi, legacy, decodeVcsDiff, decodeSessionList })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
@@ -432,7 +518,19 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
|||||||
current: serverSDK.currentApi,
|
current: serverSDK.currentApi,
|
||||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||||
directory,
|
directory,
|
||||||
|
decodeVcsDiff,
|
||||||
|
decodeSessionList,
|
||||||
}),
|
}),
|
||||||
|
createApi(next: string) {
|
||||||
|
return createCompatibleApi({
|
||||||
|
protocol: serverSDK.protocol,
|
||||||
|
current: serverSDK.currentApi,
|
||||||
|
legacy: (target) => serverSDK.createClient({ directory: target ?? next, throwOnError: true }),
|
||||||
|
directory: next,
|
||||||
|
decodeVcsDiff,
|
||||||
|
decodeSessionList,
|
||||||
|
})
|
||||||
|
},
|
||||||
event: emitter,
|
event: emitter,
|
||||||
get url() {
|
get url() {
|
||||||
return serverSDK.url
|
return serverSDK.url
|
||||||
|
|||||||
@@ -223,6 +223,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"), directory: "/repo/worktree", path: undefined }
|
||||||
|
const ctx = setup({ child: current })
|
||||||
|
ctx.store.remember(current)
|
||||||
|
|
||||||
|
ctx.store.apply({
|
||||||
|
type: "session.next.moved",
|
||||||
|
properties: {
|
||||||
|
sessionID: "child",
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
subdirectory: "packages/app",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ctx.store.get("child")).toMatchObject({ directory: "/repo", path: "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") })
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { normalizeSessionMessages } from "@/utils/session-message"
|
|||||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||||
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
import type { DecodedLegacyMessagePage } from "./session-message-decode"
|
||||||
|
|
||||||
type MessageApi = ServerApi["message"]
|
type MessageApi = ServerApi["message"]
|
||||||
|
|
||||||
@@ -29,10 +30,16 @@ const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
|||||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const initialMessagePageSize = 20
|
const initialMessagePageSize = 20
|
||||||
const historyMessagePageSize = 200
|
const historyMessagePageSize = 50
|
||||||
const sessionInfoLimit = 2_048
|
const sessionInfoLimit = 2_048
|
||||||
const emptyIDs: ReadonlySet<string> = new Set()
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
|
function yieldToMain() {
|
||||||
|
const scheduler = (globalThis as { scheduler?: { yield: () => Promise<void> } }).scheduler
|
||||||
|
if (scheduler) return scheduler.yield()
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
|
||||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||||
const boundary = source.find(
|
const boundary = source.find(
|
||||||
(message) =>
|
(message) =>
|
||||||
@@ -183,7 +190,11 @@ function reconcileFetched<T extends { id: string }>(
|
|||||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
type ServerSessionOptions = {
|
||||||
|
retry?: typeof retry
|
||||||
|
protocol?: Promise<"v1" | "v2">
|
||||||
|
decodeMessages?: (buffer: ArrayBuffer) => Promise<DecodedLegacyMessagePage>
|
||||||
|
}
|
||||||
|
|
||||||
export function createServerSession(
|
export function createServerSession(
|
||||||
client: OpencodeClient,
|
client: OpencodeClient,
|
||||||
@@ -301,22 +312,24 @@ export function createServerSession(
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolve = (sessionID: string, options?: { force?: boolean }) => {
|
const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => {
|
||||||
const cached = data.info[sessionID]
|
const cached = data.info[sessionID]
|
||||||
if (cached && !options?.force) return Promise.resolve(cached)
|
if (cached && !options?.force) return Promise.resolve(cached)
|
||||||
const pending = requests.get(sessionID)
|
const pending = options?.signal ? undefined : requests.get(sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const active = generation(sessionID)
|
const active = generation(sessionID)
|
||||||
const request = sessionApi
|
const request = sessionApi
|
||||||
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
? sessionApi.get({ sessionID }, { signal: options?.signal }).then(normalizeSessionInfo)
|
||||||
: client.session.get({ sessionID }).then((result) => {
|
: client.session.get({ sessionID }, { signal: options?.signal }).then((result) => {
|
||||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||||
return result.data
|
return result.data
|
||||||
})
|
})
|
||||||
const resolved = request.then((result) => {
|
const resolved = request.then((result) => {
|
||||||
|
if (options?.signal?.aborted) return result
|
||||||
if (generations.get(sessionID) !== active) return result
|
if (generations.get(sessionID) !== active) return result
|
||||||
return remember(result)
|
return remember(result)
|
||||||
})
|
})
|
||||||
|
if (options?.signal) return resolved
|
||||||
requests.set(sessionID, resolved)
|
requests.set(sessionID, resolved)
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||||
@@ -552,6 +565,7 @@ export function createServerSession(
|
|||||||
if (!response.data.length) break
|
if (!response.data.length) break
|
||||||
}
|
}
|
||||||
const response = pages.at(-1)!
|
const response = pages.at(-1)!
|
||||||
|
await yieldToMain()
|
||||||
const source = pages.flatMap((page) => page.data).toReversed()
|
const source = pages.flatMap((page) => page.data).toReversed()
|
||||||
const normalized = normalizeSessionMessages(sessionID, source)
|
const normalized = normalizeSessionMessages(sessionID, source)
|
||||||
return {
|
return {
|
||||||
@@ -566,10 +580,21 @@ export function createServerSession(
|
|||||||
complete: response.data.length === 0,
|
complete: response.data.length === 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const response = await (options?.retry ?? retry)(() => {
|
const response = await (options?.retry ?? retry)(async () => {
|
||||||
onAttempt?.()
|
onAttempt?.()
|
||||||
return client.session.messages({ sessionID, limit, before })
|
if (!options?.decodeMessages) return client.session.messages({ sessionID, limit, before })
|
||||||
|
const response = await client.session.messages({ sessionID, limit, before }, { parseAs: "arrayBuffer" })
|
||||||
|
if (!(response.data instanceof ArrayBuffer)) throw new Error("Session messages response is not an ArrayBuffer")
|
||||||
|
return { response, decoded: await options.decodeMessages(response.data) }
|
||||||
})
|
})
|
||||||
|
await yieldToMain()
|
||||||
|
if ("decoded" in response)
|
||||||
|
return {
|
||||||
|
...response.decoded,
|
||||||
|
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||||
|
cursor: response.response.response.headers.get("x-next-cursor") ?? undefined,
|
||||||
|
complete: !response.response.response.headers.get("x-next-cursor"),
|
||||||
|
}
|
||||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||||
return {
|
return {
|
||||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||||
@@ -1018,6 +1043,27 @@ export function createServerSession(
|
|||||||
evict([sessionID])
|
evict([sessionID])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "session.next.moved": {
|
||||||
|
const props = event.properties as {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
location: { directory: string; workspaceID?: string }
|
||||||
|
subdirectory?: string
|
||||||
|
}
|
||||||
|
const current = data.info[props.sessionID]
|
||||||
|
if (!current) {
|
||||||
|
void resolve(props.sessionID, { force: true }).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remember({
|
||||||
|
...current,
|
||||||
|
directory: props.location.directory,
|
||||||
|
path: props.subdirectory,
|
||||||
|
workspaceID: props.location.workspaceID,
|
||||||
|
time: { ...current.time, updated: props.timestamp },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
case "todo.updated": {
|
case "todo.updated": {
|
||||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||||
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||||
@@ -1341,6 +1387,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: [] }]]))
|
||||||
|
indexLegacyMessage(input.message)
|
||||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
@@ -1374,6 +1421,9 @@ export function createServerSession(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setData("session_message", input.sessionID, (messages) =>
|
||||||
|
messages?.filter((message) => message.id !== input.messageID),
|
||||||
|
)
|
||||||
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)))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { Event, OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import type {
|
import type {
|
||||||
McpListInput,
|
McpListInput,
|
||||||
McpResourceCatalogInput,
|
McpResourceCatalogInput,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionListInput,
|
SessionListInput,
|
||||||
|
OpenCodeEvent,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||||
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
import {
|
||||||
|
captureSessionMove,
|
||||||
|
loadActiveSessionsQuery,
|
||||||
|
loadMcpQuery,
|
||||||
|
loadMcpResourcesQuery,
|
||||||
|
seedActiveSessionStatuses,
|
||||||
|
} 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"
|
||||||
|
import { adaptServerEvent } from "./server-sdk"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
type McpApi = ServerApi["mcp"]
|
||||||
@@ -102,6 +110,52 @@ describe("active session query", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("session move normalization", () => {
|
||||||
|
test("captures and applies current moves from the source placement", () => {
|
||||||
|
const session = createServerSession({} as OpencodeClient)
|
||||||
|
session.remember(sessionAt("/source"))
|
||||||
|
const current = {
|
||||||
|
id: "event-current-move",
|
||||||
|
created: 10,
|
||||||
|
type: "session.moved",
|
||||||
|
data: { sessionID: "session", location: { directory: "/destination" } },
|
||||||
|
} as OpenCodeEvent
|
||||||
|
const event = adaptServerEvent(current)
|
||||||
|
|
||||||
|
expect(captureSessionMove(event, session.get)).toEqual({
|
||||||
|
sessionID: "session",
|
||||||
|
from: "/source",
|
||||||
|
refresh: "session.next.moved",
|
||||||
|
})
|
||||||
|
session.applyV2(current)
|
||||||
|
session.apply(event)
|
||||||
|
expect(session.get("session")?.directory).toBe("/destination")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("captures and applies V1 moves from the source placement", () => {
|
||||||
|
const session = createServerSession({} as OpencodeClient)
|
||||||
|
session.remember(sessionAt("/source"))
|
||||||
|
const event = {
|
||||||
|
type: "session.next.moved",
|
||||||
|
properties: {
|
||||||
|
timestamp: 10,
|
||||||
|
sessionID: "session",
|
||||||
|
location: { directory: "/destination" },
|
||||||
|
subdirectory: "packages/app",
|
||||||
|
},
|
||||||
|
} as Event
|
||||||
|
|
||||||
|
expect(captureSessionMove(event, session.get)).toEqual({
|
||||||
|
sessionID: "session",
|
||||||
|
from: "/source",
|
||||||
|
refresh: "session.next.moved",
|
||||||
|
})
|
||||||
|
session.apply(event)
|
||||||
|
expect(session.get("session")).toMatchObject({ directory: "/destination", path: "packages/app" })
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
describe("pickDirectoriesToEvict", () => {
|
describe("pickDirectoriesToEvict", () => {
|
||||||
test("keeps pinned stores and evicts idle stores", () => {
|
test("keeps pinned stores and evicts idle stores", () => {
|
||||||
const now = 5_000
|
const now = 5_000
|
||||||
@@ -174,6 +228,18 @@ function sessionInfo(id: string) {
|
|||||||
} as SessionInfo
|
} as SessionInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionAt(directory: string): Session {
|
||||||
|
return {
|
||||||
|
id: "session",
|
||||||
|
slug: "session",
|
||||||
|
projectID: "project",
|
||||||
|
directory,
|
||||||
|
title: "Session",
|
||||||
|
version: "",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("estimateRootSessionTotal", () => {
|
describe("estimateRootSessionTotal", () => {
|
||||||
test("keeps exact total for full fetches", () => {
|
test("keeps exact total for full fetches", () => {
|
||||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import type {
|
|||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
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 { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
import { type Accessor, batch, createMemo, createSignal, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import { ServerSDK } from "./server-sdk"
|
import { ServerSDK, type ServerEvent } from "./server-sdk"
|
||||||
import {
|
import {
|
||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
@@ -46,6 +46,32 @@ import { ServerConnection, useServer } from "./server"
|
|||||||
import { retry } from "@opencode-ai/core/util/retry"
|
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"
|
||||||
|
|
||||||
|
export function captureSessionMove(event: ServerEvent, get: (sessionID: string) => { directory: string } | undefined) {
|
||||||
|
const sessionID =
|
||||||
|
event.current?.type === "session.moved"
|
||||||
|
? event.current.data.sessionID
|
||||||
|
: event.type === "session.next.moved"
|
||||||
|
? event.properties.sessionID
|
||||||
|
: undefined
|
||||||
|
if (!sessionID) return
|
||||||
|
return { sessionID, from: get(sessionID)?.directory, refresh: "session.next.moved" as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||||
|
const type: string = event.type
|
||||||
|
const current: string | undefined = event.current?.type
|
||||||
|
return (
|
||||||
|
type === "session.created" ||
|
||||||
|
type === "session.updated" ||
|
||||||
|
type === "session.deleted" ||
|
||||||
|
type === "session.next.moved" ||
|
||||||
|
current === "session.moved" ||
|
||||||
|
current === "session.renamed" ||
|
||||||
|
current === "session.archived" ||
|
||||||
|
current === "session.forked"
|
||||||
|
)
|
||||||
|
}
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
import type {
|
import type {
|
||||||
@@ -59,6 +85,7 @@ import type {
|
|||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { toggleMcp } from "./global-sync/mcp"
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession, type ServerSession } from "./server-session"
|
import { createServerSession, type ServerSession } from "./server-session"
|
||||||
|
import { decodeSessionMessages } from "./session-message-decoder"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -226,6 +253,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
|
|
||||||
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
|
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
|
||||||
protocol: serverSDK.protocol,
|
protocol: serverSDK.protocol,
|
||||||
|
decodeMessages: decodeSessionMessages,
|
||||||
})
|
})
|
||||||
const queryOptionsApi = makeQueryOptionsApi(
|
const queryOptionsApi = makeQueryOptionsApi(
|
||||||
serverSDK.scope,
|
serverSDK.scope,
|
||||||
@@ -235,11 +263,18 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
serverSDK.protocol,
|
serverSDK.protocol,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [providersEnabled, setProvidersEnabled] = createSignal(false)
|
||||||
|
const [backgroundEnabled, setBackgroundEnabled] = createSignal(false)
|
||||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
queries: [
|
||||||
|
{ ...queryOptionsApi.globalConfig(), enabled: backgroundEnabled() },
|
||||||
|
{ ...queryOptionsApi.providers(null), enabled: providersEnabled() },
|
||||||
|
{ ...queryOptionsApi.path(null), enabled: backgroundEnabled() },
|
||||||
|
],
|
||||||
}))
|
}))
|
||||||
const activeSessionsQuery = useQuery(() =>
|
const activeSessionsQuery = useQuery(() =>
|
||||||
loadActiveSessionsQuery(serverSDK.scope, {
|
({
|
||||||
|
...loadActiveSessionsQuery(serverSDK.scope, {
|
||||||
active: async () => {
|
active: async () => {
|
||||||
if ((await serverSDK.protocol) === "v1") {
|
if ((await serverSDK.protocol) === "v1") {
|
||||||
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
||||||
@@ -261,6 +296,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
return active
|
return active
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
enabled: backgroundEnabled(),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||||
@@ -299,10 +336,36 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
let bootingRoot = false
|
let bootingRoot = false
|
||||||
let eventFrame: number | undefined
|
let eventFrame: number | undefined
|
||||||
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let providerFrame: number | undefined
|
||||||
|
let providerIdle: number | undefined
|
||||||
|
let providerTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
providerFrame = requestAnimationFrame(() => {
|
||||||
|
providerFrame = requestAnimationFrame(() => {
|
||||||
|
providerFrame = undefined
|
||||||
|
providerTimer = setTimeout(() => {
|
||||||
|
providerTimer = undefined
|
||||||
|
if ("requestIdleCallback" in window) {
|
||||||
|
providerIdle = requestIdleCallback(() => {
|
||||||
|
setProvidersEnabled(true)
|
||||||
|
setBackgroundEnabled(true)
|
||||||
|
}, { timeout: 5_000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProvidersEnabled(true)
|
||||||
|
setBackgroundEnabled(true)
|
||||||
|
}, 10_000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
||||||
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
||||||
|
if (providerFrame !== undefined) cancelAnimationFrame(providerFrame)
|
||||||
|
if (providerIdle !== undefined) cancelIdleCallback(providerIdle)
|
||||||
|
if (providerTimer !== undefined) clearTimeout(providerTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||||
@@ -528,19 +591,55 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reindexSession = (sessionID: string, from?: string) => {
|
||||||
|
const next = session.get(sessionID)
|
||||||
|
if (!next) return
|
||||||
|
indexSession(next)
|
||||||
|
if (!from) return
|
||||||
|
const source = children.children[directoryKey(from)]
|
||||||
|
if (!source) return
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: {
|
||||||
|
type: "session.moved",
|
||||||
|
properties: {
|
||||||
|
sessionID,
|
||||||
|
projectID: next.projectID,
|
||||||
|
location: { directory: next.directory, workspaceID: next.workspaceID },
|
||||||
|
subpath: next.path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: from,
|
||||||
|
store: source[0],
|
||||||
|
setStore: source[1],
|
||||||
|
push: queue.push,
|
||||||
|
retainedLimit: sessionMeta.get(directoryKey(from))?.limit,
|
||||||
|
sessionContent: false,
|
||||||
|
permission: session.data.permission,
|
||||||
|
loadLsp() {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const unsub = serverSDK.event.listen((e) => {
|
const unsub = serverSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const event = e.details
|
const event = e.details
|
||||||
const eventType: string = event.type
|
const eventType: string = event.type
|
||||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||||
|
const moved = captureSessionMove(event, session.get)
|
||||||
|
|
||||||
if (event.current) session.applyV2(event.current)
|
if (event.current) session.applyV2(event.current)
|
||||||
session.apply(event)
|
session.apply(event)
|
||||||
|
if (moved) reindexSession(moved.sessionID, moved.from)
|
||||||
|
if (shouldRefreshWorkspaceSessions(event)) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
predicate: (query) =>
|
||||||
|
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
|
||||||
|
})
|
||||||
|
}
|
||||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||||
homeSessions.apply(event)
|
homeSessions.apply(event)
|
||||||
}
|
}
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(moved?.refresh ?? event.type)
|
||||||
if (eventType === "integration.connection.updated") void refreshProviders()
|
if (eventType === "integration.connection.updated") void refreshProviders()
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
@@ -572,10 +671,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
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 })
|
||||||
@@ -682,12 +777,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
child: children.child,
|
child: children.child,
|
||||||
peek: children.peek,
|
peek: children.peek,
|
||||||
disableMcp: children.disableMcp,
|
disableMcp: children.disableMcp,
|
||||||
|
enableProviders: children.enableProviders,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
|
loadProviders: () => setProvidersEnabled(true),
|
||||||
refreshProviders,
|
refreshProviders,
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
session,
|
session,
|
||||||
|
reindexSession,
|
||||||
homeSessions,
|
homeSessions,
|
||||||
mcp: {
|
mcp: {
|
||||||
toggle: async (directory: string, name: string) => {
|
toggle: async (directory: string, name: string) => {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import type { Message, Part, Session, SessionV2Info } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode"
|
||||||
|
|
||||||
|
test("decodes and projects a legacy message page", () => {
|
||||||
|
const info = {
|
||||||
|
id: "message",
|
||||||
|
sessionID: "session",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
} as Message
|
||||||
|
const part = {
|
||||||
|
id: "part",
|
||||||
|
sessionID: "session",
|
||||||
|
messageID: info.id,
|
||||||
|
type: "text",
|
||||||
|
text: "hello",
|
||||||
|
} as Part
|
||||||
|
const result = decodeLegacyMessagePage(new TextEncoder().encode(JSON.stringify([{ info, parts: [part] }])).buffer)
|
||||||
|
|
||||||
|
expect(result.session).toEqual([info])
|
||||||
|
expect(result.part).toEqual([{ id: info.id, part: [part] }])
|
||||||
|
expect(result.source).toEqual([{ id: info.id, type: "user", text: "hello", time: info.time }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("decodes and projects a legacy session list", () => {
|
||||||
|
const session = {
|
||||||
|
id: "session",
|
||||||
|
projectID: "project",
|
||||||
|
directory: "/repo",
|
||||||
|
title: "Session",
|
||||||
|
version: "1",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
} as Session
|
||||||
|
const result = decodeLegacySessionList(new TextEncoder().encode(JSON.stringify([session])).buffer)
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ id: session.id, title: session.title, location: { directory: "/repo" } }),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("bounds Home sessions by directory before returning from the decoder", () => {
|
||||||
|
const session = (id: string, directory: string, updated: number) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
projectID: "project",
|
||||||
|
location: { directory },
|
||||||
|
subpath: "",
|
||||||
|
title: id,
|
||||||
|
time: { created: updated, updated },
|
||||||
|
}) as SessionV2Info
|
||||||
|
const page = {
|
||||||
|
data: [session("old", "/repo", 1), session("new", "/repo", 2), session("other", "/other", 3)],
|
||||||
|
cursor: {},
|
||||||
|
}
|
||||||
|
const result = decodeHomeSessionPage(new TextEncoder().encode(JSON.stringify(page)).buffer, {
|
||||||
|
directories: ["/repo"],
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.data.map((item) => item.id)).toEqual(["new"])
|
||||||
|
})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
|
import type { Message, Part, Session, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { message as cleanMessage } from "@/utils/diffs"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { parseHomeSessionIndex } from "./global-sync/home-session-index"
|
||||||
|
import { takeRecentSessions } from "./global-sync/session-trim"
|
||||||
|
|
||||||
|
export type DecodedLegacyMessagePage = {
|
||||||
|
session: Message[]
|
||||||
|
part: { id: string; part: Part[] }[]
|
||||||
|
source: SessionMessageInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeLegacyMessagePage(buffer: ArrayBuffer): DecodedLegacyMessagePage {
|
||||||
|
const text = new TextDecoder().decode(buffer)
|
||||||
|
const items = (text ? (JSON.parse(text) as { info?: Message; parts?: Part[] }[]) : []).filter(
|
||||||
|
(item): item is { info: Message; parts: Part[] } => !!item.info?.id && Array.isArray(item.parts),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => compare(a.id, b.id)),
|
||||||
|
part: items.map((item) => ({
|
||||||
|
id: item.info.id,
|
||||||
|
part: item.parts.filter((part) => !!part?.id).sort((a, b) => compare(a.id, b.id)),
|
||||||
|
})),
|
||||||
|
source: items
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => compare(a.info.id, b.info.id))
|
||||||
|
.map((item) =>
|
||||||
|
item.info.role === "user"
|
||||||
|
? {
|
||||||
|
id: item.info.id,
|
||||||
|
type: "user" as const,
|
||||||
|
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||||
|
time: item.info.time,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: item.info.id,
|
||||||
|
type: "assistant" as const,
|
||||||
|
agent: item.info.agent ?? item.info.mode,
|
||||||
|
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
|
||||||
|
content: [],
|
||||||
|
time: item.info.time,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeLegacySessionList(buffer: ArrayBuffer) {
|
||||||
|
const text = new TextDecoder().decode(buffer)
|
||||||
|
return (text ? (JSON.parse(text) as Session[]) : []).map(legacySessionInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeHomeSessionPage(buffer: ArrayBuffer, options?: { directories: string[]; limit: number }) {
|
||||||
|
const text = new TextDecoder().decode(buffer)
|
||||||
|
const page = (text ? JSON.parse(text) : { data: [], cursor: {} }) as V2SessionListResponse
|
||||||
|
const sessions = parseHomeSessionIndex(page.data)
|
||||||
|
if (!options) return { data: sessions, cursor: page.cursor }
|
||||||
|
const directories = new Set(options.directories.map(pathKey))
|
||||||
|
return {
|
||||||
|
data: [...Map.groupBy(sessions, (session) => pathKey(session.directory))]
|
||||||
|
.filter(([directory]) => directories.has(directory))
|
||||||
|
.flatMap(([, items]) => takeRecentSessions(items, options.limit, Number.NEGATIVE_INFINITY)),
|
||||||
|
cursor: page.cursor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacySessionInfo(session: Session): SessionInfo {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
parentID: session.parentID,
|
||||||
|
projectID: session.projectID,
|
||||||
|
agent: session.agent,
|
||||||
|
model: session.model && {
|
||||||
|
id: session.model.id,
|
||||||
|
providerID: session.model.providerID,
|
||||||
|
variant: session.model.variant,
|
||||||
|
},
|
||||||
|
cost: session.cost ?? 0,
|
||||||
|
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: session.time,
|
||||||
|
title: session.title,
|
||||||
|
location: { directory: session.directory, workspaceID: session.workspaceID },
|
||||||
|
subpath: session.path,
|
||||||
|
revert: session.revert && {
|
||||||
|
messageID: session.revert.messageID,
|
||||||
|
partID: session.revert.partID,
|
||||||
|
snapshot: session.revert.snapshot,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compare(a: string, b: string) {
|
||||||
|
return a < b ? -1 : a > b ? 1 : 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { DecodedLegacyMessagePage } from "./session-message-decode"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
type Response = { id: number; data?: unknown; error?: string }
|
||||||
|
|
||||||
|
let worker: Worker | undefined
|
||||||
|
let nextID = 0
|
||||||
|
const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
|
||||||
|
|
||||||
|
export function decodeSessionMessages(buffer: ArrayBuffer) {
|
||||||
|
return decode<DecodedLegacyMessagePage>("messages", buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeSessionList(buffer: ArrayBuffer) {
|
||||||
|
return decode<SessionInfo[]>("sessions", buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeHomeSessionPage(buffer: ArrayBuffer, options: { directories: string[]; limit: number }) {
|
||||||
|
return decode<{ data: Session[]; cursor: { next?: string } }>("homeSessions", buffer, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode<T>(
|
||||||
|
type: "messages" | "sessions" | "homeSessions",
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
options?: { directories: string[]; limit: number },
|
||||||
|
) {
|
||||||
|
const id = ++nextID
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
pending.set(id, { resolve: (value) => resolve(value as T), reject })
|
||||||
|
getWorker().postMessage({ id, type, buffer, options }, [buffer])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorker() {
|
||||||
|
if (worker) return worker
|
||||||
|
worker = new Worker(new URL("./session-message-decoder.worker.ts", import.meta.url), { type: "module" })
|
||||||
|
worker.onmessage = (event: MessageEvent<Response>) => {
|
||||||
|
const request = pending.get(event.data.id)
|
||||||
|
if (!request) return
|
||||||
|
pending.delete(event.data.id)
|
||||||
|
if (event.data.error) {
|
||||||
|
request.reject(new Error(event.data.error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.resolve(event.data.data)
|
||||||
|
}
|
||||||
|
worker.onerror = (event) => {
|
||||||
|
const error = new Error(event.message)
|
||||||
|
pending.forEach((request) => request.reject(error))
|
||||||
|
pending.clear()
|
||||||
|
worker?.terminate()
|
||||||
|
worker = undefined
|
||||||
|
}
|
||||||
|
return worker
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode"
|
||||||
|
|
||||||
|
type DecoderRequest = {
|
||||||
|
id: number
|
||||||
|
type: "messages" | "sessions" | "homeSessions"
|
||||||
|
buffer: ArrayBuffer
|
||||||
|
options?: { directories: string[]; limit: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<DecoderRequest>) => {
|
||||||
|
try {
|
||||||
|
self.postMessage({
|
||||||
|
id: event.data.id,
|
||||||
|
data: (() => {
|
||||||
|
if (event.data.type === "messages") return decodeLegacyMessagePage(event.data.buffer)
|
||||||
|
if (event.data.type === "sessions") return decodeLegacySessionList(event.data.buffer)
|
||||||
|
return decodeHomeSessionPage(event.data.buffer, event.data.options)
|
||||||
|
})(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -3,6 +3,10 @@ import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-
|
|||||||
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 { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
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
|
||||||
@@ -50,6 +54,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
|
||||||
}
|
}
|
||||||
@@ -206,6 +214,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,
|
||||||
@@ -355,6 +367,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setStore("general", "followup", "steer")
|
setStore("general", "followup", "steer")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !newLayoutDesigns() || store.general?.showFileTree !== true) return
|
||||||
|
setStore("general", "showFileTree", false)
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
get current() {
|
get current() {
|
||||||
@@ -453,7 +470,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
visibility: {
|
visibility: {
|
||||||
fileTree: visible(showFileTree),
|
fileTree: createMemo(() => !newLayoutDesigns()),
|
||||||
search: visible(showSearch),
|
search: visible(showSearch),
|
||||||
status: visible(showStatus),
|
status: visible(showStatus),
|
||||||
customAgents: visible(showCustomAgents),
|
customAgents: visible(showCustomAgents),
|
||||||
@@ -499,6 +516,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) {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||||
|
|
||||||
|
function history(): TabHistory {
|
||||||
|
return { stack: [], index: -1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tab history", () => {
|
||||||
|
test("moves backward and forward through selected tabs", () => {
|
||||||
|
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||||
|
const available = new Set(selected.stack)
|
||||||
|
|
||||||
|
const previous = previousTab(selected, available)
|
||||||
|
expect(previous?.key).toBe("b")
|
||||||
|
|
||||||
|
const first = previousTab(previous!.state, available)
|
||||||
|
expect(first?.key).toBe("a")
|
||||||
|
|
||||||
|
const next = nextTab(first!.state, available)
|
||||||
|
expect(next?.key).toBe("b")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces forward history after a new selection", () => {
|
||||||
|
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||||
|
const previous = previousTab(selected, new Set(selected.stack))
|
||||||
|
const next = rememberTab(previous!.state, "d")
|
||||||
|
|
||||||
|
expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 })
|
||||||
|
expect(nextTab(next, new Set(next.stack))).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips tabs that are no longer open", () => {
|
||||||
|
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||||
|
|
||||||
|
expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips a repeated current tab after closing the previous selection", () => {
|
||||||
|
const selected = ["a", "b", "c", "b"].reduce(rememberTab, history())
|
||||||
|
|
||||||
|
expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const MAX_TAB_HISTORY = 100
|
||||||
|
|
||||||
|
export type TabHistory = {
|
||||||
|
stack: string[]
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberTab(state: TabHistory, key: string): TabHistory {
|
||||||
|
if (state.stack[state.index] === key) return state
|
||||||
|
const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY)
|
||||||
|
return { stack, index: stack.length - 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previousTab(state: TabHistory, available: Set<string>) {
|
||||||
|
return move(state, -1, available)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextTab(state: TabHistory, available: Set<string>) {
|
||||||
|
return move(state, 1, available)
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(state: TabHistory, offset: -1 | 1, available: Set<string>) {
|
||||||
|
const current = state.stack[state.index]
|
||||||
|
for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) {
|
||||||
|
const key = state.stack[index]
|
||||||
|
if (key && key !== current && available.has(key)) return { state: { ...state, index }, key }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||||
|
|
||||||
export type SessionTab = {
|
export type SessionTab = {
|
||||||
type: "session"
|
type: "session"
|
||||||
@@ -74,6 +75,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
const memory = createTabMemory(getOwner())
|
const memory = createTabMemory(getOwner())
|
||||||
|
|
||||||
const closing = new Set<string>()
|
const closing = new Set<string>()
|
||||||
|
let history: TabHistory = { stack: [], index: -1 }
|
||||||
let recentWrite = 0
|
let recentWrite = 0
|
||||||
let recentValue: string | undefined
|
let recentValue: string | undefined
|
||||||
|
|
||||||
@@ -148,10 +150,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const navigateTab = (tab: Tab) => {
|
const navigateTab = (tab: Tab) => {
|
||||||
const href = tabHref(tab)
|
const href = tabHref(tab)
|
||||||
|
history = rememberTab(history, tabKey(tab))
|
||||||
setRecentKey(tabKey(tab))
|
setRecentKey(tabKey(tab))
|
||||||
navigate(href)
|
navigate(href)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moveHistory = (direction: "previous" | "next") => {
|
||||||
|
const available = new Set(store.map(tabKey))
|
||||||
|
const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available)
|
||||||
|
if (!result) return
|
||||||
|
const tab = store.find((item) => tabKey(item) === result.key)
|
||||||
|
if (!tab) return
|
||||||
|
history = result.state
|
||||||
|
navigateTab(tab)
|
||||||
|
}
|
||||||
|
|
||||||
const removeTab = (index: number) => {
|
const removeTab = (index: number) => {
|
||||||
const tab = store[index]
|
const tab = store[index]
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
@@ -177,6 +190,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
|
active() {
|
||||||
|
if (location.pathname === "/") return
|
||||||
|
const key = recentKey()
|
||||||
|
return store.find((tab) => tabKey(tab) === key)
|
||||||
|
},
|
||||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||||
const next = { type: "session" as const, ...tab }
|
const next = { type: "session" as const, ...tab }
|
||||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||||
@@ -357,8 +375,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
select: navigateTab,
|
select: navigateTab,
|
||||||
remember(tab: Tab) {
|
remember(tab: Tab) {
|
||||||
const key = tabKey(tab)
|
const key = tabKey(tab)
|
||||||
|
history = rememberTab(history, key)
|
||||||
if (recentKey() !== key) setRecentKey(key)
|
if (recentKey() !== key) setRecentKey(key)
|
||||||
},
|
},
|
||||||
|
previous: () => moveHistory("previous"),
|
||||||
|
next: () => moveHistory("next"),
|
||||||
toggleHome(input: { home: boolean; current?: Tab }) {
|
toggleHome(input: { home: boolean; current?: Tab }) {
|
||||||
if (input.home) {
|
if (input.home) {
|
||||||
const tab = store.find((tab) => tabKey(tab) === recentKey())
|
const tab = store.find((tab) => tabKey(tab) === recentKey())
|
||||||
|
|||||||
@@ -2,12 +2,43 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { DESKTOP_MENU } from "./desktop-menu"
|
import { DESKTOP_MENU } from "./desktop-menu"
|
||||||
|
|
||||||
describe("desktop menu", () => {
|
describe("desktop menu", () => {
|
||||||
|
test("navigates between tabs", () => {
|
||||||
|
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||||
|
(item) => item.type === "item" && (item.command === "tab.prev" || item.command === "tab.next"),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(items).toEqual([
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.previousSession",
|
||||||
|
command: "tab.prev",
|
||||||
|
accelerator: { macos: "Cmd+Alt+Left" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.nextSession",
|
||||||
|
command: "tab.next",
|
||||||
|
accelerator: { macos: "Cmd+Alt+Right" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("exports logs through the desktop command registry", () => {
|
test("exports logs through the desktop command registry", () => {
|
||||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||||
(item) => item.type === "item" && item.label === "Export Logs...",
|
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(items).toHaveLength(2)
|
expect(items).toHaveLength(2)
|
||||||
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
|
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("provides translated labels for role-backed entries", () => {
|
||||||
|
const windowMenu = DESKTOP_MENU.find((menu) => menu.role === "windowMenu")
|
||||||
|
const roleItems = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||||
|
(item) => item.type === "item" && item.role && item.labelKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(windowMenu?.labelKey).toBe("desktop.menu.window")
|
||||||
|
expect(roleItems.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { DesktopNativeKey } from "./i18n/desktop-native"
|
||||||
|
|
||||||
export type DesktopMenuPlatform = "macos" | "windows"
|
export type DesktopMenuPlatform = "macos" | "windows"
|
||||||
|
|
||||||
export type DesktopMenuAction =
|
export type DesktopMenuAction =
|
||||||
@@ -44,7 +46,7 @@ export type DesktopMenuRole =
|
|||||||
|
|
||||||
export type DesktopMenuItem = {
|
export type DesktopMenuItem = {
|
||||||
type: "item"
|
type: "item"
|
||||||
label?: string
|
labelKey?: DesktopNativeKey
|
||||||
command?: string
|
command?: string
|
||||||
action?: DesktopMenuAction
|
action?: DesktopMenuAction
|
||||||
role?: DesktopMenuRole
|
role?: DesktopMenuRole
|
||||||
@@ -63,7 +65,7 @@ export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
|
|||||||
|
|
||||||
export type DesktopMenu = {
|
export type DesktopMenu = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
labelKey: DesktopNativeKey
|
||||||
role?: DesktopMenuRole
|
role?: DesktopMenuRole
|
||||||
items?: DesktopMenuEntry[]
|
items?: DesktopMenuEntry[]
|
||||||
platforms?: DesktopMenuPlatform[]
|
platforms?: DesktopMenuPlatform[]
|
||||||
@@ -72,15 +74,20 @@ export type DesktopMenu = {
|
|||||||
export const DESKTOP_MENU: DesktopMenu[] = [
|
export const DESKTOP_MENU: DesktopMenu[] = [
|
||||||
{
|
{
|
||||||
id: "app",
|
id: "app",
|
||||||
label: "OpenCode",
|
labelKey: "desktop.menu.app",
|
||||||
platforms: ["macos"],
|
platforms: ["macos"],
|
||||||
items: [
|
items: [
|
||||||
{ type: "item", role: "about" },
|
{ type: "item", role: "about" },
|
||||||
{ type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" },
|
{
|
||||||
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
|
type: "item",
|
||||||
{ type: "item", label: "Reload Webview", action: "view.reload" },
|
labelKey: "desktop.menu.checkForUpdates",
|
||||||
{ type: "item", label: "Restart", action: "app.relaunch" },
|
action: "app.checkForUpdates",
|
||||||
{ type: "item", label: "Export Logs...", command: "logs.export" },
|
enabled: "updater",
|
||||||
|
},
|
||||||
|
{ type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
|
||||||
|
{ type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" },
|
||||||
|
{ type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" },
|
||||||
|
{ type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ type: "item", role: "hide" },
|
{ type: "item", role: "hide" },
|
||||||
{ type: "item", role: "hideOthers" },
|
{ type: "item", role: "hideOthers" },
|
||||||
@@ -91,46 +98,81 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "file",
|
id: "file",
|
||||||
label: "File",
|
labelKey: "desktop.menu.file",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
label: "New Session",
|
labelKey: "desktop.menu.newSession",
|
||||||
command: "session.new",
|
command: "session.new",
|
||||||
accelerator: { macos: "Shift+Cmd+S" },
|
accelerator: { macos: "Shift+Cmd+S" },
|
||||||
},
|
},
|
||||||
{ type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
|
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
label: "Settings",
|
labelKey: "desktop.menu.openProject",
|
||||||
|
command: "project.open",
|
||||||
|
accelerator: { macos: "Cmd+O" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.settings",
|
||||||
command: "settings.open",
|
command: "settings.open",
|
||||||
accelerator: { windows: "Ctrl+," },
|
accelerator: { windows: "Ctrl+," },
|
||||||
platforms: ["windows"],
|
platforms: ["windows"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
label: "New Window",
|
labelKey: "desktop.menu.newWindow",
|
||||||
action: "window.new",
|
action: "window.new",
|
||||||
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
|
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ type: "item", label: "Close Window", action: "window.close", role: "close" },
|
{ type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close", role: "close" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "edit",
|
id: "edit",
|
||||||
label: "Edit",
|
labelKey: "desktop.menu.edit",
|
||||||
items: [
|
items: [
|
||||||
{ type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
|
|
||||||
{ type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
|
|
||||||
{ type: "separator" },
|
|
||||||
{ type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
|
|
||||||
{ type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
|
|
||||||
{ type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
|
|
||||||
{ type: "item", label: "Delete", action: "edit.delete" },
|
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
label: "Select All",
|
labelKey: "desktop.menu.undo",
|
||||||
|
action: "edit.undo",
|
||||||
|
role: "undo",
|
||||||
|
accelerator: { windows: "Ctrl+Z" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.redo",
|
||||||
|
action: "edit.redo",
|
||||||
|
role: "redo",
|
||||||
|
accelerator: { windows: "Ctrl+Y" },
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.cut",
|
||||||
|
action: "edit.cut",
|
||||||
|
role: "cut",
|
||||||
|
accelerator: { windows: "Ctrl+X" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.copy",
|
||||||
|
action: "edit.copy",
|
||||||
|
role: "copy",
|
||||||
|
accelerator: { windows: "Ctrl+C" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.paste",
|
||||||
|
action: "edit.paste",
|
||||||
|
role: "paste",
|
||||||
|
accelerator: { windows: "Ctrl+V" },
|
||||||
|
},
|
||||||
|
{ type: "item", labelKey: "desktop.menu.delete", action: "edit.delete" },
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.selectAll",
|
||||||
action: "edit.selectAll",
|
action: "edit.selectAll",
|
||||||
role: "selectAll",
|
role: "selectAll",
|
||||||
accelerator: { windows: "Ctrl+A" },
|
accelerator: { windows: "Ctrl+A" },
|
||||||
@@ -139,47 +181,87 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "view",
|
id: "view",
|
||||||
label: "View",
|
labelKey: "desktop.menu.view",
|
||||||
items: [
|
items: [
|
||||||
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" },
|
{ type: "item", labelKey: "desktop.menu.toggleSidebar", command: "sidebar.toggle" },
|
||||||
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
|
{
|
||||||
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.toggleTerminal",
|
||||||
|
command: "terminal.toggle",
|
||||||
|
accelerator: { macos: "Ctrl+`" },
|
||||||
|
},
|
||||||
|
{ type: "item", labelKey: "desktop.menu.toggleFileTree", command: "fileTree.toggle" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ type: "item", label: "Reload", action: "view.reload", role: "reload" },
|
{ type: "item", labelKey: "desktop.menu.reload", action: "view.reload", role: "reload" },
|
||||||
{ type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" },
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.toggleDeveloperTools",
|
||||||
|
action: "view.toggleDevTools",
|
||||||
|
role: "toggleDevTools",
|
||||||
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
label: "Actual Size",
|
labelKey: "desktop.menu.actualSize",
|
||||||
action: "view.resetZoom",
|
action: "view.resetZoom",
|
||||||
role: "resetZoom",
|
role: "resetZoom",
|
||||||
accelerator: { windows: "Ctrl+0" },
|
accelerator: { windows: "Ctrl+0" },
|
||||||
},
|
},
|
||||||
{ type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } },
|
{
|
||||||
{ type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } },
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.zoomIn",
|
||||||
|
action: "view.zoomIn",
|
||||||
|
role: "zoomIn",
|
||||||
|
accelerator: { windows: "Ctrl++" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.zoomOut",
|
||||||
|
action: "view.zoomOut",
|
||||||
|
role: "zoomOut",
|
||||||
|
accelerator: { windows: "Ctrl+-" },
|
||||||
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" },
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.toggleFullScreen",
|
||||||
|
action: "view.toggleFullscreen",
|
||||||
|
role: "togglefullscreen",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "go",
|
id: "go",
|
||||||
label: "Go",
|
labelKey: "desktop.menu.go",
|
||||||
items: [
|
items: [
|
||||||
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
|
{ type: "item", labelKey: "desktop.menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
|
||||||
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
|
{ type: "item", labelKey: "desktop.menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
|
{ type: "item", labelKey: "desktop.menu.previousSession", command: "tab.prev", accelerator: { macos: "Cmd+Alt+Left" } },
|
||||||
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
|
{ type: "item", labelKey: "desktop.menu.nextSession", command: "tab.next", accelerator: { macos: "Cmd+Alt+Right" } },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
label: "Previous Project",
|
labelKey: "desktop.menu.previousSession",
|
||||||
|
command: "session.previous",
|
||||||
|
accelerator: { macos: "Option+Up" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.nextSession",
|
||||||
|
command: "session.next",
|
||||||
|
accelerator: { macos: "Option+Down" },
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.previousProject",
|
||||||
command: "project.previous",
|
command: "project.previous",
|
||||||
accelerator: { macos: "Cmd+Option+Up" },
|
accelerator: { macos: "Cmd+Option+Up" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
label: "Next Project",
|
labelKey: "desktop.menu.nextProject",
|
||||||
command: "project.next",
|
command: "project.next",
|
||||||
accelerator: { macos: "Cmd+Option+Down" },
|
accelerator: { macos: "Cmd+Option+Down" },
|
||||||
},
|
},
|
||||||
@@ -187,31 +269,31 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "window",
|
id: "window",
|
||||||
label: "Window",
|
labelKey: "desktop.menu.window",
|
||||||
role: "windowMenu",
|
role: "windowMenu",
|
||||||
items: [
|
items: [
|
||||||
{ type: "item", label: "Minimize", action: "window.minimize" },
|
{ type: "item", labelKey: "desktop.menu.minimize", action: "window.minimize" },
|
||||||
{ type: "item", label: "Maximize", action: "window.toggleMaximize" },
|
{ type: "item", labelKey: "desktop.menu.maximize", action: "window.toggleMaximize" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ type: "item", label: "Close Window", action: "window.close" },
|
{ type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "help",
|
id: "help",
|
||||||
label: "Help",
|
labelKey: "desktop.menu.help",
|
||||||
items: [
|
items: [
|
||||||
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
|
{ type: "item", labelKey: "desktop.menu.documentation", href: "https://opencode.ai/docs" },
|
||||||
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
|
{ type: "item", labelKey: "desktop.menu.supportForum", href: "https://discord.com/invite/opencode" },
|
||||||
{ type: "item", label: "Export Logs...", command: "logs.export" },
|
{ type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
label: "Share Feedback",
|
labelKey: "desktop.menu.shareFeedback",
|
||||||
href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml",
|
href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
label: "Report a Bug",
|
labelKey: "desktop.menu.reportBug",
|
||||||
href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml",
|
href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
|||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const dir = () => (directory ? directory() : decode64(params.dir))
|
const dir = () => (directory ? directory() : decode64(params.dir))
|
||||||
|
createEffect(() => {
|
||||||
|
const value = dir()
|
||||||
|
if (value) {
|
||||||
|
serverSync().enableProviders(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
const providers = () => {
|
const providers = () => {
|
||||||
const value = dir()
|
const value = dir()
|
||||||
const projectStore = value ? serverSync().child(value)[0] : undefined
|
const projectStore = value ? serverSync().child(value)[0] : undefined
|
||||||
|
|||||||
+250
-82
@@ -1,4 +1,102 @@
|
|||||||
export const dict = {
|
export const dict = {
|
||||||
|
"desktop.menu.app": "OpenCode",
|
||||||
|
"desktop.menu.file": "ملف",
|
||||||
|
"desktop.menu.edit": "تحرير",
|
||||||
|
"desktop.menu.view": "عرض",
|
||||||
|
"desktop.menu.go": "انتقال",
|
||||||
|
"desktop.menu.window": "نافذة",
|
||||||
|
"desktop.menu.help": "مساعدة",
|
||||||
|
"desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...",
|
||||||
|
"desktop.menu.settings": "الإعدادات",
|
||||||
|
"desktop.menu.reloadWebview": "إعادة تحميل Webview",
|
||||||
|
"desktop.menu.restart": "إعادة التشغيل",
|
||||||
|
"desktop.menu.exportLogs": "تصدير السجلات...",
|
||||||
|
"desktop.menu.newSession": "جلسة جديدة",
|
||||||
|
"desktop.menu.openProject": "فتح مشروع...",
|
||||||
|
"desktop.menu.newWindow": "نافذة جديدة",
|
||||||
|
"desktop.menu.closeWindow": "إغلاق النافذة",
|
||||||
|
"desktop.menu.undo": "تراجع",
|
||||||
|
"desktop.menu.redo": "إعادة",
|
||||||
|
"desktop.menu.cut": "قص",
|
||||||
|
"desktop.menu.copy": "نسخ",
|
||||||
|
"desktop.menu.paste": "لصق",
|
||||||
|
"desktop.menu.delete": "حذف",
|
||||||
|
"desktop.menu.selectAll": "تحديد الكل",
|
||||||
|
"desktop.menu.toggleSidebar": "إظهار الشريط الجانبي أو إخفاؤه",
|
||||||
|
"desktop.menu.toggleTerminal": "إظهار المحطة الطرفية أو إخفاؤها",
|
||||||
|
"desktop.menu.toggleFileTree": "إظهار شجرة الملفات أو إخفاؤها",
|
||||||
|
"desktop.menu.reload": "إعادة التحميل",
|
||||||
|
"desktop.menu.toggleDeveloperTools": "إظهار أدوات المطور أو إخفاؤها",
|
||||||
|
"desktop.menu.actualSize": "الحجم الفعلي",
|
||||||
|
"desktop.menu.zoomIn": "تكبير",
|
||||||
|
"desktop.menu.zoomOut": "تصغير",
|
||||||
|
"desktop.menu.toggleFullScreen": "الدخول إلى ملء الشاشة أو الخروج منه",
|
||||||
|
"desktop.menu.back": "رجوع",
|
||||||
|
"desktop.menu.forward": "تقدم",
|
||||||
|
"desktop.menu.previousSession": "الجلسة السابقة",
|
||||||
|
"desktop.menu.nextSession": "الجلسة التالية",
|
||||||
|
"desktop.menu.previousProject": "المشروع السابق",
|
||||||
|
"desktop.menu.nextProject": "المشروع التالي",
|
||||||
|
"desktop.menu.minimize": "تصغير النافذة",
|
||||||
|
"desktop.menu.maximize": "تكبير النافذة",
|
||||||
|
"desktop.menu.documentation": "وثائق OpenCode",
|
||||||
|
"desktop.menu.supportForum": "منتدى الدعم",
|
||||||
|
"desktop.menu.shareFeedback": "إرسال الملاحظات",
|
||||||
|
"desktop.menu.reportBug": "الإبلاغ عن خلل",
|
||||||
|
"desktop.menu.ariaLabel": "قائمة OpenCode",
|
||||||
|
|
||||||
|
"desktop.updater.dialog.checkFailed.message": "فشل التحقق من وجود تحديثات.",
|
||||||
|
"desktop.updater.dialog.checkFailed.title": "خطأ في التحديث",
|
||||||
|
"desktop.updater.dialog.upToDate.message": "لديك أحدث إصدار.",
|
||||||
|
"desktop.updater.dialog.upToDate.title": "لا توجد تحديثات",
|
||||||
|
"desktop.updater.dialog.ready.message": "تم تنزيل التحديث {{version}}. هل تريد إعادة التشغيل الآن؟",
|
||||||
|
"desktop.updater.dialog.ready.title": "التحديث جاهز",
|
||||||
|
"desktop.updater.dialog.restart": "إعادة التشغيل",
|
||||||
|
"desktop.updater.dialog.later": "لاحقًا",
|
||||||
|
|
||||||
|
"desktop.recovery.action.relaunch": "إعادة تشغيل التطبيق",
|
||||||
|
"desktop.recovery.action.exportLogs": "تصدير السجلات",
|
||||||
|
"desktop.recovery.action.keepWaiting": "متابعة الانتظار",
|
||||||
|
"desktop.recovery.action.quit": "إنهاء",
|
||||||
|
"desktop.recovery.loadFailed": "فشل تحميل OpenCode",
|
||||||
|
"desktop.recovery.terminated": "توقفت نافذة OpenCode بشكل غير متوقع",
|
||||||
|
"desktop.recovery.unresponsive": "OpenCode لا يستجيب",
|
||||||
|
"desktop.recovery.unresponsive.detail": "يمكنك إعادة تشغيل التطبيق أو فتح السجلات أو متابعة الانتظار.",
|
||||||
|
"desktop.recovery.loadFailed.detail": "النافذة: {{window}}\nعنوان URL: {{url}}\nالخطأ: {{code}} {{description}}",
|
||||||
|
"desktop.recovery.terminated.detail": "النافذة: {{window}}\nالسبب: {{reason}}\nالرمز: {{code}}",
|
||||||
|
"desktop.recovery.unknown": "<غير معروف>",
|
||||||
|
|
||||||
|
"desktop.dialog.chooseFolder": "اختيار مجلد",
|
||||||
|
"desktop.dialog.chooseFile": "اختيار ملف",
|
||||||
|
"desktop.dialog.saveFile": "حفظ الملف",
|
||||||
|
"desktop.dialog.files": "الملفات",
|
||||||
|
"desktop.server.local": "الخادم المحلي",
|
||||||
|
|
||||||
|
"desktop.wsl.error.windowsOnly": "لا يتوفر WSL إلا على Windows",
|
||||||
|
"desktop.wsl.error.unavailable": "WSL غير متاح",
|
||||||
|
"desktop.wsl.error.listInstalled": "فشل سرد توزيعات WSL المثبتة",
|
||||||
|
"desktop.wsl.error.listOnline": "فشل سرد توزيعات WSL المتاحة عبر الإنترنت",
|
||||||
|
"desktop.wsl.error.executeDistro": "تعذر تنفيذ الأوامر في التوزيعة",
|
||||||
|
"desktop.wsl.error.installWsl": "فشل تثبيت WSL",
|
||||||
|
"desktop.wsl.error.installDistro": "فشل تثبيت التوزيعة: {{distro}}",
|
||||||
|
"desktop.wsl.error.installOpencode": "فشل تثبيت OpenCode",
|
||||||
|
"desktop.wsl.error.alreadyAdded": "تمت إضافة {{distro}} بالفعل",
|
||||||
|
"desktop.wsl.error.opencodeMissing": "opencode غير مثبت في هذه التوزيعة",
|
||||||
|
"desktop.wsl.error.opencodeCannotRun": "opencode مثبت، لكن تعذر تشغيله",
|
||||||
|
"desktop.wsl.error.opencodeNotInstalled": "OpenCode غير مثبت في {{distro}}",
|
||||||
|
"desktop.wsl.error.updateVersion":
|
||||||
|
"اكتمل تحديث OpenCode، لكن {{distro}} ما زالت تعرض الإصدار {{installed}}؛ الإصدار المتوقع هو {{expected}}",
|
||||||
|
"desktop.wsl.error.noVersion": "لا يوجد إصدار",
|
||||||
|
"desktop.wsl.error.serverExited": "خرج خادم WSL بعد بدء التشغيل (code={{code}} signal={{signal}})",
|
||||||
|
"desktop.wsl.error.serverExitedBeforeHealthy":
|
||||||
|
"خرج خادم WSL قبل أن يصبح سليمًا (code={{code}} signal={{signal}}){{output}}",
|
||||||
|
"desktop.wsl.error.healthTimeout": "انتهت مهلة فحص سلامة العملية المصاحبة للتوزيعة {{distro}} بعد {{timeout}}ms",
|
||||||
|
"desktop.wsl.error.commandTimeout": "انتهت مهلة {{command}} {{args}} بعد {{timeout}}ms",
|
||||||
|
"desktop.wsl.error.failedPort": "فشل الحصول على المنفذ",
|
||||||
|
|
||||||
|
"desktop.picker.error.notSelected": "لم يحدد منتقي الملفات ملفًا",
|
||||||
|
"desktop.picker.error.sizeLimit": "تتجاوز المرفقات المحددة الحد البالغ {{limit}} MB",
|
||||||
|
|
||||||
"command.category.suggested": "مقترح",
|
"command.category.suggested": "مقترح",
|
||||||
"command.category.view": "عرض",
|
"command.category.view": "عرض",
|
||||||
"command.category.project": "مشروع",
|
"command.category.project": "مشروع",
|
||||||
@@ -16,6 +114,7 @@ export const dict = {
|
|||||||
"command.category.permissions": "أذونات",
|
"command.category.permissions": "أذونات",
|
||||||
"command.category.workspace": "مساحة عمل",
|
"command.category.workspace": "مساحة عمل",
|
||||||
"command.category.settings": "إعدادات",
|
"command.category.settings": "إعدادات",
|
||||||
|
"command.logs.export": "تصدير السجلات",
|
||||||
"theme.scheme.system": "نظام",
|
"theme.scheme.system": "نظام",
|
||||||
"theme.scheme.light": "فاتح",
|
"theme.scheme.light": "فاتح",
|
||||||
"theme.scheme.dark": "داكن",
|
"theme.scheme.dark": "داكن",
|
||||||
@@ -59,16 +158,16 @@ export const dict = {
|
|||||||
"command.message.next.description": "انتقل إلى رسالة المستخدم التالية",
|
"command.message.next.description": "انتقل إلى رسالة المستخدم التالية",
|
||||||
"command.model.choose": "اختيار نموذج",
|
"command.model.choose": "اختيار نموذج",
|
||||||
"command.model.choose.description": "حدد نموذجًا مختلفًا",
|
"command.model.choose.description": "حدد نموذجًا مختلفًا",
|
||||||
"command.mcp.toggle": "تبديل MCPs",
|
"command.mcp.toggle": "تبديل خوادم MCP",
|
||||||
"command.mcp.toggle.description": "تبديل MCPs",
|
"command.mcp.toggle.description": "تبديل خوادم MCP",
|
||||||
"command.agent.cycle": "تغيير الوكيل",
|
"command.agent.cycle": "تغيير الوكيل",
|
||||||
"command.agent.cycle.description": "التبديل إلى الوكيل التالي",
|
"command.agent.cycle.description": "التبديل إلى الوكيل التالي",
|
||||||
"command.agent.cycle.reverse": "تغيير الوكيل للخلف",
|
"command.agent.cycle.reverse": "تغيير الوكيل للخلف",
|
||||||
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
|
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
|
||||||
"command.model.variant.cycle": "تغيير جهد التفكير",
|
"command.model.variant.cycle": "تغيير جهد الاستدلال",
|
||||||
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
|
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
|
||||||
"command.prompt.mode.shell": "Shell",
|
"command.prompt.mode.shell": "Shell",
|
||||||
"command.prompt.mode.normal": "Prompt",
|
"command.prompt.mode.normal": "موجّه",
|
||||||
"command.permissions.autoaccept.enable": "قبول الأذونات تلقائيًا",
|
"command.permissions.autoaccept.enable": "قبول الأذونات تلقائيًا",
|
||||||
"command.permissions.autoaccept.disable": "إيقاف قبول الأذونات تلقائيًا",
|
"command.permissions.autoaccept.disable": "إيقاف قبول الأذونات تلقائيًا",
|
||||||
"command.workspace.toggle": "تبديل مساحات العمل",
|
"command.workspace.toggle": "تبديل مساحات العمل",
|
||||||
@@ -94,13 +193,14 @@ export const dict = {
|
|||||||
"dialog.provider.empty": "لم يتم العثور على موفرين",
|
"dialog.provider.empty": "لم يتم العثور على موفرين",
|
||||||
"dialog.provider.group.popular": "شائع",
|
"dialog.provider.group.popular": "شائع",
|
||||||
"dialog.provider.group.other": "آخر",
|
"dialog.provider.group.other": "آخر",
|
||||||
|
"dialog.provider.custom.label": "موفر مخصص متوافق مع OpenAI",
|
||||||
"dialog.provider.tag.recommended": "موصى به",
|
"dialog.provider.tag.recommended": "موصى به",
|
||||||
"dialog.provider.opencode.note": "نماذج مختارة تتضمن Claude و GPT و Gemini والمزيد",
|
"dialog.provider.opencode.note": "نماذج مختارة تتضمن Claude و GPT و Gemini والمزيد",
|
||||||
"dialog.provider.opencode.tagline": "نماذج موثوقة ومحسنة",
|
"dialog.provider.opencode.tagline": "نماذج موثوقة ومحسنة",
|
||||||
"dialog.provider.opencodeGo.tagline": "اشتراك منخفض التكلفة للجميع",
|
"dialog.provider.opencodeGo.tagline": "اشتراك منخفض التكلفة للجميع",
|
||||||
"dialog.provider.anthropic.note": "اتصل باستخدام Claude Pro/Max أو مفتاح API",
|
"dialog.provider.anthropic.note": "وصول مباشر إلى نماذج Claude، بما فيها Pro و Max",
|
||||||
"dialog.provider.copilot.note": "اتصل باستخدام Copilot أو مفتاح API",
|
"dialog.provider.copilot.note": "نماذج ذكاء اصطناعي للمساعدة في البرمجة عبر GitHub Copilot",
|
||||||
"dialog.provider.openai.note": "اتصل باستخدام ChatGPT Pro/Plus أو مفتاح API",
|
"dialog.provider.openai.note": "نماذج GPT لمهام الذكاء الاصطناعي العامة السريعة والمتقدمة",
|
||||||
"dialog.provider.google.note": "نماذج Gemini لاستجابات سريعة ومنظمة",
|
"dialog.provider.google.note": "نماذج Gemini لاستجابات سريعة ومنظمة",
|
||||||
"dialog.provider.openrouter.note": "الوصول إلى جميع النماذج المدعومة من موفر واحد",
|
"dialog.provider.openrouter.note": "الوصول إلى جميع النماذج المدعومة من موفر واحد",
|
||||||
"dialog.provider.vercel.note": "وصول موحد إلى نماذج الذكاء الاصطناعي مع توجيه ذكي",
|
"dialog.provider.vercel.note": "وصول موحد إلى نماذج الذكاء الاصطناعي مع توجيه ذكي",
|
||||||
@@ -112,17 +212,20 @@ export const dict = {
|
|||||||
"dialog.model.manage.provider.toggle": "تبديل جميع نماذج {{provider}}",
|
"dialog.model.manage.provider.toggle": "تبديل جميع نماذج {{provider}}",
|
||||||
"dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode",
|
"dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode",
|
||||||
"dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين",
|
"dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين",
|
||||||
|
"dialog.model.unpaid.viewMoreProviders": "عرض أكثر من 70 موفرًا إضافيًا",
|
||||||
"dialog.provider.viewAll": "عرض المزيد من الموفرين",
|
"dialog.provider.viewAll": "عرض المزيد من الموفرين",
|
||||||
"provider.connect.title": "اتصال {{provider}}",
|
"provider.connect.title": "الاتصال بـ {{provider}}",
|
||||||
"provider.connect.title.anthropicProMax": "تسجيل الدخول باستخدام Claude Pro/Max",
|
"provider.connect.title.anthropicProMax": "تسجيل الدخول باستخدام Claude Pro/Max",
|
||||||
"provider.connect.selectMethod": "حدد طريقة تسجيل الدخول لـ {{provider}}.",
|
"provider.connect.selectMethod": "حدد طريقة تسجيل الدخول لـ {{provider}}.",
|
||||||
"provider.connect.method.apiKey": "مفتاح API",
|
"provider.connect.method.apiKey": "مفتاح API",
|
||||||
|
"provider.connect.method.browser": "المتصفح",
|
||||||
|
"provider.connect.method.headless": "بدون واجهة رسومية",
|
||||||
"provider.connect.status.inProgress": "جارٍ التفويض...",
|
"provider.connect.status.inProgress": "جارٍ التفويض...",
|
||||||
"provider.connect.status.waiting": "في انتظار التفويض...",
|
"provider.connect.status.waiting": "في انتظار التفويض...",
|
||||||
"provider.connect.status.failed": "فشل التفويض: {{error}}",
|
"provider.connect.status.failed": "فشل التفويض: {{error}}",
|
||||||
"provider.connect.apiKey.description":
|
"provider.connect.apiKey.description":
|
||||||
"أدخل مفتاح واجهة برمجة تطبيقات {{provider}} الخاص بك لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.",
|
"أدخل مفتاح API الخاص بـ {{provider}} لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.",
|
||||||
"provider.connect.apiKey.label": "مفتاح واجهة برمجة تطبيقات {{provider}}",
|
"provider.connect.apiKey.label": "مفتاح API لـ {{provider}}",
|
||||||
"provider.connect.apiKey.placeholder": "مفتاح API",
|
"provider.connect.apiKey.placeholder": "مفتاح API",
|
||||||
"provider.connect.apiKey.required": "مفتاح API مطلوب",
|
"provider.connect.apiKey.required": "مفتاح API مطلوب",
|
||||||
"provider.connect.opencodeZen.line1":
|
"provider.connect.opencodeZen.line1":
|
||||||
@@ -148,6 +251,7 @@ export const dict = {
|
|||||||
"provider.connect.toast.connected.title": "تم توصيل {{provider}}",
|
"provider.connect.toast.connected.title": "تم توصيل {{provider}}",
|
||||||
"provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.",
|
"provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.",
|
||||||
"provider.custom.title": "موفر مخصص",
|
"provider.custom.title": "موفر مخصص",
|
||||||
|
"provider.custom.unavailable": "الموفرون المخصصون غير متاحين على هذا الخادم",
|
||||||
"provider.custom.description.prefix": "تكوين موفر متوافق مع OpenAI. راجع ",
|
"provider.custom.description.prefix": "تكوين موفر متوافق مع OpenAI. راجع ",
|
||||||
"provider.custom.description.link": "وثائق تكوين الموفر",
|
"provider.custom.description.link": "وثائق تكوين الموفر",
|
||||||
"provider.custom.description.suffix": ".",
|
"provider.custom.description.suffix": ".",
|
||||||
@@ -196,26 +300,26 @@ export const dict = {
|
|||||||
"model.input.image": "صورة",
|
"model.input.image": "صورة",
|
||||||
"model.input.audio": "صوت",
|
"model.input.audio": "صوت",
|
||||||
"model.input.video": "فيديو",
|
"model.input.video": "فيديو",
|
||||||
"model.input.pdf": "pdf",
|
"model.input.pdf": "PDF",
|
||||||
"model.tooltip.allows": "يسمح: {{inputs}}",
|
"model.tooltip.allows": "يسمح: {{inputs}}",
|
||||||
"model.tooltip.context.label": "السياق",
|
"model.tooltip.context.label": "السياق",
|
||||||
"model.tooltip.inputs": "المدخلات",
|
"model.tooltip.inputs": "المدخلات",
|
||||||
"model.tooltip.model": "النموذج",
|
"model.tooltip.model": "النموذج",
|
||||||
"model.tooltip.provider": "الموفر",
|
"model.tooltip.provider": "الموفر",
|
||||||
"model.tooltip.reasoning": "الاستنتاج",
|
"model.tooltip.reasoning": "الاستدلال",
|
||||||
"model.tooltip.reasoning.allowed": "يسمح بالاستنتاج",
|
"model.tooltip.reasoning.allowed": "يدعم الاستدلال",
|
||||||
"model.tooltip.reasoning.none": "بدون استنتاج",
|
"model.tooltip.reasoning.none": "بدون استدلال",
|
||||||
"model.tooltip.context": "حد السياق {{limit}}",
|
"model.tooltip.context": "حد السياق {{limit}}",
|
||||||
"common.search.placeholder": "بحث",
|
"common.search.placeholder": "بحث",
|
||||||
"common.goBack": "رجوع",
|
"common.goBack": "رجوع",
|
||||||
"common.goForward": "انتقل للأمام",
|
"common.goForward": "التقدم",
|
||||||
"common.loading": "جارٍ التحميل",
|
"common.loading": "جارٍ التحميل",
|
||||||
"common.loading.ellipsis": "...",
|
"common.loading.ellipsis": "...",
|
||||||
"common.cancel": "إلغاء",
|
"common.cancel": "إلغاء",
|
||||||
"common.clear": "مسح",
|
"common.clear": "مسح",
|
||||||
"common.connect": "اتصال",
|
"common.connect": "اتصال",
|
||||||
"common.disconnect": "قطع الاتصال",
|
"common.disconnect": "قطع الاتصال",
|
||||||
"common.continue": "إرسال",
|
"common.continue": "متابعة",
|
||||||
"common.submit": "إرسال",
|
"common.submit": "إرسال",
|
||||||
"common.save": "حفظ",
|
"common.save": "حفظ",
|
||||||
"common.saving": "جارٍ الحفظ...",
|
"common.saving": "جارٍ الحفظ...",
|
||||||
@@ -227,7 +331,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
|
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
|
||||||
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
|
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
"prompt.mode.normal": "Prompt",
|
"prompt.mode.normal": "موجّه",
|
||||||
"prompt.mode.shell.exit": "esc للخروج",
|
"prompt.mode.shell.exit": "esc للخروج",
|
||||||
"session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.",
|
"session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.",
|
||||||
"session.child.backToParent": "العودة إلى الجلسة الرئيسية.",
|
"session.child.backToParent": "العودة إلى الجلسة الرئيسية.",
|
||||||
@@ -249,17 +353,17 @@ export const dict = {
|
|||||||
"prompt.example.16": "إضافة معالجة الأخطاء لهذه الدالة",
|
"prompt.example.16": "إضافة معالجة الأخطاء لهذه الدالة",
|
||||||
"prompt.example.17": "اشرح نمط regex هذا",
|
"prompt.example.17": "اشرح نمط regex هذا",
|
||||||
"prompt.example.18": "تحويل هذا إلى TypeScript",
|
"prompt.example.18": "تحويل هذا إلى TypeScript",
|
||||||
"prompt.example.19": "إضافة تسجيل الدخول (logging) في جميع أنحاء قاعدة التعليمات البرمجية",
|
"prompt.example.19": "إضافة التسجيل في جميع أنحاء قاعدة التعليمات البرمجية",
|
||||||
"prompt.example.20": "ما هي التبعيات القديمة؟",
|
"prompt.example.20": "ما هي التبعيات القديمة؟",
|
||||||
"prompt.example.21": "ساعدني في كتابة برنامج نصي للهجرة",
|
"prompt.example.21": "ساعدني في كتابة برنامج نصي للترحيل",
|
||||||
"prompt.example.22": "تنفيذ التخزين المؤقت لهذه النقطة النهائية",
|
"prompt.example.22": "تنفيذ التخزين المؤقت لنقطة النهاية هذه",
|
||||||
"prompt.example.23": "إضافة ترقيم الصفحات إلى هذه القائمة",
|
"prompt.example.23": "إضافة ترقيم الصفحات إلى هذه القائمة",
|
||||||
"prompt.example.24": "إنشاء أمر CLI لـ...",
|
"prompt.example.24": "إنشاء أمر CLI لـ...",
|
||||||
"prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟",
|
"prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟",
|
||||||
"prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
|
"prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
|
||||||
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
|
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
|
||||||
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF أو الملفات النصية هنا",
|
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF أو الملفات النصية هنا",
|
||||||
"prompt.dropzone.file.label": "أفلت لإشارة @ للملف",
|
"prompt.dropzone.file.label": "أفلت الملف للإشارة إليه باستخدام @",
|
||||||
"prompt.slash.badge.custom": "مخصص",
|
"prompt.slash.badge.custom": "مخصص",
|
||||||
"prompt.slash.badge.skill": "مهارة",
|
"prompt.slash.badge.skill": "مهارة",
|
||||||
"prompt.slash.badge.mcp": "mcp",
|
"prompt.slash.badge.mcp": "mcp",
|
||||||
@@ -267,7 +371,7 @@ export const dict = {
|
|||||||
"prompt.context.includeActiveFile": "تضمين الملف النشط",
|
"prompt.context.includeActiveFile": "تضمين الملف النشط",
|
||||||
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
|
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
|
||||||
"prompt.context.removeFile": "إزالة الملف من السياق",
|
"prompt.context.removeFile": "إزالة الملف من السياق",
|
||||||
"prompt.action.attachFile": "إرفاق ملف",
|
"prompt.action.attachFile": "إضافة ملفات",
|
||||||
"prompt.menu.addImagesAndFiles": "إضافة ملفات والمزيد",
|
"prompt.menu.addImagesAndFiles": "إضافة ملفات والمزيد",
|
||||||
"prompt.menu.imagesAndFiles": "الصور والملفات",
|
"prompt.menu.imagesAndFiles": "الصور والملفات",
|
||||||
"prompt.menu.commands": "الأوامر",
|
"prompt.menu.commands": "الأوامر",
|
||||||
@@ -275,7 +379,7 @@ export const dict = {
|
|||||||
"prompt.menu.shellCommand": "أمر shell",
|
"prompt.menu.shellCommand": "أمر shell",
|
||||||
"prompt.attachment.remove": "إزالة المرفق",
|
"prompt.attachment.remove": "إزالة المرفق",
|
||||||
"prompt.action.send": "إرسال",
|
"prompt.action.send": "إرسال",
|
||||||
"prompt.action.stop": "توقف",
|
"prompt.action.stop": "إيقاف",
|
||||||
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
|
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
|
||||||
"prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل",
|
"prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل",
|
||||||
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
|
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
|
||||||
@@ -287,10 +391,10 @@ export const dict = {
|
|||||||
"prompt.toast.commandSendFailed.title": "فشل إرسال الأمر",
|
"prompt.toast.commandSendFailed.title": "فشل إرسال الأمر",
|
||||||
"prompt.toast.promptSendFailed.title": "فشل إرسال الموجه",
|
"prompt.toast.promptSendFailed.title": "فشل إرسال الموجه",
|
||||||
"prompt.toast.promptSendFailed.description": "تعذر استرداد الجلسة",
|
"prompt.toast.promptSendFailed.description": "تعذر استرداد الجلسة",
|
||||||
"dialog.mcp.title": "MCPs",
|
"dialog.mcp.title": "خوادم MCP",
|
||||||
"dialog.mcp.description": "{{enabled}} من {{total}} مفعل",
|
"dialog.mcp.description": "تم تفعيل {{enabled}} من أصل {{total}}",
|
||||||
"dialog.mcp.empty": "لم يتم تكوين MCPs",
|
"dialog.mcp.empty": "لم يتم تكوين أي خوادم MCP",
|
||||||
"dialog.lsp.empty": "تم الكشف تلقائيًا عن LSPs من أنواع الملفات",
|
"dialog.lsp.empty": "تُكتشف خوادم LSP تلقائيًا من أنواع الملفات",
|
||||||
"dialog.plugins.empty": "الإضافات المكونة في opencode.json",
|
"dialog.plugins.empty": "الإضافات المكونة في opencode.json",
|
||||||
"mcp.status.connected": "متصل",
|
"mcp.status.connected": "متصل",
|
||||||
"mcp.status.failed": "فشل",
|
"mcp.status.failed": "فشل",
|
||||||
@@ -401,6 +505,21 @@ export const dict = {
|
|||||||
"wsl.onboarding.desktopVersion": "سطح المكتب {{version}}",
|
"wsl.onboarding.desktopVersion": "سطح المكتب {{version}}",
|
||||||
"wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.",
|
"wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.",
|
||||||
"wsl.onboarding.adding": "جارٍ الإضافة...",
|
"wsl.onboarding.adding": "جارٍ الإضافة...",
|
||||||
|
"help.tabs.toast.ariaLabel": "نقدّم علامات التبويب. نظّم عملك وجلساتك النشطة باستخدام علامات التبويب",
|
||||||
|
"help.tabs.toast.dismiss": "إغلاق معلومات علامات التبويب",
|
||||||
|
"help.tabs.title": "نقدّم علامات التبويب",
|
||||||
|
"help.tabs.description": "نظّم عملك وجلساتك النشطة باستخدام علامات التبويب",
|
||||||
|
"help.tabs.date": "14 يوليو",
|
||||||
|
"help.tabs.introduction": "أصبح OpenCode Desktop الآن قائمًا على علامات التبويب.",
|
||||||
|
"help.tabs.sessions":
|
||||||
|
"ابدأ جلسة جديدة في علامة تبويب، أو افتح جلسة حالية من أي من مشاريعك. افتح علامة تبويب جديدة عندما تبدأ عملاً جديدًا، وأغلقها عند الانتهاء.",
|
||||||
|
"help.tabs.organize":
|
||||||
|
"يسهّل إبقاء بضع علامات تبويب مفتوحة تنظيم جلساتك النشطة. أعد تسمية علامات التبويب بأسماء يسهل تذكرها إذا كنت تنوي إبقاءها مفتوحة.",
|
||||||
|
"help.tabs.home":
|
||||||
|
"ستجد جميع جلساتك ومشاريعك في شاشة الصفحة الرئيسية الجديدة. يؤدي تحديد جلسة إلى فتحها في علامة تبويب.",
|
||||||
|
"help.tabs.persistence": "عندما تعيد فتح التطبيق، تظل علامات تبويبك مفتوحة.",
|
||||||
|
"help.tabs.worktrees":
|
||||||
|
"لا يدعم التصميم الجديد أشجار عمل Git حتى الآن، وستتوفر قريبًا. لذلك، إذا كنت تفضل مواصلة استخدام التخطيط السابق، فيمكنك التبديل بين التخطيطات في الإعدادات. ضع في اعتبارك أن التخطيط الجديد سيصبح دائمًا خلال بضعة أسابيع.",
|
||||||
"dialog.project.edit.title": "تحرير المشروع",
|
"dialog.project.edit.title": "تحرير المشروع",
|
||||||
"dialog.project.edit.name": "الاسم",
|
"dialog.project.edit.name": "الاسم",
|
||||||
"dialog.project.edit.icon": "أيقونة",
|
"dialog.project.edit.icon": "أيقونة",
|
||||||
@@ -409,35 +528,35 @@ export const dict = {
|
|||||||
"dialog.project.edit.icon.recommended": "موصى به: 128x128px",
|
"dialog.project.edit.icon.recommended": "موصى به: 128x128px",
|
||||||
"dialog.project.edit.color": "لون",
|
"dialog.project.edit.color": "لون",
|
||||||
"dialog.project.edit.color.select": "اختر لون {{color}}",
|
"dialog.project.edit.color.select": "اختر لون {{color}}",
|
||||||
"dialog.project.edit.worktree.startup": "سكريبت بدء تشغيل مساحة العمل",
|
"dialog.project.edit.worktree.startup": "برنامج نصي لبدء تشغيل مساحة العمل",
|
||||||
"dialog.project.edit.worktree.startup.description": "يتم تشغيله بعد إنشاء مساحة عمل جديدة (شجرة عمل).",
|
"dialog.project.edit.worktree.startup.description": "يتم تشغيله بعد إنشاء مساحة عمل جديدة (شجرة عمل).",
|
||||||
"dialog.project.edit.worktree.startup.placeholder": "مثال: bun install",
|
"dialog.project.edit.worktree.startup.placeholder": "مثال: bun install",
|
||||||
"context.breakdown.title": "تفصيل السياق",
|
"context.breakdown.title": "تفصيل السياق",
|
||||||
"context.breakdown.note": 'تفصيل تقريبي لرموز الإدخال. يشمل "أخرى" تعريفات الأدوات والنفقات العامة.',
|
"context.breakdown.note": 'تفصيل تقريبي لرموز الإدخال المميزة. يشمل "أخرى" تعريفات الأدوات والأعباء الإضافية.',
|
||||||
"context.breakdown.system": "النظام",
|
"context.breakdown.system": "النظام",
|
||||||
"context.breakdown.user": "المستخدم",
|
"context.breakdown.user": "المستخدم",
|
||||||
"context.breakdown.assistant": "المساعد",
|
"context.breakdown.assistant": "المساعد",
|
||||||
"context.breakdown.tool": "استدعاءات الأداة",
|
"context.breakdown.tool": "استدعاءات الأدوات",
|
||||||
"context.breakdown.other": "أخرى",
|
"context.breakdown.other": "أخرى",
|
||||||
"context.systemPrompt.title": "موجه النظام",
|
"context.systemPrompt.title": "موجّه النظام",
|
||||||
"context.rawMessages.title": "الرسائل الخام",
|
"context.rawMessages.title": "الرسائل الخام",
|
||||||
"context.stats.session": "جلسة",
|
"context.stats.session": "جلسة",
|
||||||
"context.stats.messages": "رسائل",
|
"context.stats.messages": "رسائل",
|
||||||
"context.stats.provider": "موفر",
|
"context.stats.provider": "موفر",
|
||||||
"context.stats.model": "نموذج",
|
"context.stats.model": "نموذج",
|
||||||
"context.stats.limit": "حد السياق",
|
"context.stats.limit": "حد السياق",
|
||||||
"context.stats.totalTokens": "إجمالي الرموز",
|
"context.stats.totalTokens": "إجمالي الرموز المميزة",
|
||||||
"context.stats.usage": "استخدام",
|
"context.stats.usage": "استخدام",
|
||||||
"context.stats.inputTokens": "رموز الإدخال",
|
"context.stats.inputTokens": "رموز الإدخال المميزة",
|
||||||
"context.stats.outputTokens": "رموز الإخراج",
|
"context.stats.outputTokens": "رموز الإخراج المميزة",
|
||||||
"context.stats.reasoningTokens": "رموز الاستنتاج",
|
"context.stats.reasoningTokens": "رموز الاستدلال المميزة",
|
||||||
"context.stats.cacheTokens": "رموز التخزين المؤقت (قراءة/كتابة)",
|
"context.stats.cacheTokens": "رموز التخزين المؤقت المميزة (قراءة/كتابة)",
|
||||||
"context.stats.userMessages": "رسائل المستخدم",
|
"context.stats.userMessages": "رسائل المستخدم",
|
||||||
"context.stats.assistantMessages": "رسائل المساعد",
|
"context.stats.assistantMessages": "رسائل المساعد",
|
||||||
"context.stats.totalCost": "التكلفة الإجمالية",
|
"context.stats.totalCost": "التكلفة الإجمالية",
|
||||||
"context.stats.sessionCreated": "تم إنشاء الجلسة",
|
"context.stats.sessionCreated": "تم إنشاء الجلسة",
|
||||||
"context.stats.lastActivity": "آخر نشاط",
|
"context.stats.lastActivity": "آخر نشاط",
|
||||||
"context.usage.tokens": "رموز",
|
"context.usage.tokens": "الرموز المميزة",
|
||||||
"context.usage.usage": "استخدام",
|
"context.usage.usage": "استخدام",
|
||||||
"context.usage.cost": "تكلفة",
|
"context.usage.cost": "تكلفة",
|
||||||
"context.usage.clickToView": "انقر لعرض السياق",
|
"context.usage.clickToView": "انقر لعرض السياق",
|
||||||
@@ -465,9 +584,9 @@ export const dict = {
|
|||||||
"toast.theme.title": "تم تبديل السمة",
|
"toast.theme.title": "تم تبديل السمة",
|
||||||
"toast.scheme.title": "مخطط الألوان",
|
"toast.scheme.title": "مخطط الألوان",
|
||||||
"toast.workspace.enabled.title": "تم تمكين مساحات العمل",
|
"toast.workspace.enabled.title": "تم تمكين مساحات العمل",
|
||||||
"toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي",
|
"toast.workspace.enabled.description": "تُعرض الآن عدة أشجار عمل في الشريط الجانبي",
|
||||||
"toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
|
"toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
|
||||||
"toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي",
|
"toast.workspace.disabled.description": "تُعرض شجرة العمل الرئيسية فقط في الشريط الجانبي",
|
||||||
"toast.permissions.autoaccept.on.title": "يتم قبول الأذونات تلقائيًا",
|
"toast.permissions.autoaccept.on.title": "يتم قبول الأذونات تلقائيًا",
|
||||||
"toast.permissions.autoaccept.on.description": "ستتم الموافقة على طلبات الأذونات تلقائيًا",
|
"toast.permissions.autoaccept.on.description": "ستتم الموافقة على طلبات الأذونات تلقائيًا",
|
||||||
"toast.permissions.autoaccept.off.title": "تم إيقاف قبول الأذونات تلقائيًا",
|
"toast.permissions.autoaccept.off.title": "تم إيقاف قبول الأذونات تلقائيًا",
|
||||||
@@ -489,7 +608,7 @@ export const dict = {
|
|||||||
"toast.session.unshare.failed.description": "حدث خطأ أثناء إلغاء مشاركة الجلسة",
|
"toast.session.unshare.failed.description": "حدث خطأ أثناء إلغاء مشاركة الجلسة",
|
||||||
"toast.session.listFailed.title": "فشل تحميل الجلسات لـ {{project}}",
|
"toast.session.listFailed.title": "فشل تحميل الجلسات لـ {{project}}",
|
||||||
"toast.update.title": "تحديث متاح",
|
"toast.update.title": "تحديث متاح",
|
||||||
"toast.update.description": "نسخة جديدة من OpenCode ({{version}}) متاحة الآن للتثبيت.",
|
"toast.update.description": "يتوفر الآن إصدار جديد من OpenCode ({{version}}) للتثبيت.",
|
||||||
"toast.update.action.installRestart": "تثبيت وإعادة تشغيل",
|
"toast.update.action.installRestart": "تثبيت وإعادة تشغيل",
|
||||||
"toast.update.action.notYet": "ليس الآن",
|
"toast.update.action.notYet": "ليس الآن",
|
||||||
"error.page.title": "حدث خطأ ما",
|
"error.page.title": "حدث خطأ ما",
|
||||||
@@ -509,7 +628,7 @@ export const dict = {
|
|||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
|
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
|
||||||
"error.serverSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
|
"error.serverSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
|
||||||
"directory.error.invalidUrl": "دليل غير صالح في عنوان URL.",
|
"directory.error.invalidUrl": "مجلد غير صالح في عنوان URL.",
|
||||||
"error.chain.unknown": "خطأ غير معروف",
|
"error.chain.unknown": "خطأ غير معروف",
|
||||||
"error.chain.causedBy": "بسبب:",
|
"error.chain.causedBy": "بسبب:",
|
||||||
"error.chain.apiError": "خطأ API",
|
"error.chain.apiError": "خطأ API",
|
||||||
@@ -537,7 +656,7 @@ export const dict = {
|
|||||||
"notification.session.responseReady.title": "الاستجابة جاهزة",
|
"notification.session.responseReady.title": "الاستجابة جاهزة",
|
||||||
"notification.session.error.title": "خطأ في الجلسة",
|
"notification.session.error.title": "خطأ في الجلسة",
|
||||||
"notification.session.error.fallbackDescription": "حدث خطأ",
|
"notification.session.error.fallbackDescription": "حدث خطأ",
|
||||||
"home.recentProjects": "المشاريع الحديثة",
|
"home.recentProjects": "المشاريع الأخيرة",
|
||||||
"home.empty.title": "لا توجد مشاريع حديثة",
|
"home.empty.title": "لا توجد مشاريع حديثة",
|
||||||
"home.empty.description": "ابدأ بفتح مشروع محلي",
|
"home.empty.description": "ابدأ بفتح مشروع محلي",
|
||||||
"home.title": "الرئيسية",
|
"home.title": "الرئيسية",
|
||||||
@@ -555,7 +674,7 @@ export const dict = {
|
|||||||
"home.sessions.group.today": "اليوم",
|
"home.sessions.group.today": "اليوم",
|
||||||
"home.sessions.group.yesterday": "أمس",
|
"home.sessions.group.yesterday": "أمس",
|
||||||
"home.sessions.group.older": "الأقدم",
|
"home.sessions.group.older": "الأقدم",
|
||||||
"home.providerTip": "اتصل بأكثر من 75 مزودًا لاستخدام نماذج أخرى، بما فيها Claude وGPT وGemini وغيرها",
|
"home.providerTip": "اتصل بأكثر من 75 موفرًا لاستخدام نماذج أخرى، بما فيها Claude وGPT وGemini وغيرها",
|
||||||
"session.tab.session": "جلسة",
|
"session.tab.session": "جلسة",
|
||||||
"session.tab.review": "مراجعة",
|
"session.tab.review": "مراجعة",
|
||||||
"session.tab.context": "سياق",
|
"session.tab.context": "سياق",
|
||||||
@@ -565,7 +684,7 @@ export const dict = {
|
|||||||
"session.error.notFound.description": "يشير هذا التبويب إلى جلسة لم تعد موجودة على هذا الخادم.",
|
"session.error.notFound.description": "يشير هذا التبويب إلى جلسة لم تعد موجودة على هذا الخادم.",
|
||||||
"session.error.notFound.closeTab": "إغلاق علامة التبويب",
|
"session.error.notFound.closeTab": "إغلاق علامة التبويب",
|
||||||
"session.error.serverConnection": "تعذر الاتصال بهذا الخادم",
|
"session.error.serverConnection": "تعذر الاتصال بهذا الخادم",
|
||||||
"session.review.filesChanged": "تم تغيير {{count}} ملفات",
|
"session.review.filesChanged": "الملفات المتغيرة: {{count}}",
|
||||||
"session.review.change.one": "تغيير",
|
"session.review.change.one": "تغيير",
|
||||||
"session.review.change.other": "تغييرات",
|
"session.review.change.other": "تغييرات",
|
||||||
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
|
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
|
||||||
@@ -573,7 +692,7 @@ export const dict = {
|
|||||||
"session.review.noChanges": "لا توجد تغييرات",
|
"session.review.noChanges": "لا توجد تغييرات",
|
||||||
"session.review.noUncommittedChanges": "لا توجد تغييرات غير مُودَعة بعد",
|
"session.review.noUncommittedChanges": "لا توجد تغييرات غير مُودَعة بعد",
|
||||||
"session.review.noBranchChanges": "لا توجد تغييرات في الفرع بعد",
|
"session.review.noBranchChanges": "لا توجد تغييرات في الفرع بعد",
|
||||||
"session.review.noVcs": "لم يتم اكتشاف نظام التحكم في الإصدار Git، لن يتم عرض التغييرات",
|
"session.review.noVcs": "لم يُكتشف نظام Git للتحكم في الإصدارات، لذا لن تُعرض التغييرات",
|
||||||
"session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة",
|
"session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة",
|
||||||
"session.files.selectToOpen": "اختر ملفًا لفتحه",
|
"session.files.selectToOpen": "اختر ملفًا لفتحه",
|
||||||
"session.files.all": "كل الملفات",
|
"session.files.all": "كل الملفات",
|
||||||
@@ -591,14 +710,26 @@ export const dict = {
|
|||||||
"session.question.minimize": "تصغير السؤال",
|
"session.question.minimize": "تصغير السؤال",
|
||||||
"session.question.restore": "استعادة السؤال",
|
"session.question.restore": "استعادة السؤال",
|
||||||
"session.question.pending.one": "{{count}} سؤال معلق",
|
"session.question.pending.one": "{{count}} سؤال معلق",
|
||||||
|
"session.question.pending.zero": "عدد الأسئلة المعلقة: {{count}}",
|
||||||
|
"session.question.pending.two": "عدد الأسئلة المعلقة: {{count}}",
|
||||||
|
"session.question.pending.few": "{{count}} أسئلة معلقة",
|
||||||
|
"session.question.pending.many": "{{count}} سؤالًا معلقًا",
|
||||||
"session.question.pending.other": "الأسئلة المعلقة: {{count}}",
|
"session.question.pending.other": "الأسئلة المعلقة: {{count}}",
|
||||||
"session.followupDock.summary.one": "{{count}} رسالة في الانتظار",
|
"session.followupDock.summary.one": "{{count}} رسالة في قائمة الانتظار",
|
||||||
"session.followupDock.summary.other": "{{count}} رسائل في الانتظار",
|
"session.followupDock.summary.zero": "عدد الرسائل في قائمة الانتظار: {{count}}",
|
||||||
|
"session.followupDock.summary.two": "عدد الرسائل في قائمة الانتظار: {{count}}",
|
||||||
|
"session.followupDock.summary.few": "{{count}} رسائل في قائمة الانتظار",
|
||||||
|
"session.followupDock.summary.many": "{{count}} رسالةً في قائمة الانتظار",
|
||||||
|
"session.followupDock.summary.other": "{{count}} رسائل في قائمة الانتظار",
|
||||||
"session.followupDock.sendNow": "إرسال الآن",
|
"session.followupDock.sendNow": "إرسال الآن",
|
||||||
"session.followupDock.edit": "تحرير",
|
"session.followupDock.edit": "تحرير",
|
||||||
"session.followupDock.collapse": "طي الرسائل المنتظرة",
|
"session.followupDock.collapse": "طي الرسائل المنتظرة",
|
||||||
"session.followupDock.expand": "توسيع الرسائل المنتظرة",
|
"session.followupDock.expand": "توسيع الرسائل المنتظرة",
|
||||||
"session.revertDock.summary.one": "{{count}} رسالة تم التراجع عنها",
|
"session.revertDock.summary.one": "{{count}} رسالة تم التراجع عنها",
|
||||||
|
"session.revertDock.summary.zero": "عدد الرسائل التي تم التراجع عنها: {{count}}",
|
||||||
|
"session.revertDock.summary.two": "عدد الرسائل التي تم التراجع عنها: {{count}}",
|
||||||
|
"session.revertDock.summary.few": "{{count}} رسائل تم التراجع عنها",
|
||||||
|
"session.revertDock.summary.many": "{{count}} رسالةً تم التراجع عنها",
|
||||||
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
|
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
|
||||||
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
|
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
|
||||||
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
|
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
|
||||||
@@ -650,11 +781,17 @@ export const dict = {
|
|||||||
"terminal.close": "إغلاق المحطة الطرفية",
|
"terminal.close": "إغلاق المحطة الطرفية",
|
||||||
"terminal.connectionLost.title": "فقد الاتصال",
|
"terminal.connectionLost.title": "فقد الاتصال",
|
||||||
"terminal.connectionLost.description": "انقطع اتصال المحطة الطرفية. يمكن أن يحدث هذا عند إعادة تشغيل الخادم.",
|
"terminal.connectionLost.description": "انقطع اتصال المحطة الطرفية. يمكن أن يحدث هذا عند إعادة تشغيل الخادم.",
|
||||||
|
"terminal.connectTicket.csrfError": "رُفضت تذكرة اتصال PTY بسبب فحوصات الأصل أو CSRF. تحقق من إعداد CORS للخادم.",
|
||||||
|
"terminal.connectTicket.statusError": "فشلت تذكرة اتصال PTY بالحالة {{status}}",
|
||||||
|
|
||||||
|
"titlebar.update": "تحديث",
|
||||||
|
"titlebar.updateVersion": "تحديث {{version}}",
|
||||||
|
|
||||||
"common.closeTab": "إغلاق علامة التبويب",
|
"common.closeTab": "إغلاق علامة التبويب",
|
||||||
"common.dismiss": "رفض",
|
"common.dismiss": "إهمال",
|
||||||
"common.requestFailed": "فشل الطلب",
|
"common.requestFailed": "فشل الطلب",
|
||||||
"common.moreOptions": "مزيد من الخيارات",
|
"common.moreOptions": "مزيد من الخيارات",
|
||||||
"common.learnMore": "اعرف المزيد",
|
"common.learnMore": "اطّلع على المزيد",
|
||||||
"common.rename": "إعادة تسمية",
|
"common.rename": "إعادة تسمية",
|
||||||
"common.reset": "إعادة تعيين",
|
"common.reset": "إعادة تعيين",
|
||||||
"common.archive": "أرشفة",
|
"common.archive": "أرشفة",
|
||||||
@@ -672,7 +809,7 @@ export const dict = {
|
|||||||
"sidebar.gettingStarted.title": "البدء",
|
"sidebar.gettingStarted.title": "البدء",
|
||||||
"sidebar.gettingStarted.line1": "يتضمن OpenCode نماذج مجانية حتى تتمكن من البدء فورًا.",
|
"sidebar.gettingStarted.line1": "يتضمن OpenCode نماذج مجانية حتى تتمكن من البدء فورًا.",
|
||||||
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
|
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
|
||||||
"sidebar.project.recentSessions": "الجلسات الحديثة",
|
"sidebar.project.recentSessions": "الجلسات الأخيرة",
|
||||||
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
|
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
|
||||||
"sidebar.project.clearNotifications": "مسح الإشعارات",
|
"sidebar.project.clearNotifications": "مسح الإشعارات",
|
||||||
"sidebar.empty.title": "لا توجد مشاريع مفتوحة",
|
"sidebar.empty.title": "لا توجد مشاريع مفتوحة",
|
||||||
@@ -691,7 +828,7 @@ export const dict = {
|
|||||||
"settings.general.section.updates": "التحديثات",
|
"settings.general.section.updates": "التحديثات",
|
||||||
"settings.general.section.sounds": "المؤثرات الصوتية",
|
"settings.general.section.sounds": "المؤثرات الصوتية",
|
||||||
"settings.general.section.feed": "الخلاصة",
|
"settings.general.section.feed": "الخلاصة",
|
||||||
"settings.general.section.display": "شاشة العرض",
|
"settings.general.section.display": "العرض",
|
||||||
"settings.general.row.language.title": "اللغة",
|
"settings.general.row.language.title": "اللغة",
|
||||||
"settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
|
"settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
|
||||||
"settings.general.row.shell.title": "Shell المحطة الطرفية",
|
"settings.general.row.shell.title": "Shell المحطة الطرفية",
|
||||||
@@ -728,10 +865,11 @@ export const dict = {
|
|||||||
"settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي",
|
"settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي",
|
||||||
"settings.general.row.mobileTitlebarBottom.description":
|
"settings.general.row.mobileTitlebarBottom.description":
|
||||||
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
|
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
|
||||||
"settings.general.row.showCustomAgents.title": "الوكلاء المخصصون",
|
"settings.general.row.showCustomAgents.title": "إظهار الوكيل",
|
||||||
"settings.general.row.showCustomAgents.description": "إظهار محدد الوكلاء في محرر الرسائل",
|
"settings.general.row.showCustomAgents.description":
|
||||||
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج",
|
"التبديل بين الوكلاء في محرر الرسائل. عند إخفائه، يُستخدم وكيل Build افتراضيًا.",
|
||||||
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني",
|
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستدلال",
|
||||||
|
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استدلال النموذج في الشريط الزمني",
|
||||||
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
|
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
|
||||||
"settings.general.row.shellToolPartsExpanded.description":
|
"settings.general.row.shellToolPartsExpanded.description":
|
||||||
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
|
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
|
||||||
@@ -744,11 +882,12 @@ export const dict = {
|
|||||||
"استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.",
|
"استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.",
|
||||||
"settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد",
|
"settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد",
|
||||||
"settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا",
|
"settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا",
|
||||||
"settings.general.row.newInterfaceNotice.dismiss": "رفض",
|
"settings.general.row.newInterfaceNotice.dismiss": "إهمال",
|
||||||
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
|
"settings.general.row.pinchZoom.title": "التكبير بالمقاربة والمباعدة",
|
||||||
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
|
"settings.general.row.pinchZoom.description":
|
||||||
|
"السماح بإيماءات مقاربة الأصابع ومباعدتها على لوحة التعقب والتمرير مع الضغط على Ctrl للتكبير",
|
||||||
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
||||||
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
|
"settings.general.row.wayland.description": "تعطيل الرجوع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
|
||||||
"settings.general.row.wayland.tooltip":
|
"settings.general.row.wayland.tooltip":
|
||||||
"على Linux مع شاشات بمعدلات تحديث مختلطة، يمكن أن يكون Wayland الأصلي أكثر استقرارًا.",
|
"على Linux مع شاشات بمعدلات تحديث مختلطة، يمكن أن يكون Wayland الأصلي أكثر استقرارًا.",
|
||||||
"settings.general.row.releaseNotes.title": "ملاحظات الإصدار",
|
"settings.general.row.releaseNotes.title": "ملاحظات الإصدار",
|
||||||
@@ -761,7 +900,7 @@ export const dict = {
|
|||||||
"settings.updates.action.checking": "جارٍ التحقق...",
|
"settings.updates.action.checking": "جارٍ التحقق...",
|
||||||
"settings.updates.action.downloading": "جارٍ التنزيل...",
|
"settings.updates.action.downloading": "جارٍ التنزيل...",
|
||||||
"settings.updates.action.installing": "جارٍ التثبيت...",
|
"settings.updates.action.installing": "جارٍ التثبيت...",
|
||||||
"settings.updates.toast.latest.title": "أنت على آخر إصدار",
|
"settings.updates.toast.latest.title": "لديك أحدث إصدار",
|
||||||
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
|
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
|
||||||
"sound.option.none": "بلا",
|
"sound.option.none": "بلا",
|
||||||
"sound.option.alert01": "تنبيه 01",
|
"sound.option.alert01": "تنبيه 01",
|
||||||
@@ -823,17 +962,17 @@ export const dict = {
|
|||||||
"settings.general.sounds.errors.description": "تشغيل صوت عند حدوث خطأ",
|
"settings.general.sounds.errors.description": "تشغيل صوت عند حدوث خطأ",
|
||||||
"settings.shortcuts.title": "اختصارات لوحة المفاتيح",
|
"settings.shortcuts.title": "اختصارات لوحة المفاتيح",
|
||||||
"settings.shortcuts.reset.button": "إعادة التعيين إلى الافتراضيات",
|
"settings.shortcuts.reset.button": "إعادة التعيين إلى الافتراضيات",
|
||||||
"settings.shortcuts.reset.toast.title": "تم إعادة تعيين الاختصارات",
|
"settings.shortcuts.reset.toast.title": "أُعيد تعيين الاختصارات",
|
||||||
"settings.shortcuts.reset.toast.description": "تم إعادة تعيين اختصارات لوحة المفاتيح إلى الافتراضيات.",
|
"settings.shortcuts.reset.toast.description": "أُعيد تعيين اختصارات لوحة المفاتيح إلى الإعدادات الافتراضية.",
|
||||||
"settings.shortcuts.conflict.title": "الاختصار قيد الاستخدام بالفعل",
|
"settings.shortcuts.conflict.title": "الاختصار قيد الاستخدام بالفعل",
|
||||||
"settings.shortcuts.conflict.description": "{{keybind}} معين بالفعل لـ {{titles}}.",
|
"settings.shortcuts.conflict.description": "الاختصار {{keybind}} مخصص بالفعل لـ {{titles}}.",
|
||||||
"settings.shortcuts.unassigned": "غير معين",
|
"settings.shortcuts.unassigned": "غير مخصص",
|
||||||
"settings.shortcuts.pressKeys": "اضغط على المفاتيح",
|
"settings.shortcuts.pressKeys": "اضغط على المفاتيح",
|
||||||
"settings.shortcuts.search.placeholder": "البحث في الاختصارات",
|
"settings.shortcuts.search.placeholder": "البحث في الاختصارات",
|
||||||
"settings.shortcuts.search.empty": "لم يتم العثور على اختصارات",
|
"settings.shortcuts.search.empty": "لم يتم العثور على اختصارات",
|
||||||
"settings.shortcuts.group.general": "عام",
|
"settings.shortcuts.group.general": "عام",
|
||||||
"settings.shortcuts.group.session": "جلسة",
|
"settings.shortcuts.group.session": "جلسة",
|
||||||
"settings.shortcuts.group.navigation": "تصفح",
|
"settings.shortcuts.group.navigation": "التنقل",
|
||||||
"settings.shortcuts.group.modelAndAgent": "النموذج والوكيل",
|
"settings.shortcuts.group.modelAndAgent": "النموذج والوكيل",
|
||||||
"settings.shortcuts.group.terminal": "المحطة الطرفية",
|
"settings.shortcuts.group.terminal": "المحطة الطرفية",
|
||||||
"settings.shortcuts.group.prompt": "موجه",
|
"settings.shortcuts.group.prompt": "موجه",
|
||||||
@@ -858,19 +997,18 @@ export const dict = {
|
|||||||
"settings.permissions.description": "تحكم في الأدوات التي يمكن للخادم استخدامها بشكل افتراضي.",
|
"settings.permissions.description": "تحكم في الأدوات التي يمكن للخادم استخدامها بشكل افتراضي.",
|
||||||
"settings.permissions.section.tools": "الأدوات",
|
"settings.permissions.section.tools": "الأدوات",
|
||||||
"settings.permissions.toast.updateFailed.title": "فشل تحديث الأذونات",
|
"settings.permissions.toast.updateFailed.title": "فشل تحديث الأذونات",
|
||||||
"settings.permissions.action.allow": "سماح",
|
"settings.permissions.action.allow": "السماح",
|
||||||
"settings.permissions.action.ask": "سؤال",
|
"settings.permissions.action.ask": "طلب الإذن",
|
||||||
"settings.permissions.action.deny": "رفض",
|
"settings.permissions.action.deny": "الرفض",
|
||||||
"settings.permissions.tool.read.title": "قراءة",
|
"settings.permissions.tool.read.title": "قراءة",
|
||||||
"settings.permissions.tool.read.description": "قراءة ملف (يطابق مسار الملف)",
|
"settings.permissions.tool.read.description": "قراءة ملف (يطابق مسار الملف)",
|
||||||
"settings.permissions.tool.edit.title": "تحرير",
|
"settings.permissions.tool.edit.title": "تحرير",
|
||||||
"settings.permissions.tool.edit.description":
|
"settings.permissions.tool.edit.description": "تعديل الملفات، بما في ذلك عمليات التحرير والكتابة وتطبيق الرقع",
|
||||||
"تعديل الملفات، بما في ذلك التحرير والكتابة والتصحيحات والتحرير المتعدد",
|
|
||||||
"settings.permissions.tool.glob.title": "Glob",
|
"settings.permissions.tool.glob.title": "Glob",
|
||||||
"settings.permissions.tool.glob.description": "مطابقة الملفات باستخدام أنماط glob",
|
"settings.permissions.tool.glob.description": "مطابقة الملفات باستخدام أنماط glob",
|
||||||
"settings.permissions.tool.grep.title": "Grep",
|
"settings.permissions.tool.grep.title": "Grep",
|
||||||
"settings.permissions.tool.grep.description": "البحث في محتويات الملف باستخدام التعبيرات العادية",
|
"settings.permissions.tool.grep.description": "البحث في محتويات الملف باستخدام التعبيرات العادية",
|
||||||
"settings.permissions.tool.list.title": "قائمة",
|
"settings.permissions.tool.list.title": "سرد",
|
||||||
"settings.permissions.tool.list.description": "سرد الملفات داخل دليل",
|
"settings.permissions.tool.list.description": "سرد الملفات داخل دليل",
|
||||||
"settings.permissions.tool.bash.title": "Bash",
|
"settings.permissions.tool.bash.title": "Bash",
|
||||||
"settings.permissions.tool.bash.description": "تشغيل أوامر shell",
|
"settings.permissions.tool.bash.description": "تشغيل أوامر shell",
|
||||||
@@ -888,7 +1026,7 @@ export const dict = {
|
|||||||
"settings.permissions.tool.websearch.description": "البحث في الويب",
|
"settings.permissions.tool.websearch.description": "البحث في الويب",
|
||||||
"settings.permissions.tool.external_directory.title": "دليل خارجي",
|
"settings.permissions.tool.external_directory.title": "دليل خارجي",
|
||||||
"settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع",
|
"settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع",
|
||||||
"settings.permissions.tool.doom_loop.title": "حلقة الموت",
|
"settings.permissions.tool.doom_loop.title": "حلقة مفرغة",
|
||||||
"settings.permissions.tool.doom_loop.description": "اكتشاف استدعاءات الأدوات المتكررة بمدخلات متطابقة",
|
"settings.permissions.tool.doom_loop.description": "اكتشاف استدعاءات الأدوات المتكررة بمدخلات متطابقة",
|
||||||
"session.delete.failed.title": "فشل حذف الجلسة",
|
"session.delete.failed.title": "فشل حذف الجلسة",
|
||||||
"session.delete.title": "حذف الجلسة",
|
"session.delete.title": "حذف الجلسة",
|
||||||
@@ -896,7 +1034,7 @@ export const dict = {
|
|||||||
"session.delete.button": "حذف الجلسة",
|
"session.delete.button": "حذف الجلسة",
|
||||||
"workspace.new": "مساحة عمل جديدة",
|
"workspace.new": "مساحة عمل جديدة",
|
||||||
"workspace.type.local": "محلي",
|
"workspace.type.local": "محلي",
|
||||||
"workspace.type.sandbox": "صندوق رمل",
|
"workspace.type.sandbox": "بيئة معزولة",
|
||||||
"workspace.create.failed.title": "فشل إنشاء مساحة العمل",
|
"workspace.create.failed.title": "فشل إنشاء مساحة العمل",
|
||||||
"workspace.delete.failed.title": "فشل حذف مساحة العمل",
|
"workspace.delete.failed.title": "فشل حذف مساحة العمل",
|
||||||
"workspace.resetting.title": "إعادة تعيين مساحة العمل",
|
"workspace.resetting.title": "إعادة تعيين مساحة العمل",
|
||||||
@@ -906,7 +1044,7 @@ export const dict = {
|
|||||||
"workspace.reset.success.description": "مساحة العمل تطابق الآن الفرع الافتراضي.",
|
"workspace.reset.success.description": "مساحة العمل تطابق الآن الفرع الافتراضي.",
|
||||||
"workspace.error.stillPreparing": "مساحة العمل لا تزال قيد الإعداد",
|
"workspace.error.stillPreparing": "مساحة العمل لا تزال قيد الإعداد",
|
||||||
"workspace.status.checking": "التحقق من التغييرات غير المدمجة...",
|
"workspace.status.checking": "التحقق من التغييرات غير المدمجة...",
|
||||||
"workspace.status.error": "تعذر التحقق من حالة git.",
|
"workspace.status.error": "تعذر التحقق من حالة Git.",
|
||||||
"workspace.status.clean": "لم يتم اكتشاف تغييرات غير مدمجة.",
|
"workspace.status.clean": "لم يتم اكتشاف تغييرات غير مدمجة.",
|
||||||
"workspace.status.dirty": "تم اكتشاف تغييرات غير مدمجة في مساحة العمل هذه.",
|
"workspace.status.dirty": "تم اكتشاف تغييرات غير مدمجة في مساحة العمل هذه.",
|
||||||
"workspace.delete.title": "حذف مساحة العمل",
|
"workspace.delete.title": "حذف مساحة العمل",
|
||||||
@@ -922,7 +1060,7 @@ export const dict = {
|
|||||||
"common.open": "فتح",
|
"common.open": "فتح",
|
||||||
"dialog.releaseNotes.action.getStarted": "البدء",
|
"dialog.releaseNotes.action.getStarted": "البدء",
|
||||||
"dialog.releaseNotes.action.next": "التالي",
|
"dialog.releaseNotes.action.next": "التالي",
|
||||||
"dialog.releaseNotes.action.hideFuture": "عدم إظهار هذا في المستقبل",
|
"dialog.releaseNotes.action.hideFuture": "عدم إظهارها مستقبلاً",
|
||||||
"dialog.releaseNotes.media.alt": "معاينة الإصدار",
|
"dialog.releaseNotes.media.alt": "معاينة الإصدار",
|
||||||
"toast.project.reloadFailed.title": "فشل في إعادة تحميل {{project}}",
|
"toast.project.reloadFailed.title": "فشل في إعادة تحميل {{project}}",
|
||||||
"error.server.invalidConfiguration": "تكوين غير صالح",
|
"error.server.invalidConfiguration": "تكوين غير صالح",
|
||||||
@@ -932,22 +1070,25 @@ export const dict = {
|
|||||||
"common.time.hoursAgo.short": "قبل {{count}} س",
|
"common.time.hoursAgo.short": "قبل {{count}} س",
|
||||||
"common.time.daysAgo.short": "قبل {{count}} ي",
|
"common.time.daysAgo.short": "قبل {{count}} ي",
|
||||||
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
|
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
|
||||||
"settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.",
|
"settings.providers.custom.description": "أضف موفرًا متوافقًا مع OpenAI باستخدام عنوان URL الأساسي.",
|
||||||
|
|
||||||
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
|
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
|
||||||
"app.server.retrying": "جاري إعادة المحاولة تلقائيًا...",
|
"app.server.retrying": "جارٍ إعادة المحاولة تلقائيًا...",
|
||||||
"app.server.otherServers": "خوادم أخرى",
|
"app.server.otherServers": "خوادم أخرى",
|
||||||
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
|
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
|
||||||
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
|
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
|
||||||
"server.row.noUsername": "لا يوجد اسم مستخدم",
|
"server.row.noUsername": "لا يوجد اسم مستخدم",
|
||||||
"session.review.noVcs.createGit.title": "إنشاء مستودع Git",
|
"session.review.noVcs.createGit.title": "إنشاء مستودع Git",
|
||||||
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
|
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
|
||||||
"session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...",
|
"session.review.noVcs.createGit.actionLoading": "جارٍ إنشاء مستودع Git...",
|
||||||
"session.review.noVcs.createGit.action": "إنشاء مستودع Git",
|
"session.review.noVcs.createGit.action": "إنشاء مستودع Git",
|
||||||
"session.todo.progress": "تم إكمال {{done}} من {{total}} مهام",
|
"session.todo.progress": "تم إكمال {{done}} من {{total}} مهام",
|
||||||
"session.question.progress": "{{current}} من {{total}} أسئلة",
|
"session.question.progress": "{{current}} من {{total}} أسئلة",
|
||||||
"session.header.open.finder": "Finder",
|
"session.header.open.finder": "Finder",
|
||||||
"session.header.open.fileExplorer": "مستكشف الملفات",
|
"session.header.open.fileExplorer": "مستكشف الملفات",
|
||||||
|
"session.header.reveal.finder": "إظهار في Finder",
|
||||||
|
"session.header.reveal.fileExplorer": "إظهار في مستكشف الملفات",
|
||||||
|
"session.header.reveal.containingFolder": "فتح المجلد الحاوي",
|
||||||
"session.header.open.fileManager": "مدير الملفات",
|
"session.header.open.fileManager": "مدير الملفات",
|
||||||
"session.header.open.app.vscode": "VS Code",
|
"session.header.open.app.vscode": "VS Code",
|
||||||
"session.header.open.app.cursor": "Cursor",
|
"session.header.open.app.cursor": "Cursor",
|
||||||
@@ -965,9 +1106,9 @@ export const dict = {
|
|||||||
"debugBar.ariaLabel": "تشخيص أداء التطوير",
|
"debugBar.ariaLabel": "تشخيص أداء التطوير",
|
||||||
"debugBar.na": "غير متاح",
|
"debugBar.na": "غير متاح",
|
||||||
"debugBar.nav.label": "NAV",
|
"debugBar.nav.label": "NAV",
|
||||||
"debugBar.nav.tip": "آخر انتقال مكتمل للمسار يمس صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.",
|
"debugBar.nav.tip": "آخر انتقال مكتمل للمسار يتضمن صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.",
|
||||||
"debugBar.fps.label": "FPS",
|
"debugBar.fps.label": "FPS",
|
||||||
"debugBar.fps.tip": "الإطارات المتجددة في الثانية خلال آخر 5 ثوانٍ.",
|
"debugBar.fps.tip": "متوسط متحرك لمعدل الإطارات في الثانية خلال آخر 5 ثوانٍ.",
|
||||||
"debugBar.frame.label": "FRAME",
|
"debugBar.frame.label": "FRAME",
|
||||||
"debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.",
|
"debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.",
|
||||||
"debugBar.jank.label": "JANK",
|
"debugBar.jank.label": "JANK",
|
||||||
@@ -979,10 +1120,18 @@ export const dict = {
|
|||||||
"debugBar.inp.label": "INP",
|
"debugBar.inp.label": "INP",
|
||||||
"debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.",
|
"debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.",
|
||||||
"debugBar.cls.label": "CLS",
|
"debugBar.cls.label": "CLS",
|
||||||
"debugBar.cls.tip": "التحول التخطيطي التراكمي لعمر التطبيق الحالي.",
|
"debugBar.cls.tip": "التحول التخطيطي التراكمي منذ تشغيل التطبيق الحالي.",
|
||||||
"debugBar.mem.label": "MEM",
|
"debugBar.mem.label": "MEM",
|
||||||
"debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.",
|
"debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.",
|
||||||
"debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.",
|
"debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.",
|
||||||
|
"debugBar.focus.label": "FOCUS",
|
||||||
|
"debugBar.focus.tip": "فرض أنماط التركيز على جميع العناصر التفاعلية",
|
||||||
|
"debugBar.focus.on": "تشغيل",
|
||||||
|
"debugBar.focus.off": "إيقاف",
|
||||||
|
"debugBar.direction.label": "DIR",
|
||||||
|
"debugBar.direction.tip": "فرض اتجاه التخطيط على التطبيق بالكامل دون تغيير اللغة المحددة",
|
||||||
|
"debugBar.direction.ltr": "LTR",
|
||||||
|
"debugBar.direction.rtl": "RTL",
|
||||||
"common.key.ctrl": "Ctrl",
|
"common.key.ctrl": "Ctrl",
|
||||||
"common.key.alt": "Alt",
|
"common.key.alt": "Alt",
|
||||||
"common.key.shift": "Shift",
|
"common.key.shift": "Shift",
|
||||||
@@ -998,7 +1147,7 @@ export const dict = {
|
|||||||
"common.key.pageDown": "Page Down",
|
"common.key.pageDown": "Page Down",
|
||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "غير معروف",
|
"common.unknown": "غير معروف",
|
||||||
"error.page.circular": "[دائري]",
|
"error.page.circular": "[مرجع دائري]",
|
||||||
"error.serverSDK.noServerAvailable": "لا يوجد خادم متاح",
|
"error.serverSDK.noServerAvailable": "لا يوجد خادم متاح",
|
||||||
"error.serverSDK.serverNotAvailable": "الخادم غير متاح",
|
"error.serverSDK.serverNotAvailable": "الخادم غير متاح",
|
||||||
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
|
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
|
||||||
@@ -1006,4 +1155,23 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
|
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
|
||||||
"error.childStore.storeCreateFailed": "فشل إنشاء المخزن",
|
"error.childStore.storeCreateFailed": "فشل إنشاء المخزن",
|
||||||
"terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}",
|
"terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+206
-44
@@ -1,4 +1,104 @@
|
|||||||
export const dict = {
|
export const dict = {
|
||||||
|
"desktop.menu.app": "OpenCode",
|
||||||
|
"desktop.menu.file": "Arquivo",
|
||||||
|
"desktop.menu.edit": "Editar",
|
||||||
|
"desktop.menu.view": "Visualizar",
|
||||||
|
"desktop.menu.go": "Ir",
|
||||||
|
"desktop.menu.window": "Janela",
|
||||||
|
"desktop.menu.help": "Ajuda",
|
||||||
|
"desktop.menu.checkForUpdates": "Verificar atualizações...",
|
||||||
|
"desktop.menu.settings": "Configurações",
|
||||||
|
"desktop.menu.reloadWebview": "Recarregar WebView",
|
||||||
|
"desktop.menu.restart": "Reiniciar",
|
||||||
|
"desktop.menu.exportLogs": "Exportar logs...",
|
||||||
|
"desktop.menu.newSession": "Nova sessão",
|
||||||
|
"desktop.menu.openProject": "Abrir projeto...",
|
||||||
|
"desktop.menu.newWindow": "Nova janela",
|
||||||
|
"desktop.menu.closeWindow": "Fechar janela",
|
||||||
|
"desktop.menu.undo": "Desfazer",
|
||||||
|
"desktop.menu.redo": "Refazer",
|
||||||
|
"desktop.menu.cut": "Recortar",
|
||||||
|
"desktop.menu.copy": "Copiar",
|
||||||
|
"desktop.menu.paste": "Colar",
|
||||||
|
"desktop.menu.delete": "Excluir",
|
||||||
|
"desktop.menu.selectAll": "Selecionar tudo",
|
||||||
|
"desktop.menu.toggleSidebar": "Alternar barra lateral",
|
||||||
|
"desktop.menu.toggleTerminal": "Alternar terminal",
|
||||||
|
"desktop.menu.toggleFileTree": "Alternar árvore de arquivos",
|
||||||
|
"desktop.menu.reload": "Recarregar",
|
||||||
|
"desktop.menu.toggleDeveloperTools": "Alternar ferramentas de desenvolvedor",
|
||||||
|
"desktop.menu.actualSize": "Tamanho real",
|
||||||
|
"desktop.menu.zoomIn": "Ampliar",
|
||||||
|
"desktop.menu.zoomOut": "Reduzir",
|
||||||
|
"desktop.menu.toggleFullScreen": "Alternar tela inteira",
|
||||||
|
"desktop.menu.back": "Voltar",
|
||||||
|
"desktop.menu.forward": "Avançar",
|
||||||
|
"desktop.menu.previousSession": "Sessão anterior",
|
||||||
|
"desktop.menu.nextSession": "Próxima sessão",
|
||||||
|
"desktop.menu.previousProject": "Projeto anterior",
|
||||||
|
"desktop.menu.nextProject": "Próximo projeto",
|
||||||
|
"desktop.menu.minimize": "Minimizar",
|
||||||
|
"desktop.menu.maximize": "Maximizar",
|
||||||
|
"desktop.menu.documentation": "Documentação do OpenCode",
|
||||||
|
"desktop.menu.supportForum": "Fórum de suporte",
|
||||||
|
"desktop.menu.shareFeedback": "Enviar comentários",
|
||||||
|
"desktop.menu.reportBug": "Relatar um bug",
|
||||||
|
"desktop.menu.ariaLabel": "Menu do OpenCode",
|
||||||
|
|
||||||
|
"desktop.updater.dialog.checkFailed.message": "Falha ao verificar atualizações.",
|
||||||
|
"desktop.updater.dialog.checkFailed.title": "Erro de atualização",
|
||||||
|
"desktop.updater.dialog.upToDate.message": "Você está em dia.",
|
||||||
|
"desktop.updater.dialog.upToDate.title": "Nenhuma atualização",
|
||||||
|
"desktop.updater.dialog.ready.message": "A atualização {{version}} foi baixada. Deseja reiniciar agora?",
|
||||||
|
"desktop.updater.dialog.ready.title": "Atualização pronta",
|
||||||
|
"desktop.updater.dialog.restart": "Reiniciar",
|
||||||
|
"desktop.updater.dialog.later": "Mais tarde",
|
||||||
|
|
||||||
|
"desktop.recovery.action.relaunch": "Reabrir",
|
||||||
|
"desktop.recovery.action.exportLogs": "Exportar logs",
|
||||||
|
"desktop.recovery.action.keepWaiting": "Continuar aguardando",
|
||||||
|
"desktop.recovery.action.quit": "Encerrar",
|
||||||
|
"desktop.recovery.loadFailed": "Falha ao carregar o OpenCode",
|
||||||
|
"desktop.recovery.terminated": "A janela do OpenCode foi encerrada inesperadamente",
|
||||||
|
"desktop.recovery.unresponsive": "O OpenCode não está respondendo",
|
||||||
|
"desktop.recovery.unresponsive.detail": "Você pode reabrir o aplicativo, abrir os logs ou continuar aguardando.",
|
||||||
|
"desktop.recovery.loadFailed.detail": "Janela: {{window}}\nURL: {{url}}\nErro: {{code}} {{description}}",
|
||||||
|
"desktop.recovery.terminated.detail": "Janela: {{window}}\nMotivo: {{reason}}\nCódigo: {{code}}",
|
||||||
|
"desktop.recovery.unknown": "<desconhecido>",
|
||||||
|
|
||||||
|
"desktop.dialog.chooseFolder": "Escolher uma pasta",
|
||||||
|
"desktop.dialog.chooseFile": "Escolher um arquivo",
|
||||||
|
"desktop.dialog.saveFile": "Salvar arquivo",
|
||||||
|
"desktop.dialog.files": "Arquivos",
|
||||||
|
"desktop.server.local": "Servidor local",
|
||||||
|
|
||||||
|
"desktop.wsl.error.windowsOnly": "O WSL está disponível somente no Windows",
|
||||||
|
"desktop.wsl.error.unavailable": "O WSL está indisponível",
|
||||||
|
"desktop.wsl.error.listInstalled": "Falha ao listar as distribuições WSL instaladas",
|
||||||
|
"desktop.wsl.error.listOnline": "Falha ao listar as distribuições WSL disponíveis online",
|
||||||
|
"desktop.wsl.error.executeDistro": "Não é possível executar comandos na distribuição",
|
||||||
|
"desktop.wsl.error.installWsl": "Falha na instalação do WSL",
|
||||||
|
"desktop.wsl.error.installDistro": "Falha ao instalar a distribuição: {{distro}}",
|
||||||
|
"desktop.wsl.error.installOpencode": "Falha na instalação do OpenCode",
|
||||||
|
"desktop.wsl.error.alreadyAdded": "{{distro}} já foi adicionada",
|
||||||
|
"desktop.wsl.error.opencodeMissing": "O opencode não está instalado nesta distribuição",
|
||||||
|
"desktop.wsl.error.opencodeCannotRun": "O opencode está instalado, mas não pôde ser executado",
|
||||||
|
"desktop.wsl.error.opencodeNotInstalled": "O OpenCode não está instalado em {{distro}}",
|
||||||
|
"desktop.wsl.error.updateVersion":
|
||||||
|
"A atualização do OpenCode foi concluída, mas {{distro}} ainda informa a versão {{installed}}; a versão esperada é {{expected}}",
|
||||||
|
"desktop.wsl.error.noVersion": "sem versão",
|
||||||
|
"desktop.wsl.error.serverExited":
|
||||||
|
"O servidor WSL foi encerrado após a inicialização (código={{code}} sinal={{signal}})",
|
||||||
|
"desktop.wsl.error.serverExitedBeforeHealthy":
|
||||||
|
"O servidor WSL foi encerrado antes de ficar íntegro (código={{code}} sinal={{signal}}){{output}}",
|
||||||
|
"desktop.wsl.error.healthTimeout":
|
||||||
|
"O tempo limite da verificação de integridade do sidecar de {{distro}} foi excedido após {{timeout}}ms",
|
||||||
|
"desktop.wsl.error.commandTimeout": "O tempo limite de {{command}} {{args}} foi excedido após {{timeout}}ms",
|
||||||
|
"desktop.wsl.error.failedPort": "Falha ao obter a porta",
|
||||||
|
|
||||||
|
"desktop.picker.error.notSelected": "O arquivo não foi selecionado pelo seletor",
|
||||||
|
"desktop.picker.error.sizeLimit": "Os anexos selecionados excedem o limite de {{limit}} MB",
|
||||||
|
|
||||||
"command.category.suggested": "Sugerido",
|
"command.category.suggested": "Sugerido",
|
||||||
"command.category.view": "Visualizar",
|
"command.category.view": "Visualizar",
|
||||||
"command.category.project": "Projeto",
|
"command.category.project": "Projeto",
|
||||||
@@ -16,6 +116,7 @@ export const dict = {
|
|||||||
"command.category.permissions": "Permissões",
|
"command.category.permissions": "Permissões",
|
||||||
"command.category.workspace": "Espaço de trabalho",
|
"command.category.workspace": "Espaço de trabalho",
|
||||||
"command.category.settings": "Configurações",
|
"command.category.settings": "Configurações",
|
||||||
|
"command.logs.export": "Exportar logs",
|
||||||
"theme.scheme.system": "Sistema",
|
"theme.scheme.system": "Sistema",
|
||||||
"theme.scheme.light": "Claro",
|
"theme.scheme.light": "Claro",
|
||||||
"theme.scheme.dark": "Escuro",
|
"theme.scheme.dark": "Escuro",
|
||||||
@@ -94,13 +195,14 @@ export const dict = {
|
|||||||
"dialog.provider.empty": "Nenhum provedor encontrado",
|
"dialog.provider.empty": "Nenhum provedor encontrado",
|
||||||
"dialog.provider.group.popular": "Popular",
|
"dialog.provider.group.popular": "Popular",
|
||||||
"dialog.provider.group.other": "Outro",
|
"dialog.provider.group.other": "Outro",
|
||||||
|
"dialog.provider.custom.label": "Provedor personalizado compatível com OpenAI",
|
||||||
"dialog.provider.tag.recommended": "Recomendado",
|
"dialog.provider.tag.recommended": "Recomendado",
|
||||||
"dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais",
|
"dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais",
|
||||||
"dialog.provider.opencode.tagline": "Modelos otimizados e confiáveis",
|
"dialog.provider.opencode.tagline": "Modelos otimizados e confiáveis",
|
||||||
"dialog.provider.opencodeGo.tagline": "Assinatura de baixo custo para todos",
|
"dialog.provider.opencodeGo.tagline": "Assinatura de baixo custo para todos",
|
||||||
"dialog.provider.anthropic.note": "Conectar com Claude Pro/Max ou chave de API",
|
"dialog.provider.anthropic.note": "Acesso direto aos modelos Claude, incluindo Pro e Max",
|
||||||
"dialog.provider.copilot.note": "Conectar com Copilot ou chave de API",
|
"dialog.provider.copilot.note": "Modelos de IA para auxílio à programação pelo GitHub Copilot",
|
||||||
"dialog.provider.openai.note": "Conectar com ChatGPT Pro/Plus ou chave de API",
|
"dialog.provider.openai.note": "Modelos GPT rápidos e avançados para tarefas gerais de IA",
|
||||||
"dialog.provider.google.note": "Modelos Gemini para respostas rápidas e estruturadas",
|
"dialog.provider.google.note": "Modelos Gemini para respostas rápidas e estruturadas",
|
||||||
"dialog.provider.openrouter.note": "Acesse todos os modelos suportados de um único provedor",
|
"dialog.provider.openrouter.note": "Acesse todos os modelos suportados de um único provedor",
|
||||||
"dialog.provider.vercel.note": "Acesso unificado a modelos de IA com roteamento inteligente",
|
"dialog.provider.vercel.note": "Acesso unificado a modelos de IA com roteamento inteligente",
|
||||||
@@ -112,11 +214,14 @@ export const dict = {
|
|||||||
"dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}",
|
"dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}",
|
||||||
"dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode",
|
"dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode",
|
||||||
"dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares",
|
"dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares",
|
||||||
|
"dialog.model.unpaid.viewMoreProviders": "Ver mais de 70 provedores",
|
||||||
"dialog.provider.viewAll": "Ver mais provedores",
|
"dialog.provider.viewAll": "Ver mais provedores",
|
||||||
"provider.connect.title": "Conectar {{provider}}",
|
"provider.connect.title": "Conectar {{provider}}",
|
||||||
"provider.connect.title.anthropicProMax": "Entrar com Claude Pro/Max",
|
"provider.connect.title.anthropicProMax": "Entrar com Claude Pro/Max",
|
||||||
"provider.connect.selectMethod": "Selecionar método de login para {{provider}}.",
|
"provider.connect.selectMethod": "Selecionar método de login para {{provider}}.",
|
||||||
"provider.connect.method.apiKey": "Chave de API",
|
"provider.connect.method.apiKey": "Chave de API",
|
||||||
|
"provider.connect.method.browser": "Navegador",
|
||||||
|
"provider.connect.method.headless": "Sem interface gráfica",
|
||||||
"provider.connect.status.inProgress": "Autorização em andamento...",
|
"provider.connect.status.inProgress": "Autorização em andamento...",
|
||||||
"provider.connect.status.waiting": "Aguardando autorização...",
|
"provider.connect.status.waiting": "Aguardando autorização...",
|
||||||
"provider.connect.status.failed": "Autorização falhou: {{error}}",
|
"provider.connect.status.failed": "Autorização falhou: {{error}}",
|
||||||
@@ -148,15 +253,16 @@ export const dict = {
|
|||||||
"provider.connect.toast.connected.title": "{{provider}} conectado",
|
"provider.connect.toast.connected.title": "{{provider}} conectado",
|
||||||
"provider.connect.toast.connected.description": "Modelos do {{provider}} agora estão disponíveis para uso.",
|
"provider.connect.toast.connected.description": "Modelos do {{provider}} agora estão disponíveis para uso.",
|
||||||
"provider.custom.title": "Provedor personalizado",
|
"provider.custom.title": "Provedor personalizado",
|
||||||
|
"provider.custom.unavailable": "Provedores personalizados não estão disponíveis neste servidor",
|
||||||
"provider.custom.description.prefix": "Configure um provedor compatível com OpenAI. Veja a ",
|
"provider.custom.description.prefix": "Configure um provedor compatível com OpenAI. Veja a ",
|
||||||
"provider.custom.description.link": "documentação de configuração do provedor",
|
"provider.custom.description.link": "documentação de configuração do provedor",
|
||||||
"provider.custom.description.suffix": ".",
|
"provider.custom.description.suffix": ".",
|
||||||
"provider.custom.field.providerID.label": "ID do Provedor",
|
"provider.custom.field.providerID.label": "ID do provedor",
|
||||||
"provider.custom.field.providerID.placeholder": "meuprovedor",
|
"provider.custom.field.providerID.placeholder": "meuprovedor",
|
||||||
"provider.custom.field.providerID.description": "Letras minúsculas, números, hifens ou sublinhados",
|
"provider.custom.field.providerID.description": "Letras minúsculas, números, hifens ou sublinhados",
|
||||||
"provider.custom.field.name.label": "Nome de exibição",
|
"provider.custom.field.name.label": "Nome de exibição",
|
||||||
"provider.custom.field.name.placeholder": "Meu Provedor de IA",
|
"provider.custom.field.name.placeholder": "Meu provedor de IA",
|
||||||
"provider.custom.field.baseURL.label": "URL Base",
|
"provider.custom.field.baseURL.label": "URL base",
|
||||||
"provider.custom.field.baseURL.placeholder": "https://api.meuprovedor.com/v1",
|
"provider.custom.field.baseURL.placeholder": "https://api.meuprovedor.com/v1",
|
||||||
"provider.custom.field.apiKey.label": "Chave de API",
|
"provider.custom.field.apiKey.label": "Chave de API",
|
||||||
"provider.custom.field.apiKey.placeholder": "Chave de API",
|
"provider.custom.field.apiKey.placeholder": "Chave de API",
|
||||||
@@ -165,7 +271,7 @@ export const dict = {
|
|||||||
"provider.custom.models.id.label": "ID",
|
"provider.custom.models.id.label": "ID",
|
||||||
"provider.custom.models.id.placeholder": "id-do-modelo",
|
"provider.custom.models.id.placeholder": "id-do-modelo",
|
||||||
"provider.custom.models.name.label": "Nome",
|
"provider.custom.models.name.label": "Nome",
|
||||||
"provider.custom.models.name.placeholder": "Nome de Exibição",
|
"provider.custom.models.name.placeholder": "Nome de exibição",
|
||||||
"provider.custom.models.remove": "Remover modelo",
|
"provider.custom.models.remove": "Remover modelo",
|
||||||
"provider.custom.models.add": "Adicionar modelo",
|
"provider.custom.models.add": "Adicionar modelo",
|
||||||
"provider.custom.headers.label": "Cabeçalhos (opcional)",
|
"provider.custom.headers.label": "Cabeçalhos (opcional)",
|
||||||
@@ -175,11 +281,11 @@ export const dict = {
|
|||||||
"provider.custom.headers.value.placeholder": "valor",
|
"provider.custom.headers.value.placeholder": "valor",
|
||||||
"provider.custom.headers.remove": "Remover cabeçalho",
|
"provider.custom.headers.remove": "Remover cabeçalho",
|
||||||
"provider.custom.headers.add": "Adicionar cabeçalho",
|
"provider.custom.headers.add": "Adicionar cabeçalho",
|
||||||
"provider.custom.error.providerID.required": "ID do Provedor é obrigatório",
|
"provider.custom.error.providerID.required": "O ID do provedor é obrigatório",
|
||||||
"provider.custom.error.providerID.format": "Use letras minúsculas, números, hifens ou sublinhados",
|
"provider.custom.error.providerID.format": "Use letras minúsculas, números, hifens ou sublinhados",
|
||||||
"provider.custom.error.providerID.exists": "Esse ID de provedor já existe",
|
"provider.custom.error.providerID.exists": "Esse ID de provedor já existe",
|
||||||
"provider.custom.error.name.required": "Nome de exibição é obrigatório",
|
"provider.custom.error.name.required": "O nome de exibição é obrigatório",
|
||||||
"provider.custom.error.baseURL.required": "URL Base é obrigatória",
|
"provider.custom.error.baseURL.required": "A URL base é obrigatória",
|
||||||
"provider.custom.error.baseURL.format": "Deve começar com http:// ou https://",
|
"provider.custom.error.baseURL.format": "Deve começar com http:// ou https://",
|
||||||
"provider.custom.error.required": "Obrigatório",
|
"provider.custom.error.required": "Obrigatório",
|
||||||
"provider.custom.error.duplicate": "Duplicado",
|
"provider.custom.error.duplicate": "Duplicado",
|
||||||
@@ -215,7 +321,7 @@ export const dict = {
|
|||||||
"common.clear": "Limpar",
|
"common.clear": "Limpar",
|
||||||
"common.connect": "Conectar",
|
"common.connect": "Conectar",
|
||||||
"common.disconnect": "Desconectar",
|
"common.disconnect": "Desconectar",
|
||||||
"common.continue": "Enviar",
|
"common.continue": "Continuar",
|
||||||
"common.submit": "Enviar",
|
"common.submit": "Enviar",
|
||||||
"common.save": "Salvar",
|
"common.save": "Salvar",
|
||||||
"common.saving": "Salvando...",
|
"common.saving": "Salvando...",
|
||||||
@@ -267,7 +373,7 @@ export const dict = {
|
|||||||
"prompt.context.includeActiveFile": "Incluir arquivo ativo",
|
"prompt.context.includeActiveFile": "Incluir arquivo ativo",
|
||||||
"prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
|
"prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
|
||||||
"prompt.context.removeFile": "Remover arquivo do contexto",
|
"prompt.context.removeFile": "Remover arquivo do contexto",
|
||||||
"prompt.action.attachFile": "Anexar arquivo",
|
"prompt.action.attachFile": "Adicionar arquivos",
|
||||||
"prompt.menu.addImagesAndFiles": "Adicionar arquivos e mais",
|
"prompt.menu.addImagesAndFiles": "Adicionar arquivos e mais",
|
||||||
"prompt.menu.imagesAndFiles": "Imagens e arquivos",
|
"prompt.menu.imagesAndFiles": "Imagens e arquivos",
|
||||||
"prompt.menu.commands": "Comandos",
|
"prompt.menu.commands": "Comandos",
|
||||||
@@ -288,7 +394,7 @@ export const dict = {
|
|||||||
"prompt.toast.promptSendFailed.title": "Falha ao enviar prompt",
|
"prompt.toast.promptSendFailed.title": "Falha ao enviar prompt",
|
||||||
"prompt.toast.promptSendFailed.description": "Não foi possível recuperar a sessão",
|
"prompt.toast.promptSendFailed.description": "Não foi possível recuperar a sessão",
|
||||||
"dialog.mcp.title": "MCPs",
|
"dialog.mcp.title": "MCPs",
|
||||||
"dialog.mcp.description": "{{enabled}} of {{total}} habilitados",
|
"dialog.mcp.description": "{{enabled}} de {{total}} ativados",
|
||||||
"dialog.mcp.empty": "Nenhum MCP configurado",
|
"dialog.mcp.empty": "Nenhum MCP configurado",
|
||||||
"dialog.lsp.empty": "LSPs detectados automaticamente pelos tipos de arquivo",
|
"dialog.lsp.empty": "LSPs detectados automaticamente pelos tipos de arquivo",
|
||||||
"dialog.plugins.empty": "Plugins configurados em opencode.json",
|
"dialog.plugins.empty": "Plugins configurados em opencode.json",
|
||||||
@@ -310,11 +416,11 @@ export const dict = {
|
|||||||
"dialog.server.search.placeholder": "Buscar servidores",
|
"dialog.server.search.placeholder": "Buscar servidores",
|
||||||
"dialog.server.empty": "Nenhum servidor ainda",
|
"dialog.server.empty": "Nenhum servidor ainda",
|
||||||
"dialog.server.add.title": "Adicionar um servidor",
|
"dialog.server.add.title": "Adicionar um servidor",
|
||||||
"dialog.server.add.url": "URL do servidor",
|
"dialog.server.add.url": "Endereço do servidor",
|
||||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||||
"dialog.server.add.error": "Não foi possível conectar ao servidor",
|
"dialog.server.add.error": "Não foi possível conectar ao servidor",
|
||||||
"dialog.server.add.checking": "Verificando...",
|
"dialog.server.add.checking": "Verificando...",
|
||||||
"dialog.server.add.button": "Adicionar",
|
"dialog.server.add.button": "Adicionar servidor",
|
||||||
"dialog.server.add.name": "Nome do servidor (opcional)",
|
"dialog.server.add.name": "Nome do servidor (opcional)",
|
||||||
"dialog.server.add.namePlaceholder": "Localhost",
|
"dialog.server.add.namePlaceholder": "Localhost",
|
||||||
"dialog.server.add.username": "Nome de usuário (opcional)",
|
"dialog.server.add.username": "Nome de usuário (opcional)",
|
||||||
@@ -322,7 +428,7 @@ export const dict = {
|
|||||||
"dialog.server.edit.title": "Editar servidor",
|
"dialog.server.edit.title": "Editar servidor",
|
||||||
"dialog.server.default.title": "Servidor padrão",
|
"dialog.server.default.title": "Servidor padrão",
|
||||||
"dialog.server.default.description":
|
"dialog.server.default.description":
|
||||||
"Conectar a este servidor na inicialização do aplicativo ao invés de iniciar um servidor local. Requer reinicialização.",
|
"Conectar a este servidor na inicialização do aplicativo em vez de iniciar um servidor local. Requer reinicialização.",
|
||||||
"dialog.server.default.none": "Nenhum servidor selecionado",
|
"dialog.server.default.none": "Nenhum servidor selecionado",
|
||||||
"dialog.server.default.set": "Definir servidor atual como padrão",
|
"dialog.server.default.set": "Definir servidor atual como padrão",
|
||||||
"dialog.server.default.clear": "Limpar",
|
"dialog.server.default.clear": "Limpar",
|
||||||
@@ -402,6 +508,21 @@ export const dict = {
|
|||||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||||
"wsl.onboarding.versionMismatch": "A versão instalada não corresponde à versão do aplicativo para desktop.",
|
"wsl.onboarding.versionMismatch": "A versão instalada não corresponde à versão do aplicativo para desktop.",
|
||||||
"wsl.onboarding.adding": "Adicionando...",
|
"wsl.onboarding.adding": "Adicionando...",
|
||||||
|
"help.tabs.toast.ariaLabel": "Apresentamos as abas. Organize seu trabalho e suas sessões ativas com abas",
|
||||||
|
"help.tabs.toast.dismiss": "Dispensar informações sobre as abas",
|
||||||
|
"help.tabs.title": "Apresentamos as abas",
|
||||||
|
"help.tabs.description": "Organize seu trabalho e suas sessões ativas com abas",
|
||||||
|
"help.tabs.date": "14 de julho",
|
||||||
|
"help.tabs.introduction": "Agora, o OpenCode Desktop é organizado em abas.",
|
||||||
|
"help.tabs.sessions":
|
||||||
|
"Inicie uma nova sessão em uma aba ou abra uma sessão existente de qualquer um dos seus projetos. Abra uma nova aba quando começar algo novo e feche-a quando terminar.",
|
||||||
|
"help.tabs.organize":
|
||||||
|
"Manter algumas abas abertas facilita a organização das sessões ativas. Renomeie as abas com nomes fáceis de lembrar se pretende mantê-las abertas.",
|
||||||
|
"help.tabs.home":
|
||||||
|
"Você encontrará todas as suas sessões e projetos na nova tela Início. Ao selecionar uma sessão, ela será aberta em uma aba.",
|
||||||
|
"help.tabs.persistence": "Quando você reabrir o aplicativo, suas abas ainda estarão abertas.",
|
||||||
|
"help.tabs.worktrees":
|
||||||
|
"O novo design ainda não oferece suporte a Git Worktrees, mas esse recurso estará disponível em breve. Se preferir continuar usando o layout anterior, você poderá alternar entre os layouts nas Configurações. Lembre-se apenas de que o novo layout se tornará permanente em algumas semanas.",
|
||||||
"dialog.project.edit.title": "Editar projeto",
|
"dialog.project.edit.title": "Editar projeto",
|
||||||
"dialog.project.edit.name": "Nome",
|
"dialog.project.edit.name": "Nome",
|
||||||
"dialog.project.edit.icon": "Ícone",
|
"dialog.project.edit.icon": "Ícone",
|
||||||
@@ -412,33 +533,33 @@ export const dict = {
|
|||||||
"dialog.project.edit.color.select": "Selecionar cor {{color}}",
|
"dialog.project.edit.color.select": "Selecionar cor {{color}}",
|
||||||
"dialog.project.edit.worktree.startup": "Script de inicialização do espaço de trabalho",
|
"dialog.project.edit.worktree.startup": "Script de inicialização do espaço de trabalho",
|
||||||
"dialog.project.edit.worktree.startup.description": "Executa após criar um novo espaço de trabalho (worktree).",
|
"dialog.project.edit.worktree.startup.description": "Executa após criar um novo espaço de trabalho (worktree).",
|
||||||
"dialog.project.edit.worktree.startup.placeholder": "ex: bun install",
|
"dialog.project.edit.worktree.startup.placeholder": "Ex.: bun install",
|
||||||
"context.breakdown.title": "Detalhamento do Contexto",
|
"context.breakdown.title": "Detalhamento do contexto",
|
||||||
"context.breakdown.note":
|
"context.breakdown.note":
|
||||||
'Detalhamento aproximado dos tokens de entrada. "Outros" inclui definições de ferramentas e overhead.',
|
'Detalhamento aproximado dos tokens de entrada. "Outros" inclui definições de ferramentas e sobrecarga.',
|
||||||
"context.breakdown.system": "Sistema",
|
"context.breakdown.system": "Sistema",
|
||||||
"context.breakdown.user": "Usuário",
|
"context.breakdown.user": "Usuário",
|
||||||
"context.breakdown.assistant": "Assistente",
|
"context.breakdown.assistant": "Assistente",
|
||||||
"context.breakdown.tool": "Chamadas de Ferramentas",
|
"context.breakdown.tool": "Chamadas de ferramentas",
|
||||||
"context.breakdown.other": "Outros",
|
"context.breakdown.other": "Outros",
|
||||||
"context.systemPrompt.title": "Prompt do Sistema",
|
"context.systemPrompt.title": "Prompt do sistema",
|
||||||
"context.rawMessages.title": "Mensagens brutas",
|
"context.rawMessages.title": "Mensagens brutas",
|
||||||
"context.stats.session": "Sessão",
|
"context.stats.session": "Sessão",
|
||||||
"context.stats.messages": "Mensagens",
|
"context.stats.messages": "Mensagens",
|
||||||
"context.stats.provider": "Provedor",
|
"context.stats.provider": "Provedor",
|
||||||
"context.stats.model": "Modelo",
|
"context.stats.model": "Modelo",
|
||||||
"context.stats.limit": "Limite de Contexto",
|
"context.stats.limit": "Limite de contexto",
|
||||||
"context.stats.totalTokens": "Total de Tokens",
|
"context.stats.totalTokens": "Total de tokens",
|
||||||
"context.stats.usage": "Uso",
|
"context.stats.usage": "Uso",
|
||||||
"context.stats.inputTokens": "Tokens de Entrada",
|
"context.stats.inputTokens": "Tokens de entrada",
|
||||||
"context.stats.outputTokens": "Tokens de Saída",
|
"context.stats.outputTokens": "Tokens de saída",
|
||||||
"context.stats.reasoningTokens": "Tokens de Raciocínio",
|
"context.stats.reasoningTokens": "Tokens de raciocínio",
|
||||||
"context.stats.cacheTokens": "Tokens de Cache (leitura/escrita)",
|
"context.stats.cacheTokens": "Tokens de cache (leitura/escrita)",
|
||||||
"context.stats.userMessages": "Mensagens de Usuário",
|
"context.stats.userMessages": "Mensagens do usuário",
|
||||||
"context.stats.assistantMessages": "Mensagens do Assistente",
|
"context.stats.assistantMessages": "Mensagens do assistente",
|
||||||
"context.stats.totalCost": "Custo Total",
|
"context.stats.totalCost": "Custo total",
|
||||||
"context.stats.sessionCreated": "Sessão Criada",
|
"context.stats.sessionCreated": "Sessão criada",
|
||||||
"context.stats.lastActivity": "Última Atividade",
|
"context.stats.lastActivity": "Última atividade",
|
||||||
"context.usage.tokens": "Tokens",
|
"context.usage.tokens": "Tokens",
|
||||||
"context.usage.usage": "Uso",
|
"context.usage.usage": "Uso",
|
||||||
"context.usage.cost": "Custo",
|
"context.usage.cost": "Custo",
|
||||||
@@ -497,7 +618,7 @@ export const dict = {
|
|||||||
"error.page.title": "Algo deu errado",
|
"error.page.title": "Algo deu errado",
|
||||||
"error.page.description": "Ocorreu um erro ao carregar a aplicação.",
|
"error.page.description": "Ocorreu um erro ao carregar a aplicação.",
|
||||||
"error.page.description.localServerStartup": "Ocorreu um erro ao iniciar o servidor local.",
|
"error.page.description.localServerStartup": "Ocorreu um erro ao iniciar o servidor local.",
|
||||||
"error.page.details.label": "Detalhes do Erro",
|
"error.page.details.label": "Detalhes do erro",
|
||||||
"error.page.action.restart": "Reiniciar",
|
"error.page.action.restart": "Reiniciar",
|
||||||
"error.page.action.report": "Reportar erro",
|
"error.page.action.report": "Reportar erro",
|
||||||
"error.page.action.reported": "Erro reportado",
|
"error.page.action.reported": "Erro reportado",
|
||||||
@@ -570,7 +691,7 @@ export const dict = {
|
|||||||
"session.error.notFound.description": "Esta aba aponta para uma sessão que não existe mais neste servidor.",
|
"session.error.notFound.description": "Esta aba aponta para uma sessão que não existe mais neste servidor.",
|
||||||
"session.error.notFound.closeTab": "Fechar aba",
|
"session.error.notFound.closeTab": "Fechar aba",
|
||||||
"session.error.serverConnection": "Não é possível conectar a este servidor",
|
"session.error.serverConnection": "Não é possível conectar a este servidor",
|
||||||
"session.review.filesChanged": "{{count}} Arquivos Alterados",
|
"session.review.filesChanged": "{{count}} arquivos alterados",
|
||||||
"session.review.change.one": "Alteração",
|
"session.review.change.one": "Alteração",
|
||||||
"session.review.change.other": "Alterações",
|
"session.review.change.other": "Alterações",
|
||||||
"session.review.loadingChanges": "Carregando alterações...",
|
"session.review.loadingChanges": "Carregando alterações...",
|
||||||
@@ -597,14 +718,17 @@ export const dict = {
|
|||||||
"session.question.minimize": "Minimizar pergunta",
|
"session.question.minimize": "Minimizar pergunta",
|
||||||
"session.question.restore": "Restaurar pergunta",
|
"session.question.restore": "Restaurar pergunta",
|
||||||
"session.question.pending.one": "{{count}} pergunta pendente",
|
"session.question.pending.one": "{{count}} pergunta pendente",
|
||||||
|
"session.question.pending.many": "{{count}} de perguntas pendentes",
|
||||||
"session.question.pending.other": "{{count}} perguntas pendentes",
|
"session.question.pending.other": "{{count}} perguntas pendentes",
|
||||||
"session.followupDock.summary.one": "{{count}} mensagem na fila",
|
"session.followupDock.summary.one": "{{count}} mensagem na fila",
|
||||||
|
"session.followupDock.summary.many": "{{count}} de mensagens na fila",
|
||||||
"session.followupDock.summary.other": "{{count}} mensagens na fila",
|
"session.followupDock.summary.other": "{{count}} mensagens na fila",
|
||||||
"session.followupDock.sendNow": "Enviar agora",
|
"session.followupDock.sendNow": "Enviar agora",
|
||||||
"session.followupDock.edit": "Editar",
|
"session.followupDock.edit": "Editar",
|
||||||
"session.followupDock.collapse": "Recolher mensagens na fila",
|
"session.followupDock.collapse": "Recolher mensagens na fila",
|
||||||
"session.followupDock.expand": "Expandir mensagens na fila",
|
"session.followupDock.expand": "Expandir mensagens na fila",
|
||||||
"session.revertDock.summary.one": "{{count}} mensagem revertida",
|
"session.revertDock.summary.one": "{{count}} mensagem revertida",
|
||||||
|
"session.revertDock.summary.many": "{{count}} de mensagens revertidas",
|
||||||
"session.revertDock.summary.other": "{{count}} mensagens revertidas",
|
"session.revertDock.summary.other": "{{count}} mensagens revertidas",
|
||||||
"session.revertDock.collapse": "Recolher mensagens revertidas",
|
"session.revertDock.collapse": "Recolher mensagens revertidas",
|
||||||
"session.revertDock.expand": "Expandir mensagens revertidas",
|
"session.revertDock.expand": "Expandir mensagens revertidas",
|
||||||
@@ -656,9 +780,16 @@ export const dict = {
|
|||||||
"terminal.title": "Terminal",
|
"terminal.title": "Terminal",
|
||||||
"terminal.title.numbered": "Terminal {{number}}",
|
"terminal.title.numbered": "Terminal {{number}}",
|
||||||
"terminal.close": "Fechar terminal",
|
"terminal.close": "Fechar terminal",
|
||||||
"terminal.connectionLost.title": "Conexão Perdida",
|
"terminal.connectionLost.title": "Conexão perdida",
|
||||||
"terminal.connectionLost.description":
|
"terminal.connectionLost.description":
|
||||||
"A conexão do terminal foi interrompida. Isso pode acontecer quando o servidor reinicia.",
|
"A conexão do terminal foi interrompida. Isso pode acontecer quando o servidor reinicia.",
|
||||||
|
"terminal.connectTicket.csrfError":
|
||||||
|
"O tíquete de conexão PTY foi rejeitado pelas verificações de origem ou CSRF. Verifique a configuração de CORS do servidor.",
|
||||||
|
"terminal.connectTicket.statusError": "O tíquete de conexão PTY falhou com o status {{status}}",
|
||||||
|
|
||||||
|
"titlebar.update": "Atualizar",
|
||||||
|
"titlebar.updateVersion": "Atualizar {{version}}",
|
||||||
|
|
||||||
"common.closeTab": "Fechar aba",
|
"common.closeTab": "Fechar aba",
|
||||||
"common.dismiss": "Descartar",
|
"common.dismiss": "Descartar",
|
||||||
"common.requestFailed": "Requisição falhou",
|
"common.requestFailed": "Requisição falhou",
|
||||||
@@ -706,7 +837,7 @@ export const dict = {
|
|||||||
"settings.general.row.shell.title": "Shell do terminal",
|
"settings.general.row.shell.title": "Shell do terminal",
|
||||||
"settings.general.row.shell.description":
|
"settings.general.row.shell.description":
|
||||||
"Escolha o shell usado no terminal. Os shells compatíveis também são usados nas chamadas de ferramentas do agente.",
|
"Escolha o shell usado no terminal. Os shells compatíveis também são usados nas chamadas de ferramentas do agente.",
|
||||||
"settings.general.row.shell.autoDefault": "Automático (Padrão)",
|
"settings.general.row.shell.autoDefault": "Automático (padrão)",
|
||||||
"settings.general.row.shell.terminalOnly": "somente no terminal",
|
"settings.general.row.shell.terminalOnly": "somente no terminal",
|
||||||
"settings.general.row.appearance.title": "Aparência",
|
"settings.general.row.appearance.title": "Aparência",
|
||||||
"settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo",
|
"settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo",
|
||||||
@@ -739,8 +870,9 @@ export const dict = {
|
|||||||
"settings.general.row.mobileTitlebarBottom.title": "Navegação inferior",
|
"settings.general.row.mobileTitlebarBottom.title": "Navegação inferior",
|
||||||
"settings.general.row.mobileTitlebarBottom.description":
|
"settings.general.row.mobileTitlebarBottom.description":
|
||||||
"Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis",
|
"Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis",
|
||||||
"settings.general.row.showCustomAgents.title": "Agentes personalizados",
|
"settings.general.row.showCustomAgents.title": "Mostrar agente",
|
||||||
"settings.general.row.showCustomAgents.description": "Mostrar o seletor de agentes na área de composição",
|
"settings.general.row.showCustomAgents.description":
|
||||||
|
"Alternar entre agentes na área de composição. Quando oculto, usa o agente Build como padrão.",
|
||||||
"settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio",
|
"settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio",
|
||||||
"settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo",
|
"settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo",
|
||||||
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
|
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
|
||||||
@@ -823,14 +955,15 @@ export const dict = {
|
|||||||
"sound.option.yup06": "Sim 06",
|
"sound.option.yup06": "Sim 06",
|
||||||
"settings.general.notifications.agent.title": "Agente",
|
"settings.general.notifications.agent.title": "Agente",
|
||||||
"settings.general.notifications.agent.description":
|
"settings.general.notifications.agent.description":
|
||||||
"Mostrar notificação do sistema quando o agente estiver completo ou precisar de atenção",
|
"Mostrar notificação do sistema quando o agente concluir o trabalho ou precisar de atenção",
|
||||||
"settings.general.notifications.permissions.title": "Permissões",
|
"settings.general.notifications.permissions.title": "Permissões",
|
||||||
"settings.general.notifications.permissions.description":
|
"settings.general.notifications.permissions.description":
|
||||||
"Mostrar notificação do sistema quando uma permissão for necessária",
|
"Mostrar notificação do sistema quando uma permissão for necessária",
|
||||||
"settings.general.notifications.errors.title": "Erros",
|
"settings.general.notifications.errors.title": "Erros",
|
||||||
"settings.general.notifications.errors.description": "Mostrar notificação do sistema quando ocorrer um erro",
|
"settings.general.notifications.errors.description": "Mostrar notificação do sistema quando ocorrer um erro",
|
||||||
"settings.general.sounds.agent.title": "Agente",
|
"settings.general.sounds.agent.title": "Agente",
|
||||||
"settings.general.sounds.agent.description": "Reproduzir som quando o agente estiver completo ou precisar de atenção",
|
"settings.general.sounds.agent.description":
|
||||||
|
"Reproduzir som quando o agente concluir o trabalho ou precisar de atenção",
|
||||||
"settings.general.sounds.permissions.title": "Permissões",
|
"settings.general.sounds.permissions.title": "Permissões",
|
||||||
"settings.general.sounds.permissions.description": "Reproduzir som quando uma permissão for necessária",
|
"settings.general.sounds.permissions.description": "Reproduzir som quando uma permissão for necessária",
|
||||||
"settings.general.sounds.errors.title": "Erros",
|
"settings.general.sounds.errors.title": "Erros",
|
||||||
@@ -878,8 +1011,7 @@ export const dict = {
|
|||||||
"settings.permissions.tool.read.title": "Ler",
|
"settings.permissions.tool.read.title": "Ler",
|
||||||
"settings.permissions.tool.read.description": "Ler um arquivo (corresponde ao caminho do arquivo)",
|
"settings.permissions.tool.read.description": "Ler um arquivo (corresponde ao caminho do arquivo)",
|
||||||
"settings.permissions.tool.edit.title": "Editar",
|
"settings.permissions.tool.edit.title": "Editar",
|
||||||
"settings.permissions.tool.edit.description":
|
"settings.permissions.tool.edit.description": "Modificar arquivos, incluindo edições, escritas e patches",
|
||||||
"Modificar arquivos, incluindo edições, escritas, patches e multi-edições",
|
|
||||||
"settings.permissions.tool.glob.title": "Glob",
|
"settings.permissions.tool.glob.title": "Glob",
|
||||||
"settings.permissions.tool.glob.description": "Corresponder arquivos usando padrões glob",
|
"settings.permissions.tool.glob.description": "Corresponder arquivos usando padrões glob",
|
||||||
"settings.permissions.tool.grep.title": "Grep",
|
"settings.permissions.tool.grep.title": "Grep",
|
||||||
@@ -889,7 +1021,7 @@ export const dict = {
|
|||||||
"settings.permissions.tool.bash.title": "Bash",
|
"settings.permissions.tool.bash.title": "Bash",
|
||||||
"settings.permissions.tool.bash.description": "Executar comandos shell",
|
"settings.permissions.tool.bash.description": "Executar comandos shell",
|
||||||
"settings.permissions.tool.task.title": "Tarefa",
|
"settings.permissions.tool.task.title": "Tarefa",
|
||||||
"settings.permissions.tool.task.description": "Lançar sub-agentes",
|
"settings.permissions.tool.task.description": "Iniciar subagentes",
|
||||||
"settings.permissions.tool.skill.title": "Habilidade",
|
"settings.permissions.tool.skill.title": "Habilidade",
|
||||||
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
|
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
@@ -946,7 +1078,7 @@ export const dict = {
|
|||||||
"common.time.hoursAgo.short": "{{count}}h atrás",
|
"common.time.hoursAgo.short": "{{count}}h atrás",
|
||||||
"common.time.daysAgo.short": "{{count}}d atrás",
|
"common.time.daysAgo.short": "{{count}}d atrás",
|
||||||
"settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente",
|
"settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente",
|
||||||
"settings.providers.custom.description": "Adicionar um provedor compatível com a OpenAI através do URL base.",
|
"settings.providers.custom.description": "Adicionar um provedor compatível com OpenAI por meio da URL base.",
|
||||||
|
|
||||||
"app.server.unreachable": "Não foi possível conectar a {{server}}",
|
"app.server.unreachable": "Não foi possível conectar a {{server}}",
|
||||||
"app.server.retrying": "Tentando novamente automaticamente...",
|
"app.server.retrying": "Tentando novamente automaticamente...",
|
||||||
@@ -962,6 +1094,9 @@ export const dict = {
|
|||||||
"session.question.progress": "{{current}} de {{total}} perguntas",
|
"session.question.progress": "{{current}} de {{total}} perguntas",
|
||||||
"session.header.open.finder": "Finder",
|
"session.header.open.finder": "Finder",
|
||||||
"session.header.open.fileExplorer": "Explorador de Arquivos",
|
"session.header.open.fileExplorer": "Explorador de Arquivos",
|
||||||
|
"session.header.reveal.finder": "Revelar no Finder",
|
||||||
|
"session.header.reveal.fileExplorer": "Revelar no Explorador de Arquivos",
|
||||||
|
"session.header.reveal.containingFolder": "Abrir pasta que contém este item",
|
||||||
"session.header.open.fileManager": "Gerenciador de Arquivos",
|
"session.header.open.fileManager": "Gerenciador de Arquivos",
|
||||||
"session.header.open.app.vscode": "VS Code",
|
"session.header.open.app.vscode": "VS Code",
|
||||||
"session.header.open.app.cursor": "Cursor",
|
"session.header.open.app.cursor": "Cursor",
|
||||||
@@ -999,6 +1134,14 @@ export const dict = {
|
|||||||
"debugBar.mem.label": "MEM",
|
"debugBar.mem.label": "MEM",
|
||||||
"debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.",
|
"debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.",
|
||||||
"debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.",
|
"debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.",
|
||||||
|
"debugBar.focus.label": "FOCUS",
|
||||||
|
"debugBar.focus.tip": "Forçar estilos de foco em todos os elementos interativos",
|
||||||
|
"debugBar.focus.on": "LIGADO",
|
||||||
|
"debugBar.focus.off": "DESLIGADO",
|
||||||
|
"debugBar.direction.label": "DIR",
|
||||||
|
"debugBar.direction.tip": "Forçar a direção do layout de todo o aplicativo sem alterar o idioma selecionado",
|
||||||
|
"debugBar.direction.ltr": "LTR",
|
||||||
|
"debugBar.direction.rtl": "RTL",
|
||||||
"common.key.ctrl": "Ctrl",
|
"common.key.ctrl": "Ctrl",
|
||||||
"common.key.alt": "Alt",
|
"common.key.alt": "Alt",
|
||||||
"common.key.shift": "Shift",
|
"common.key.shift": "Shift",
|
||||||
@@ -1022,4 +1165,23 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
|
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
|
||||||
"error.childStore.storeCreateFailed": "Falha ao criar armazenamento",
|
"error.childStore.storeCreateFailed": "Falha ao criar armazenamento",
|
||||||
"terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}",
|
"terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
+204
-42
@@ -1,4 +1,104 @@
|
|||||||
export const dict = {
|
export const dict = {
|
||||||
|
"desktop.menu.app": "OpenCode",
|
||||||
|
"desktop.menu.file": "Datoteka",
|
||||||
|
"desktop.menu.edit": "Uredi",
|
||||||
|
"desktop.menu.view": "Prikaz",
|
||||||
|
"desktop.menu.go": "Idi",
|
||||||
|
"desktop.menu.window": "Prozor",
|
||||||
|
"desktop.menu.help": "Pomoć",
|
||||||
|
"desktop.menu.checkForUpdates": "Provjeri ima li ažuriranja...",
|
||||||
|
"desktop.menu.settings": "Postavke",
|
||||||
|
"desktop.menu.reloadWebview": "Ponovo učitaj Webview",
|
||||||
|
"desktop.menu.restart": "Ponovo pokreni",
|
||||||
|
"desktop.menu.exportLogs": "Izvezi zapisnike...",
|
||||||
|
"desktop.menu.newSession": "Nova sesija",
|
||||||
|
"desktop.menu.openProject": "Otvori projekat...",
|
||||||
|
"desktop.menu.newWindow": "Novi prozor",
|
||||||
|
"desktop.menu.closeWindow": "Zatvori prozor",
|
||||||
|
"desktop.menu.undo": "Poništi",
|
||||||
|
"desktop.menu.redo": "Vrati",
|
||||||
|
"desktop.menu.cut": "Izreži",
|
||||||
|
"desktop.menu.copy": "Kopiraj",
|
||||||
|
"desktop.menu.paste": "Zalijepi",
|
||||||
|
"desktop.menu.delete": "Izbriši",
|
||||||
|
"desktop.menu.selectAll": "Odaberi sve",
|
||||||
|
"desktop.menu.toggleSidebar": "Prikaži ili sakrij bočnu traku",
|
||||||
|
"desktop.menu.toggleTerminal": "Prikaži ili sakrij terminal",
|
||||||
|
"desktop.menu.toggleFileTree": "Prikaži ili sakrij stablo datoteka",
|
||||||
|
"desktop.menu.reload": "Ponovo učitaj",
|
||||||
|
"desktop.menu.toggleDeveloperTools": "Prikaži ili sakrij alate za programere",
|
||||||
|
"desktop.menu.actualSize": "Stvarna veličina",
|
||||||
|
"desktop.menu.zoomIn": "Uvećaj",
|
||||||
|
"desktop.menu.zoomOut": "Umanji",
|
||||||
|
"desktop.menu.toggleFullScreen": "Prikaži ili sakrij prikaz preko cijelog ekrana",
|
||||||
|
"desktop.menu.back": "Nazad",
|
||||||
|
"desktop.menu.forward": "Naprijed",
|
||||||
|
"desktop.menu.previousSession": "Prethodna sesija",
|
||||||
|
"desktop.menu.nextSession": "Sljedeća sesija",
|
||||||
|
"desktop.menu.previousProject": "Prethodni projekat",
|
||||||
|
"desktop.menu.nextProject": "Sljedeći projekat",
|
||||||
|
"desktop.menu.minimize": "Minimiziraj",
|
||||||
|
"desktop.menu.maximize": "Maksimiziraj",
|
||||||
|
"desktop.menu.documentation": "Dokumentacija za OpenCode",
|
||||||
|
"desktop.menu.supportForum": "Forum za podršku",
|
||||||
|
"desktop.menu.shareFeedback": "Pošalji povratne informacije",
|
||||||
|
"desktop.menu.reportBug": "Prijavi grešku",
|
||||||
|
"desktop.menu.ariaLabel": "OpenCode meni",
|
||||||
|
|
||||||
|
"desktop.updater.dialog.checkFailed.message": "Provjera ažuriranja nije uspjela.",
|
||||||
|
"desktop.updater.dialog.checkFailed.title": "Greška pri ažuriranju",
|
||||||
|
"desktop.updater.dialog.upToDate.message": "Sve je ažurno.",
|
||||||
|
"desktop.updater.dialog.upToDate.title": "Nema ažuriranja",
|
||||||
|
"desktop.updater.dialog.ready.message":
|
||||||
|
"Ažuriranje {{version}} je preuzeto. Želiš li sada ponovo pokrenuti aplikaciju?",
|
||||||
|
"desktop.updater.dialog.ready.title": "Ažuriranje je spremno",
|
||||||
|
"desktop.updater.dialog.restart": "Ponovo pokreni",
|
||||||
|
"desktop.updater.dialog.later": "Kasnije",
|
||||||
|
|
||||||
|
"desktop.recovery.action.relaunch": "Ponovo pokreni",
|
||||||
|
"desktop.recovery.action.exportLogs": "Izvezi zapisnike",
|
||||||
|
"desktop.recovery.action.keepWaiting": "Nastavi čekati",
|
||||||
|
"desktop.recovery.action.quit": "Izađi",
|
||||||
|
"desktop.recovery.loadFailed": "OpenCode se nije uspio učitati",
|
||||||
|
"desktop.recovery.terminated": "Prozor OpenCode-a neočekivano je zatvoren",
|
||||||
|
"desktop.recovery.unresponsive": "OpenCode ne reaguje",
|
||||||
|
"desktop.recovery.unresponsive.detail": "Možeš ponovo pokrenuti aplikaciju, otvoriti zapisnike ili nastaviti čekati.",
|
||||||
|
"desktop.recovery.loadFailed.detail": "Prozor: {{window}}\nURL: {{url}}\nGreška: {{code}} {{description}}",
|
||||||
|
"desktop.recovery.terminated.detail": "Prozor: {{window}}\nRazlog: {{reason}}\nKod: {{code}}",
|
||||||
|
"desktop.recovery.unknown": "<nepoznato>",
|
||||||
|
|
||||||
|
"desktop.dialog.chooseFolder": "Odaberi fasciklu",
|
||||||
|
"desktop.dialog.chooseFile": "Odaberi datoteku",
|
||||||
|
"desktop.dialog.saveFile": "Sačuvaj datoteku",
|
||||||
|
"desktop.dialog.files": "Datoteke",
|
||||||
|
"desktop.server.local": "Lokalni server",
|
||||||
|
|
||||||
|
"desktop.wsl.error.windowsOnly": "WSL je dostupan samo u Windowsu",
|
||||||
|
"desktop.wsl.error.unavailable": "WSL nije dostupan",
|
||||||
|
"desktop.wsl.error.listInstalled": "Nije uspjelo prikazivanje instaliranih WSL distribucija",
|
||||||
|
"desktop.wsl.error.listOnline": "Nije uspjelo prikazivanje dostupnih WSL distribucija",
|
||||||
|
"desktop.wsl.error.executeDistro": "Nije moguće izvršiti komande u distribuciji",
|
||||||
|
"desktop.wsl.error.installWsl": "Instalacija WSL-a nije uspjela",
|
||||||
|
"desktop.wsl.error.installDistro": "Instalacija distribucije nije uspjela: {{distro}}",
|
||||||
|
"desktop.wsl.error.installOpencode": "Instalacija OpenCode-a nije uspjela",
|
||||||
|
"desktop.wsl.error.alreadyAdded": "Distribucija {{distro}} je već dodana",
|
||||||
|
"desktop.wsl.error.opencodeMissing": "opencode nije instaliran u ovoj distribuciji",
|
||||||
|
"desktop.wsl.error.opencodeCannotRun": "opencode je instaliran, ali se ne može pokrenuti",
|
||||||
|
"desktop.wsl.error.opencodeNotInstalled": "OpenCode nije instaliran u distribuciji {{distro}}",
|
||||||
|
"desktop.wsl.error.updateVersion":
|
||||||
|
"Ažuriranje OpenCode-a je završeno, ali {{distro}} i dalje prijavljuje {{installed}}; očekivano je {{expected}}",
|
||||||
|
"desktop.wsl.error.noVersion": "nema verzije",
|
||||||
|
"desktop.wsl.error.serverExited": "WSL server se zatvorio nakon pokretanja (code={{code}} signal={{signal}})",
|
||||||
|
"desktop.wsl.error.serverExitedBeforeHealthy":
|
||||||
|
"WSL server se zatvorio prije nego što je postao dostupan (code={{code}} signal={{signal}}){{output}}",
|
||||||
|
"desktop.wsl.error.healthTimeout":
|
||||||
|
"Isteklo je vrijeme provjere dostupnosti komponente Sidecar za {{distro}} nakon {{timeout}} ms",
|
||||||
|
"desktop.wsl.error.commandTimeout": "Isteklo je vrijeme za {{command}} {{args}} nakon {{timeout}} ms",
|
||||||
|
"desktop.wsl.error.failedPort": "Nije uspjelo dohvaćanje porta",
|
||||||
|
|
||||||
|
"desktop.picker.error.notSelected": "Datoteka nije odabrana u izborniku",
|
||||||
|
"desktop.picker.error.sizeLimit": "Odabrani prilozi premašuju ograničenje od {{limit}} MB",
|
||||||
|
|
||||||
"command.category.suggested": "Predloženo",
|
"command.category.suggested": "Predloženo",
|
||||||
"command.category.view": "Prikaz",
|
"command.category.view": "Prikaz",
|
||||||
"command.category.project": "Projekat",
|
"command.category.project": "Projekat",
|
||||||
@@ -16,6 +116,7 @@ export const dict = {
|
|||||||
"command.category.permissions": "Dozvole",
|
"command.category.permissions": "Dozvole",
|
||||||
"command.category.workspace": "Radni prostor",
|
"command.category.workspace": "Radni prostor",
|
||||||
"command.category.settings": "Postavke",
|
"command.category.settings": "Postavke",
|
||||||
|
"command.logs.export": "Izvezi zapisnike",
|
||||||
|
|
||||||
"theme.scheme.system": "Sistem",
|
"theme.scheme.system": "Sistem",
|
||||||
"theme.scheme.light": "Svijetlo",
|
"theme.scheme.light": "Svijetlo",
|
||||||
@@ -102,6 +203,7 @@ export const dict = {
|
|||||||
"dialog.provider.empty": "Nema pronađenih provajdera",
|
"dialog.provider.empty": "Nema pronađenih provajdera",
|
||||||
"dialog.provider.group.popular": "Popularno",
|
"dialog.provider.group.popular": "Popularno",
|
||||||
"dialog.provider.group.other": "Ostalo",
|
"dialog.provider.group.other": "Ostalo",
|
||||||
|
"dialog.provider.custom.label": "Prilagođeni provajder kompatibilan s OpenAI-jem",
|
||||||
"dialog.provider.tag.recommended": "Preporučeno",
|
"dialog.provider.tag.recommended": "Preporučeno",
|
||||||
"dialog.provider.opencode.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge",
|
"dialog.provider.opencode.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge",
|
||||||
"dialog.provider.opencode.tagline": "Pouzdani optimizovani modeli",
|
"dialog.provider.opencode.tagline": "Pouzdani optimizovani modeli",
|
||||||
@@ -122,6 +224,7 @@ export const dict = {
|
|||||||
|
|
||||||
"dialog.model.unpaid.freeModels.title": "Besplatni modeli koje obezbjeđuje OpenCode",
|
"dialog.model.unpaid.freeModels.title": "Besplatni modeli koje obezbjeđuje OpenCode",
|
||||||
"dialog.model.unpaid.addMore.title": "Dodaj još modela od popularnih provajdera",
|
"dialog.model.unpaid.addMore.title": "Dodaj još modela od popularnih provajdera",
|
||||||
|
"dialog.model.unpaid.viewMoreProviders": "Pogledaj još više od 70 provajdera",
|
||||||
|
|
||||||
"dialog.provider.viewAll": "Prikaži više provajdera",
|
"dialog.provider.viewAll": "Prikaži više provajdera",
|
||||||
|
|
||||||
@@ -129,6 +232,8 @@ export const dict = {
|
|||||||
"provider.connect.title.anthropicProMax": "Prijavi se putem Claude Pro/Max",
|
"provider.connect.title.anthropicProMax": "Prijavi se putem Claude Pro/Max",
|
||||||
"provider.connect.selectMethod": "Odaberi način prijave za {{provider}}.",
|
"provider.connect.selectMethod": "Odaberi način prijave za {{provider}}.",
|
||||||
"provider.connect.method.apiKey": "API ključ",
|
"provider.connect.method.apiKey": "API ključ",
|
||||||
|
"provider.connect.method.browser": "Preglednik",
|
||||||
|
"provider.connect.method.headless": "Bez grafičkog interfejsa",
|
||||||
"provider.connect.status.inProgress": "Autorizacija je u toku...",
|
"provider.connect.status.inProgress": "Autorizacija je u toku...",
|
||||||
"provider.connect.status.waiting": "Čekanje na autorizaciju...",
|
"provider.connect.status.waiting": "Čekanje na autorizaciju...",
|
||||||
"provider.connect.status.failed": "Autorizacija nije uspjela: {{error}}",
|
"provider.connect.status.failed": "Autorizacija nije uspjela: {{error}}",
|
||||||
@@ -161,6 +266,7 @@ export const dict = {
|
|||||||
"provider.connect.toast.connected.description": "{{provider}} modeli su sada dostupni za korištenje.",
|
"provider.connect.toast.connected.description": "{{provider}} modeli su sada dostupni za korištenje.",
|
||||||
|
|
||||||
"provider.custom.title": "Prilagođeni provajder",
|
"provider.custom.title": "Prilagođeni provajder",
|
||||||
|
"provider.custom.unavailable": "Prilagođeni provajderi nisu dostupni na ovom serveru",
|
||||||
"provider.custom.description.prefix": "Konfiguriši OpenAI-kompatibilnog provajdera. Pogledaj ",
|
"provider.custom.description.prefix": "Konfiguriši OpenAI-kompatibilnog provajdera. Pogledaj ",
|
||||||
"provider.custom.description.link": "dokumentaciju za konfiguraciju provajdera",
|
"provider.custom.description.link": "dokumentaciju za konfiguraciju provajdera",
|
||||||
"provider.custom.description.suffix": ".",
|
"provider.custom.description.suffix": ".",
|
||||||
@@ -232,7 +338,7 @@ export const dict = {
|
|||||||
"common.clear": "Očisti",
|
"common.clear": "Očisti",
|
||||||
"common.connect": "Poveži",
|
"common.connect": "Poveži",
|
||||||
"common.disconnect": "Prekini vezu",
|
"common.disconnect": "Prekini vezu",
|
||||||
"common.continue": "Pošalji",
|
"common.continue": "Nastavi",
|
||||||
"common.submit": "Pošalji",
|
"common.submit": "Pošalji",
|
||||||
"common.save": "Sačuvaj",
|
"common.save": "Sačuvaj",
|
||||||
"common.saving": "Čuvanje...",
|
"common.saving": "Čuvanje...",
|
||||||
@@ -263,7 +369,7 @@ export const dict = {
|
|||||||
"prompt.example.11": "Optimizuj upite prema bazi podataka",
|
"prompt.example.11": "Optimizuj upite prema bazi podataka",
|
||||||
"prompt.example.12": "Dodaj validaciju ulaza",
|
"prompt.example.12": "Dodaj validaciju ulaza",
|
||||||
"prompt.example.13": "Napravi novu komponentu za...",
|
"prompt.example.13": "Napravi novu komponentu za...",
|
||||||
"prompt.example.14": "Kako da deployam ovaj projekat?",
|
"prompt.example.14": "Kako da postavim ovaj projekat?",
|
||||||
"prompt.example.15": "Pregledaj moj kod prema najboljim praksama",
|
"prompt.example.15": "Pregledaj moj kod prema najboljim praksama",
|
||||||
"prompt.example.16": "Dodaj obradu grešaka u ovu funkciju",
|
"prompt.example.16": "Dodaj obradu grešaka u ovu funkciju",
|
||||||
"prompt.example.17": "Objasni ovaj regex obrazac",
|
"prompt.example.17": "Objasni ovaj regex obrazac",
|
||||||
@@ -271,14 +377,14 @@ export const dict = {
|
|||||||
"prompt.example.19": "Dodaj logovanje kroz cijelu bazu koda",
|
"prompt.example.19": "Dodaj logovanje kroz cijelu bazu koda",
|
||||||
"prompt.example.20": "Koje su zavisnosti zastarjele?",
|
"prompt.example.20": "Koje su zavisnosti zastarjele?",
|
||||||
"prompt.example.21": "Pomozi mi da napišem migracijsku skriptu",
|
"prompt.example.21": "Pomozi mi da napišem migracijsku skriptu",
|
||||||
"prompt.example.22": "Implementiraj keširanje za ovaj endpoint",
|
"prompt.example.22": "Implementiraj keširanje za ovu krajnju tačku",
|
||||||
"prompt.example.23": "Dodaj paginaciju u ovu listu",
|
"prompt.example.23": "Dodaj paginaciju u ovu listu",
|
||||||
"prompt.example.24": "Napravi CLI komandu za...",
|
"prompt.example.24": "Napravi CLI komandu za...",
|
||||||
"prompt.example.25": "Kako ovdje rade varijable okruženja?",
|
"prompt.example.25": "Kako ovdje rade varijable okruženja?",
|
||||||
|
|
||||||
"prompt.popover.emptyResults": "Nema rezultata",
|
"prompt.popover.emptyResults": "Nema rezultata",
|
||||||
"prompt.popover.emptyCommands": "Nema komandi",
|
"prompt.popover.emptyCommands": "Nema komandi",
|
||||||
"prompt.dropzone.label": "Ovdje prevucite slike, PDF-ove ili tekstualne datoteke",
|
"prompt.dropzone.label": "Ovdje prevuci slike, PDF-ove ili tekstualne datoteke",
|
||||||
"prompt.dropzone.file.label": "Spusti za @spominjanje datoteke",
|
"prompt.dropzone.file.label": "Spusti za @spominjanje datoteke",
|
||||||
"prompt.slash.badge.custom": "prilagođeno",
|
"prompt.slash.badge.custom": "prilagođeno",
|
||||||
"prompt.slash.badge.skill": "skill",
|
"prompt.slash.badge.skill": "skill",
|
||||||
@@ -287,7 +393,7 @@ export const dict = {
|
|||||||
"prompt.context.includeActiveFile": "Uključi aktivnu datoteku",
|
"prompt.context.includeActiveFile": "Uključi aktivnu datoteku",
|
||||||
"prompt.context.removeActiveFile": "Ukloni aktivnu datoteku iz konteksta",
|
"prompt.context.removeActiveFile": "Ukloni aktivnu datoteku iz konteksta",
|
||||||
"prompt.context.removeFile": "Ukloni datoteku iz konteksta",
|
"prompt.context.removeFile": "Ukloni datoteku iz konteksta",
|
||||||
"prompt.action.attachFile": "Priloži datoteku",
|
"prompt.action.attachFile": "Dodaj datoteke",
|
||||||
"prompt.menu.addImagesAndFiles": "Dodaj datoteke i više",
|
"prompt.menu.addImagesAndFiles": "Dodaj datoteke i više",
|
||||||
"prompt.menu.imagesAndFiles": "Slike i datoteke",
|
"prompt.menu.imagesAndFiles": "Slike i datoteke",
|
||||||
"prompt.menu.commands": "Komande",
|
"prompt.menu.commands": "Komande",
|
||||||
@@ -311,7 +417,7 @@ export const dict = {
|
|||||||
|
|
||||||
"dialog.mcp.title": "MCP-ovi",
|
"dialog.mcp.title": "MCP-ovi",
|
||||||
"dialog.mcp.description": "{{enabled}} od {{total}} omogućeno",
|
"dialog.mcp.description": "{{enabled}} od {{total}} omogućeno",
|
||||||
"dialog.mcp.empty": "Nema konfigurisnih MCP-ova",
|
"dialog.mcp.empty": "Nema konfigurisanih MCP-ova",
|
||||||
|
|
||||||
"dialog.lsp.empty": "LSP-ovi se automatski otkrivaju prema tipu datoteke",
|
"dialog.lsp.empty": "LSP-ovi se automatski otkrivaju prema tipu datoteke",
|
||||||
"dialog.plugins.empty": "Plugini su konfigurisani u opencode.json",
|
"dialog.plugins.empty": "Plugini su konfigurisani u opencode.json",
|
||||||
@@ -319,18 +425,18 @@ export const dict = {
|
|||||||
"mcp.status.connected": "povezano",
|
"mcp.status.connected": "povezano",
|
||||||
"mcp.status.failed": "neuspjelo",
|
"mcp.status.failed": "neuspjelo",
|
||||||
"mcp.status.needs_auth": "potrebna autentifikacija",
|
"mcp.status.needs_auth": "potrebna autentifikacija",
|
||||||
"mcp.auth.clickToAuthenticate": "Kliknite za autentifikaciju",
|
"mcp.auth.clickToAuthenticate": "Klikni za autentifikaciju",
|
||||||
"mcp.status.disabled": "onemogućeno",
|
"mcp.status.disabled": "onemogućeno",
|
||||||
|
|
||||||
"dialog.fork.empty": "Nema poruka za fork",
|
"dialog.fork.empty": "Nema poruka za fork",
|
||||||
|
|
||||||
"dialog.directory.search.placeholder": "Pretraži foldere",
|
"dialog.directory.search.placeholder": "Pretraži fascikle",
|
||||||
"dialog.directory.empty": "Nema pronađenih foldera",
|
"dialog.directory.empty": "Nema pronađenih fascikli",
|
||||||
"dialog.directory.action.selectFile": "Odaberi datoteku",
|
"dialog.directory.action.selectFile": "Odaberi datoteku",
|
||||||
"dialog.directory.action.selectFolder": "Odaberi folder",
|
"dialog.directory.action.selectFolder": "Odaberi fasciklu",
|
||||||
"dialog.directory.root": "Korijen",
|
"dialog.directory.root": "Korijen",
|
||||||
"dialog.directory.parent": "Nadređeni folder",
|
"dialog.directory.parent": "Nadređena fascikla",
|
||||||
"dialog.directory.readError": "Nije moguće pročitati ovaj folder",
|
"dialog.directory.readError": "Nije moguće pročitati ovu fasciklu",
|
||||||
|
|
||||||
"dialog.server.title": "Serveri",
|
"dialog.server.title": "Serveri",
|
||||||
"dialog.server.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.",
|
"dialog.server.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.",
|
||||||
@@ -349,7 +455,7 @@ export const dict = {
|
|||||||
"dialog.server.edit.title": "Uredi server",
|
"dialog.server.edit.title": "Uredi server",
|
||||||
"dialog.server.default.title": "Podrazumijevani server",
|
"dialog.server.default.title": "Podrazumijevani server",
|
||||||
"dialog.server.default.description":
|
"dialog.server.default.description":
|
||||||
"Poveži se na ovaj server pri pokretanju aplikacije umjesto pokretanja lokalnog servera. Potreban je restart.",
|
"Poveži se na ovaj server pri pokretanju aplikacije umjesto pokretanja lokalnog servera. Potrebno je ponovo pokrenuti aplikaciju.",
|
||||||
"dialog.server.default.none": "Nije odabran server",
|
"dialog.server.default.none": "Nije odabran server",
|
||||||
"dialog.server.default.set": "Postavi trenutni server kao podrazumijevani",
|
"dialog.server.default.set": "Postavi trenutni server kao podrazumijevani",
|
||||||
"dialog.server.default.clear": "Očisti",
|
"dialog.server.default.clear": "Očisti",
|
||||||
@@ -431,6 +537,22 @@ export const dict = {
|
|||||||
"wsl.onboarding.versionMismatch": "Instalirana verzija ne odgovara verziji desktop aplikacije.",
|
"wsl.onboarding.versionMismatch": "Instalirana verzija ne odgovara verziji desktop aplikacije.",
|
||||||
"wsl.onboarding.adding": "Dodavanje...",
|
"wsl.onboarding.adding": "Dodavanje...",
|
||||||
|
|
||||||
|
"help.tabs.toast.ariaLabel": "Predstavljamo kartice. Organizuj svoj rad i aktivne sesije pomoću kartica",
|
||||||
|
"help.tabs.toast.dismiss": "Odbaci informacije o karticama",
|
||||||
|
"help.tabs.title": "Predstavljamo kartice",
|
||||||
|
"help.tabs.description": "Organizuj svoj rad i aktivne sesije pomoću kartica",
|
||||||
|
"help.tabs.date": "14. juli",
|
||||||
|
"help.tabs.introduction": "OpenCode Desktop sada je organizovan oko kartica.",
|
||||||
|
"help.tabs.sessions":
|
||||||
|
"Započni novu sesiju u kartici ili otvori postojeću sesiju iz bilo kojeg projekta. Otvori novu karticu kada započinješ nešto novo, a zatvori je kada završiš.",
|
||||||
|
"help.tabs.organize":
|
||||||
|
"Nekoliko otvorenih kartica olakšava organizovanje aktivnih sesija. Preimenuj kartice tako da ih možeš lako prepoznati ako ih namjeravaš zadržati.",
|
||||||
|
"help.tabs.home":
|
||||||
|
"Sve svoje sesije i projekte pronaći ćeš na novom početnom ekranu. Odabirom sesije ona se otvara u kartici.",
|
||||||
|
"help.tabs.persistence": "Kada ponovo otvoriš aplikaciju, tvoje kartice ostaju otvorene.",
|
||||||
|
"help.tabs.worktrees":
|
||||||
|
"Novi dizajn još ne podržava Git Worktrees, ali podrška stiže uskoro. Ako želiš nastaviti koristiti prethodni raspored, možeš mijenjati rasporede u Postavkama. Imaj na umu da će novi raspored za nekoliko sedmica postati trajan.",
|
||||||
|
|
||||||
"dialog.project.edit.title": "Uredi projekat",
|
"dialog.project.edit.title": "Uredi projekat",
|
||||||
"dialog.project.edit.name": "Naziv",
|
"dialog.project.edit.name": "Naziv",
|
||||||
"dialog.project.edit.icon": "Ikonica",
|
"dialog.project.edit.icon": "Ikonica",
|
||||||
@@ -445,7 +567,7 @@ export const dict = {
|
|||||||
|
|
||||||
"context.breakdown.title": "Razlaganje konteksta",
|
"context.breakdown.title": "Razlaganje konteksta",
|
||||||
"context.breakdown.note":
|
"context.breakdown.note":
|
||||||
'Približna raspodjela ulaznih tokena. "Ostalo" uključuje definicije alata i dodatni overhead.',
|
'Približna raspodjela ulaznih tokena. "Ostalo" uključuje definicije alata i dodatno opterećenje.',
|
||||||
"context.breakdown.system": "Sistem",
|
"context.breakdown.system": "Sistem",
|
||||||
"context.breakdown.user": "Korisnik",
|
"context.breakdown.user": "Korisnik",
|
||||||
"context.breakdown.assistant": "Asistent",
|
"context.breakdown.assistant": "Asistent",
|
||||||
@@ -465,7 +587,7 @@ export const dict = {
|
|||||||
"context.stats.inputTokens": "Ulazni tokeni",
|
"context.stats.inputTokens": "Ulazni tokeni",
|
||||||
"context.stats.outputTokens": "Izlazni tokeni",
|
"context.stats.outputTokens": "Izlazni tokeni",
|
||||||
"context.stats.reasoningTokens": "Tokeni za rasuđivanje",
|
"context.stats.reasoningTokens": "Tokeni za rasuđivanje",
|
||||||
"context.stats.cacheTokens": "Cache tokeni (čitanje/pisanje)",
|
"context.stats.cacheTokens": "Keširani tokeni (čitanje/pisanje)",
|
||||||
"context.stats.userMessages": "Korisničke poruke",
|
"context.stats.userMessages": "Korisničke poruke",
|
||||||
"context.stats.assistantMessages": "Poruke asistenta",
|
"context.stats.assistantMessages": "Poruke asistenta",
|
||||||
"context.stats.totalCost": "Ukupni trošak",
|
"context.stats.totalCost": "Ukupni trošak",
|
||||||
@@ -537,14 +659,14 @@ export const dict = {
|
|||||||
|
|
||||||
"toast.update.title": "Dostupno ažuriranje",
|
"toast.update.title": "Dostupno ažuriranje",
|
||||||
"toast.update.description": "Nova verzija OpenCode-a ({{version}}) je dostupna za instalaciju.",
|
"toast.update.description": "Nova verzija OpenCode-a ({{version}}) je dostupna za instalaciju.",
|
||||||
"toast.update.action.installRestart": "Instaliraj i restartuj",
|
"toast.update.action.installRestart": "Instaliraj i ponovo pokreni",
|
||||||
"toast.update.action.notYet": "Ne još",
|
"toast.update.action.notYet": "Ne još",
|
||||||
|
|
||||||
"error.page.title": "Nešto je pošlo po zlu",
|
"error.page.title": "Nešto je pošlo po zlu",
|
||||||
"error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.",
|
"error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.",
|
||||||
"error.page.description.localServerStartup": "Došlo je do greške pri pokretanju lokalnog servera.",
|
"error.page.description.localServerStartup": "Došlo je do greške pri pokretanju lokalnog servera.",
|
||||||
"error.page.details.label": "Detalji greške",
|
"error.page.details.label": "Detalji greške",
|
||||||
"error.page.action.restart": "Restartuj",
|
"error.page.action.restart": "Ponovo pokreni",
|
||||||
"error.page.action.report": "Prijavi grešku",
|
"error.page.action.report": "Prijavi grešku",
|
||||||
"error.page.action.reported": "Greška prijavljena",
|
"error.page.action.reported": "Greška prijavljena",
|
||||||
"error.page.action.exportLogs": "Izvezi logove",
|
"error.page.action.exportLogs": "Izvezi logove",
|
||||||
@@ -569,7 +691,7 @@ export const dict = {
|
|||||||
"error.chain.responseBody": "Tijelo odgovora:\n{{body}}",
|
"error.chain.responseBody": "Tijelo odgovora:\n{{body}}",
|
||||||
"error.chain.didYouMean": "Da li si mislio: {{suggestions}}",
|
"error.chain.didYouMean": "Da li si mislio: {{suggestions}}",
|
||||||
"error.chain.modelNotFound": "Model nije pronađen: {{provider}}/{{model}}",
|
"error.chain.modelNotFound": "Model nije pronađen: {{provider}}/{{model}}",
|
||||||
"error.chain.checkConfig": "Provjeri konfiguraciju (opencode.json) provider/model names",
|
"error.chain.checkConfig": "Provjeri nazive provajdera/modela u konfiguraciji (opencode.json)",
|
||||||
"error.chain.mcpFailed": 'MCP server "{{name}}" nije uspio. Napomena: OpenCode još ne podržava MCP autentifikaciju.',
|
"error.chain.mcpFailed": 'MCP server "{{name}}" nije uspio. Napomena: OpenCode još ne podržava MCP autentifikaciju.',
|
||||||
"error.chain.providerAuthFailed": "Autentifikacija provajdera nije uspjela ({{provider}}): {{message}}",
|
"error.chain.providerAuthFailed": "Autentifikacija provajdera nije uspjela ({{provider}}): {{message}}",
|
||||||
"error.chain.providerInitFailed":
|
"error.chain.providerInitFailed":
|
||||||
@@ -611,7 +733,7 @@ export const dict = {
|
|||||||
"home.sessions.group.yesterday": "Jučer",
|
"home.sessions.group.yesterday": "Jučer",
|
||||||
"home.sessions.group.older": "Starije",
|
"home.sessions.group.older": "Starije",
|
||||||
"home.providerTip":
|
"home.providerTip":
|
||||||
"Povežite se s više od 75 pružalaca usluga kako biste koristili druge modele, uključujući Claude, GPT, Gemini i druge",
|
"Poveži se s više od 75 provajdera da koristiš druge modele, uključujući Claude, GPT, Gemini i druge",
|
||||||
|
|
||||||
"session.tab.session": "Sesija",
|
"session.tab.session": "Sesija",
|
||||||
"session.tab.review": "Pregled",
|
"session.tab.review": "Pregled",
|
||||||
@@ -627,11 +749,11 @@ export const dict = {
|
|||||||
"session.review.change.other": "Izmjene",
|
"session.review.change.other": "Izmjene",
|
||||||
"session.review.loadingChanges": "Učitavanje izmjena...",
|
"session.review.loadingChanges": "Učitavanje izmjena...",
|
||||||
"session.review.empty": "Još nema izmjena u ovoj sesiji",
|
"session.review.empty": "Još nema izmjena u ovoj sesiji",
|
||||||
"session.review.noVcs": "Nije detektovan Git sistem kontrole verzija, promjene se ne prikazuju",
|
"session.review.noVcs": "Nije otkriven Git sistem za kontrolu verzija, pa se promjene ne prikazuju",
|
||||||
"session.review.noSnapshot":
|
"session.review.noSnapshot":
|
||||||
"Praćenje snimaka (snapshot) je onemogućeno u konfiguraciji, pa promjene sesije nisu dostupne",
|
"Praćenje snimaka (snapshot) je onemogućeno u konfiguraciji, pa promjene sesije nisu dostupne",
|
||||||
"session.review.noChanges": "Nema izmjena",
|
"session.review.noChanges": "Nema izmjena",
|
||||||
"session.review.noUncommittedChanges": "Još nema nepotvrđenih promjena",
|
"session.review.noUncommittedChanges": "Još nema promjena koje nisu commitovane",
|
||||||
"session.review.noBranchChanges": "Još nema promjena na grani",
|
"session.review.noBranchChanges": "Još nema promjena na grani",
|
||||||
|
|
||||||
"session.files.selectToOpen": "Odaberi datoteku za otvaranje",
|
"session.files.selectToOpen": "Odaberi datoteku za otvaranje",
|
||||||
@@ -652,14 +774,17 @@ export const dict = {
|
|||||||
"session.question.minimize": "Minimiziraj pitanje",
|
"session.question.minimize": "Minimiziraj pitanje",
|
||||||
"session.question.restore": "Vrati pitanje",
|
"session.question.restore": "Vrati pitanje",
|
||||||
"session.question.pending.one": "{{count}} pitanje na čekanju",
|
"session.question.pending.one": "{{count}} pitanje na čekanju",
|
||||||
|
"session.question.pending.few": "{{count}} pitanja na čekanju",
|
||||||
"session.question.pending.other": "{{count}} pitanja na čekanju",
|
"session.question.pending.other": "{{count}} pitanja na čekanju",
|
||||||
"session.followupDock.summary.one": "{{count}} poruka na čekanju",
|
"session.followupDock.summary.one": "{{count}} poruka na čekanju",
|
||||||
|
"session.followupDock.summary.few": "{{count}} poruke na čekanju",
|
||||||
"session.followupDock.summary.other": "{{count}} poruka na čekanju",
|
"session.followupDock.summary.other": "{{count}} poruka na čekanju",
|
||||||
"session.followupDock.sendNow": "Pošalji sada",
|
"session.followupDock.sendNow": "Pošalji sada",
|
||||||
"session.followupDock.edit": "Uredi",
|
"session.followupDock.edit": "Uredi",
|
||||||
"session.followupDock.collapse": "Sažmi poruke na čekanju",
|
"session.followupDock.collapse": "Sažmi poruke na čekanju",
|
||||||
"session.followupDock.expand": "Proširi poruke na čekanju",
|
"session.followupDock.expand": "Proširi poruke na čekanju",
|
||||||
"session.revertDock.summary.one": "{{count}} vraćena poruka",
|
"session.revertDock.summary.one": "{{count}} vraćena poruka",
|
||||||
|
"session.revertDock.summary.few": "{{count}} vraćene poruke",
|
||||||
"session.revertDock.summary.other": "{{count}} vraćenih poruka",
|
"session.revertDock.summary.other": "{{count}} vraćenih poruka",
|
||||||
"session.revertDock.collapse": "Sažmi vraćene poruke",
|
"session.revertDock.collapse": "Sažmi vraćene poruke",
|
||||||
"session.revertDock.expand": "Proširi vraćene poruke",
|
"session.revertDock.expand": "Proširi vraćene poruke",
|
||||||
@@ -717,7 +842,13 @@ export const dict = {
|
|||||||
"terminal.close": "Zatvori terminal",
|
"terminal.close": "Zatvori terminal",
|
||||||
"terminal.connectionLost.title": "Veza prekinuta",
|
"terminal.connectionLost.title": "Veza prekinuta",
|
||||||
"terminal.connectionLost.description":
|
"terminal.connectionLost.description":
|
||||||
"Veza s terminalom je prekinuta. Ovo se može desiti kada se server restartuje.",
|
"Veza s terminalom je prekinuta. Ovo se može desiti kada se server ponovo pokrene.",
|
||||||
|
"terminal.connectTicket.csrfError":
|
||||||
|
"PTY tiket za povezivanje odbijen je zbog provjera porijekla ili CSRF-a. Provjeri CORS konfiguraciju servera.",
|
||||||
|
"terminal.connectTicket.statusError": "PTY tiket za povezivanje nije uspio sa statusom {{status}}",
|
||||||
|
|
||||||
|
"titlebar.update": "Ažuriraj",
|
||||||
|
"titlebar.updateVersion": "Ažuriraj {{version}}",
|
||||||
|
|
||||||
"common.closeTab": "Zatvori karticu",
|
"common.closeTab": "Zatvori karticu",
|
||||||
"common.dismiss": "Odbaci",
|
"common.dismiss": "Odbaci",
|
||||||
@@ -725,7 +856,7 @@ export const dict = {
|
|||||||
"common.moreOptions": "Više opcija",
|
"common.moreOptions": "Više opcija",
|
||||||
"common.learnMore": "Saznaj više",
|
"common.learnMore": "Saznaj više",
|
||||||
"common.rename": "Preimenuj",
|
"common.rename": "Preimenuj",
|
||||||
"common.reset": "Resetuj",
|
"common.reset": "Vrati na početno stanje",
|
||||||
"common.archive": "Arhiviraj",
|
"common.archive": "Arhiviraj",
|
||||||
"common.delete": "Izbriši",
|
"common.delete": "Izbriši",
|
||||||
"common.close": "Zatvori",
|
"common.close": "Zatvori",
|
||||||
@@ -782,7 +913,7 @@ export const dict = {
|
|||||||
"settings.general.row.font.title": "Font za kod",
|
"settings.general.row.font.title": "Font za kod",
|
||||||
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
|
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
|
||||||
"settings.general.row.terminalFont.title": "Font terminala",
|
"settings.general.row.terminalFont.title": "Font terminala",
|
||||||
"settings.general.row.terminalFont.description": "Prilagodite font koji se koristi u terminalu",
|
"settings.general.row.terminalFont.description": "Prilagodi font koji se koristi u terminalu",
|
||||||
"settings.general.row.uiFont.title": "UI font",
|
"settings.general.row.uiFont.title": "UI font",
|
||||||
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
|
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
|
||||||
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
||||||
@@ -817,15 +948,16 @@ export const dict = {
|
|||||||
"settings.general.row.newInterface.title": "Novi raspored",
|
"settings.general.row.newInterface.title": "Novi raspored",
|
||||||
"settings.general.row.newInterface.badge": "Novo",
|
"settings.general.row.newInterface.badge": "Novo",
|
||||||
"settings.general.row.newInterface.description":
|
"settings.general.row.newInterface.description":
|
||||||
"Koristite nove kartice i raspored početne stranice. Ograničeno vrijeme možete se prebacivati između rasporeda.",
|
"Koristi nove kartice i raspored početne stranice. Ograničeno vrijeme možeš se prebacivati između rasporeda.",
|
||||||
"settings.general.row.newInterfaceNotice.title": "Sada koristite novi raspored",
|
"settings.general.row.newInterfaceNotice.title": "Sada koristiš novi raspored",
|
||||||
"settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan",
|
"settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan",
|
||||||
"settings.general.row.newInterfaceNotice.dismiss": "Odbaci",
|
"settings.general.row.newInterfaceNotice.dismiss": "Odbaci",
|
||||||
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
|
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
|
||||||
"settings.general.row.pinchZoom.description":
|
"settings.general.row.pinchZoom.description":
|
||||||
"Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja",
|
"Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja",
|
||||||
"settings.general.row.wayland.title": "Koristi nativni Wayland",
|
"settings.general.row.wayland.title": "Koristi nativni Wayland",
|
||||||
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
|
"settings.general.row.wayland.description":
|
||||||
|
"Onemogući rezervni X11 način rada na Waylandu. Zahtijeva ponovno pokretanje.",
|
||||||
"settings.general.row.wayland.tooltip":
|
"settings.general.row.wayland.tooltip":
|
||||||
"Na Linuxu sa monitorima miješanih stopa osvježavanja, nativni Wayland može biti stabilniji.",
|
"Na Linuxu sa monitorima miješanih stopa osvježavanja, nativni Wayland može biti stabilniji.",
|
||||||
|
|
||||||
@@ -941,7 +1073,7 @@ export const dict = {
|
|||||||
"settings.mcp.description": "MCP postavke će se ovdje moći podešavati.",
|
"settings.mcp.description": "MCP postavke će se ovdje moći podešavati.",
|
||||||
|
|
||||||
"settings.permissions.title": "Dozvole",
|
"settings.permissions.title": "Dozvole",
|
||||||
"settings.permissions.description": "Kontroliši koje alate server smije koristiti po defaultu.",
|
"settings.permissions.description": "Kontroliši koje alate server smije koristiti zadano.",
|
||||||
"settings.permissions.section.tools": "Alati",
|
"settings.permissions.section.tools": "Alati",
|
||||||
"settings.permissions.toast.updateFailed.title": "Neuspjelo ažuriranje dozvola",
|
"settings.permissions.toast.updateFailed.title": "Neuspjelo ažuriranje dozvola",
|
||||||
|
|
||||||
@@ -953,7 +1085,7 @@ export const dict = {
|
|||||||
"settings.permissions.tool.read.description": "Čitanje datoteke (podudara se s putanjom datoteke)",
|
"settings.permissions.tool.read.description": "Čitanje datoteke (podudara se s putanjom datoteke)",
|
||||||
"settings.permissions.tool.edit.title": "Uređivanje",
|
"settings.permissions.tool.edit.title": "Uređivanje",
|
||||||
"settings.permissions.tool.edit.description":
|
"settings.permissions.tool.edit.description":
|
||||||
"Mijenjanje datoteka, uključujući izmjene, pisanja, patch-eve i multi-izmjene",
|
"Mijenjanje datoteka, uključujući uređivanje, pisanje, zakrpe i višestruke izmjene",
|
||||||
"settings.permissions.tool.glob.title": "Glob",
|
"settings.permissions.tool.glob.title": "Glob",
|
||||||
"settings.permissions.tool.glob.description": "Podudaranje datoteka pomoću glob šablona",
|
"settings.permissions.tool.glob.description": "Podudaranje datoteka pomoću glob šablona",
|
||||||
"settings.permissions.tool.grep.title": "Grep",
|
"settings.permissions.tool.grep.title": "Grep",
|
||||||
@@ -989,26 +1121,26 @@ export const dict = {
|
|||||||
"workspace.type.sandbox": "sandbox",
|
"workspace.type.sandbox": "sandbox",
|
||||||
"workspace.create.failed.title": "Neuspješno kreiranje radnog prostora",
|
"workspace.create.failed.title": "Neuspješno kreiranje radnog prostora",
|
||||||
"workspace.delete.failed.title": "Neuspješno brisanje radnog prostora",
|
"workspace.delete.failed.title": "Neuspješno brisanje radnog prostora",
|
||||||
"workspace.resetting.title": "Resetovanje radnog prostora",
|
"workspace.resetting.title": "Vraćanje radnog prostora na početno stanje",
|
||||||
"workspace.resetting.description": "Ovo može potrajati minut.",
|
"workspace.resetting.description": "Ovo može potrajati minut.",
|
||||||
"workspace.reset.failed.title": "Neuspješno resetovanje radnog prostora",
|
"workspace.reset.failed.title": "Neuspješno vraćanje radnog prostora na početno stanje",
|
||||||
"workspace.reset.success.title": "Radni prostor resetovan",
|
"workspace.reset.success.title": "Radni prostor vraćen na početno stanje",
|
||||||
"workspace.reset.success.description": "Radni prostor sada odgovara podrazumijevanoj grani.",
|
"workspace.reset.success.description": "Radni prostor sada odgovara podrazumijevanoj grani.",
|
||||||
"workspace.error.stillPreparing": "Radni prostor se još priprema",
|
"workspace.error.stillPreparing": "Radni prostor se još priprema",
|
||||||
"workspace.status.checking": "Provjera neobjedinjenih promjena...",
|
"workspace.status.checking": "Provjera promjena koje nisu commitovane...",
|
||||||
"workspace.status.error": "Nije moguće provjeriti git status.",
|
"workspace.status.error": "Nije moguće provjeriti git status.",
|
||||||
"workspace.status.clean": "Nisu pronađene neobjedinjene promjene.",
|
"workspace.status.clean": "Nisu pronađene promjene koje nisu commitovane.",
|
||||||
"workspace.status.dirty": "Pronađene su neobjedinjene promjene u ovom radnom prostoru.",
|
"workspace.status.dirty": "Pronađene su promjene koje nisu commitovane u ovom radnom prostoru.",
|
||||||
"workspace.delete.title": "Izbriši radni prostor",
|
"workspace.delete.title": "Izbriši radni prostor",
|
||||||
"workspace.delete.confirm": 'Izbriši radni prostor "{{name}}"?',
|
"workspace.delete.confirm": 'Izbriši radni prostor "{{name}}"?',
|
||||||
"workspace.delete.button": "Izbriši radni prostor",
|
"workspace.delete.button": "Izbriši radni prostor",
|
||||||
"workspace.reset.title": "Resetuj radni prostor",
|
"workspace.reset.title": "Vrati radni prostor na početno stanje",
|
||||||
"workspace.reset.confirm": 'Resetuj radni prostor "{{name}}"?',
|
"workspace.reset.confirm": 'Vrati radni prostor "{{name}}" na početno stanje?',
|
||||||
"workspace.reset.button": "Resetuj radni prostor",
|
"workspace.reset.button": "Vrati radni prostor na početno stanje",
|
||||||
"workspace.reset.archived.none": "Nijedna aktivna sesija neće biti arhivirana.",
|
"workspace.reset.archived.none": "Nijedna aktivna sesija neće biti arhivirana.",
|
||||||
"workspace.reset.archived.one": "1 sesija će biti arhivirana.",
|
"workspace.reset.archived.one": "1 sesija će biti arhivirana.",
|
||||||
"workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.",
|
"workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.",
|
||||||
"workspace.reset.note": "Ovo će resetovati radni prostor da odgovara podrazumijevanoj grani.",
|
"workspace.reset.note": "Ovo će vratiti radni prostor na stanje podrazumijevane grane.",
|
||||||
"common.open": "Otvori",
|
"common.open": "Otvori",
|
||||||
"dialog.releaseNotes.action.getStarted": "Započni",
|
"dialog.releaseNotes.action.getStarted": "Započni",
|
||||||
"dialog.releaseNotes.action.next": "Sljedeće",
|
"dialog.releaseNotes.action.next": "Sljedeće",
|
||||||
@@ -1021,8 +1153,8 @@ export const dict = {
|
|||||||
"common.time.minutesAgo.short": "prije {{count}} min",
|
"common.time.minutesAgo.short": "prije {{count}} min",
|
||||||
"common.time.hoursAgo.short": "prije {{count}} h",
|
"common.time.hoursAgo.short": "prije {{count}} h",
|
||||||
"common.time.daysAgo.short": "prije {{count}} d",
|
"common.time.daysAgo.short": "prije {{count}} d",
|
||||||
"settings.providers.connected.environmentDescription": "Povezano sa vašim varijablama okruženja",
|
"settings.providers.connected.environmentDescription": "Povezano s tvojim varijablama okruženja",
|
||||||
"settings.providers.custom.description": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.",
|
"settings.providers.custom.description": "Dodaj provajdera kompatibilnog s OpenAI putem baznog URL-a.",
|
||||||
|
|
||||||
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
|
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
|
||||||
"app.server.retrying": "Automatski ponovni pokušaj...",
|
"app.server.retrying": "Automatski ponovni pokušaj...",
|
||||||
@@ -1031,13 +1163,16 @@ export const dict = {
|
|||||||
"dialog.server.add.passwordPlaceholder": "lozinka",
|
"dialog.server.add.passwordPlaceholder": "lozinka",
|
||||||
"server.row.noUsername": "nema korisničkog imena",
|
"server.row.noUsername": "nema korisničkog imena",
|
||||||
"session.review.noVcs.createGit.title": "Kreiraj Git repozitorij",
|
"session.review.noVcs.createGit.title": "Kreiraj Git repozitorij",
|
||||||
"session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu",
|
"session.review.noVcs.createGit.description": "Prati, pregledaj i poništi promjene u ovom projektu",
|
||||||
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
|
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
|
||||||
"session.review.noVcs.createGit.action": "Kreiraj Git repozitorij",
|
"session.review.noVcs.createGit.action": "Kreiraj Git repozitorij",
|
||||||
"session.todo.progress": "{{done}} od {{total}} zadataka završeno",
|
"session.todo.progress": "{{done}} od {{total}} zadataka završeno",
|
||||||
"session.question.progress": "{{current}} od {{total}} pitanja",
|
"session.question.progress": "{{current}} od {{total}} pitanja",
|
||||||
"session.header.open.finder": "Finder",
|
"session.header.open.finder": "Finder",
|
||||||
"session.header.open.fileExplorer": "File Explorer",
|
"session.header.open.fileExplorer": "File Explorer",
|
||||||
|
"session.header.reveal.finder": "Prikaži u Finderu",
|
||||||
|
"session.header.reveal.fileExplorer": "Prikaži u File Exploreru",
|
||||||
|
"session.header.reveal.containingFolder": "Otvori fasciklu koja sadrži ovu stavku",
|
||||||
"session.header.open.fileManager": "File Manager",
|
"session.header.open.fileManager": "File Manager",
|
||||||
"session.header.open.app.vscode": "VS Code",
|
"session.header.open.app.vscode": "VS Code",
|
||||||
"session.header.open.app.cursor": "Cursor",
|
"session.header.open.app.cursor": "Cursor",
|
||||||
@@ -1075,6 +1210,14 @@ export const dict = {
|
|||||||
"debugBar.mem.label": "MEM",
|
"debugBar.mem.label": "MEM",
|
||||||
"debugBar.mem.tipUnavailable": "Korišteni JS heap naspram limita heapa. Samo Chromium.",
|
"debugBar.mem.tipUnavailable": "Korišteni JS heap naspram limita heapa. Samo Chromium.",
|
||||||
"debugBar.mem.tip": "Korišteni JS heap naspram limita heapa. {{used}} od {{limit}}.",
|
"debugBar.mem.tip": "Korišteni JS heap naspram limita heapa. {{used}} od {{limit}}.",
|
||||||
|
"debugBar.focus.label": "FOCUS",
|
||||||
|
"debugBar.focus.tip": "Prisilno primijeni stilove fokusa na sve interaktivne elemente",
|
||||||
|
"debugBar.focus.on": "UKLJUČENO",
|
||||||
|
"debugBar.focus.off": "ISKLJUČENO",
|
||||||
|
"debugBar.direction.label": "DIR",
|
||||||
|
"debugBar.direction.tip": "Prisilno postavi smjer rasporeda cijele aplikacije bez promjene odabranog jezika",
|
||||||
|
"debugBar.direction.ltr": "LTR",
|
||||||
|
"debugBar.direction.rtl": "RTL",
|
||||||
"common.key.ctrl": "Ctrl",
|
"common.key.ctrl": "Ctrl",
|
||||||
"common.key.alt": "Alt",
|
"common.key.alt": "Alt",
|
||||||
"common.key.shift": "Shift",
|
"common.key.shift": "Shift",
|
||||||
@@ -1098,4 +1241,23 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
|
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
|
||||||
"error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta",
|
"error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta",
|
||||||
"terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}",
|
"terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-38
@@ -102,6 +102,7 @@ export const dict = {
|
|||||||
"dialog.provider.empty": "Ingen udbydere fundet",
|
"dialog.provider.empty": "Ingen udbydere fundet",
|
||||||
"dialog.provider.group.popular": "Populære",
|
"dialog.provider.group.popular": "Populære",
|
||||||
"dialog.provider.group.other": "Andre",
|
"dialog.provider.group.other": "Andre",
|
||||||
|
"dialog.provider.custom.label": "Brugerdefineret OpenAI-kompatibel udbyder",
|
||||||
"dialog.provider.tag.recommended": "Anbefalet",
|
"dialog.provider.tag.recommended": "Anbefalet",
|
||||||
"dialog.provider.opencode.note": "Udvalgte modeller inklusive Claude, GPT, Gemini og flere",
|
"dialog.provider.opencode.note": "Udvalgte modeller inklusive Claude, GPT, Gemini og flere",
|
||||||
"dialog.provider.opencode.tagline": "Pålidelige optimerede modeller",
|
"dialog.provider.opencode.tagline": "Pålidelige optimerede modeller",
|
||||||
@@ -122,6 +123,7 @@ export const dict = {
|
|||||||
|
|
||||||
"dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af OpenCode",
|
"dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af OpenCode",
|
||||||
"dialog.model.unpaid.addMore.title": "Tilføj flere modeller fra populære udbydere",
|
"dialog.model.unpaid.addMore.title": "Tilføj flere modeller fra populære udbydere",
|
||||||
|
"dialog.model.unpaid.viewMoreProviders": "Se mere end 70 yderligere udbydere",
|
||||||
|
|
||||||
"dialog.provider.viewAll": "Vis flere udbydere",
|
"dialog.provider.viewAll": "Vis flere udbydere",
|
||||||
|
|
||||||
@@ -129,12 +131,14 @@ export const dict = {
|
|||||||
"provider.connect.title.anthropicProMax": "Log ind med Claude Pro/Max",
|
"provider.connect.title.anthropicProMax": "Log ind med Claude Pro/Max",
|
||||||
"provider.connect.selectMethod": "Vælg loginmetode for {{provider}}.",
|
"provider.connect.selectMethod": "Vælg loginmetode for {{provider}}.",
|
||||||
"provider.connect.method.apiKey": "API-nøgle",
|
"provider.connect.method.apiKey": "API-nøgle",
|
||||||
|
"provider.connect.method.browser": "Browser",
|
||||||
|
"provider.connect.method.headless": "Browserløs",
|
||||||
"provider.connect.status.inProgress": "Godkendelse i gang...",
|
"provider.connect.status.inProgress": "Godkendelse i gang...",
|
||||||
"provider.connect.status.waiting": "Venter på godkendelse...",
|
"provider.connect.status.waiting": "Venter på godkendelse...",
|
||||||
"provider.connect.status.failed": "Godkendelse mislykkedes: {{error}}",
|
"provider.connect.status.failed": "Godkendelse mislykkedes: {{error}}",
|
||||||
"provider.connect.apiKey.description":
|
"provider.connect.apiKey.description":
|
||||||
"Indtast din {{provider}} API-nøgle for at forbinde din konto og bruge {{provider}} modeller i OpenCode.",
|
"Indtast din API-nøgle til {{provider}} for at forbinde din konto og bruge modeller fra {{provider}} i OpenCode.",
|
||||||
"provider.connect.apiKey.label": "{{provider}} API-nøgle",
|
"provider.connect.apiKey.label": "API-nøgle til {{provider}}",
|
||||||
"provider.connect.apiKey.placeholder": "API-nøgle",
|
"provider.connect.apiKey.placeholder": "API-nøgle",
|
||||||
"provider.connect.apiKey.required": "API-nøgle er påkrævet",
|
"provider.connect.apiKey.required": "API-nøgle er påkrævet",
|
||||||
"provider.connect.opencodeZen.line1":
|
"provider.connect.opencodeZen.line1":
|
||||||
@@ -147,7 +151,7 @@ export const dict = {
|
|||||||
"provider.connect.oauth.code.visit.prefix": "Besøg ",
|
"provider.connect.oauth.code.visit.prefix": "Besøg ",
|
||||||
"provider.connect.oauth.code.visit.link": "dette link",
|
"provider.connect.oauth.code.visit.link": "dette link",
|
||||||
"provider.connect.oauth.code.visit.suffix":
|
"provider.connect.oauth.code.visit.suffix":
|
||||||
" for at hente din godkendelseskode for at forbinde din konto og bruge {{provider}} modeller i OpenCode.",
|
" for at hente din godkendelseskode, forbinde din konto og bruge modeller fra {{provider}} i OpenCode.",
|
||||||
"provider.connect.oauth.code.label": "{{method}} godkendelseskode",
|
"provider.connect.oauth.code.label": "{{method}} godkendelseskode",
|
||||||
"provider.connect.oauth.code.placeholder": "Godkendelseskode",
|
"provider.connect.oauth.code.placeholder": "Godkendelseskode",
|
||||||
"provider.connect.oauth.code.required": "Godkendelseskode er påkrævet",
|
"provider.connect.oauth.code.required": "Godkendelseskode er påkrævet",
|
||||||
@@ -155,12 +159,13 @@ export const dict = {
|
|||||||
"provider.connect.oauth.auto.visit.prefix": "Besøg ",
|
"provider.connect.oauth.auto.visit.prefix": "Besøg ",
|
||||||
"provider.connect.oauth.auto.visit.link": "dette link",
|
"provider.connect.oauth.auto.visit.link": "dette link",
|
||||||
"provider.connect.oauth.auto.visit.suffix":
|
"provider.connect.oauth.auto.visit.suffix":
|
||||||
" og indtast koden nedenfor for at forbinde din konto og bruge {{provider}} modeller i OpenCode.",
|
" og indtast koden nedenfor for at forbinde din konto og bruge modeller fra {{provider}} i OpenCode.",
|
||||||
"provider.connect.oauth.auto.confirmationCode": "Bekræftelseskode",
|
"provider.connect.oauth.auto.confirmationCode": "Bekræftelseskode",
|
||||||
"provider.connect.toast.connected.title": "{{provider}} forbundet",
|
"provider.connect.toast.connected.title": "{{provider}} forbundet",
|
||||||
"provider.connect.toast.connected.description": "{{provider}} modeller er nu tilgængelige.",
|
"provider.connect.toast.connected.description": "Modeller fra {{provider}} er nu tilgængelige.",
|
||||||
|
|
||||||
"provider.custom.title": "Brugerdefineret udbyder",
|
"provider.custom.title": "Brugerdefineret udbyder",
|
||||||
|
"provider.custom.unavailable": "Brugerdefinerede udbydere er ikke tilgængelige på denne server",
|
||||||
"provider.custom.description.prefix": "Konfigurer en OpenAI-kompatibel udbyder. Se ",
|
"provider.custom.description.prefix": "Konfigurer en OpenAI-kompatibel udbyder. Se ",
|
||||||
"provider.custom.description.link": "dokumentation for udbyderkonfiguration",
|
"provider.custom.description.link": "dokumentation for udbyderkonfiguration",
|
||||||
"provider.custom.description.suffix": ".",
|
"provider.custom.description.suffix": ".",
|
||||||
@@ -217,9 +222,9 @@ export const dict = {
|
|||||||
"model.tooltip.inputs": "Input",
|
"model.tooltip.inputs": "Input",
|
||||||
"model.tooltip.model": "Model",
|
"model.tooltip.model": "Model",
|
||||||
"model.tooltip.provider": "Udbyder",
|
"model.tooltip.provider": "Udbyder",
|
||||||
"model.tooltip.reasoning": "Tænkning",
|
"model.tooltip.reasoning": "Ræsonnering",
|
||||||
"model.tooltip.reasoning.allowed": "Tillader tænkning",
|
"model.tooltip.reasoning.allowed": "Tillader ræsonnering",
|
||||||
"model.tooltip.reasoning.none": "Ingen tænkning",
|
"model.tooltip.reasoning.none": "Ingen ræsonnering",
|
||||||
"model.tooltip.context": "Kontekstgrænse {{limit}}",
|
"model.tooltip.context": "Kontekstgrænse {{limit}}",
|
||||||
"common.search.placeholder": "Søg",
|
"common.search.placeholder": "Søg",
|
||||||
"common.goBack": "Gå tilbage",
|
"common.goBack": "Gå tilbage",
|
||||||
@@ -230,7 +235,7 @@ export const dict = {
|
|||||||
"common.clear": "Ryd",
|
"common.clear": "Ryd",
|
||||||
"common.connect": "Forbind",
|
"common.connect": "Forbind",
|
||||||
"common.disconnect": "Frakobl",
|
"common.disconnect": "Frakobl",
|
||||||
"common.continue": "Indsend",
|
"common.continue": "Fortsæt",
|
||||||
"common.submit": "Indsend",
|
"common.submit": "Indsend",
|
||||||
"common.save": "Gem",
|
"common.save": "Gem",
|
||||||
"common.saving": "Gemmer...",
|
"common.saving": "Gemmer...",
|
||||||
@@ -253,7 +258,7 @@ export const dict = {
|
|||||||
"prompt.example.3": "Ret ødelagte tests",
|
"prompt.example.3": "Ret ødelagte tests",
|
||||||
"prompt.example.4": "Forklar hvordan godkendelse fungerer",
|
"prompt.example.4": "Forklar hvordan godkendelse fungerer",
|
||||||
"prompt.example.5": "Find og ret sikkerhedshuller",
|
"prompt.example.5": "Find og ret sikkerhedshuller",
|
||||||
"prompt.example.6": "Tilføj enhedstests for brugerservice",
|
"prompt.example.6": "Tilføj enhedstests for brugertjenesten",
|
||||||
"prompt.example.7": "Refaktorer denne funktion så den er mere læsbar",
|
"prompt.example.7": "Refaktorer denne funktion så den er mere læsbar",
|
||||||
"prompt.example.8": "Hvad betyder denne fejl?",
|
"prompt.example.8": "Hvad betyder denne fejl?",
|
||||||
"prompt.example.9": "Hjælp mig med at debugge dette problem",
|
"prompt.example.9": "Hjælp mig med at debugge dette problem",
|
||||||
@@ -335,7 +340,7 @@ export const dict = {
|
|||||||
"dialog.server.search.placeholder": "Søg servere",
|
"dialog.server.search.placeholder": "Søg servere",
|
||||||
"dialog.server.empty": "Ingen servere endnu",
|
"dialog.server.empty": "Ingen servere endnu",
|
||||||
"dialog.server.add.title": "Tilføj en server",
|
"dialog.server.add.title": "Tilføj en server",
|
||||||
"dialog.server.add.url": "Server URL",
|
"dialog.server.add.url": "Serveradresse",
|
||||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||||
"dialog.server.add.error": "Kunne ikke forbinde til server",
|
"dialog.server.add.error": "Kunne ikke forbinde til server",
|
||||||
"dialog.server.add.checking": "Tjekker...",
|
"dialog.server.add.checking": "Tjekker...",
|
||||||
@@ -347,7 +352,7 @@ export const dict = {
|
|||||||
"dialog.server.edit.title": "Rediger server",
|
"dialog.server.edit.title": "Rediger server",
|
||||||
"dialog.server.default.title": "Standardserver",
|
"dialog.server.default.title": "Standardserver",
|
||||||
"dialog.server.default.description":
|
"dialog.server.default.description":
|
||||||
"Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.",
|
"Opret forbindelse til denne server, når appen starter, i stedet for at starte en lokal server. Kræver genstart.",
|
||||||
"dialog.server.default.none": "Ingen server valgt",
|
"dialog.server.default.none": "Ingen server valgt",
|
||||||
"dialog.server.default.set": "Sæt nuværende server som standard",
|
"dialog.server.default.set": "Sæt nuværende server som standard",
|
||||||
"dialog.server.default.clear": "Ryd",
|
"dialog.server.default.clear": "Ryd",
|
||||||
@@ -462,8 +467,8 @@ export const dict = {
|
|||||||
"context.stats.usage": "Forbrug",
|
"context.stats.usage": "Forbrug",
|
||||||
"context.stats.inputTokens": "Input-tokens",
|
"context.stats.inputTokens": "Input-tokens",
|
||||||
"context.stats.outputTokens": "Output-tokens",
|
"context.stats.outputTokens": "Output-tokens",
|
||||||
"context.stats.reasoningTokens": "Tænke Tokens",
|
"context.stats.reasoningTokens": "Ræsonneringstokens",
|
||||||
"context.stats.cacheTokens": "Cache Tokens (læs/skriv)",
|
"context.stats.cacheTokens": "Cachetokens (læsning/skrivning)",
|
||||||
"context.stats.userMessages": "Brugerbeskeder",
|
"context.stats.userMessages": "Brugerbeskeder",
|
||||||
"context.stats.assistantMessages": "Assistentbeskeder",
|
"context.stats.assistantMessages": "Assistentbeskeder",
|
||||||
"context.stats.totalCost": "Samlede omkostninger",
|
"context.stats.totalCost": "Samlede omkostninger",
|
||||||
@@ -566,7 +571,7 @@ export const dict = {
|
|||||||
"error.chain.responseBody": "Svarindhold:\n{{body}}",
|
"error.chain.responseBody": "Svarindhold:\n{{body}}",
|
||||||
"error.chain.didYouMean": "Mente du: {{suggestions}}",
|
"error.chain.didYouMean": "Mente du: {{suggestions}}",
|
||||||
"error.chain.modelNotFound": "Model ikke fundet: {{provider}}/{{model}}",
|
"error.chain.modelNotFound": "Model ikke fundet: {{provider}}/{{model}}",
|
||||||
"error.chain.checkConfig": "Tjek dine konfigurations (opencode.json) udbyder/modelnavne",
|
"error.chain.checkConfig": "Kontrollér udbyder- og modelnavnene i din konfiguration (opencode.json)",
|
||||||
"error.chain.mcpFailed": 'MCP-server "{{name}}" fejlede. Bemærk, OpenCode understøtter ikke MCP-godkendelse endnu.',
|
"error.chain.mcpFailed": 'MCP-server "{{name}}" fejlede. Bemærk, OpenCode understøtter ikke MCP-godkendelse endnu.',
|
||||||
"error.chain.providerAuthFailed": "Udbydergodkendelse mislykkedes ({{provider}}): {{message}}",
|
"error.chain.providerAuthFailed": "Udbydergodkendelse mislykkedes ({{provider}}): {{message}}",
|
||||||
"error.chain.providerInitFailed":
|
"error.chain.providerInitFailed":
|
||||||
@@ -619,12 +624,12 @@ export const dict = {
|
|||||||
"session.error.notFound.description": "Denne fane henviser til en session, der ikke længere findes på denne server.",
|
"session.error.notFound.description": "Denne fane henviser til en session, der ikke længere findes på denne server.",
|
||||||
"session.error.notFound.closeTab": "Luk fane",
|
"session.error.notFound.closeTab": "Luk fane",
|
||||||
"session.error.serverConnection": "Kan ikke oprette forbindelse til denne server",
|
"session.error.serverConnection": "Kan ikke oprette forbindelse til denne server",
|
||||||
"session.review.filesChanged": "{{count}} Filer ændret",
|
"session.review.filesChanged": "{{count}} ændrede filer",
|
||||||
"session.review.change.one": "Ændring",
|
"session.review.change.one": "Ændring",
|
||||||
"session.review.change.other": "Ændringer",
|
"session.review.change.other": "Ændringer",
|
||||||
"session.review.loadingChanges": "Indlæser ændringer...",
|
"session.review.loadingChanges": "Indlæser ændringer...",
|
||||||
"session.review.empty": "Ingen ændringer i denne session endnu",
|
"session.review.empty": "Ingen ændringer i denne session endnu",
|
||||||
"session.review.noVcs": "Intet Git versionsstyringssystem fundet, ændringer vises ikke",
|
"session.review.noVcs": "Intet Git-versionsstyringssystem fundet, så ændringer vises ikke",
|
||||||
"session.review.noSnapshot":
|
"session.review.noSnapshot":
|
||||||
"Snapshot-sporing er deaktiveret i konfigurationen, så sessionsændringer er ikke tilgængelige",
|
"Snapshot-sporing er deaktiveret i konfigurationen, så sessionsændringer er ikke tilgængelige",
|
||||||
"session.review.noChanges": "Ingen ændringer",
|
"session.review.noChanges": "Ingen ændringer",
|
||||||
@@ -715,6 +720,13 @@ export const dict = {
|
|||||||
|
|
||||||
"terminal.connectionLost.title": "Forbindelse mistet",
|
"terminal.connectionLost.title": "Forbindelse mistet",
|
||||||
"terminal.connectionLost.description": "Terminalforbindelsen blev afbrudt. Dette kan ske, når serveren genstarter.",
|
"terminal.connectionLost.description": "Terminalforbindelsen blev afbrudt. Dette kan ske, når serveren genstarter.",
|
||||||
|
"terminal.connectTicket.csrfError":
|
||||||
|
"PTY-forbindelsesticket blev afvist ved kontrollen af oprindelse eller CSRF. Kontrollér serverens CORS-konfiguration.",
|
||||||
|
"terminal.connectTicket.statusError": "PTY-forbindelsesticket mislykkedes med {{status}}",
|
||||||
|
|
||||||
|
"titlebar.update": "Opdater",
|
||||||
|
"titlebar.updateVersion": "Opdater {{version}}",
|
||||||
|
|
||||||
"common.closeTab": "Luk fane",
|
"common.closeTab": "Luk fane",
|
||||||
"common.dismiss": "Afvis",
|
"common.dismiss": "Afvis",
|
||||||
"common.requestFailed": "Forespørgsel mislykkedes",
|
"common.requestFailed": "Forespørgsel mislykkedes",
|
||||||
@@ -750,7 +762,7 @@ export const dict = {
|
|||||||
"settings.tab.general": "Generelt",
|
"settings.tab.general": "Generelt",
|
||||||
"settings.tab.shortcuts": "Genveje",
|
"settings.tab.shortcuts": "Genveje",
|
||||||
"settings.desktop.section.wsl": "WSL",
|
"settings.desktop.section.wsl": "WSL",
|
||||||
"settings.desktop.wsl.title": "WSL integration",
|
"settings.desktop.wsl.title": "WSL-integration",
|
||||||
"settings.desktop.wsl.description": "Kør OpenCode-serveren inde i WSL på Windows.",
|
"settings.desktop.wsl.description": "Kør OpenCode-serveren inde i WSL på Windows.",
|
||||||
|
|
||||||
"settings.general.section.appearance": "Udseende",
|
"settings.general.section.appearance": "Udseende",
|
||||||
@@ -771,15 +783,16 @@ export const dict = {
|
|||||||
"settings.general.row.appearance.title": "Udseende",
|
"settings.general.row.appearance.title": "Udseende",
|
||||||
"settings.general.row.appearance.description": "Tilpas hvordan OpenCode ser ud på din enhed",
|
"settings.general.row.appearance.description": "Tilpas hvordan OpenCode ser ud på din enhed",
|
||||||
"settings.general.row.colorScheme.title": "Farveskema",
|
"settings.general.row.colorScheme.title": "Farveskema",
|
||||||
"settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema",
|
"settings.general.row.colorScheme.description":
|
||||||
|
"Vælg, om OpenCode skal følge systemtemaet eller bruge et lyst eller mørkt tema",
|
||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
|
"settings.general.row.theme.description": "Tilpas OpenCodes tema.",
|
||||||
"settings.general.row.font.title": "Kode-skrifttype",
|
"settings.general.row.font.title": "Kode-skrifttype",
|
||||||
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
|
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
|
||||||
"settings.general.row.terminalFont.title": "Terminalskrifttype",
|
"settings.general.row.terminalFont.title": "Terminalskrifttype",
|
||||||
"settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen",
|
"settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen",
|
||||||
"settings.general.row.uiFont.title": "UI-skrifttype",
|
"settings.general.row.uiFont.title": "UI-skrifttype",
|
||||||
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
|
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugergrænsefladen",
|
||||||
"settings.general.row.followup.title": "Opfølgningsadfærd",
|
"settings.general.row.followup.title": "Opfølgningsadfærd",
|
||||||
"settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
|
"settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
|
||||||
"settings.general.row.followup.option.queue": "Kø",
|
"settings.general.row.followup.option.queue": "Kø",
|
||||||
@@ -797,10 +810,11 @@ export const dict = {
|
|||||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation nederst",
|
"settings.general.row.mobileTitlebarBottom.title": "Navigation nederst",
|
||||||
"settings.general.row.mobileTitlebarBottom.description":
|
"settings.general.row.mobileTitlebarBottom.description":
|
||||||
"Placer titellinjen og sessionsfanerne nederst på skærmen på mobilenheder",
|
"Placer titellinjen og sessionsfanerne nederst på skærmen på mobilenheder",
|
||||||
"settings.general.row.showCustomAgents.title": "Brugerdefinerede agenter",
|
"settings.general.row.showCustomAgents.title": "Vis agent",
|
||||||
"settings.general.row.showCustomAgents.description": "Vis agentvælgeren i promptfeltet",
|
"settings.general.row.showCustomAgents.description":
|
||||||
"settings.general.row.reasoningSummaries.title": "Vis tænkeoversigter",
|
"Skift mellem agenter i promptfeltet. Når vælgeren er skjult, bruges Build-agenten som standard.",
|
||||||
"settings.general.row.reasoningSummaries.description": "Vis model tænkeoversigter i tidslinjen",
|
"settings.general.row.reasoningSummaries.title": "Vis ræsonneringsoversigter",
|
||||||
|
"settings.general.row.reasoningSummaries.description": "Vis oversigter over modellens ræsonnering på tidslinjen",
|
||||||
|
|
||||||
"settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
|
"settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
|
||||||
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
|
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
|
||||||
@@ -816,13 +830,13 @@ export const dict = {
|
|||||||
"settings.general.row.newInterfaceNotice.dismiss": "Afvis",
|
"settings.general.row.newInterfaceNotice.dismiss": "Afvis",
|
||||||
"settings.general.row.pinchZoom.title": "Knib for at zoome",
|
"settings.general.row.pinchZoom.title": "Knib for at zoome",
|
||||||
"settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome",
|
"settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome",
|
||||||
"settings.general.row.wayland.title": "Brug native Wayland",
|
"settings.general.row.wayland.title": "Brug indbygget Wayland",
|
||||||
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
|
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
|
||||||
"settings.general.row.wayland.tooltip":
|
"settings.general.row.wayland.tooltip":
|
||||||
"På Linux med skærme med blandet opdateringshastighed kan native Wayland være mere stabilt.",
|
"På Linux med skærme med forskellige opdateringshastigheder kan indbygget Wayland være mere stabilt.",
|
||||||
|
|
||||||
"settings.general.row.releaseNotes.title": "Udgivelsesnoter",
|
"settings.general.row.releaseNotes.title": "Udgivelsesnoter",
|
||||||
"settings.general.row.releaseNotes.description": 'Vis "Hvad er nyt"-popups efter opdateringer',
|
"settings.general.row.releaseNotes.description": 'Vis pop op-vinduer med "Hvad er nyt" efter opdateringer',
|
||||||
|
|
||||||
"settings.updates.row.startup.title": "Tjek for opdateringer ved opstart",
|
"settings.updates.row.startup.title": "Tjek for opdateringer ved opstart",
|
||||||
"settings.updates.row.startup.description": "Tjek automatisk for opdateringer, når OpenCode starter",
|
"settings.updates.row.startup.description": "Tjek automatisk for opdateringer, når OpenCode starter",
|
||||||
@@ -945,9 +959,9 @@ export const dict = {
|
|||||||
"settings.permissions.tool.read.description": "Læsning af en fil (matcher filstien)",
|
"settings.permissions.tool.read.description": "Læsning af en fil (matcher filstien)",
|
||||||
"settings.permissions.tool.edit.title": "Rediger",
|
"settings.permissions.tool.edit.title": "Rediger",
|
||||||
"settings.permissions.tool.edit.description":
|
"settings.permissions.tool.edit.description":
|
||||||
"Ændre filer, herunder redigeringer, skrivninger, patches og multi-redigeringer",
|
"Rediger filer, herunder med redigeringer, skrivning, patches og multiredigeringer",
|
||||||
"settings.permissions.tool.glob.title": "Glob",
|
"settings.permissions.tool.glob.title": "Glob",
|
||||||
"settings.permissions.tool.glob.description": "Match filer ved hjælp af glob-mønstre",
|
"settings.permissions.tool.glob.description": "Find filer ved hjælp af globmønstre",
|
||||||
"settings.permissions.tool.grep.title": "Grep",
|
"settings.permissions.tool.grep.title": "Grep",
|
||||||
"settings.permissions.tool.grep.description": "Søg i filindhold ved hjælp af regulære udtryk",
|
"settings.permissions.tool.grep.description": "Søg i filindhold ved hjælp af regulære udtryk",
|
||||||
"settings.permissions.tool.list.title": "Liste",
|
"settings.permissions.tool.list.title": "Liste",
|
||||||
@@ -960,8 +974,8 @@ export const dict = {
|
|||||||
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
|
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
|
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
|
||||||
"settings.permissions.tool.todowrite.title": "Skriv To-do",
|
"settings.permissions.tool.todowrite.title": "Skriv to-do",
|
||||||
"settings.permissions.tool.todowrite.description": "Opdater to-do listen",
|
"settings.permissions.tool.todowrite.description": "Opdater to-do-listen",
|
||||||
"settings.permissions.tool.webfetch.title": "Webhentning",
|
"settings.permissions.tool.webfetch.title": "Webhentning",
|
||||||
"settings.permissions.tool.webfetch.description": "Hent indhold fra en URL",
|
"settings.permissions.tool.webfetch.description": "Hent indhold fra en URL",
|
||||||
"settings.permissions.tool.websearch.title": "Websøgning",
|
"settings.permissions.tool.websearch.title": "Websøgning",
|
||||||
@@ -988,7 +1002,7 @@ export const dict = {
|
|||||||
"workspace.reset.success.description": "Arbejdsområdet matcher nu hovedgrenen.",
|
"workspace.reset.success.description": "Arbejdsområdet matcher nu hovedgrenen.",
|
||||||
"workspace.error.stillPreparing": "Arbejdsområdet er stadig ved at blive klargjort",
|
"workspace.error.stillPreparing": "Arbejdsområdet er stadig ved at blive klargjort",
|
||||||
"workspace.status.checking": "Tjekker for uflettede ændringer...",
|
"workspace.status.checking": "Tjekker for uflettede ændringer...",
|
||||||
"workspace.status.error": "Kunne ikke bekræfte git-status.",
|
"workspace.status.error": "Kunne ikke bekræfte Git-status.",
|
||||||
"workspace.status.clean": "Ingen uflettede ændringer fundet.",
|
"workspace.status.clean": "Ingen uflettede ændringer fundet.",
|
||||||
"workspace.status.dirty": "Uflettede ændringer fundet i dette arbejdsområde.",
|
"workspace.status.dirty": "Uflettede ændringer fundet i dette arbejdsområde.",
|
||||||
"workspace.delete.title": "Slet arbejdsområde",
|
"workspace.delete.title": "Slet arbejdsområde",
|
||||||
@@ -1030,6 +1044,9 @@ export const dict = {
|
|||||||
"session.question.progress": "{{current}} af {{total}} spørgsmål",
|
"session.question.progress": "{{current}} af {{total}} spørgsmål",
|
||||||
"session.header.open.finder": "Finder",
|
"session.header.open.finder": "Finder",
|
||||||
"session.header.open.fileExplorer": "Stifinder",
|
"session.header.open.fileExplorer": "Stifinder",
|
||||||
|
"session.header.reveal.finder": "Vis i Finder",
|
||||||
|
"session.header.reveal.fileExplorer": "Vis i Stifinder",
|
||||||
|
"session.header.reveal.containingFolder": "Åbn den overordnede mappe",
|
||||||
"session.header.open.fileManager": "Filhåndtering",
|
"session.header.open.fileManager": "Filhåndtering",
|
||||||
"session.header.open.app.vscode": "VS Code",
|
"session.header.open.app.vscode": "VS Code",
|
||||||
"session.header.open.app.cursor": "Cursor",
|
"session.header.open.app.cursor": "Cursor",
|
||||||
@@ -1050,11 +1067,11 @@ export const dict = {
|
|||||||
"debugBar.nav.tip":
|
"debugBar.nav.tip":
|
||||||
"Sidste gennemførte ruteovergang, der berører en sessionsside, målt fra routerstart til den første optegning efter den falder til ro.",
|
"Sidste gennemførte ruteovergang, der berører en sessionsside, målt fra routerstart til den første optegning efter den falder til ro.",
|
||||||
"debugBar.fps.label": "FPS",
|
"debugBar.fps.label": "FPS",
|
||||||
"debugBar.fps.tip": "Rullende billeder pr. sekund over de sidste 5 sekunder.",
|
"debugBar.fps.tip": "Rullende antal billedrammer pr. sekund over de sidste 5 sekunder.",
|
||||||
"debugBar.frame.label": "FRAME",
|
"debugBar.frame.label": "FRAME",
|
||||||
"debugBar.frame.tip": "Værste billedtid over de sidste 5 sekunder.",
|
"debugBar.frame.tip": "Længste billedrammetid over de sidste 5 sekunder.",
|
||||||
"debugBar.jank.label": "JANK",
|
"debugBar.jank.label": "JANK",
|
||||||
"debugBar.jank.tip": "Billeder over 32ms i de sidste 5 sekunder.",
|
"debugBar.jank.tip": "Billedrammer over 32 ms i de sidste 5 sekunder.",
|
||||||
"debugBar.long.label": "LONG",
|
"debugBar.long.label": "LONG",
|
||||||
"debugBar.long.tip": "Blokeret tid og antal lange opgaver i de sidste 5 sekunder. Maks opgave: {{max}}.",
|
"debugBar.long.tip": "Blokeret tid og antal lange opgaver i de sidste 5 sekunder. Maks opgave: {{max}}.",
|
||||||
"debugBar.delay.label": "DELAY",
|
"debugBar.delay.label": "DELAY",
|
||||||
@@ -1065,8 +1082,16 @@ export const dict = {
|
|||||||
"debugBar.cls.label": "CLS",
|
"debugBar.cls.label": "CLS",
|
||||||
"debugBar.cls.tip": "Kumulativt layoutskift for den nuværende app-levetid.",
|
"debugBar.cls.tip": "Kumulativt layoutskift for den nuværende app-levetid.",
|
||||||
"debugBar.mem.label": "MEM",
|
"debugBar.mem.label": "MEM",
|
||||||
"debugBar.mem.tipUnavailable": "Brugt JS-heap vs heap-grænse. Kun Chromium.",
|
"debugBar.mem.tipUnavailable": "Brugt JS-heap sammenholdt med heapgrænsen. Kun Chromium.",
|
||||||
"debugBar.mem.tip": "Brugt JS-heap vs heap-grænse. {{used}} af {{limit}}.",
|
"debugBar.mem.tip": "Brugt JS-heap sammenholdt med heapgrænsen. {{used}} af {{limit}}.",
|
||||||
|
"debugBar.focus.label": "FOCUS",
|
||||||
|
"debugBar.focus.tip": "Gennemtving fokusstile for alle interaktive elementer",
|
||||||
|
"debugBar.focus.on": "TIL",
|
||||||
|
"debugBar.focus.off": "FRA",
|
||||||
|
"debugBar.direction.label": "DIR",
|
||||||
|
"debugBar.direction.tip": "Gennemtving layoutretningen for hele appen uden at ændre det valgte sprog",
|
||||||
|
"debugBar.direction.ltr": "LTR",
|
||||||
|
"debugBar.direction.rtl": "RTL",
|
||||||
"common.key.ctrl": "Ctrl",
|
"common.key.ctrl": "Ctrl",
|
||||||
"common.key.alt": "Alt",
|
"common.key.alt": "Alt",
|
||||||
"common.key.shift": "Shift",
|
"common.key.shift": "Shift",
|
||||||
@@ -1090,4 +1115,140 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
|
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
|
||||||
"error.childStore.storeCreateFailed": "Kunne ikke oprette lager",
|
"error.childStore.storeCreateFailed": "Kunne ikke oprette lager",
|
||||||
"terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}",
|
"terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}",
|
||||||
|
|
||||||
|
"desktop.menu.app": "OpenCode",
|
||||||
|
"desktop.menu.file": "Arkiv",
|
||||||
|
"desktop.menu.edit": "Rediger",
|
||||||
|
"desktop.menu.view": "Oversigt",
|
||||||
|
"desktop.menu.go": "Gå",
|
||||||
|
"desktop.menu.window": "Vindue",
|
||||||
|
"desktop.menu.help": "Hjælp",
|
||||||
|
"desktop.menu.checkForUpdates": "Søg efter opdateringer...",
|
||||||
|
"desktop.menu.settings": "Indstillinger",
|
||||||
|
"desktop.menu.reloadWebview": "Genindlæs webvisning",
|
||||||
|
"desktop.menu.restart": "Genstart",
|
||||||
|
"desktop.menu.exportLogs": "Eksportér logfiler...",
|
||||||
|
"desktop.menu.newSession": "Ny session",
|
||||||
|
"desktop.menu.openProject": "Åbn projekt...",
|
||||||
|
"desktop.menu.newWindow": "Nyt vindue",
|
||||||
|
"desktop.menu.closeWindow": "Luk vindue",
|
||||||
|
"desktop.menu.undo": "Fortryd",
|
||||||
|
"desktop.menu.redo": "Gentag",
|
||||||
|
"desktop.menu.cut": "Klip",
|
||||||
|
"desktop.menu.copy": "Kopier",
|
||||||
|
"desktop.menu.paste": "Sæt ind",
|
||||||
|
"desktop.menu.delete": "Slet",
|
||||||
|
"desktop.menu.selectAll": "Vælg alt",
|
||||||
|
"desktop.menu.toggleSidebar": "Vis eller skjul indholdsoversigt",
|
||||||
|
"desktop.menu.toggleTerminal": "Vis eller skjul terminal",
|
||||||
|
"desktop.menu.toggleFileTree": "Vis eller skjul filtræ",
|
||||||
|
"desktop.menu.reload": "Genindlæs",
|
||||||
|
"desktop.menu.toggleDeveloperTools": "Vis eller skjul udviklerværktøjer",
|
||||||
|
"desktop.menu.actualSize": "Faktisk størrelse",
|
||||||
|
"desktop.menu.zoomIn": "Zoom ind",
|
||||||
|
"desktop.menu.zoomOut": "Zoom ud",
|
||||||
|
"desktop.menu.toggleFullScreen": "Slå fuldskærmsvisning til eller fra",
|
||||||
|
"desktop.menu.back": "Tilbage",
|
||||||
|
"desktop.menu.forward": "Frem",
|
||||||
|
"desktop.menu.previousSession": "Forrige session",
|
||||||
|
"desktop.menu.nextSession": "Næste session",
|
||||||
|
"desktop.menu.previousProject": "Forrige projekt",
|
||||||
|
"desktop.menu.nextProject": "Næste projekt",
|
||||||
|
"desktop.menu.minimize": "Minimer",
|
||||||
|
"desktop.menu.maximize": "Maksimer",
|
||||||
|
"desktop.menu.documentation": "OpenCode-dokumentation",
|
||||||
|
"desktop.menu.supportForum": "Supportforum",
|
||||||
|
"desktop.menu.shareFeedback": "Giv feedback",
|
||||||
|
"desktop.menu.reportBug": "Rapportér en fejl",
|
||||||
|
"desktop.menu.ariaLabel": "OpenCode-menu",
|
||||||
|
|
||||||
|
"desktop.updater.dialog.checkFailed.message": "Søgningen efter opdateringer mislykkedes.",
|
||||||
|
"desktop.updater.dialog.checkFailed.title": "Opdateringsfejl",
|
||||||
|
"desktop.updater.dialog.upToDate.message": "Du er opdateret.",
|
||||||
|
"desktop.updater.dialog.upToDate.title": "Ingen opdateringer",
|
||||||
|
"desktop.updater.dialog.ready.message": "Opdatering {{version}} er downloadet. Vil du genstarte nu?",
|
||||||
|
"desktop.updater.dialog.ready.title": "Opdateringen er klar",
|
||||||
|
"desktop.updater.dialog.restart": "Genstart",
|
||||||
|
"desktop.updater.dialog.later": "Senere",
|
||||||
|
|
||||||
|
"desktop.recovery.action.relaunch": "Start igen",
|
||||||
|
"desktop.recovery.action.exportLogs": "Eksportér logfiler",
|
||||||
|
"desktop.recovery.action.keepWaiting": "Vent fortsat",
|
||||||
|
"desktop.recovery.action.quit": "Afslut",
|
||||||
|
"desktop.recovery.loadFailed": "OpenCode kunne ikke indlæses",
|
||||||
|
"desktop.recovery.terminated": "OpenCode-vinduet blev uventet afsluttet",
|
||||||
|
"desktop.recovery.unresponsive": "OpenCode svarer ikke",
|
||||||
|
"desktop.recovery.unresponsive.detail": "Du kan starte appen igen, åbne logfilerne eller fortsætte med at vente.",
|
||||||
|
"desktop.recovery.loadFailed.detail": "Vindue: {{window}}\nURL: {{url}}\nFejl: {{code}} {{description}}",
|
||||||
|
"desktop.recovery.terminated.detail": "Vindue: {{window}}\nÅrsag: {{reason}}\nKode: {{code}}",
|
||||||
|
"desktop.recovery.unknown": "<ukendt>",
|
||||||
|
|
||||||
|
"desktop.dialog.chooseFolder": "Vælg en mappe",
|
||||||
|
"desktop.dialog.chooseFile": "Vælg en fil",
|
||||||
|
"desktop.dialog.saveFile": "Gem fil",
|
||||||
|
"desktop.dialog.files": "Filer",
|
||||||
|
"desktop.server.local": "Lokal server",
|
||||||
|
|
||||||
|
"desktop.wsl.error.windowsOnly": "WSL er kun tilgængelig i Windows",
|
||||||
|
"desktop.wsl.error.unavailable": "WSL er ikke tilgængelig",
|
||||||
|
"desktop.wsl.error.listInstalled": "Kunne ikke vise installerede WSL-distributioner",
|
||||||
|
"desktop.wsl.error.listOnline": "Kunne ikke vise WSL-distributioner på nettet",
|
||||||
|
"desktop.wsl.error.executeDistro": "Kan ikke udføre kommandoer i distributionen",
|
||||||
|
"desktop.wsl.error.installWsl": "Installationen af WSL mislykkedes",
|
||||||
|
"desktop.wsl.error.installDistro": "Kunne ikke installere distributionen: {{distro}}",
|
||||||
|
"desktop.wsl.error.installOpencode": "Installationen af OpenCode mislykkedes",
|
||||||
|
"desktop.wsl.error.alreadyAdded": "{{distro}} er allerede tilføjet",
|
||||||
|
"desktop.wsl.error.opencodeMissing": "opencode er ikke installeret i denne distribution",
|
||||||
|
"desktop.wsl.error.opencodeCannotRun": "opencode er installeret, men kunne ikke køre",
|
||||||
|
"desktop.wsl.error.opencodeNotInstalled": "OpenCode er ikke installeret i {{distro}}",
|
||||||
|
"desktop.wsl.error.updateVersion":
|
||||||
|
"Opdateringen af OpenCode er fuldført, men {{distro}} rapporterer stadig {{installed}}. Forventet: {{expected}}",
|
||||||
|
"desktop.wsl.error.noVersion": "ingen version",
|
||||||
|
"desktop.wsl.error.serverExited": "WSL-serveren blev afsluttet efter opstart (code={{code}} signal={{signal}})",
|
||||||
|
"desktop.wsl.error.serverExitedBeforeHealthy":
|
||||||
|
"WSL-serveren blev afsluttet, før den var klar (code={{code}} signal={{signal}}){{output}}",
|
||||||
|
"desktop.wsl.error.healthTimeout":
|
||||||
|
"Tilstandskontrollen af sidecar-processen for {{distro}} fik timeout efter {{timeout}} ms",
|
||||||
|
"desktop.wsl.error.commandTimeout": "{{command}} {{args}} fik timeout efter {{timeout}} ms",
|
||||||
|
"desktop.wsl.error.failedPort": "Kunne ikke hente porten",
|
||||||
|
|
||||||
|
"desktop.picker.error.notSelected": "Filen blev ikke valgt i filvælgeren",
|
||||||
|
"desktop.picker.error.sizeLimit": "De valgte vedhæftede filer overskrider grænsen på {{limit}} MB",
|
||||||
|
|
||||||
|
"command.logs.export": "Eksportér logfiler",
|
||||||
|
|
||||||
|
"help.tabs.toast.ariaLabel": "Introduktion til faner. Organiser dit arbejde og dine aktive sessioner med faner",
|
||||||
|
"help.tabs.toast.dismiss": "Luk oplysninger om faner",
|
||||||
|
"help.tabs.title": "Introduktion til faner",
|
||||||
|
"help.tabs.description": "Organiser dit arbejde og dine aktive sessioner med faner",
|
||||||
|
"help.tabs.date": "14. juli",
|
||||||
|
"help.tabs.introduction": "OpenCode Desktop er nu bygget op omkring faner.",
|
||||||
|
"help.tabs.sessions":
|
||||||
|
"Start en ny session i en fane, eller åbn en eksisterende session fra et af dine projekter. Åbn en ny fane, når du går i gang med noget nyt, og luk den, når du er færdig.",
|
||||||
|
"help.tabs.organize":
|
||||||
|
"Når du holder nogle få faner åbne, er det nemmere at organisere dine aktive sessioner. Giv fanerne navne, der er nemme at huske, hvis du vil beholde dem.",
|
||||||
|
"help.tabs.home":
|
||||||
|
"Du finder alle dine sessioner og projekter på den nye startskærm. Når du vælger en session, åbnes den i en fane.",
|
||||||
|
"help.tabs.persistence": "Dine faner er stadig åbne, når du åbner appen igen.",
|
||||||
|
"help.tabs.worktrees":
|
||||||
|
"Det nye design understøtter endnu ikke Git Worktrees, men understøttelsen kommer snart. Hvis du foretrækker at fortsætte med det tidligere layout, kan du skifte mellem layoutene under Indstillinger. Bemærk, at det nye layout bliver permanent om nogle få uger.",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user