mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 22:21:25 -04:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1140d8422e | |||
| 9ecfb847fa | |||
| 588288ce87 | |||
| 0156d3028f | |||
| 5e5e305bb7 | |||
| d87f987800 | |||
| 545b86587d | |||
| 7fb3fbdf27 | |||
| 31f946eed7 | |||
| 45daa7fece | |||
| a5bbe636e7 | |||
| 2e1ce87bf3 | |||
| 56e8a4f7d7 | |||
| b3a012cbdd | |||
| 51b9c726cf | |||
| b4e49d5b32 | |||
| 2b29854589 | |||
| 8ce5038bd8 | |||
| 446510a6a9 | |||
| 49028b6245 | |||
| d595c7e265 | |||
| 17cd4a8c32 | |||
| 9bbf7918de | |||
| 97f502e79a | |||
| ac5e249083 | |||
| 140e796b21 | |||
| dd02cea9e7 | |||
| a8062ea314 | |||
| f47684787a | |||
| 8168f0f0f6 | |||
| 8036440693 | |||
| cf7503687a | |||
| 6f8e1dda15 | |||
| 4dcfd9182c | |||
| d7c0db8cee | |||
| 184da0e42e | |||
| a244d82aba | |||
| 34e5809059 | |||
| 2db96c9b7e | |||
| 9976269ab1 | |||
| 5df822be11 | |||
| 233f069070 | |||
| 4a1982f5c9 | |||
| 1c7f65f105 | |||
| a84151e482 | |||
| 0340a4ff77 | |||
| f4faacbe87 | |||
| 7dbfd0b934 | |||
| 08a2a623dc | |||
| 60c88336b9 | |||
| 8a03fc265b | |||
| d0ba538924 | |||
| 772e976d80 | |||
| c769380776 | |||
| 01131c75d8 | |||
| ea982c3832 | |||
| d09d5612f3 | |||
| 85ef35dd64 | |||
| 251177d56c | |||
| 13b9e55c72 | |||
| b94e55c7c0 | |||
| 9c6bb4ff72 | |||
| c2e019ba7d | |||
| b8374b5a7c | |||
| b4665a8bf8 | |||
| 08096b5e61 | |||
| 2e43d41af0 | |||
| 518772c2ba | |||
| fc9021ccec | |||
| 3bebfe6b1b | |||
| 3b18c64782 | |||
| c6c599b872 | |||
| 6b41ae910c | |||
| 5c860d4142 | |||
| 4f9207daac | |||
| ae7d63272c | |||
| 5cc3a51357 | |||
| e357c90212 | |||
| fb1edc4918 | |||
| 353e0eef8a | |||
| a4c17f08b1 | |||
| 3cd9ee5a73 | |||
| 7c56319aa4 | |||
| 13fa4937b2 | |||
| 1db5c2402c | |||
| 0abbcddac2 | |||
| dd25d143c5 | |||
| d8a57ee15c | |||
| aa52d30d7f | |||
| 83d633fd13 | |||
| 824455e2f1 | |||
| 62de2b3695 | |||
| 7550aa4b89 | |||
| c33a2011c4 | |||
| fff6764ed4 | |||
| 9644a008a4 | |||
| ccb6b7c3ea | |||
| f95f877e5f | |||
| fe96d4aebd | |||
| 3a95d56144 | |||
| ecd8be7593 | |||
| a72c3880bb | |||
| ccd8676bee | |||
| 5a533e366f | |||
| 77429f5982 | |||
| 6cfe4bc34f | |||
| 95013d2936 | |||
| 14a5529793 | |||
| 0b2e8707e3 | |||
| adf178a6b9 | |||
| 78f85b1cd6 | |||
| 888dc67cc3 | |||
| fd9ee435ec | |||
| 1c25b2f298 | |||
| ea9fffce2e | |||
| 516f0266b8 | |||
| 39397deb8d | |||
| 65fd2e5c91 |
@@ -1,4 +1,5 @@
|
||||
adamdotdevin
|
||||
arvsrn
|
||||
Brendonovich
|
||||
fwang
|
||||
Hona
|
||||
@@ -7,11 +8,14 @@ jayair
|
||||
jlongster
|
||||
kitlangton
|
||||
kommander
|
||||
ludvigrask
|
||||
MrMushrooooom
|
||||
nexxeln
|
||||
R44VC0RP
|
||||
rekram1-node
|
||||
thdxr
|
||||
simonklee
|
||||
Slickstef11
|
||||
usrnk1
|
||||
vimtor
|
||||
starptech
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
|
||||
const TEAM = {
|
||||
tui: ["kommander", "simonklee"],
|
||||
desktop_web: ["Hona", "Brendonovich"],
|
||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
|
||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
||||
inference: ["fwang", "MrMushrooooom", "starptech"],
|
||||
windows: ["Hona"],
|
||||
} as const
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -29,8 +29,9 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
@@ -72,6 +73,7 @@
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -93,7 +95,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -141,7 +143,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -155,7 +157,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -191,7 +193,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -218,7 +220,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -240,7 +242,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -264,7 +266,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -284,7 +286,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -308,7 +310,7 @@
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
"@ai-sdk/togetherai": "2.0.41",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@ai-sdk/xai": "3.0.82",
|
||||
"@ai-sdk/xai": "3.0.102",
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -378,7 +380,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -432,7 +434,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -446,7 +448,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -458,7 +460,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -490,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -506,7 +508,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -537,7 +539,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -556,7 +558,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -582,7 +584,7 @@
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/togetherai": "2.0.41",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@ai-sdk/xai": "3.0.82",
|
||||
"@ai-sdk/xai": "3.0.102",
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
@@ -687,7 +689,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -763,7 +765,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -778,7 +780,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -793,7 +795,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -837,7 +839,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -850,7 +852,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
@@ -883,7 +885,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -902,7 +904,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -943,7 +945,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -970,7 +972,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1021,7 +1023,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1064,6 +1066,7 @@
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
@@ -1073,7 +1076,6 @@
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
},
|
||||
@@ -1086,6 +1088,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||
@@ -1211,7 +1214,7 @@
|
||||
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
|
||||
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.102", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.56", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NeQyOR7OCqDMgaLS4uNX/ep/HrwUzzFYLzXQSRoqLy2jsnqxAJhsgltRwAwf+ADjyPBIAKEOestWnIQA+LrLrQ=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
@@ -1467,6 +1470,10 @@
|
||||
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
|
||||
|
||||
"@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
|
||||
|
||||
"@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
|
||||
|
||||
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
@@ -2667,6 +2674,8 @@
|
||||
|
||||
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
|
||||
|
||||
"@solid-primitives/memo": ["@solid-primitives/memo@1.5.0", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nSMHpFdnOMP88t7lqtktUXJlhQdJk0BQs2v3jUqcn+OtFwUm27Oa/coKl1BHDxL/65jR5drjqxxvuhaIpiUi0w=="],
|
||||
|
||||
"@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="],
|
||||
|
||||
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="],
|
||||
@@ -5099,11 +5108,15 @@
|
||||
|
||||
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||
|
||||
"solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
|
||||
|
||||
"solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
|
||||
|
||||
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
|
||||
|
||||
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
|
||||
|
||||
"solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="],
|
||||
"solid-presence": ["solid-presence@0.2.0", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA=="],
|
||||
|
||||
"solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="],
|
||||
|
||||
@@ -5111,6 +5124,8 @@
|
||||
|
||||
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
||||
|
||||
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
|
||||
|
||||
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
@@ -5693,7 +5708,11 @@
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="],
|
||||
|
||||
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
@@ -5917,6 +5936,8 @@
|
||||
|
||||
"@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
||||
|
||||
"@kobalte/core/solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="],
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
@@ -6143,6 +6164,8 @@
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
|
||||
|
||||
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
|
||||
|
||||
"ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
@@ -6441,6 +6464,8 @@
|
||||
|
||||
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
||||
|
||||
"solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
|
||||
|
||||
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
|
||||
|
||||
"sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="],
|
||||
@@ -6577,6 +6602,8 @@
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -6939,6 +6966,8 @@
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
@@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
|
||||
configuration: {
|
||||
enforceWorkgroupConfiguration: true,
|
||||
publishCloudwatchMetricsEnabled: true,
|
||||
// Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
|
||||
// so a regression cannot silently burn money. Stats sync full passes scan
|
||||
// ~250 GB as of 2026-07.
|
||||
bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
|
||||
resultConfiguration: {
|
||||
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
|
||||
},
|
||||
|
||||
+3
-1
@@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
|
||||
cluster: lakeCluster,
|
||||
architecture: "arm64",
|
||||
cpu: "0.25 vCPU",
|
||||
memory: "0.5 GB",
|
||||
// 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
|
||||
// stats queries (~$5/pass) every ~5 minutes instead of hourly.
|
||||
memory: "2 GB",
|
||||
image: {
|
||||
context: ".",
|
||||
dockerfile: "packages/stats/server/Dockerfile",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-KTyd2ISQ4n1E3tm1LMEHtz+rKRZga+SONn5J6H0pheQ=",
|
||||
"aarch64-linux": "sha256-ZIeaaqRr4JorEmmwFYuitxfYEAtrP8C6IlD+pmFRko0=",
|
||||
"aarch64-darwin": "sha256-Po8FISoBzUMwJSn6nw243/hLT/gAQCrm5HbwXe2uA0g=",
|
||||
"x86_64-darwin": "sha256-5TZzrCnvg1z00YP6O9U0SXjL04r+VmF7LVrqQ9BbSn4="
|
||||
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
|
||||
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
|
||||
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
|
||||
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -19,6 +19,7 @@
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"translate:app": "bun run script/translate-app.ts",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
},
|
||||
"workspaces": {
|
||||
@@ -46,6 +47,7 @@
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "24.12.2",
|
||||
"@types/semver": "7.7.1",
|
||||
@@ -146,7 +148,7 @@
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/FileBrowserSidebar"
|
||||
const projectID = "proj_file_browser_sidebar"
|
||||
const sessionID = "ses_file_browser_sidebar"
|
||||
const title = "File browser sidebar"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
|
||||
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
|
||||
const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// The file-browser sidebar must stay mounted across preview/pinned file-tab
|
||||
// switches. Remounting resets scroll and filter state.
|
||||
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
|
||||
await setup(page)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
|
||||
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||
await expect(sidebar).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
|
||||
|
||||
await panel.getByRole("button", { name: "file-00.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
|
||||
|
||||
const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
await viewport.hover()
|
||||
await page.mouse.wheel(0, 100_000)
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
const scrolled = await viewport.evaluate((element) => element.scrollTop)
|
||||
expect(scrolled).toBeGreaterThan(0)
|
||||
await writeProbe(page)
|
||||
|
||||
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||
|
||||
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
|
||||
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
||||
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
||||
await panel.getByRole("tab", { name: "file-78.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
|
||||
async function writeProbe(page: Page) {
|
||||
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
|
||||
;(el as Probed).__e2eProbe = probe
|
||||
}, PROBE)
|
||||
}
|
||||
|
||||
async function readProbe(page: Page) {
|
||||
return page
|
||||
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
|
||||
.evaluate((el) => (el as Probed).__e2eProbe)
|
||||
}
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "file-browser-sidebar",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [],
|
||||
fileList: (path) => {
|
||||
if (path) return []
|
||||
return files.map((name) => ({
|
||||
name,
|
||||
path: name,
|
||||
absolute: `${directory}/${name}`,
|
||||
type: "file" as const,
|
||||
ignored: false,
|
||||
}))
|
||||
},
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const draftID = "draft_legacy_new_session"
|
||||
const directory = "C:/OpenCode/LegacyNewSession"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_legacy_new_session",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "legacy-new-session",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
|
||||
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
|
||||
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
|
||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewOpenFile"
|
||||
const projectID = "proj_review_open_file"
|
||||
const sessionID = "ses_review_open_file"
|
||||
const title = "Review open file"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("opens and searches project files inline", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "open-file-project",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [fileDiff("src/changed.ts")],
|
||||
fileList: (path) => {
|
||||
if (path) return []
|
||||
return [
|
||||
fileNode("README.md"),
|
||||
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
|
||||
]
|
||||
},
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
findFiles: (input) => {
|
||||
searches.push(input)
|
||||
return input.query === "nested" ? ["src/nested.ts"] : []
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
||||
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await expect(sidebar).toBeVisible()
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebar).toBeHidden()
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||
await expect(filter).toBeFocused()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
|
||||
|
||||
await panel.getByRole("button", { name: "README.md" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
||||
await expect(sidebar).toBeVisible()
|
||||
await filter.fill("nested")
|
||||
const result = panel.getByRole("option", { name: /nested\.ts/ })
|
||||
await expect(result).toBeVisible()
|
||||
const resultID = await result.getAttribute("id")
|
||||
expect(resultID).toBeTruthy()
|
||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||
await filter.press("Enter")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await panel.getByRole("tab", { name: /Review/ }).click()
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||
await page.keyboard.press("Control+w")
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||
})
|
||||
|
||||
function fileNode(path: string) {
|
||||
return {
|
||||
name: path,
|
||||
path,
|
||||
absolute: `${directory}/${path}`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
}
|
||||
}
|
||||
|
||||
function fileDiff(file: string) {
|
||||
return {
|
||||
file,
|
||||
before: "before\n",
|
||||
after: "after\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewStatePersistence"
|
||||
const projectID = "proj_review_state_persistence"
|
||||
const sessionA = "ses_review_state_a"
|
||||
const sessionB = "ses_review_state_b"
|
||||
const titleA = "Alpha review state"
|
||||
const titleB = "Beta review state"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("restores review mode and selected file per session", async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.goto(sessionHref(sessionA))
|
||||
await expectSessionTitle(page, titleA)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await selectFile(page, "beta.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await selectFile(page, "gamma.ts")
|
||||
|
||||
await switchSession(page, titleA)
|
||||
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
await selectMode(page, "Branch changes", "Git changes")
|
||||
await expectSelectedFile(page, "alpha.ts")
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "gamma.ts")
|
||||
})
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
await page.getByRole("option", { name: next }).click()
|
||||
}
|
||||
|
||||
async function selectFile(page: Page, file: string) {
|
||||
await page.getByRole("button", { name: file }).click()
|
||||
await expectSelectedFile(page, file)
|
||||
}
|
||||
|
||||
async function expectSelectedFile(page: Page, file: string) {
|
||||
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, title: string) {
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-state-persistence",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
||||
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
||||
),
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
}
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function diff(file: string) {
|
||||
return {
|
||||
file,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
||||
}
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
@@ -28,8 +28,8 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
|
||||
await expectSessionTitle(page, titleA)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
const reviewTab = page.getByRole("tab", { name: /Review/ })
|
||||
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
|
||||
const reviewTab = page.locator("#session-side-panel-review-tab")
|
||||
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
|
||||
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
||||
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||
|
||||
@@ -9,12 +9,19 @@ const title = "Review terminal stacked"
|
||||
const branchDiffs = [
|
||||
fileDiff(".github/actions/setup-bun/action.yml", 7),
|
||||
...Array.from({ length: 2_739 }, (_, index) =>
|
||||
fileDiff(`src/branch/generated-${String(index).padStart(4, "0")}.ts`, 100),
|
||||
fileDiff(
|
||||
`src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
|
||||
100,
|
||||
false,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
||||
let detailVersion = 1
|
||||
let detailFailures = 1
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -48,7 +55,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
sessionStatus: { [sessionID]: { type: "idle" } },
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
})
|
||||
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
@@ -57,17 +67,25 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) =>
|
||||
route.fulfill({
|
||||
await page.route("**/vcs/diff**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
|
||||
const detail = scope?.endsWith("/src/branch/d00027")
|
||||
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? branchDiffs
|
||||
url.searchParams.get("mode") === "branch"
|
||||
? detail
|
||||
? branchDiffs
|
||||
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
||||
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
||||
: branchDiffs
|
||||
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -93,10 +111,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
|
||||
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
|
||||
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
@@ -113,41 +131,65 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
})
|
||||
expect(bottomGap).toBeGreaterThanOrEqual(0)
|
||||
expect(bottomGap).toBeLessThanOrEqual(16)
|
||||
const lazyDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
await lastFile.click()
|
||||
await lazyDiff
|
||||
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
||||
await expect(preview).toContainText("after-1")
|
||||
detailVersion = 2
|
||||
events.push(statusEvent("busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
const refreshedDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
events.push(statusEvent("idle"))
|
||||
await refreshedDiff
|
||||
await expect(preview).toContainText("after-2")
|
||||
await selectMode(page, "Branch changes", "Git changes")
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
await page.getByRole("button", { name: "git-0.ts" }).click()
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
||||
await filter.fill("generated-2738")
|
||||
await expectTree(page, 1, "generated-2738.ts")
|
||||
await filter.fill("")
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
|
||||
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
|
||||
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toHaveCount(0)
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await page.setViewportSize({ width: 1_000, height: 700 })
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
await page.setViewportSize({ width: 1_000, height: 120 })
|
||||
await page.setViewportSize({ width: 1_400, height: 900 })
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
})
|
||||
|
||||
@@ -201,12 +243,21 @@ function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
function fileDiff(file: string, additions: number) {
|
||||
function statusEvent(type: "busy" | "idle") {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||
}
|
||||
}
|
||||
|
||||
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
|
||||
return {
|
||||
file,
|
||||
additions,
|
||||
deletions: 0,
|
||||
status: "modified",
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
||||
patch: loaded
|
||||
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
|
||||
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,10 @@ test.describe("session timeline projection", () => {
|
||||
parentID: "msg_2000_diff_next_user",
|
||||
created: 1700000011000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
|
||||
await setupTimeline(page, {
|
||||
messages: [user, assistantMessage(), nextUser, nextAssistant],
|
||||
settings: { newLayoutDesigns: false },
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const deviceScaleFactor of [1.25, 1.5]) {
|
||||
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
|
||||
const shellID = "prt_shell_outline"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
|
||||
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
|
||||
reducedMotion: true,
|
||||
deviceScaleFactor,
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
|
||||
const output = part.locator('[data-component="bash-output"]')
|
||||
const row = page.locator("[data-timeline-key]", { has: part })
|
||||
await expect(output).toBeVisible()
|
||||
await timeline.settle()
|
||||
|
||||
const geometry = await row.evaluate((element) => {
|
||||
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
|
||||
if (!output) throw new Error("Shell output is unavailable")
|
||||
const rowRect = element.getBoundingClientRect()
|
||||
const outputRect = output.getBoundingClientRect()
|
||||
// Match a rounded-down measurement at a fractional device-pixel phase.
|
||||
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
|
||||
element.style.transform = "translateY(0.25px)"
|
||||
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
|
||||
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
|
||||
const style = getComputedStyle(output)
|
||||
return {
|
||||
outputWidth: outputRect.width,
|
||||
outputHeight: outputRect.height,
|
||||
borderColor: style.borderTopColor,
|
||||
boxShadow: style.boxShadow,
|
||||
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||
}
|
||||
})
|
||||
await timeline.settle()
|
||||
|
||||
const clipped = await row.evaluate((element) => {
|
||||
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
|
||||
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
|
||||
})
|
||||
expect(clipped).toBeCloseTo(0.49, 1)
|
||||
|
||||
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
|
||||
const edges = await captureCardEdges(page, output)
|
||||
|
||||
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
|
||||
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
|
||||
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
|
||||
expect(geometry.boxShadow).toBe("none")
|
||||
expect(geometry.clipMargin).toBe("0.5px")
|
||||
expect(edges.magenta.top).toBeGreaterThan(0.75)
|
||||
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
|
||||
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
|
||||
const patchID = "prt_patch_outline"
|
||||
const file = {
|
||||
filePath: "src/outline.ts",
|
||||
relativePath: "src/outline.ts",
|
||||
type: "update",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "const outline = false\n",
|
||||
after: "const outline = true\n",
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
|
||||
reducedMotion: true,
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
const row = page.locator("[data-timeline-key]", { has: part })
|
||||
await expect(card).toBeVisible()
|
||||
await timeline.settle()
|
||||
|
||||
const geometry = await row.evaluate((element) => {
|
||||
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
if (!card) throw new Error("Patch card is unavailable")
|
||||
const rowRect = element.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
|
||||
const clipMargin = getComputedStyle(element).overflowClipMargin
|
||||
const bottom = element.getBoundingClientRect().bottom
|
||||
return {
|
||||
overflow: card.getBoundingClientRect().bottom - bottom,
|
||||
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
|
||||
clipMargin,
|
||||
cardWidth: cardRect.width,
|
||||
cardHeight: cardRect.height,
|
||||
}
|
||||
})
|
||||
await timeline.settle()
|
||||
|
||||
expect(geometry.overflow).toBeCloseTo(0.49, 1)
|
||||
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
|
||||
const edges = await captureCardEdges(page, card)
|
||||
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
|
||||
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
|
||||
expect(edges.luminance.top).toBeLessThan(245)
|
||||
expect(edges.luminance.bottom).toBeLessThan(245)
|
||||
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
|
||||
expect(geometry.clipMargin).toBe("0.5px")
|
||||
})
|
||||
|
||||
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
|
||||
const secondUserID = "msg_outline_second_user"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: [
|
||||
{
|
||||
file: "src/summary.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
|
||||
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
|
||||
assistantMessage([], {
|
||||
id: "msg_outline_second_assistant",
|
||||
parentID: secondUserID,
|
||||
created: 1700000011000,
|
||||
}),
|
||||
],
|
||||
})
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
|
||||
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
|
||||
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||
})),
|
||||
)
|
||||
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
|
||||
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
|
||||
})
|
||||
|
||||
async function captureCardEdges(page: Page, card: Locator) {
|
||||
const box = await card.boundingBox()
|
||||
if (!box) throw new Error("Tool card bounds are unavailable")
|
||||
const viewport = page.viewportSize()
|
||||
if (!viewport) throw new Error("Viewport bounds are unavailable")
|
||||
const screenshot = await page.screenshot()
|
||||
return page.evaluate(
|
||||
async ({ source, box, viewport }) => {
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("2D canvas is unavailable")
|
||||
context.drawImage(image, 0, 0)
|
||||
const scale = {
|
||||
x: image.naturalWidth / viewport.width,
|
||||
y: image.naturalHeight / viewport.height,
|
||||
}
|
||||
const rows = (candidates: number[]) => {
|
||||
const left = Math.floor((box.x + 8) * scale.x)
|
||||
const width = Math.floor((box.width - 16) * scale.x)
|
||||
return candidates.map((row) => {
|
||||
const pixels = context.getImageData(left, row, width, 1).data
|
||||
const indexes = Array.from({ length: width }, (_, index) => index * 4)
|
||||
return {
|
||||
luminance:
|
||||
indexes
|
||||
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
|
||||
.reduce((sum, value) => sum + value, 0) / width,
|
||||
magenta:
|
||||
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
|
||||
.length / width,
|
||||
}
|
||||
})
|
||||
}
|
||||
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
|
||||
const columns = new Uint32Array(image.naturalWidth)
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
|
||||
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
|
||||
}
|
||||
const top = box.y * scale.y
|
||||
const bottom = (box.y + box.height) * scale.y
|
||||
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
|
||||
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
|
||||
return {
|
||||
box,
|
||||
luminance: {
|
||||
top: Math.min(...topRows.map((row) => row.luminance)),
|
||||
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
|
||||
},
|
||||
magenta: {
|
||||
top: Math.max(...topRows.map((row) => row.magenta)),
|
||||
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
|
||||
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
||||
viewport,
|
||||
box,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface MockServerConfig {
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
fileList?: (path: string) => unknown | Promise<unknown>
|
||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||
sessionStatus?: unknown
|
||||
}
|
||||
|
||||
@@ -66,6 +67,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/file/content" && config.fileContent)
|
||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/find/file" && config.findFiles)
|
||||
return json(
|
||||
route,
|
||||
await config.findFiles({
|
||||
query: url.searchParams.get("query") ?? "",
|
||||
dirs: url.searchParams.get("dirs") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
}),
|
||||
)
|
||||
if (path === "/api/reference")
|
||||
return json(route, {
|
||||
location: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.18",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -47,6 +47,7 @@
|
||||
"vite-plugin-solid": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
@@ -88,6 +89,7 @@
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
+71
-17
@@ -9,9 +9,10 @@ import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
@@ -32,7 +33,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
@@ -52,9 +53,10 @@ import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||
|
||||
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
@@ -72,7 +74,7 @@ const SessionRoute = () => {
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
{(_) => {
|
||||
const persisted = tabs.store().filter((item) => item.type === "session")
|
||||
const persisted = tabs.store.filter((item) => item.type === "session")
|
||||
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
|
||||
}}
|
||||
</Show>
|
||||
@@ -88,10 +90,14 @@ const SessionRoute = () => {
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return <SessionPage />
|
||||
return (
|
||||
<SessionRouteErrorBoundary sessionID={params.id}>
|
||||
<SessionPage />
|
||||
</SessionRouteErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => {
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => {
|
||||
@@ -105,14 +111,47 @@ const TargetSessionRoute = () => {
|
||||
// re-resolves reactively instead); both rely on this key for server changes.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetSessionRouteContent />
|
||||
</ServerSyncProvider>
|
||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => (
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
)
|
||||
|
||||
function LegacyTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
return (
|
||||
<TargetServerRoute>
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
||||
<LegacyTargetSessionRedirect />
|
||||
</SessionRouteErrorBoundary>
|
||||
</TargetServerRoute>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyTargetSessionRedirect() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const sync = useServerSync()
|
||||
const current = createSessionLineage(
|
||||
() => params.id,
|
||||
() => sync().session.lineage,
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const directory = current()?.session.directory
|
||||
if (!directory) return
|
||||
navigate(legacySessionHref(directory, params.id), { replace: true })
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
@@ -136,15 +175,23 @@ function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>)
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Show
|
||||
when={tabs.store().find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||
keyed
|
||||
fallback={<Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
{(draft) => (
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
|
||||
>
|
||||
<ResolvedDraftRoute draft={draft} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
@@ -542,7 +589,14 @@ function Routes(props: { serverScoped?: JSX.Element }) {
|
||||
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
||||
)}
|
||||
>
|
||||
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
|
||||
<Show when={!settings.general.newLayoutDesigns()}>
|
||||
{
|
||||
<>
|
||||
<Route path="/" component={LegacyHome} />
|
||||
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
|
||||
</>
|
||||
}
|
||||
</Show>
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
@@ -550,15 +604,15 @@ function Routes(props: { serverScoped?: JSX.Element }) {
|
||||
</Route>
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
<Route path="/" component={NewHome} />
|
||||
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
|
||||
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</Show>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyTargetSessionRoute() {
|
||||
function NewLayoutLegacySessionRedirect() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ id: string }>()
|
||||
@@ -568,7 +622,7 @@ function LegacyTargetSessionRoute() {
|
||||
<Navigate
|
||||
href={sessionHref(
|
||||
legacySessionServer(
|
||||
tabs.store().filter((item) => item.type === "session"),
|
||||
tabs.store.filter((item) => item.type === "session"),
|
||||
params.id,
|
||||
server.key,
|
||||
),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,156 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { createEditProjectModel } from "./edit-project"
|
||||
|
||||
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const language = useLanguage()
|
||||
const model = createEditProjectModel(props)
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<form onSubmit={model.submit} class="contents">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||
<TextInputV2
|
||||
autofocus
|
||||
appearance="large"
|
||||
class="!w-full"
|
||||
value={model.store.name}
|
||||
placeholder={model.folderName()}
|
||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.icon")}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||
}}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||
onDrop={model.drop}
|
||||
onDragOver={model.dragOver}
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
src={getProjectAvatarSource(props.project.id, {
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: model.store.iconOverride,
|
||||
})}
|
||||
variant={getProjectAvatarVariant(model.store.color)}
|
||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||
/>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": model.store.iconHover,
|
||||
"opacity-0": !model.store.iconHover,
|
||||
}}
|
||||
>
|
||||
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={(element) => {
|
||||
model.setIconInput(element)
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={model.inputChange}
|
||||
/>
|
||||
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.color")}
|
||||
</div>
|
||||
<div class="-ml-1 flex gap-1.5">
|
||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||
{(color) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||
getProjectAvatarVariant(model.store.color) === color,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||
model.setStore(
|
||||
"color",
|
||||
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
variant={getProjectAvatarVariant(color)}
|
||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||
<TextareaV2
|
||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||
rows={3}
|
||||
value={model.store.startup}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
spellcheck={false}
|
||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,123 +1,32 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For, Show } from "solid-js"
|
||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { createEditProjectModel } from "./edit-project"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
|
||||
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
const serverSDK = () => serverCtx().sdk
|
||||
const serverSync = () => serverCtx().sync
|
||||
|
||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||
const defaultName = createMemo(() => props.project.name || folderName())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
name: defaultName(),
|
||||
color: props.project.icon?.color,
|
||||
iconOverride: props.project.icon?.override,
|
||||
startup: props.project.commands?.start ?? "",
|
||||
dragOver: false,
|
||||
iconHover: false,
|
||||
})
|
||||
|
||||
let iconInput: HTMLInputElement | undefined
|
||||
|
||||
function handleFileSelect(file: File) {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
setStore("iconOverride", e.target?.result as string)
|
||||
setStore("iconHover", false)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setStore("dragOver", false)
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file) handleFileSelect(file)
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setStore("dragOver", true)
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
setStore("dragOver", false)
|
||||
}
|
||||
|
||||
function handleInputChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) handleFileSelect(file)
|
||||
}
|
||||
|
||||
function clearIcon() {
|
||||
setStore("iconOverride", "")
|
||||
}
|
||||
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await serverSDK().client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
})
|
||||
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
serverSync().project.meta(props.project.worktree, {
|
||||
name,
|
||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||
commands: { start: start || undefined },
|
||||
})
|
||||
dialog.close()
|
||||
},
|
||||
}))
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
if (saveMutation.isPending) return
|
||||
saveMutation.mutate()
|
||||
}
|
||||
const model = createEditProjectModel(props)
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
|
||||
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label={language.t("dialog.project.edit.name")}
|
||||
placeholder={folderName()}
|
||||
value={store.name}
|
||||
onChange={(v) => setStore("name", v)}
|
||||
placeholder={model.folderName()}
|
||||
value={model.store.name}
|
||||
onChange={(v) => model.setStore("name", v)}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<div class="flex gap-3 items-start">
|
||||
<div
|
||||
class="relative"
|
||||
onMouseEnter={() => setStore("iconHover", true)}
|
||||
onMouseLeave={() => setStore("iconHover", false)}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||
>
|
||||
<div
|
||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
||||
classList={{
|
||||
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
|
||||
"border-border-base hover:border-border-strong": !store.dragOver,
|
||||
"overflow-hidden": !!store.iconOverride,
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => {
|
||||
if (store.iconOverride && store.iconHover) {
|
||||
clearIcon()
|
||||
} else {
|
||||
iconInput?.click()
|
||||
}
|
||||
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
|
||||
"border-border-base hover:border-border-strong": !model.store.dragOver,
|
||||
"overflow-hidden": !!model.store.iconOverride,
|
||||
}}
|
||||
onDrop={model.drop}
|
||||
onDragOver={model.dragOver}
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<Show
|
||||
when={getProjectAvatarSource(props.project.id, {
|
||||
color: store.color,
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: store.iconOverride,
|
||||
override: model.store.iconOverride,
|
||||
})}
|
||||
fallback={
|
||||
<div class="size-full flex items-center justify-center">
|
||||
<Avatar
|
||||
fallback={store.name || defaultName()}
|
||||
{...getAvatarColors(store.color)}
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
{...getAvatarColors(model.store.color)}
|
||||
class="size-full text-[32px]"
|
||||
/>
|
||||
</div>
|
||||
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<div
|
||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": store.iconHover && !store.iconOverride,
|
||||
"opacity-0": !(store.iconHover && !store.iconOverride),
|
||||
"opacity-100": model.store.iconHover && !model.store.iconOverride,
|
||||
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
|
||||
}}
|
||||
>
|
||||
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||
@@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<div
|
||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": store.iconHover && !!store.iconOverride,
|
||||
"opacity-0": !(store.iconHover && !!store.iconOverride),
|
||||
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
|
||||
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||
@@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<input
|
||||
id="icon-upload"
|
||||
ref={(el) => {
|
||||
iconInput = el
|
||||
model.setIconInput(el)
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={handleInputChange}
|
||||
onChange={model.inputChange}
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||
@@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!store.iconOverride}>
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
||||
<div class="flex gap-1.5">
|
||||
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||
aria-pressed={store.color === color}
|
||||
aria-pressed={model.store.color === color}
|
||||
classList={{
|
||||
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
||||
store.color === color,
|
||||
model.store.color === color,
|
||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||
store.color !== color,
|
||||
model.store.color !== color,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (store.color === color && !props.project.icon?.url) return
|
||||
setStore("color", store.color === color ? undefined : color)
|
||||
if (model.store.color === color && !props.project.icon?.url) return
|
||||
model.setStore("color", model.store.color === color ? undefined : color)
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
fallback={store.name || defaultName()}
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
{...getAvatarColors(color)}
|
||||
class="size-full rounded"
|
||||
/>
|
||||
@@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
label={language.t("dialog.project.edit.worktree.startup")}
|
||||
description={language.t("dialog.project.edit.worktree.startup.description")}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
value={store.startup}
|
||||
onChange={(v) => setStore("startup", v)}
|
||||
value={model.store.startup}
|
||||
onChange={(v) => model.setStore("startup", v)}
|
||||
spellcheck={false}
|
||||
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -20,14 +20,11 @@ import {
|
||||
uniqueCommandPaletteEntries,
|
||||
type CommandPaletteEntry,
|
||||
} from "./command-palette"
|
||||
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
|
||||
|
||||
const DialogSelectFileV2 = lazy(() =>
|
||||
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
||||
)
|
||||
const DialogCommandPaletteV2 = lazy(() =>
|
||||
import("./dialog-command-palette-v2").then((module) => ({ default: module.DialogCommandPaletteV2 })),
|
||||
)
|
||||
|
||||
type DialogSelectFileMode = "all" | "files"
|
||||
|
||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
||||
const local = useLocal()
|
||||
const model = props.model ?? local.model
|
||||
const dialog = useDialog()
|
||||
const directory = () => decode64(local.slug())
|
||||
const providers = useProviders(directory)
|
||||
const language = useLanguage()
|
||||
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
|
||||
const currentKey = createMemo(() => {
|
||||
const c = model.current()
|
||||
return c ? `${c.provider.id}:${c.id}` : undefined
|
||||
})
|
||||
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||
|
||||
const openProviders = (provider?: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
|
||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
// Focus starts on the dialog's close button, outside the list, so listen at the
|
||||
// document level while the dialog is mounted instead of on the list container.
|
||||
let listEl: HTMLDivElement | undefined
|
||||
onMount(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
|
||||
if (!listEl) return
|
||||
const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
|
||||
if (buttons.length === 0) return
|
||||
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const next =
|
||||
index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
|
||||
buttons[(next + buttons.length) % buttons.length]?.focus()
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
|
||||
<DialogHeader closeLabel={language.t("common.close")}>
|
||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
|
||||
<DialogBody class="min-h-0 flex-1 gap-0">
|
||||
<ScrollView class="min-h-0 flex-1 w-full">
|
||||
<div ref={listEl} class="flex min-h-full flex-col">
|
||||
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
|
||||
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||
</div>
|
||||
</div>
|
||||
<For each={model.list()}>
|
||||
{(item) => (
|
||||
<TooltipV2
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<Show when={isFree(item)}>
|
||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
||||
</Show>
|
||||
<Show when={currentKey() === modelKey(item)}>
|
||||
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</button>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col p-2.5 pt-0">
|
||||
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
|
||||
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.addMore.title")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col">
|
||||
<For
|
||||
each={[...providers.popular()].sort((a, b) => {
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})}
|
||||
>
|
||||
{(provider) => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => openProviders(provider.id)}
|
||||
>
|
||||
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{provider.name}</span>
|
||||
<Show when={provider.id === "opencode"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.opencode.tagline")}
|
||||
</span>
|
||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={provider.id === "opencode-go"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
||||
</span>
|
||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={provider.id === "anthropic"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.anthropic.note")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => openProviders()}
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Icon name="dot-grid" size="small" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</DialogBody>
|
||||
</DialogV2>
|
||||
)
|
||||
}
|
||||
@@ -474,7 +474,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
}}
|
||||
onSelect={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<span class="min-w-0 truncate leading-5">{item.name}</span>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||
const defaultName = createMemo(() => props.project.name || folderName())
|
||||
const [store, setStore] = createStore({
|
||||
name: defaultName(),
|
||||
color: props.project.icon?.color,
|
||||
iconOverride: props.project.icon?.override,
|
||||
startup: props.project.commands?.start ?? "",
|
||||
dragOver: false,
|
||||
iconHover: false,
|
||||
})
|
||||
let iconInput: HTMLInputElement | undefined
|
||||
|
||||
function selectFile(file: File) {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result
|
||||
if (typeof result !== "string") return
|
||||
setStore("iconOverride", result)
|
||||
setStore("iconHover", false)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function drop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
setStore("dragOver", false)
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (file) selectFile(file)
|
||||
}
|
||||
|
||||
function dragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
setStore("dragOver", true)
|
||||
}
|
||||
|
||||
function dragLeave() {
|
||||
setStore("dragOver", false)
|
||||
}
|
||||
|
||||
function inputChange(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0]
|
||||
if (file) selectFile(file)
|
||||
}
|
||||
|
||||
function iconClick() {
|
||||
if (store.iconOverride && store.iconHover) {
|
||||
setStore("iconOverride", "")
|
||||
return
|
||||
}
|
||||
iconInput?.click()
|
||||
}
|
||||
|
||||
const save = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await serverCtx().sdk.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
})
|
||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
serverCtx().sync.project.meta(props.project.worktree, {
|
||||
name,
|
||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||
commands: { start: start || undefined },
|
||||
})
|
||||
dialog.close()
|
||||
},
|
||||
}))
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault()
|
||||
if (save.isPending) return
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
setStore,
|
||||
folderName,
|
||||
defaultName,
|
||||
save,
|
||||
submit,
|
||||
drop,
|
||||
dragOver,
|
||||
dragLeave,
|
||||
inputChange,
|
||||
iconClick,
|
||||
close() {
|
||||
dialog.close()
|
||||
},
|
||||
setIconInput(input: HTMLInputElement) {
|
||||
iconInput = input
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
describe("file tree v2 model", () => {
|
||||
test("builds sorted depth-first rows", () => {
|
||||
describe("buildFileTreeV2Model", () => {
|
||||
test("builds a sorted tree and flattens expanded directories", () => {
|
||||
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
|
||||
|
||||
expect(model.total).toBe(8)
|
||||
@@ -18,7 +19,7 @@ describe("file tree v2 model", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("omits descendants of collapsed directories", () => {
|
||||
test("skips children of collapsed directories", () => {
|
||||
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
|
||||
|
||||
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
|
||||
@@ -28,19 +29,46 @@ describe("file tree v2 model", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("normalizes separators and duplicate paths", () => {
|
||||
test("normalizes duplicate and messy paths", () => {
|
||||
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
|
||||
const rows = flattenFileTreeV2(model, () => true)
|
||||
|
||||
expect(model.total).toBe(4)
|
||||
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
|
||||
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
|
||||
})
|
||||
|
||||
test("supports paths deeper than the legacy recursion limit", () => {
|
||||
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
|
||||
test("handles deeply nested paths", () => {
|
||||
const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
|
||||
const model = buildFileTreeV2Model([file])
|
||||
|
||||
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
|
||||
})
|
||||
})
|
||||
|
||||
describe("flattenLiveFileTreeV2", () => {
|
||||
test("flattens live children using original paths for nested lookups", () => {
|
||||
const nodes: Record<string, FileNode[]> = {
|
||||
"": [
|
||||
{ name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
|
||||
{ name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
|
||||
],
|
||||
src: [
|
||||
{ name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
|
||||
{ name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
|
||||
],
|
||||
"src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
|
||||
}
|
||||
|
||||
expect(
|
||||
flattenLiveFileTreeV2(
|
||||
(path) => nodes[path] ?? [],
|
||||
(path) => path === "src",
|
||||
).map((row) => [row.node.path, row.node.originalPath, row.level]),
|
||||
).toEqual([
|
||||
["src", "src", 0],
|
||||
["src/a.ts", "src/a.ts", 1],
|
||||
["src/lib", "src/lib", 1],
|
||||
["README.md", "README.md", 0],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,3 +75,33 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
export function flattenLiveFileTreeV2(
|
||||
children: (path: string) => readonly FileNode[],
|
||||
expanded: (path: string) => boolean,
|
||||
) {
|
||||
const rows: FileTreeV2Row[] = []
|
||||
const stack = children("")
|
||||
.toReversed()
|
||||
.map((node) => ({ node: toLiveNode(node), level: 0 }))
|
||||
|
||||
while (stack.length > 0) {
|
||||
const row = stack.pop()!
|
||||
rows.push(row)
|
||||
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
|
||||
const nested = children(row.node.originalPath)
|
||||
for (let index = nested.length - 1; index >= 0; index--) {
|
||||
stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function toLiveNode(node: FileNode): FileTreeV2Node {
|
||||
return {
|
||||
...node,
|
||||
path: normalizeFileTreeV2Path(node.path),
|
||||
originalPath: node.path,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||
import {
|
||||
buildFileTreeV2Model,
|
||||
flattenFileTreeV2,
|
||||
flattenLiveFileTreeV2,
|
||||
normalizeFileTreeV2Path,
|
||||
type FileTreeV2Node,
|
||||
} from "@/components/file-tree-v2-model"
|
||||
import { virtualScrollElement } from "@/components/virtual-scroll-element"
|
||||
|
||||
export type { Kind } from "@/components/file-tree"
|
||||
@@ -36,7 +42,7 @@ function guideLineLeft(level: number) {
|
||||
export const kindLabel = (kind: Kind) => {
|
||||
if (kind === "add") return "A"
|
||||
if (kind === "del") return "D"
|
||||
return ""
|
||||
return "M"
|
||||
}
|
||||
|
||||
export const kindChange = (kind: Kind) => {
|
||||
@@ -68,7 +74,7 @@ const FileTreeNodeV2 = (
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
const kind = () => local.kinds?.get(local.node.path)
|
||||
const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
@@ -110,12 +116,7 @@ const FileTreeNodeV2 = (
|
||||
function GuideLines(props: { level: number }) {
|
||||
return (
|
||||
<For each={Array.from({ length: props.level })}>
|
||||
{(_, index) => (
|
||||
<div
|
||||
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
|
||||
style={`left: ${guideLineLeft(index())}px`}
|
||||
/>
|
||||
)}
|
||||
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
@@ -126,12 +127,18 @@ export default function FileTreeV2(props: {
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
onFileDoubleClick?: (file: FileNode) => void
|
||||
}) {
|
||||
const file = useFile()
|
||||
const live = () => props.allowed === undefined
|
||||
const draggable = () => props.draggable ?? true
|
||||
const active = () => normalizeFileTreeV2Path(props.active ?? "")
|
||||
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
|
||||
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
|
||||
const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
|
||||
const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live()
|
||||
const rows = createMemo(() => {
|
||||
if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded)
|
||||
return flattenFileTreeV2(model()!, expanded)
|
||||
})
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
@@ -155,16 +162,49 @@ export default function FileTreeV2(props: {
|
||||
return [...indexes, index].sort((a, b) => a - b)
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!live()) return
|
||||
void file.tree.list("")
|
||||
})
|
||||
|
||||
// Only scroll when the active path changes (or first appears in the tree).
|
||||
// Do not re-scroll when expand/collapse reshuffles `rows()`.
|
||||
let scrolledActive: string | undefined
|
||||
createEffect(() => {
|
||||
const path = active()
|
||||
if (!path) return
|
||||
if (!path) {
|
||||
scrolledActive = undefined
|
||||
return
|
||||
}
|
||||
const index = rows().findIndex((row) => row.node.path === path)
|
||||
if (index < 0) return
|
||||
if (scrolledActive === path) return
|
||||
scrolledActive = path
|
||||
queueMicrotask(() => {
|
||||
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
|
||||
virtualizer.scrollToIndex(index, { align: "auto" })
|
||||
const next = rows().findIndex((row) => row.node.path === path)
|
||||
if (next < 0) return
|
||||
if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return
|
||||
virtualizer.scrollToIndex(next, { align: "auto" })
|
||||
})
|
||||
})
|
||||
|
||||
const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => {
|
||||
action?.({
|
||||
...node,
|
||||
path: node.originalPath,
|
||||
absolute: node.originalPath,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDirectory = (path: string, originalPath: string) => {
|
||||
if (expanded(path)) {
|
||||
file.tree.collapse(originalPath)
|
||||
return
|
||||
}
|
||||
file.tree.expand(originalPath, live() ? undefined : { list: false })
|
||||
}
|
||||
|
||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
@@ -175,7 +215,7 @@ export default function FileTreeV2(props: {
|
||||
<div
|
||||
ref={setRoot}
|
||||
data-component="file-tree-v2"
|
||||
data-total-rows={model().total}
|
||||
data-total-rows={live() ? rows().length : model()!.total}
|
||||
class="group/file-tree-v2"
|
||||
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
@@ -209,13 +249,8 @@ export default function FileTreeV2(props: {
|
||||
class="relative"
|
||||
onFocus={() => setFocused(row().node.path)}
|
||||
onBlur={() => setFocused(undefined)}
|
||||
onClick={() =>
|
||||
props.onFileClick?.({
|
||||
...row().node,
|
||||
path: row().node.originalPath,
|
||||
absolute: row().node.originalPath,
|
||||
})
|
||||
}
|
||||
onClick={() => selectFile(row().node, props.onFileClick)}
|
||||
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
|
||||
>
|
||||
<GuideLines level={row().level} />
|
||||
<Show when={row().level > 0}>
|
||||
@@ -239,17 +274,13 @@ export default function FileTreeV2(props: {
|
||||
class="relative"
|
||||
onFocus={() => setFocused(row().node.path)}
|
||||
onBlur={() => setFocused(undefined)}
|
||||
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
|
||||
onClick={() =>
|
||||
file.tree.state(row().node.path)?.expanded === false
|
||||
? file.tree.expand(row().node.path, { list: false })
|
||||
: file.tree.collapse(row().node.path)
|
||||
}
|
||||
aria-expanded={expanded(row().node.path)}
|
||||
onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
|
||||
>
|
||||
<GuideLines level={row().level} />
|
||||
<div
|
||||
data-slot="file-tree-v2-chevron"
|
||||
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
|
||||
data-expanded={expanded(row().node.path) ? "" : undefined}
|
||||
class="size-4 flex items-center justify-center"
|
||||
>
|
||||
<Icon name="chevron-down" />
|
||||
|
||||
@@ -201,6 +201,7 @@ export default function FileTree(props: {
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
onFileDoubleClick?: (file: FileNode) => void
|
||||
|
||||
_filter?: Filter
|
||||
_marks?: Set<string>
|
||||
@@ -440,6 +441,7 @@ export default function FileTree(props: {
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
onFileClick={props.onFileClick}
|
||||
onFileDoubleClick={props.onFileDoubleClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
_deeps={deeps()}
|
||||
@@ -462,6 +464,7 @@ export default function FileTree(props: {
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
onDblClick={() => props.onFileDoubleClick?.(node)}
|
||||
>
|
||||
<div class="w-4 shrink-0" />
|
||||
<Switch>
|
||||
|
||||
@@ -1,54 +1,144 @@
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const helpIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
data-slot="icon-svg"
|
||||
>
|
||||
<path
|
||||
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
|
||||
fill="var(--v2-icon-icon-base)"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const triggerClass =
|
||||
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
|
||||
|
||||
// TODO: wire to changelog / seen-state when available
|
||||
const showPopover = () => true
|
||||
|
||||
export function HelpButton() {
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<Show when={!state.dismissed}>
|
||||
<div class="fixed bottom-4 right-4 z-50 hidden md:block">
|
||||
<Popover
|
||||
open={shown()}
|
||||
onOpenChange={setShown}
|
||||
triggerAs="button"
|
||||
triggerProps={{
|
||||
type: "button",
|
||||
"aria-label": "Help",
|
||||
class:
|
||||
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
|
||||
}}
|
||||
trigger={<span aria-hidden="true">?</span>}
|
||||
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||
gutter={8}
|
||||
placement="top-end"
|
||||
>
|
||||
<Show when={shown()}>
|
||||
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
|
||||
onClick={() => {
|
||||
setShown(false)
|
||||
setState("dismissed", true)
|
||||
}}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
|
||||
<p class="text-12-regular text-text-weak">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</Popover>
|
||||
</div>
|
||||
</Show>
|
||||
<a
|
||||
href="https://opencode.ai"
|
||||
aria-label="Open the OpenCode website"
|
||||
data-component="icon-button-v2"
|
||||
data-size="large"
|
||||
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
platform.openLink(event.currentTarget.href)
|
||||
}}
|
||||
>
|
||||
{helpIcon}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// can remove this after the tabs rollout has been out for a while
|
||||
export function TabsInfoPopup() {
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
|
||||
// setState({ dismissed: false }) // for testing
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||
<Show when={!state.dismissed}>
|
||||
<div
|
||||
class="fixed bottom-14 right-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. A faster, more intuitive way to work."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss Tabs information"
|
||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||
onClick={() => setState("dismissed", true)}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||
onClick={() => {
|
||||
setState("dismissed", true)
|
||||
setDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={introducingTabsVideo}
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loop
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
/>
|
||||
<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]">
|
||||
Introducing Tabs
|
||||
</p>
|
||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||
A faster, more intuitive way to work.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<DrawerContent>
|
||||
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
|
||||
<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">
|
||||
June 16
|
||||
</p>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
aria-label="Close"
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative flex w-full flex-col items-start gap-6 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">
|
||||
Introducing Tabs Navigation.
|
||||
</p>
|
||||
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
|
||||
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
|
||||
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
|
||||
sessions.
|
||||
</p>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
createResource,
|
||||
Switch,
|
||||
Match,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
@@ -20,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
|
||||
import {
|
||||
ContentPart,
|
||||
DEFAULT_PROMPT,
|
||||
isCommentItem,
|
||||
isPromptEqual,
|
||||
Prompt,
|
||||
usePrompt,
|
||||
@@ -37,6 +37,8 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
@@ -44,6 +46,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
@@ -118,21 +122,21 @@ export type PromptInputControls = {
|
||||
export function createPromptInputHistory(): PromptInputHistory {
|
||||
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
|
||||
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
|
||||
return createPromptInputHistoryStore(() => normal, setNormal, () => shell, setShell)
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
}
|
||||
|
||||
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||
|
||||
function createPromptInputHistoryStore(
|
||||
normal: Accessor<PromptHistoryState>,
|
||||
normal: Store<PromptHistoryState>,
|
||||
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||
shell: Accessor<PromptHistoryState>,
|
||||
shell: Store<PromptHistoryState>,
|
||||
setShell: SetStoreFunction<PromptHistoryState>,
|
||||
): PromptInputHistory {
|
||||
return {
|
||||
entries: (mode) => (mode === "shell" ? shell().entries : normal().entries),
|
||||
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
|
||||
add(prompt, mode, comments) {
|
||||
const current = mode === "shell" ? shell() : normal()
|
||||
const current = mode === "shell" ? shell : normal
|
||||
const setCurrent = mode === "shell" ? setShell : setNormal
|
||||
const next = prependHistoryEntry(current.entries, prompt, comments)
|
||||
if (next === current.entries) return
|
||||
@@ -521,7 +525,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const setMode = (mode: "normal" | "shell") => {
|
||||
setStore("mode", mode)
|
||||
setStore("popover", null)
|
||||
setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
|
||||
requestAnimationFrame(() => editorRef?.focus())
|
||||
}
|
||||
|
||||
@@ -555,7 +559,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
])
|
||||
|
||||
const closePopover = () => setStore("popover", null)
|
||||
const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
|
||||
|
||||
const resetHistoryNavigation = (force = false) => {
|
||||
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
|
||||
@@ -801,17 +805,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||
if (!cmd) return
|
||||
const menu = store.slashMenu
|
||||
closePopover()
|
||||
const images = imageAttachments()
|
||||
|
||||
if (cmd.type === "custom") {
|
||||
const text = `/${cmd.trigger} `
|
||||
if (menu) {
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, 0)
|
||||
addPart({ type: "text", content: text, start: 0, end: text.length })
|
||||
focusEditorEnd()
|
||||
return
|
||||
}
|
||||
setEditorText(text)
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
|
||||
focusEditorEnd()
|
||||
return
|
||||
}
|
||||
|
||||
if (menu) {
|
||||
command.trigger(cmd.id, "slash")
|
||||
return
|
||||
}
|
||||
|
||||
clearEditor()
|
||||
prompt.set([...DEFAULT_PROMPT, ...images], 0)
|
||||
command.trigger(cmd.id, "slash")
|
||||
@@ -1073,10 +1090,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
if (atMatch) {
|
||||
atOnInput(atMatch[1])
|
||||
setStore("popover", "at")
|
||||
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
|
||||
} else if (slashMatch) {
|
||||
slashOnInput(slashMatch[1])
|
||||
setStore("popover", "slash")
|
||||
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
|
||||
} else {
|
||||
closePopover()
|
||||
}
|
||||
@@ -1172,6 +1189,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const openCommands = () => {
|
||||
const populated = prompt.dirty() || commentCount() > 0
|
||||
requestAnimationFrame(() => {
|
||||
if (!populated) {
|
||||
if (!addPart({ type: "text", content: "/", start: 0, end: 0 })) return
|
||||
slashOnInput("")
|
||||
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
|
||||
return
|
||||
}
|
||||
slashOnInput("")
|
||||
setStore({ popover: "slash", slashMenu: true, slashMenuQuery: "" })
|
||||
})
|
||||
}
|
||||
|
||||
const openContext = () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!addPart({ type: "text", content: "@", start: 0, end: 0 })) return
|
||||
atOnInput("")
|
||||
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
|
||||
})
|
||||
}
|
||||
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
|
||||
}
|
||||
@@ -1200,7 +1239,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
setStore("mode", "normal")
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||
@@ -1287,13 +1326,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
resetHistoryNavigation(true)
|
||||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
setPopover: (popover) => {
|
||||
if (!popover) return closePopover()
|
||||
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
|
||||
},
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
model: props.controls.model.selection,
|
||||
})
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -1326,7 +1369,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
if (cursorPosition === 0) {
|
||||
setStore("mode", "shell")
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -1461,6 +1504,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSlashMenuKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closePopover()
|
||||
requestAnimationFrame(() => editorRef.focus())
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Tab") {
|
||||
selectPopoverActive()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
|
||||
const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
|
||||
const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
|
||||
if (!nav && !ctrlNav) return
|
||||
slashOnKeyDown(event)
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const agentsLoading = () => props.controls.agents.loading
|
||||
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
|
||||
const providersLoading = () => props.controls.model.loading
|
||||
@@ -1489,9 +1555,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
})
|
||||
if (props.controls.newLayoutDesigns) {
|
||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1528,6 +1596,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
slashActive={slashActive() ?? undefined}
|
||||
setSlashActive={setSlashActive}
|
||||
onSlashSelect={handleSlashSelect}
|
||||
slashMenu={store.slashMenu}
|
||||
slashMenuQuery={store.slashMenuQuery}
|
||||
onSlashMenuInput={(value) => {
|
||||
setStore("slashMenuQuery", value)
|
||||
slashOnInput(value)
|
||||
}}
|
||||
onSlashMenuKeyDown={handleSlashMenuKeyDown}
|
||||
commandKeybind={command.keybind}
|
||||
commandKeybindParts={command.keybindParts}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
@@ -1552,7 +1627,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
)}
|
||||
/>
|
||||
<PromptContextItems
|
||||
items={contextItems()}
|
||||
items={contextItems().filter((item) => !isCommentItem(item))}
|
||||
active={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
@@ -1562,6 +1637,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
@@ -1571,6 +1647,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
comments={contextItems().filter(isCommentItem)}
|
||||
commentActive={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
}}
|
||||
onOpenComment={openComment}
|
||||
onRemoveComment={(item) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="relative min-h-[52px]"
|
||||
@@ -1626,23 +1713,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
placement="top"
|
||||
value={
|
||||
<>
|
||||
{language.t("prompt.action.attachFile")}
|
||||
{language.t("prompt.menu.addImagesAndFiles")}
|
||||
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
icon="plus"
|
||||
variant="ghost"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
|
||||
style={buttons()}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
/>
|
||||
<MenuV2 gutter={6} modal={false} placement="top-start">
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
icon={<IconV2 name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
style={buttons()}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.menu.addImagesAndFiles")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content style={{ "min-width": "180px" }}>
|
||||
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
|
||||
{language.t("prompt.menu.imagesAndFiles")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={openCommands} shortcut="/">
|
||||
{language.t("prompt.menu.commands")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={openContext} shortcut="@">
|
||||
{language.t("prompt.menu.context")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => setMode("shell")} shortcut="!">
|
||||
{language.t("prompt.menu.shellCommand")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
<Show when={showAgentControl()}>
|
||||
<ComposerAgentControl state={agentControlState()} />
|
||||
@@ -1683,7 +1789,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="max-w-[160px] justify-start capitalize"
|
||||
style={control()}
|
||||
>
|
||||
<span class="truncate">
|
||||
<span class="truncate leading-5">
|
||||
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
|
||||
</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
@@ -1759,6 +1865,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
@@ -1768,6 +1875,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
/>
|
||||
<div
|
||||
class="relative"
|
||||
@@ -1970,11 +2078,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => (
|
||||
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
|
||||
))
|
||||
})
|
||||
dialog.show(() => (
|
||||
<DialogSelectModelUnpaid model={props.controls.model.selection} />
|
||||
))
|
||||
}}
|
||||
>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
@@ -2141,30 +2247,47 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
<Show
|
||||
when={props.state.newLayoutDesigns}
|
||||
fallback={
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
<ButtonV2
|
||||
data-action="prompt-model"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
<ModelControlContent state={props.state} v2 />
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
}
|
||||
>
|
||||
@@ -2233,7 +2356,7 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="truncate leading-4">{props.state.modelName}</span>
|
||||
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
|
||||
import type { ContextItem } from "@/context/prompt"
|
||||
@@ -12,6 +14,7 @@ type ContextItemsProps = {
|
||||
active: (item: PromptContextItem) => boolean
|
||||
openComment: (item: PromptContextItem) => void
|
||||
remove: (item: PromptContextItem) => void
|
||||
newLayoutDesigns: boolean
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
@@ -27,10 +30,17 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
const selected = props.active(item)
|
||||
|
||||
return (
|
||||
<TooltipV2
|
||||
<Dynamic
|
||||
component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
|
||||
value={
|
||||
<span class="flex max-w-[300px]">
|
||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||
<span
|
||||
classList={{
|
||||
"truncate-start [unicode-bidi:plaintext] min-w-0": true,
|
||||
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||
"text-text-invert-base": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
{directory}
|
||||
</span>
|
||||
<span class="shrink-0">{filename}</span>
|
||||
@@ -78,7 +88,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
</Dynamic>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
@keyframes prompt-attachments-fade-left {
|
||||
from {
|
||||
visibility: hidden;
|
||||
}
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes prompt-attachments-fade-right {
|
||||
from {
|
||||
visibility: visible;
|
||||
}
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="prompt-attachments"] {
|
||||
timeline-scope: --prompt-attachments-scroll;
|
||||
|
||||
[data-slot^="prompt-attachments-fade-"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) {
|
||||
[data-slot="prompt-attachments-scroll"] {
|
||||
scroll-timeline: --prompt-attachments-scroll x;
|
||||
}
|
||||
|
||||
[data-slot="prompt-attachments-fade-left"] {
|
||||
animation: prompt-attachments-fade-left linear both;
|
||||
animation-timeline: --prompt-attachments-scroll;
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
|
||||
[data-slot="prompt-attachments-fade-right"] {
|
||||
animation: prompt-attachments-fade-right linear both;
|
||||
animation-timeline: --prompt-attachments-scroll;
|
||||
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,167 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import type { ImageAttachmentPart } from "@/context/prompt"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2"
|
||||
import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2"
|
||||
import { typeLabel } from "@opencode-ai/session-ui/message-file"
|
||||
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
|
||||
import "./image-attachments.css"
|
||||
|
||||
type PromptCommentItem = ContextItem & { key: string }
|
||||
|
||||
type PromptImageAttachmentsProps = {
|
||||
attachments: ImageAttachmentPart[]
|
||||
onOpen: (attachment: ImageAttachmentPart) => void
|
||||
onRemove: (id: string) => void
|
||||
removeLabel: string
|
||||
newLayoutDesigns: boolean
|
||||
comments?: PromptCommentItem[]
|
||||
commentActive?: (item: PromptCommentItem) => boolean
|
||||
onOpenComment?: (item: PromptCommentItem) => void
|
||||
onRemoveComment?: (item: PromptCommentItem) => void
|
||||
}
|
||||
|
||||
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
||||
const imageClass =
|
||||
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||
const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
// inset box-shadows do not paint over <img> content, so the hairline is a separate overlay
|
||||
const imageHairlineClassV2 =
|
||||
"absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none"
|
||||
const removeClass =
|
||||
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||
const removeClassV2 =
|
||||
"absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
||||
|
||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.attachments.length > 0}>
|
||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
||||
<div class="relative group">
|
||||
<Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
|
||||
<div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
|
||||
<div
|
||||
data-slot="prompt-attachments-scroll"
|
||||
classList={{
|
||||
"flex gap-2": true,
|
||||
"flex-nowrap overflow-x-auto no-scrollbar px-2 pt-2 pb-1": props.newLayoutDesigns,
|
||||
"flex-wrap px-3 pt-3": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
<Show when={props.newLayoutDesigns}>
|
||||
<For each={props.comments ?? []}>
|
||||
{(item) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2
|
||||
value={item.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<CommentCardV2
|
||||
comment={item.comment ?? ""}
|
||||
path={item.path}
|
||||
selection={item.selection}
|
||||
active={props.commentActive?.(item)}
|
||||
onClick={() => props.onOpenComment?.(item)}
|
||||
/>
|
||||
</TooltipV2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRemoveComment?.(item)}
|
||||
class={removeClassV2}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => {
|
||||
const image = attachment.mime.startsWith("image/")
|
||||
const media = () => (
|
||||
<Show
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
when={image}
|
||||
fallback={
|
||||
<div class={fallbackClass}>
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
<Show
|
||||
when={props.newLayoutDesigns}
|
||||
fallback={
|
||||
<div class={fallbackClass}>
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AttachmentCardV2 title={attachment.filename}>
|
||||
{typeLabel(attachment.filename, attachment.mime)}
|
||||
</AttachmentCardV2>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.filename}
|
||||
class={imageClass}
|
||||
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
|
||||
onClick={() => props.onOpen(attachment)}
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRemove(attachment.id)}
|
||||
class={removeClass}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="close" class="size-3 text-text-weak" />
|
||||
</button>
|
||||
)
|
||||
const name = () => (
|
||||
<div class={nameClass}>
|
||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
const remove = () => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRemove(attachment.id)}
|
||||
class={props.newLayoutDesigns ? removeClassV2 : removeClass}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Show when={props.newLayoutDesigns} fallback={<Icon name="close" class="size-3 text-text-weak" />}>
|
||||
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
// v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
|
||||
return (
|
||||
<Show
|
||||
when={props.newLayoutDesigns}
|
||||
fallback={
|
||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
||||
<div class="relative group">
|
||||
{media()}
|
||||
{name()}
|
||||
{remove()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
|
||||
{media()}
|
||||
<Show when={image}>
|
||||
<div class={imageHairlineClassV2} />
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
{remove()}
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={props.newLayoutDesigns}>
|
||||
<div
|
||||
data-slot="prompt-attachments-fade-left"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="prompt-attachments-fade-right"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -41,6 +41,10 @@ type PromptPopoverProps = {
|
||||
slashActive?: string
|
||||
setSlashActive: (id: string) => void
|
||||
onSlashSelect: (item: SlashCommand) => void
|
||||
slashMenu: boolean
|
||||
slashMenuQuery: string
|
||||
onSlashMenuInput: (value: string) => void
|
||||
onSlashMenuKeyDown: (event: KeyboardEvent) => void
|
||||
commandKeybind: (id: string) => string | undefined
|
||||
commandKeybindParts: (id: string) => string[]
|
||||
newLayoutDesigns: boolean
|
||||
@@ -254,6 +258,20 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.popover === "slash"}>
|
||||
<Show when={props.slashMenu}>
|
||||
<div class="px-2 py-1">
|
||||
<input
|
||||
ref={(el) => requestAnimationFrame(() => el.focus())}
|
||||
value={props.slashMenuQuery}
|
||||
onInput={(event) => props.onSlashMenuInput(event.currentTarget.value)}
|
||||
onKeyDown={props.onSlashMenuKeyDown}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
aria-label={props.t("prompt.menu.commands")}
|
||||
placeholder="/"
|
||||
class="w-full bg-transparent outline-none text-[13px] leading-5 text-v2-text-text-base placeholder:text-v2-text-text-faint"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.slashFlat.length > 0}
|
||||
fallback={
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
@@ -33,6 +34,10 @@ const prompt = {
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
dirty: () => true,
|
||||
model: {
|
||||
current: () => undefined,
|
||||
set: () => undefined,
|
||||
},
|
||||
reset: () => undefined,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
@@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("uses an injected model selection", async () => {
|
||||
params = { id: "session-1" }
|
||||
const model = {
|
||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
||||
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,
|
||||
|
||||
@@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import { batch, startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
@@ -191,6 +191,7 @@ type PromptSubmitInput = {
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
model?: ModelSelection
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
@@ -298,9 +299,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentModel = local.model.current()
|
||||
const modelSelection = input.model ?? local.model
|
||||
const currentModel = modelSelection.current()
|
||||
const currentAgent = local.agent.current()
|
||||
const variant = local.model.variant.current()
|
||||
const variant = modelSelection.variant.current()
|
||||
if (!currentModel || !currentAgent) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||
@@ -374,13 +376,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (created) {
|
||||
seed(sessionDirectory, created)
|
||||
session = created
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { PromptHistoryEntry } from "./history"
|
||||
|
||||
export type PromptInputTransientState = {
|
||||
popover: "at" | "slash" | null
|
||||
slashMenu: boolean
|
||||
slashMenuQuery: string
|
||||
historyIndex: number
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
placeholder: number
|
||||
@@ -16,6 +18,8 @@ export type PromptInputTransientState = {
|
||||
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||
setStore({
|
||||
popover: null,
|
||||
slashMenu: false,
|
||||
slashMenuQuery: "",
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
draggingType: null,
|
||||
@@ -28,6 +32,8 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
|
||||
export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
|
||||
const [store, setStore] = createStore<PromptInputTransientState>({
|
||||
popover: null,
|
||||
slashMenu: false,
|
||||
slashMenuQuery: "",
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
placeholder,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
@@ -96,10 +97,17 @@ export function PromptWorkspaceSelector(props: {
|
||||
{(branch) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<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]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
</div>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={branch()}
|
||||
class="min-w-0 max-w-[220px]"
|
||||
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]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -11,9 +12,10 @@ import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
|
||||
import { getSessionContext } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
@@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const settings = useSettings()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
|
||||
@@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
@@ -69,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
)
|
||||
|
||||
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(info()?.cost ?? 0)
|
||||
})
|
||||
@@ -127,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
|
||||
<ContextTooltipRow
|
||||
name={language.t("context.usage.tokens")}
|
||||
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
|
||||
value={context()?.total.toLocaleString(language.intl()) ?? "0"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { SessionHeader } from "./session-header"
|
||||
export { SessionContextTab } from "./session-context-tab"
|
||||
export { SortableTab, FileVisual } from "./session-sortable-tab"
|
||||
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
||||
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||
export { NewSessionView } from "./session-new-view"
|
||||
export { NewSessionDesignView } from "./session-new-design-view"
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
|
||||
|
||||
export function OpenInAppV2(props: { directory: () => string }) {
|
||||
const language = useLanguage()
|
||||
const state = useOpenInApp(props)
|
||||
|
||||
return (
|
||||
<Show when={props.directory() && state.canOpen()}>
|
||||
<SplitButtonV2 class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={language.t("session.header.open.ariaLabel", { app: state.current().label })}
|
||||
class="flex items-center"
|
||||
>
|
||||
<SplitButtonV2Action
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (state.opening()) return
|
||||
state.openDir(state.current().id)
|
||||
}}
|
||||
disabled={state.opening()}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
|
||||
>
|
||||
<Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
|
||||
<Spinner class="size-3.5" />
|
||||
</Show>
|
||||
</SplitButtonV2Action>
|
||||
</TooltipV2>
|
||||
<MenuV2
|
||||
gutter={4}
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
open={state.menu.open}
|
||||
onOpenChange={(open) => state.setMenu("open", open)}
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as={SplitButtonV2MenuTrigger}
|
||||
disabled={state.opening()}
|
||||
aria-label={language.t("session.header.open.menu")}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconV2 name="chevron-down" size="small" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="open-in-app-v2-menu">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.header.openIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.RadioGroup
|
||||
value={state.current().id}
|
||||
onChange={(value) => {
|
||||
state.selectApp(value as OpenApp)
|
||||
}}
|
||||
>
|
||||
<For each={state.options()}>
|
||||
{(option) => (
|
||||
<MenuV2.RadioItem
|
||||
value={option.id}
|
||||
disabled={state.opening()}
|
||||
onSelect={() => {
|
||||
state.selectApp(option.id)
|
||||
state.setMenu("open", false)
|
||||
state.openDir(option.id)
|
||||
}}
|
||||
>
|
||||
<AppIcon id={option.icon} />
|
||||
{option.label}
|
||||
</MenuV2.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.RadioGroup>
|
||||
</MenuV2.Group>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item
|
||||
onSelect={() => {
|
||||
state.setMenu("open", false)
|
||||
state.copyPath()
|
||||
}}
|
||||
>
|
||||
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</SplitButtonV2>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServer } from "@/context/server"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
export const OPEN_APPS = [
|
||||
"vscode",
|
||||
"cursor",
|
||||
"zed",
|
||||
"textmate",
|
||||
"antigravity",
|
||||
"finder",
|
||||
"terminal",
|
||||
"iterm2",
|
||||
"ghostty",
|
||||
"warp",
|
||||
"xcode",
|
||||
"android-studio",
|
||||
"powershell",
|
||||
"sublime-text",
|
||||
] as const
|
||||
|
||||
export type OpenApp = (typeof OPEN_APPS)[number]
|
||||
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
|
||||
|
||||
export const MAC_OPEN_APPS = [
|
||||
{
|
||||
id: "vscode",
|
||||
label: "session.header.open.app.vscode",
|
||||
icon: "vscode",
|
||||
openWith: "Visual Studio Code",
|
||||
},
|
||||
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
|
||||
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
|
||||
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
|
||||
{
|
||||
id: "antigravity",
|
||||
label: "session.header.open.app.antigravity",
|
||||
icon: "antigravity",
|
||||
openWith: "Antigravity",
|
||||
},
|
||||
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
|
||||
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
|
||||
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
|
||||
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
|
||||
{
|
||||
id: "android-studio",
|
||||
label: "session.header.open.app.androidStudio",
|
||||
icon: "android-studio",
|
||||
openWith: "Android Studio",
|
||||
},
|
||||
{
|
||||
id: "sublime-text",
|
||||
label: "session.header.open.app.sublimeText",
|
||||
icon: "sublime-text",
|
||||
openWith: "Sublime Text",
|
||||
},
|
||||
] as const
|
||||
|
||||
export const WINDOWS_OPEN_APPS = [
|
||||
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
||||
{
|
||||
id: "powershell",
|
||||
label: "session.header.open.app.powershell",
|
||||
icon: "powershell",
|
||||
openWith: "powershell",
|
||||
},
|
||||
{
|
||||
id: "sublime-text",
|
||||
label: "session.header.open.app.sublimeText",
|
||||
icon: "sublime-text",
|
||||
openWith: "Sublime Text",
|
||||
},
|
||||
] as const
|
||||
|
||||
export const LINUX_OPEN_APPS = [
|
||||
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
||||
{
|
||||
id: "sublime-text",
|
||||
label: "session.header.open.app.sublimeText",
|
||||
icon: "sublime-text",
|
||||
openWith: "Sublime Text",
|
||||
},
|
||||
] as const
|
||||
|
||||
export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenAppOS {
|
||||
if (platform.platform === "desktop" && platform.os) return platform.os
|
||||
if (typeof navigator !== "object") return "unknown"
|
||||
const value = navigator.platform || navigator.userAgent
|
||||
if (/Mac/i.test(value)) return "macos"
|
||||
if (/Win/i.test(value)) return "windows"
|
||||
if (/Linux/i.test(value)) return "linux"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export function openAppFileManager(os: OpenAppOS) {
|
||||
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
|
||||
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
|
||||
return { label: "session.header.open.fileManager", icon: "finder" as const }
|
||||
}
|
||||
|
||||
export function openAppsForOS(os: OpenAppOS) {
|
||||
if (os === "macos") return MAC_OPEN_APPS
|
||||
if (os === "windows") return WINDOWS_OPEN_APPS
|
||||
return LINUX_OPEN_APPS
|
||||
}
|
||||
|
||||
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
export function useOpenInApp(input: { directory: () => string }) {
|
||||
const platform = usePlatform()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
|
||||
const os = createMemo(() => detectOpenAppOS(platform))
|
||||
const apps = createMemo(() => openAppsForOS(os()))
|
||||
const fileManager = createMemo(() => openAppFileManager(os()))
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (platform.platform !== "desktop") return
|
||||
if (!platform.checkAppExists) return
|
||||
|
||||
const list = apps()
|
||||
|
||||
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
|
||||
|
||||
void Promise.all(
|
||||
list.map((app) =>
|
||||
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||
.then((value) => Boolean(value))
|
||||
.catch(() => false)
|
||||
.then((ok) => [app.id, ok] as const),
|
||||
),
|
||||
).then((entries) => {
|
||||
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||
})
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
return [
|
||||
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
|
||||
...apps()
|
||||
.filter((app) => exists[app.id])
|
||||
.map((app) => ({ ...app, label: language.t(app.label) })),
|
||||
] as const
|
||||
})
|
||||
|
||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
|
||||
const [menu, setMenu] = createStore({ open: false })
|
||||
const [openRequest, setOpenRequest] = createStore({
|
||||
app: undefined as OpenApp | undefined,
|
||||
})
|
||||
|
||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||
const current = createMemo(
|
||||
() =>
|
||||
options().find((o) => o.id === prefs.app) ??
|
||||
options()[0] ??
|
||||
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||
)
|
||||
const opening = createMemo(() => openRequest.app !== undefined)
|
||||
|
||||
const selectApp = (app: OpenApp | "finder") => {
|
||||
if (!options().some((item) => item.id === app)) return
|
||||
setPrefs("app", app)
|
||||
}
|
||||
|
||||
const openDir = (app: OpenApp | "finder") => {
|
||||
if (opening() || !canOpen() || !platform.openPath) return
|
||||
const directory = input.directory()
|
||||
if (!directory) return
|
||||
|
||||
const item = options().find((o) => o.id === app)
|
||||
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||
setOpenRequest("app", app)
|
||||
platform
|
||||
.openPath(directory, openWith)
|
||||
.catch((err: unknown) => showRequestError(language, err))
|
||||
.finally(() => {
|
||||
setOpenRequest("app", undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const copyPath = () => {
|
||||
const directory = input.directory()
|
||||
if (!directory) return
|
||||
navigator.clipboard
|
||||
.writeText(directory)
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("session.share.copy.copied"),
|
||||
description: directory,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => showRequestError(language, err))
|
||||
}
|
||||
|
||||
return {
|
||||
canOpen,
|
||||
opening,
|
||||
current,
|
||||
options,
|
||||
menu,
|
||||
setMenu,
|
||||
openDir,
|
||||
selectApp,
|
||||
copyPath,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
|
||||
const assistant = (
|
||||
id: string,
|
||||
@@ -38,10 +38,10 @@ const user = (id: string) => {
|
||||
}
|
||||
|
||||
describe("getSessionContext", () => {
|
||||
test("computes usage from latest assistant with tokens", () => {
|
||||
test("computes token totals and usage from latest assistant with tokens", () => {
|
||||
const messages = [
|
||||
user("u1"),
|
||||
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
||||
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
|
||||
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
|
||||
]
|
||||
const providers = [
|
||||
@@ -60,6 +60,8 @@ describe("getSessionContext", () => {
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(ctx?.message.id).toBe("a2")
|
||||
expect(ctx?.total).toBe(500)
|
||||
expect(ctx?.input).toBe(300)
|
||||
expect(ctx?.usage).toBe(50)
|
||||
expect(ctx?.providerLabel).toBe("OpenAI")
|
||||
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
||||
@@ -94,15 +96,4 @@ describe("getSessionContext", () => {
|
||||
|
||||
expect(ctx).toBeUndefined()
|
||||
})
|
||||
|
||||
test("computes stored session token totals", () => {
|
||||
expect(
|
||||
getSessionTokenTotal({
|
||||
input: 10,
|
||||
output: 20,
|
||||
reasoning: 30,
|
||||
cache: { read: 40, write: 50 },
|
||||
}),
|
||||
).toBe(150)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type Provider = {
|
||||
id: string
|
||||
@@ -21,6 +21,7 @@ type Context = {
|
||||
modelLabel: string
|
||||
limit: number | undefined
|
||||
input: number
|
||||
total: number
|
||||
usage: number | null
|
||||
}
|
||||
|
||||
@@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
input: message.tokens.input,
|
||||
total,
|
||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||
}
|
||||
}
|
||||
@@ -61,8 +63,3 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
|
||||
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
||||
return build(messages, providers)
|
||||
}
|
||||
|
||||
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
|
||||
if (!tokens) return undefined
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
|
||||
@@ -135,7 +135,6 @@ export function SessionContextTab() {
|
||||
)
|
||||
|
||||
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
@@ -204,14 +203,15 @@ export function SessionContextTab() {
|
||||
{ label: "context.stats.provider", value: providerLabel },
|
||||
{ label: "context.stats.model", value: modelLabel },
|
||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
|
||||
{
|
||||
label: "context.stats.cacheTokens",
|
||||
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
|
||||
value: () =>
|
||||
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
|
||||
},
|
||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||
|
||||
@@ -24,6 +24,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { fileManagerApp } from "@/utils/file-manager"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
@@ -175,11 +176,7 @@ export function SessionHeader() {
|
||||
return LINUX_APPS
|
||||
})
|
||||
|
||||
const fileManager = createMemo(() => {
|
||||
if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
|
||||
if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
|
||||
return { label: "session.header.open.fileManager", icon: "finder" as const }
|
||||
})
|
||||
const fileManager = createMemo(() => fileManagerApp(os()))
|
||||
|
||||
createEffect(() => {
|
||||
if (platform.platform !== "desktop") return
|
||||
@@ -229,7 +226,7 @@ export function SessionHeader() {
|
||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||
const current = createMemo(
|
||||
() =>
|
||||
options().find((o) => o.id === prefs().app) ??
|
||||
options().find((o) => o.id === prefs.app) ??
|
||||
options()[0] ??
|
||||
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { FileVisual } from "./session-sortable-tab"
|
||||
|
||||
export function SortableTabV2(props: {
|
||||
tab: string
|
||||
index: () => number
|
||||
temporary?: boolean
|
||||
onTabClose: (tab: string) => void
|
||||
onTabDoubleClick?: (tab: string) => void
|
||||
}): JSX.Element {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.tab
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
const content = createMemo(() => {
|
||||
const value = path()
|
||||
if (!value) return
|
||||
return <FileVisual path={value} temporary={props.temporary} />
|
||||
})
|
||||
return (
|
||||
<div ref={sortable.ref} class="h-full flex items-center">
|
||||
<div class="relative">
|
||||
<Tabs.Trigger
|
||||
value={props.tab}
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => props.onTabClose(props.tab)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
|
||||
>
|
||||
<Show when={content()}>{(value) => value()}</Show>
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useCommand } from "@/context/command"
|
||||
|
||||
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
||||
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5 min-w-0">
|
||||
<Show
|
||||
@@ -22,12 +22,19 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
|
||||
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
|
||||
<span class="text-14-medium truncate" classList={{ italic: props.temporary }}>
|
||||
{getFilename(props.path)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
|
||||
export function SortableTab(props: {
|
||||
tab: string
|
||||
temporary?: boolean
|
||||
onTabClose: (tab: string) => void
|
||||
onTabDoubleClick?: (tab: string) => void
|
||||
}): JSX.Element {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
@@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
const content = createMemo(() => {
|
||||
const value = path()
|
||||
if (!value) return
|
||||
return <FileVisual path={value} />
|
||||
return <FileVisual path={value} temporary={props.temporary} />
|
||||
})
|
||||
return (
|
||||
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||
@@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
|
||||
>
|
||||
<Show when={content()}>{(value) => value()}</Show>
|
||||
</Tabs.Trigger>
|
||||
|
||||
@@ -211,7 +211,17 @@ export function SortableTerminalTabV2(props: {
|
||||
<MenuV2.Context.Trigger class="relative" as="div">
|
||||
<Tabs.Trigger
|
||||
value={props.terminal.id}
|
||||
onClick={focus}
|
||||
onMouseDown={(e) => {
|
||||
// Switch on mousedown to shave the press-release delay off tab switches.
|
||||
if (e.button !== 0) return
|
||||
if (store.editing) return
|
||||
focus()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (e.detail > 0) return
|
||||
focus()
|
||||
}}
|
||||
closeButton={
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
export function useSettingsDialog() {
|
||||
export function useSettingsDialog(defaultValue?: string) {
|
||||
const dialog = useDialog()
|
||||
const params = useParams<{ id?: string }>()
|
||||
let run = 0
|
||||
@@ -19,7 +19,7 @@ export function useSettingsDialog() {
|
||||
const sessionID = params.id
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
if (dead || run !== current) return
|
||||
void dialog.show(() => <module.DialogSettings sessionID={sessionID} />)
|
||||
void dialog.show(() => <module.DialogSettings sessionID={sessionID} defaultValue={defaultValue} />)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useParams } from "@solidjs/router"
|
||||
@@ -248,6 +249,50 @@ export const SettingsGeneral: Component = () => {
|
||||
triggerVariant: "settings" as const,
|
||||
})
|
||||
|
||||
const InterfaceSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={
|
||||
<span class="flex items-center gap-2">
|
||||
{language.t("settings.general.row.newInterface.title")}
|
||||
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
|
||||
</span>
|
||||
}
|
||||
description={language.t("settings.general.row.newInterface.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (!checked) return
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
void dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const InterfaceNoticeSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||
>
|
||||
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
|
||||
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
@@ -335,23 +380,6 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (!checked) return
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
@@ -718,6 +746,14 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
<InterfaceNoticeSection />
|
||||
</Show>
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
|
||||
@@ -28,6 +28,7 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
import "./settings-v2.css"
|
||||
|
||||
let demoSoundState = {
|
||||
@@ -226,6 +227,31 @@ export const SettingsGeneralV2: Component<{
|
||||
},
|
||||
})
|
||||
|
||||
const InterfaceSection = () => (
|
||||
<LayoutTransitionToggle
|
||||
title={language.t("settings.general.row.newInterface.title")}
|
||||
badge={language.t("settings.general.row.newInterface.badge")}
|
||||
description={language.t("settings.general.row.newInterface.description")}
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (checked) return
|
||||
void import("@/components/dialog-settings").then((module) => {
|
||||
void dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const InterfaceNoticeSection = () => (
|
||||
<LayoutRetirementNotice
|
||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||
onDismiss={settings.general.dismissNewInterfaceNotice}
|
||||
/>
|
||||
)
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
@@ -312,24 +338,6 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (checked) return
|
||||
void import("@/components/dialog-settings").then((module) => {
|
||||
dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
@@ -683,6 +691,14 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
<InterfaceNoticeSection />
|
||||
</Show>
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @ts-nocheck
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
|
||||
const copy = {
|
||||
title: "New layout",
|
||||
badge: "New",
|
||||
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||
noticeTitle: "You're now using new layout",
|
||||
noticeDescription: "The previous layout is no longer available",
|
||||
dismiss: "Dismiss",
|
||||
}
|
||||
|
||||
function Frame(props) {
|
||||
return <div class="w-[640px] max-w-full">{props.children}</div>
|
||||
}
|
||||
|
||||
function ToggleExample(props) {
|
||||
const [state, setState] = createStore({ checked: props.checked })
|
||||
return (
|
||||
<Frame>
|
||||
<LayoutTransitionToggle
|
||||
title={copy.title}
|
||||
badge={copy.badge}
|
||||
description={copy.description}
|
||||
checked={state.checked}
|
||||
onChange={(checked) => setState("checked", checked)}
|
||||
/>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function NoticeExample() {
|
||||
const [state, setState] = createStore({ dismissed: false })
|
||||
return (
|
||||
<Frame>
|
||||
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
|
||||
<LayoutRetirementNotice
|
||||
title={copy.noticeTitle}
|
||||
description={copy.noticeDescription}
|
||||
dismiss={copy.dismiss}
|
||||
onDismiss={() => setState("dismissed", true)}
|
||||
/>
|
||||
</Show>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Settings/Layout transition",
|
||||
id: "app-settings-layout-transition",
|
||||
component: LayoutTransitionToggle,
|
||||
}
|
||||
|
||||
export const NewLayoutEnabled = {
|
||||
render: () => <ToggleExample checked />,
|
||||
}
|
||||
|
||||
export const PreviousLayoutEnabled = {
|
||||
render: () => <ToggleExample checked={false} />,
|
||||
}
|
||||
|
||||
export const PreviousLayoutRetired = {
|
||||
render: () => <NoticeExample />,
|
||||
}
|
||||
|
||||
export const AllStates = {
|
||||
render: () => (
|
||||
<div class="flex flex-col gap-8">
|
||||
<ToggleExample checked />
|
||||
<ToggleExample checked={false} />
|
||||
<NoticeExample />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
|
||||
export function LayoutTransitionToggle(props: {
|
||||
title: string
|
||||
badge: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div class="settings-v2-section">
|
||||
<div class="settings-v2-interface-feature">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={
|
||||
<span class="flex items-center gap-2">
|
||||
{props.title}
|
||||
<Tag variant="accent">{props.badge}</Tag>
|
||||
</span>
|
||||
}
|
||||
description={props.description}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch checked={props.checked} onChange={props.onChange} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutRetirementNotice(props: {
|
||||
title: string
|
||||
description: string
|
||||
dismiss: string
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
|
||||
{props.dismiss}
|
||||
</ButtonV2>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,11 @@
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.settings-v2-panel::-webkit-scrollbar {
|
||||
@@ -85,6 +90,11 @@
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
}
|
||||
|
||||
.settings-v2-interface-feature [data-component="settings-v2-list"] {
|
||||
background-color: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
}
|
||||
|
||||
[data-component="settings-v2-row"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -123,7 +133,7 @@
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-description"] {
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
@@ -181,6 +191,7 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 0 4px 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-v2-nav-footer > span {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
||||
import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve"
|
||||
import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||
@@ -68,6 +69,19 @@ const debugTerminal = (...values: unknown[]) => {
|
||||
console.debug("[terminal]", ...values)
|
||||
}
|
||||
|
||||
const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => {
|
||||
let current = tokens[key]
|
||||
for (let i = 0; i < 8 && current; i++) {
|
||||
const match = /^var\(--([^)]+)\)$/.exec(current.trim())
|
||||
if (!match) {
|
||||
const hex = current.trim()
|
||||
if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7)
|
||||
return hex
|
||||
}
|
||||
current = tokens[match[1]]
|
||||
}
|
||||
}
|
||||
|
||||
const useTerminalUiBindings = (input: {
|
||||
container: HTMLDivElement
|
||||
term: Term
|
||||
@@ -238,7 +252,10 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (!variant?.seeds && !variant?.palette) return fallback
|
||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||
const text = resolved["text-stronger"] ?? fallback.foreground
|
||||
const background = resolved["background-stronger"] ?? fallback.background
|
||||
const background = settings.general.newLayoutDesigns()
|
||||
? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ??
|
||||
fallback.background)
|
||||
: (resolved["background-stronger"] ?? fallback.background)
|
||||
const alpha = mode === "dark" ? 0.25 : 0.2
|
||||
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
|
||||
const selectionBackground = withAlpha(base, alpha)
|
||||
|
||||
@@ -40,7 +40,7 @@ function SessionTabSlot(props: {
|
||||
let ref!: HTMLDivElement
|
||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||
const persisted = createMemo(() => tabs.info()[props.id])
|
||||
const persisted = createMemo(() => tabs.info[props.id])
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
const ctx = props.serverCtx()
|
||||
|
||||
@@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabKey, useTabs } from "@/context/tabs"
|
||||
import type { PromptSession } from "@/context/prompt"
|
||||
import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||
|
||||
@@ -275,10 +276,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const matchRoute = (route: LayoutRoute) => {
|
||||
if (route.type === "home") return
|
||||
if (route.type === "draft") {
|
||||
return tabsStore().find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
}
|
||||
if (route.type === "session") {
|
||||
const main = tabsStore().find(
|
||||
const main = tabsStore.find(
|
||||
(item) =>
|
||||
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
||||
)
|
||||
@@ -286,7 +287,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const s = session()
|
||||
if (s?.parentID) {
|
||||
const parentID = s.parentID
|
||||
const parent = tabsStore().find(
|
||||
const parent = tabsStore.find(
|
||||
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
|
||||
)
|
||||
if (parent) return parent
|
||||
@@ -324,13 +325,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const route = layout.route()
|
||||
const activeSession = session()
|
||||
if (route.type === "session" && activeSession) {
|
||||
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
|
||||
const sessionTab = {
|
||||
type: "session" as const,
|
||||
server: route.server ?? server.key,
|
||||
sessionId: activeSession.id,
|
||||
}
|
||||
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
|
||||
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
|
||||
return
|
||||
}
|
||||
|
||||
const activeTab = currentTab()
|
||||
if (activeTab?.type === "draft") {
|
||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
|
||||
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
|
||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -395,7 +403,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
keybind: "mod+w",
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
tabsStoreActions.closeTab(tabsStore().findIndex((tab) => current === tab))
|
||||
tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -412,13 +420,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore().findIndex((tab) => tab === currentTab())
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
if (index === -1) return
|
||||
|
||||
index -= 1
|
||||
if (index === -1) index = tabsStore().length - 1
|
||||
if (index === -1) index = tabsStore.length - 1
|
||||
|
||||
const next = tabsStore()[index]
|
||||
const next = tabsStore[index]
|
||||
if (next) tabs.select(next)
|
||||
},
|
||||
},
|
||||
@@ -429,13 +437,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore().findIndex((tab) => tab === currentTab())
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
if (index === -1) return
|
||||
|
||||
index += 1
|
||||
if (index === tabsStore().length) index = 0
|
||||
if (index === tabsStore.length) index = 0
|
||||
|
||||
const next = tabsStore()[index]
|
||||
const next = tabsStore[index]
|
||||
if (next) tabs.select(next)
|
||||
},
|
||||
},
|
||||
@@ -482,7 +490,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
</TooltipV2>
|
||||
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore()}
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
@@ -491,7 +499,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
el?.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore().findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
if (index !== -1) tabsStoreActions.closeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Taken from https://www.solid-ui.com/docs/components/drawer
|
||||
* Only used in one place hence not a v2 component yet... can be promoted to ui/v2 later
|
||||
*/
|
||||
|
||||
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ContentProps, DescriptionProps, DynamicProps, LabelProps, OverlayProps } from "@corvu/drawer"
|
||||
import DrawerPrimitive from "@corvu/drawer"
|
||||
|
||||
const Drawer = DrawerPrimitive
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
type DrawerOverlayProps<T extends ValidComponent = "div"> = OverlayProps<T> & { class?: string }
|
||||
|
||||
const DrawerOverlay = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerOverlayProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerOverlayProps, ["class"])
|
||||
const drawerContext = DrawerPrimitive.useContext()
|
||||
const overlayStyle = () => {
|
||||
const state = drawerContext.transitionState()
|
||||
if (state === "opening" || state === "closing") return undefined
|
||||
const open = drawerContext.openPercentage()
|
||||
return {
|
||||
opacity: open,
|
||||
"backdrop-filter": `blur(${4 * open}px)`,
|
||||
}
|
||||
}
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
class={props.class}
|
||||
classList={{
|
||||
"fixed inset-0 z-[100] bg-v2-overlay-simple-overlay-scrim opacity-0 backdrop-blur-none transition-[opacity,backdrop-filter] duration-300 data-[opening]:opacity-100 data-[opening]:backdrop-blur-[4px] data-[closing]:opacity-0 data-[closing]:backdrop-blur-none": true,
|
||||
}}
|
||||
style={overlayStyle()}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DrawerContentProps<T extends ValidComponent = "div"> = ContentProps<T> & {
|
||||
class?: string
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerContentProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerContentProps, ["class", "children"])
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
class={props.class}
|
||||
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,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{props.children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
const DrawerHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return <div class={props.class} classList={{ "grid gap-1.5 p-4 text-center sm:text-left": true }} {...rest} />
|
||||
}
|
||||
|
||||
const DrawerFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return <div class={props.class} classList={{ "mt-auto flex flex-col gap-2 p-4": true }} {...rest} />
|
||||
}
|
||||
|
||||
type DrawerTitleProps<T extends ValidComponent = "div"> = LabelProps<T> & { class?: string }
|
||||
|
||||
const DrawerTitle = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerTitleProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerTitleProps, ["class"])
|
||||
return (
|
||||
<DrawerPrimitive.Label
|
||||
class={props.class}
|
||||
classList={{ "text-base font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base": true }}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DrawerDescriptionProps<T extends ValidComponent = "div"> = DescriptionProps<T> & {
|
||||
class?: string
|
||||
}
|
||||
|
||||
const DrawerDescription = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerDescriptionProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerDescriptionProps, ["class"])
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
class={props.class}
|
||||
classList={{
|
||||
"text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-v2-text-text-muted": true,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { upsertCommandRegistration } from "./command"
|
||||
import { resolveKeybindOption, upsertCommandRegistration } from "./command"
|
||||
|
||||
describe("upsertCommandRegistration", () => {
|
||||
test("replaces keyed registrations", () => {
|
||||
@@ -23,3 +23,19 @@ describe("upsertCommandRegistration", () => {
|
||||
expect(next[1]?.options).toBe(one)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveKeybindOption", () => {
|
||||
test("prefers a matching contextual command over the global fallback", () => {
|
||||
const fallback = { id: "tab.close", title: "Close tab" }
|
||||
const contextual = { id: "terminal.close", title: "Close terminal", when: () => true }
|
||||
|
||||
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(contextual)
|
||||
})
|
||||
|
||||
test("uses the global fallback outside the command context", () => {
|
||||
const fallback = { id: "tab.close", title: "Close tab" }
|
||||
const contextual = { id: "terminal.close", title: "Close terminal", when: () => false }
|
||||
|
||||
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,10 +82,15 @@ export interface CommandOption {
|
||||
suggested?: boolean
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
when?: (event: KeyboardEvent) => boolean
|
||||
onSelect?: (source?: "palette" | "keybind" | "slash") => void
|
||||
onHighlight?: () => (() => void) | void
|
||||
}
|
||||
|
||||
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
|
||||
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
|
||||
}
|
||||
|
||||
type CommandSource = "palette" | "keybind" | "slash"
|
||||
|
||||
export type CommandCatalogItem = {
|
||||
@@ -253,8 +258,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const warnedDuplicates = new Set<string>()
|
||||
|
||||
type CommandCatalog = Record<string, CommandCatalogItem>
|
||||
const [catalog, setCatalog, catalogReady] = persisted(
|
||||
Persist.global("command.catalog().v1"),
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
createStore<CommandCatalog>({}),
|
||||
)
|
||||
|
||||
@@ -334,7 +339,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
})
|
||||
|
||||
const keymap = createMemo(() => {
|
||||
const map = new Map<string, CommandOption>()
|
||||
const map = new Map<string, CommandOption[]>()
|
||||
for (const option of options()) {
|
||||
if (option.id.startsWith(SUGGESTED_PREFIX)) continue
|
||||
if (option.disabled) continue
|
||||
@@ -344,8 +349,12 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
for (const kb of keybinds) {
|
||||
if (!kb.key) continue
|
||||
const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
|
||||
if (map.has(sig)) continue
|
||||
map.set(sig, option)
|
||||
const existing = map.get(sig)
|
||||
if (existing) {
|
||||
existing.push(option)
|
||||
continue
|
||||
}
|
||||
map.set(sig, [option])
|
||||
}
|
||||
}
|
||||
return map
|
||||
@@ -374,7 +383,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
|
||||
const sig = signatureFromEvent(event)
|
||||
const isPalette = palette().has(sig)
|
||||
const option = keymap().get(sig)
|
||||
const option = resolveKeybindOption(keymap().get(sig), event)
|
||||
const modified = event.ctrlKey || event.metaKey || event.altKey
|
||||
const isTab = event.key === "Tab"
|
||||
|
||||
@@ -383,17 +392,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
|
||||
if (isPalette) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
showPalette()
|
||||
return
|
||||
}
|
||||
|
||||
if (!option) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
option.onSelect?.("keybind")
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(document, "keydown", handleKeyDown)
|
||||
makeEventListener(document, "keydown", handleKeyDown, { capture: true })
|
||||
})
|
||||
|
||||
function register(cb: () => CommandOption[]): void
|
||||
@@ -416,7 +427,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const keybindConfig = (id: string) => {
|
||||
if (id === PALETTE_ID) return settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
|
||||
const base = actionId(id)
|
||||
return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog()[base]?.keybind)
|
||||
return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog[base]?.keybind)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -181,13 +181,13 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
|
||||
function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
|
||||
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
|
||||
|
||||
const [store, setStore, ready] = persisted(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverScoped(scope, dir, id, "comments", [legacy]),
|
||||
createStore<CommentStore>({
|
||||
comments: {},
|
||||
}),
|
||||
)
|
||||
const session = createCommentSessionState(store(), setStore)
|
||||
const session = createCommentSessionState(store, setStore)
|
||||
|
||||
return {
|
||||
ready,
|
||||
|
||||
@@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
return promise
|
||||
}
|
||||
|
||||
const search = (query: string, dirs: "true" | "false") =>
|
||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||
sdk()
|
||||
.client.find.files({ query, dirs })
|
||||
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
|
||||
.then(
|
||||
(x) => (x.data ?? []).map(path.normalize),
|
||||
() => [],
|
||||
(error) => {
|
||||
if (options?.signal?.aborted) throw error
|
||||
return []
|
||||
},
|
||||
)
|
||||
|
||||
const stop = sdk().event.listen((e) => {
|
||||
@@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
setScrollLeft,
|
||||
selectedLines,
|
||||
setSelectedLines,
|
||||
searchFiles: (query: string) => search(query, "false"),
|
||||
searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) =>
|
||||
search(query, "false", options),
|
||||
searchFilesAndDirectories: (query: string) => search(query, "true"),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
|
||||
function createViewSession(scope: ServerScope, dir: string, id: string | undefined) {
|
||||
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
||||
|
||||
const [view, setView, ready] = persisted(
|
||||
const [view, setView, _, ready] = persisted(
|
||||
Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]),
|
||||
createStore<{
|
||||
file: Record<string, FileViewState>
|
||||
@@ -49,7 +49,7 @@ function createViewSession(scope: ServerScope, dir: string, id: string | undefin
|
||||
const meta = { pruned: false }
|
||||
|
||||
const pruneView = (keep?: string) => {
|
||||
const keys = Object.keys(view().file)
|
||||
const keys = Object.keys(view.file)
|
||||
if (keys.length <= MAX_VIEW_FILES) return
|
||||
|
||||
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
|
||||
@@ -71,9 +71,9 @@ function createViewSession(scope: ServerScope, dir: string, id: string | undefin
|
||||
pruneView()
|
||||
})
|
||||
|
||||
const scrollTop = (path: string) => view().file[path]?.scrollTop
|
||||
const scrollLeft = (path: string) => view().file[path]?.scrollLeft
|
||||
const selectedLines = (path: string) => view().file[path]?.selectedLines
|
||||
const scrollTop = (path: string) => view.file[path]?.scrollTop
|
||||
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
|
||||
const selectedLines = (path: string) => view.file[path]?.selectedLines
|
||||
|
||||
const setScrollTop = (path: string, top: number) => {
|
||||
setView(
|
||||
|
||||
@@ -9,8 +9,9 @@ import { ServerScope } from "@/utils/server-scope"
|
||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
||||
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
||||
() => store[0],
|
||||
store[0],
|
||||
store[1],
|
||||
null,
|
||||
Object.assign(() => true, { promise: undefined }),
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -157,7 +157,7 @@ export function createChildStoreManager(input: {
|
||||
)
|
||||
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
|
||||
const vcsStore = vcs[0]
|
||||
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[2] })
|
||||
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
|
||||
|
||||
const meta = runWithOwner(input.owner, () =>
|
||||
input.persist(
|
||||
@@ -166,7 +166,7 @@ export function createChildStoreManager(input: {
|
||||
),
|
||||
)
|
||||
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
|
||||
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[2] })
|
||||
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
|
||||
|
||||
const icon = runWithOwner(input.owner, () =>
|
||||
input.persist(
|
||||
@@ -175,12 +175,12 @@ export function createChildStoreManager(input: {
|
||||
),
|
||||
)
|
||||
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
|
||||
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[2] })
|
||||
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] })
|
||||
|
||||
const init = () =>
|
||||
createRoot((dispose) => {
|
||||
const initialMeta = meta[0]().value
|
||||
const initialIcon = icon[0]().value
|
||||
const initialMeta = meta[0].value
|
||||
const initialIcon = icon[0].value
|
||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||
|
||||
const pathQuery = useQuery(() => input.queryOptions.path(key))
|
||||
@@ -241,7 +241,7 @@ export function createChildStoreManager(input: {
|
||||
get lsp() {
|
||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||
},
|
||||
vcs: vcsStore().value,
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
@@ -251,28 +251,28 @@ export function createChildStoreManager(input: {
|
||||
disposers.set(key, dispose)
|
||||
mcpToggles.set(key, setMcpEnabled)
|
||||
|
||||
const onPersistedInit = (ready: Accessor<boolean> & { promise: undefined | Promise<any> }, run: () => void) => {
|
||||
if (!ready.promise) return
|
||||
void ready.promise.then(() => {
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
if (!(init instanceof Promise)) return
|
||||
void init.then(() => {
|
||||
if (children[key] !== child) return
|
||||
run()
|
||||
})
|
||||
}
|
||||
|
||||
onPersistedInit(vcs[2], () => {
|
||||
const cached = vcsStore().value
|
||||
const cached = vcsStore.value
|
||||
if (!cached?.branch) return
|
||||
child[1]("vcs", (value) => value ?? cached)
|
||||
})
|
||||
|
||||
onPersistedInit(meta[2], () => {
|
||||
if (child[0].projectMeta !== initialMeta) return
|
||||
child[1]("projectMeta", meta[0]().value)
|
||||
child[1]("projectMeta", meta[0].value)
|
||||
})
|
||||
|
||||
onPersistedInit(icon[2], () => {
|
||||
if (child[0].icon !== initialIcon) return
|
||||
child[1]("icon", icon[0]().value)
|
||||
child[1]("icon", icon[0].value)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -549,7 +549,7 @@ describe("applyDirectoryEvent", () => {
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
vcsCache: {
|
||||
store: () => cacheStore,
|
||||
store: cacheStore,
|
||||
setStore: setCacheStore,
|
||||
ready: () => true,
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction } from "solid-js/store"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
export type ProjectMeta = {
|
||||
name?: string
|
||||
@@ -85,19 +85,19 @@ export type State = {
|
||||
}
|
||||
|
||||
export type VcsCache = {
|
||||
store: Accessor<{ value: VcsInfo | undefined }>
|
||||
store: Store<{ value: VcsInfo | undefined }>
|
||||
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
export type MetaCache = {
|
||||
store: Accessor<{ value: ProjectMeta | undefined }>
|
||||
store: Store<{ value: ProjectMeta | undefined }>
|
||||
setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
export type IconCache = {
|
||||
store: Accessor<{ value: string | undefined }>
|
||||
store: Store<{ value: string | undefined }>
|
||||
setStore: SetStoreFunction<{ value: string | undefined }>
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const [store, setStore, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
||||
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
||||
|
||||
const [range, setRange] = createStore({
|
||||
from: undefined as string | undefined,
|
||||
@@ -208,7 +208,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
if (!platform.version) return
|
||||
state.started = true
|
||||
|
||||
const previous = store().version
|
||||
const previous = store.version
|
||||
if (!previous) {
|
||||
setStore("version", platform.version)
|
||||
return
|
||||
@@ -225,7 +225,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
from: () => range.from,
|
||||
to: () => range.to,
|
||||
get last() {
|
||||
return store().version
|
||||
return store.version
|
||||
},
|
||||
markSeen,
|
||||
}
|
||||
|
||||
@@ -201,14 +201,14 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
gate: false,
|
||||
init: (props: { locale?: Locale }) => {
|
||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||
const [store, setStore, ready] = persisted(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("language", ["language.v1"]),
|
||||
createStore({
|
||||
locale: initial,
|
||||
}),
|
||||
)
|
||||
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store().locale))
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||
const intl = createMemo(() => INTL[locale()])
|
||||
|
||||
const [dict] = createResource(locale, loadDict, {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
closeSessionTab,
|
||||
openSessionTab,
|
||||
previewSessionTab,
|
||||
type SessionTabState,
|
||||
} from "./layout-tabs"
|
||||
|
||||
const state = (all: string[], active?: string, preview?: string): SessionTabState => ({
|
||||
tabs: { all, active },
|
||||
preview,
|
||||
})
|
||||
|
||||
describe("previewSessionTab", () => {
|
||||
test("appends the Open File placeholder", () => {
|
||||
expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual(
|
||||
state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
|
||||
)
|
||||
})
|
||||
|
||||
test("replaces the current preview in place", () => {
|
||||
expect(
|
||||
previewSessionTab(
|
||||
state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
|
||||
"file://a.ts",
|
||||
),
|
||||
).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts"))
|
||||
})
|
||||
|
||||
test("activates a durable tab without duplicating it", () => {
|
||||
expect(
|
||||
previewSessionTab(
|
||||
state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
|
||||
"file://b.ts",
|
||||
),
|
||||
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts"))
|
||||
})
|
||||
|
||||
test("replaces a restored Open File placeholder", () => {
|
||||
expect(
|
||||
previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"),
|
||||
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("openSessionTab", () => {
|
||||
test("pins the current preview", () => {
|
||||
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual(
|
||||
state(["file://a.ts"], "file://a.ts"),
|
||||
)
|
||||
})
|
||||
|
||||
test("replaces a preview with a directly opened file", () => {
|
||||
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual(
|
||||
state(["file://b.ts"], "file://b.ts"),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps the preview when switching to Review", () => {
|
||||
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual(
|
||||
state(["file://a.ts"], "review", "file://a.ts"),
|
||||
)
|
||||
})
|
||||
|
||||
test("replaces a restored Open File placeholder with a direct open", () => {
|
||||
expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual(
|
||||
state(["file://a.ts", "file://b.ts"], "file://b.ts"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("closeSessionTab", () => {
|
||||
test("clears preview metadata and selects the left neighbor", () => {
|
||||
expect(
|
||||
closeSessionTab(
|
||||
state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"),
|
||||
"file://b.ts",
|
||||
),
|
||||
).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts"))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
export const SESSION_OPEN_FILE_TAB = "open-file"
|
||||
|
||||
export type SessionTabs = {
|
||||
active?: string
|
||||
all: string[]
|
||||
}
|
||||
|
||||
export type SessionTabState = {
|
||||
tabs: SessionTabs
|
||||
preview?: string
|
||||
}
|
||||
|
||||
const sessionTabPreview = (current: SessionTabState) =>
|
||||
current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined)
|
||||
|
||||
export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState {
|
||||
const preview = sessionTabPreview(current)
|
||||
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
|
||||
const existingIndex = current.tabs.all.indexOf(tab)
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
if (previewIndex === -1 || preview === tab) {
|
||||
return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined }
|
||||
}
|
||||
return {
|
||||
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
|
||||
}
|
||||
}
|
||||
|
||||
if (previewIndex === -1) {
|
||||
return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab }
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: {
|
||||
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
|
||||
active: tab,
|
||||
},
|
||||
preview: tab,
|
||||
}
|
||||
}
|
||||
|
||||
export function openSessionTab(current: SessionTabState, tab: string): SessionTabState {
|
||||
const preview = sessionTabPreview(current)
|
||||
if (tab === "review") {
|
||||
return {
|
||||
tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab },
|
||||
preview,
|
||||
}
|
||||
}
|
||||
|
||||
if (tab === "context") {
|
||||
return {
|
||||
tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab },
|
||||
preview,
|
||||
}
|
||||
}
|
||||
|
||||
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
|
||||
const existingIndex = current.tabs.all.indexOf(tab)
|
||||
if (existingIndex !== -1) {
|
||||
if (previewIndex === -1 || preview === tab) {
|
||||
return { tabs: { all: current.tabs.all, active: tab } }
|
||||
}
|
||||
return {
|
||||
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
|
||||
}
|
||||
}
|
||||
|
||||
if (previewIndex === -1) {
|
||||
return { tabs: { all: [...current.tabs.all, tab], active: tab } }
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: {
|
||||
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
|
||||
active: tab,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState {
|
||||
if (tab === "review") {
|
||||
if (current.tabs.active !== tab) return current
|
||||
return {
|
||||
tabs: { all: current.tabs.all, active: current.tabs.all[0] },
|
||||
preview: current.preview,
|
||||
}
|
||||
}
|
||||
|
||||
const all = current.tabs.all.filter((item) => item !== tab)
|
||||
const preview = current.preview === tab ? undefined : current.preview
|
||||
if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview }
|
||||
|
||||
const index = current.tabs.all.indexOf(tab)
|
||||
return {
|
||||
tabs: {
|
||||
all,
|
||||
active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0],
|
||||
},
|
||||
preview,
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { type DraftTab, useTabs } from "./tabs"
|
||||
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
|
||||
|
||||
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||
|
||||
@@ -46,23 +47,28 @@ export function getAvatarColors(key?: string) {
|
||||
}
|
||||
|
||||
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
|
||||
if (key === "orange") return "orange"
|
||||
if (key === "pink") return "pink"
|
||||
if (key === "cyan") return "cyan"
|
||||
if (key === "purple") return "purple"
|
||||
if (key === "mint") return "cyan"
|
||||
if (key === "lime") return "green"
|
||||
if (
|
||||
key === "orange" ||
|
||||
key === "yellow" ||
|
||||
key === "cyan" ||
|
||||
key === "green" ||
|
||||
key === "red" ||
|
||||
key === "pink" ||
|
||||
key === "blue" ||
|
||||
key === "purple" ||
|
||||
key === "gray"
|
||||
)
|
||||
return key
|
||||
return "gray"
|
||||
}
|
||||
|
||||
type SessionTabs = {
|
||||
active?: string
|
||||
all: string[]
|
||||
}
|
||||
|
||||
type SessionView = {
|
||||
scroll: Record<string, SessionScroll>
|
||||
reviewOpen?: string[]
|
||||
reviewMode?: ReviewChangeMode
|
||||
reviewFile?: string
|
||||
pendingMessage?: string
|
||||
pendingMessageAt?: number
|
||||
todoCollapsed?: boolean
|
||||
@@ -79,6 +85,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
|
||||
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
||||
|
||||
export type ReviewDiffStyle = "unified" | "split"
|
||||
export type ReviewChangeMode = "git" | "branch" | "turn"
|
||||
export type ReviewPanelSource = "context-button" | "other"
|
||||
|
||||
export type LayoutRoute =
|
||||
@@ -87,14 +94,6 @@ export type LayoutRoute =
|
||||
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
|
||||
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
|
||||
|
||||
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
|
||||
const all = current?.all ?? []
|
||||
if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
|
||||
if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
|
||||
if (!all.includes(tab)) return { all: [...all, tab], active: tab }
|
||||
return { all, active: tab }
|
||||
}
|
||||
|
||||
const sessionPath = (key: string) => {
|
||||
const dir = SessionStateKey.route(key).split("/")[0]
|
||||
if (!dir) return
|
||||
@@ -171,7 +170,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
if (value.type === "home") return value
|
||||
if (value.server) return value
|
||||
if (value.type === "draft") {
|
||||
const draft = tabs.store().find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID)
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID)
|
||||
if (draft) return { ...value, server: draft.server }
|
||||
}
|
||||
return { ...value, server: server.key }
|
||||
@@ -268,7 +267,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
|
||||
const [store, setStore, ready] = persisted(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{ ...target, migrate },
|
||||
createStore({
|
||||
sidebar: {
|
||||
@@ -308,6 +307,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
)
|
||||
const [ephemeral, setEphemeral] = createStore({
|
||||
reviewPanelSource: "other" as ReviewPanelSource,
|
||||
sessionTabPreview: {} as Record<string, string | undefined>,
|
||||
})
|
||||
|
||||
const MAX_SESSION_KEYS = 50
|
||||
@@ -350,8 +350,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
keep,
|
||||
max: MAX_SESSION_KEYS,
|
||||
used: usage.used,
|
||||
view: Object.keys(store().sessionView),
|
||||
tabs: Object.keys(store().sessionTabs),
|
||||
view: Object.keys(store.sessionView),
|
||||
tabs: Object.keys(store.sessionTabs),
|
||||
})
|
||||
if (drop.length === 0) return
|
||||
|
||||
@@ -366,6 +366,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
scroll.drop(drop)
|
||||
dropSessionState(drop)
|
||||
setEphemeral(
|
||||
"sessionTabPreview",
|
||||
produce((draft) => {
|
||||
for (const key of drop) delete draft[key]
|
||||
}),
|
||||
)
|
||||
|
||||
for (const key of drop) {
|
||||
usage.used.delete(key)
|
||||
@@ -385,9 +391,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
const scroll = createScrollPersistence({
|
||||
debounceMs: 250,
|
||||
getSnapshot: (sessionKey) => store().sessionView[sessionKey]?.scroll,
|
||||
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
|
||||
onFlush: (sessionKey, next) => {
|
||||
const current = store().sessionView[sessionKey]
|
||||
const current = store.sessionView[sessionKey]
|
||||
const keep = usage.active ?? sessionKey
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: next })
|
||||
@@ -597,18 +603,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
route,
|
||||
ready,
|
||||
home: {
|
||||
selection: createMemo(() => store().home.selection),
|
||||
selection: createMemo(() => store.home.selection),
|
||||
setSelection(selection: HomeProjectSelection) {
|
||||
setStore("home", "selection", reconcile(selection))
|
||||
},
|
||||
},
|
||||
handoff: {
|
||||
tabs: createMemo(() => store().handoff?.tabs),
|
||||
tabs: createMemo(() => store.handoff?.tabs),
|
||||
setTabs(dir: string, id: string) {
|
||||
setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
|
||||
},
|
||||
clearTabs() {
|
||||
if (!store().handoff?.tabs) return
|
||||
if (!store.handoff?.tabs) return
|
||||
setStore("handoff", "tabs", undefined)
|
||||
},
|
||||
},
|
||||
@@ -642,7 +648,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
opened: createMemo(() => store().sidebar.opened),
|
||||
opened: createMemo(() => store.sidebar.opened),
|
||||
open() {
|
||||
setStore("sidebar", "opened", true)
|
||||
},
|
||||
@@ -652,31 +658,31 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
toggle() {
|
||||
setStore("sidebar", "opened", (x) => !x)
|
||||
},
|
||||
width: createMemo(() => store().sidebar.width),
|
||||
width: createMemo(() => store.sidebar.width),
|
||||
resize(width: number) {
|
||||
setStore("sidebar", "width", width)
|
||||
},
|
||||
workspaces(directory: string) {
|
||||
return () => store().sidebar.workspaces[directory] ?? store().sidebar.workspacesDefault ?? false
|
||||
return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
|
||||
},
|
||||
setWorkspaces(directory: string, value: boolean) {
|
||||
setStore("sidebar", "workspaces", directory, value)
|
||||
},
|
||||
toggleWorkspaces(directory: string) {
|
||||
const current = store().sidebar.workspaces[directory] ?? store().sidebar.workspacesDefault ?? false
|
||||
const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
|
||||
setStore("sidebar", "workspaces", directory, !current)
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
height: createMemo(() => store().terminal.height),
|
||||
height: createMemo(() => store.terminal.height),
|
||||
resize(height: number) {
|
||||
setStore("terminal", "height", height)
|
||||
},
|
||||
},
|
||||
review: {
|
||||
diffStyle: createMemo(() => store().review?.diffStyle ?? "split"),
|
||||
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
||||
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
||||
if (!store().review) {
|
||||
if (!store.review) {
|
||||
setStore("review", { diffStyle, panelOpened: DEFAULT_REVIEW_PANEL_OPENED })
|
||||
return
|
||||
}
|
||||
@@ -684,39 +690,39 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
fileTree: {
|
||||
opened: createMemo(() => store().fileTree?.opened ?? true),
|
||||
width: createMemo(() => store().fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH),
|
||||
tab: createMemo(() => store().fileTree?.tab ?? "changes"),
|
||||
opened: createMemo(() => store.fileTree?.opened ?? true),
|
||||
width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH),
|
||||
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
|
||||
setTab(tab: "changes" | "all") {
|
||||
if (!store().fileTree) {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "tab", tab)
|
||||
},
|
||||
open() {
|
||||
if (!store().fileTree) {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", true)
|
||||
},
|
||||
close() {
|
||||
if (!store().fileTree) {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", false)
|
||||
},
|
||||
toggle() {
|
||||
if (!store().fileTree) {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", (x) => !x)
|
||||
},
|
||||
resize(width: number) {
|
||||
if (!store().fileTree) {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width, tab: "changes" })
|
||||
return
|
||||
}
|
||||
@@ -724,9 +730,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
session: {
|
||||
width: createMemo(() => store().session?.width ?? DEFAULT_SESSION_WIDTH),
|
||||
width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH),
|
||||
resize(width: number) {
|
||||
if (!store().session) {
|
||||
if (!store.session) {
|
||||
setStore("session", { width })
|
||||
return
|
||||
}
|
||||
@@ -734,7 +740,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
mobileSidebar: {
|
||||
opened: createMemo(() => store().mobileSidebar?.opened ?? false),
|
||||
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
||||
show() {
|
||||
setStore("mobileSidebar", "opened", true)
|
||||
},
|
||||
@@ -749,7 +755,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
set(sessionKey: string, messageID: string) {
|
||||
const at = Date.now()
|
||||
touch(sessionKey)
|
||||
const current = store().sessionView[sessionKey]
|
||||
const current = store.sessionView[sessionKey]
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, {
|
||||
scroll: {},
|
||||
@@ -770,7 +776,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
)
|
||||
},
|
||||
consume(sessionKey: string) {
|
||||
const current = store().sessionView[sessionKey]
|
||||
const current = store.sessionView[sessionKey]
|
||||
const message = current?.pendingMessage
|
||||
const at = current?.pendingMessageAt
|
||||
if (!message || !at) return
|
||||
@@ -790,13 +796,21 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
view(sessionKey: string | Accessor<string>) {
|
||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||
const s = createMemo(() => store().sessionView[key()] ?? { scroll: {} })
|
||||
const terminalOpened = createMemo(() => store().terminal?.opened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => store().review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
|
||||
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
|
||||
const reviewMode = createMemo(() => {
|
||||
const mode = s().reviewMode
|
||||
if (mode === "git" || mode === "branch" || mode === "turn") return mode
|
||||
})
|
||||
const reviewFile = createMemo(() => {
|
||||
const file = s().reviewFile
|
||||
if (typeof file === "string") return file
|
||||
})
|
||||
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
|
||||
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
|
||||
|
||||
function setTerminalOpened(next: boolean) {
|
||||
const current = store().terminal
|
||||
const current = store.terminal
|
||||
if (!current) {
|
||||
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
|
||||
return
|
||||
@@ -809,7 +823,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
|
||||
const nextSource = next ? source : "other"
|
||||
const current = store().review
|
||||
const current = store.review
|
||||
if (!current) {
|
||||
batch(() => {
|
||||
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
|
||||
@@ -840,7 +854,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
get: () => s().todoCollapsed ?? false,
|
||||
set(collapsed: boolean) {
|
||||
const session = key()
|
||||
const current = store().sessionView[session]
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed })
|
||||
} else {
|
||||
@@ -874,11 +888,37 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
review: {
|
||||
mode: reviewMode,
|
||||
setMode(mode: ReviewChangeMode) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, { scroll: {}, reviewMode: mode })
|
||||
prune(session)
|
||||
return
|
||||
}
|
||||
if (current.reviewMode === mode) return
|
||||
setStore("sessionView", session, "reviewMode", mode)
|
||||
prune(session)
|
||||
},
|
||||
file: reviewFile,
|
||||
setFile(file: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, { scroll: {}, reviewFile: file })
|
||||
prune(session)
|
||||
return
|
||||
}
|
||||
if (current.reviewFile === file) return
|
||||
setStore("sessionView", session, "reviewFile", file)
|
||||
prune(session)
|
||||
},
|
||||
open: createMemo(() => s().reviewOpen ?? []),
|
||||
setOpen(open: string[]) {
|
||||
const session = key()
|
||||
const next = Array.from(new Set(open))
|
||||
const current = store().sessionView[session]
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
@@ -892,7 +932,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
openPath(path: string) {
|
||||
const session = key()
|
||||
const current = store().sessionView[session]
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
@@ -911,7 +951,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
closePath(path: string) {
|
||||
const session = key()
|
||||
const current = store().sessionView[session]?.reviewOpen
|
||||
const current = store.sessionView[session]?.reviewOpen
|
||||
if (!current) return
|
||||
|
||||
const index = current.indexOf(path)
|
||||
@@ -928,7 +968,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
togglePath(path: string) {
|
||||
const session = key()
|
||||
const current = store().sessionView[session]?.reviewOpen
|
||||
const current = store.sessionView[session]?.reviewOpen
|
||||
if (!current || !current.includes(path)) {
|
||||
this.openPath(path)
|
||||
return
|
||||
@@ -942,17 +982,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
tabs(sessionKey: string | Accessor<string>) {
|
||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||
const path = createMemo(() => sessionPath(key()))
|
||||
const tabs = createMemo(() => store().sessionTabs[key()] ?? { all: [] })
|
||||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
|
||||
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
|
||||
const apply = (session: string, next: ReturnType<typeof openSessionTab>) => {
|
||||
batch(() => {
|
||||
setStore("sessionTabs", session, next.tabs)
|
||||
setEphemeral("sessionTabPreview", session, next.preview)
|
||||
})
|
||||
}
|
||||
return {
|
||||
tabs,
|
||||
active: createMemo(() => tabs().active),
|
||||
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
||||
preview: createMemo(() => ephemeral.sessionTabPreview[key()]),
|
||||
setActive(tab: string | undefined) {
|
||||
const session = key()
|
||||
const next = tab ? normalize(tab) : tab
|
||||
if (!store().sessionTabs[session]) {
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: [], active: next })
|
||||
} else {
|
||||
setStore("sessionTabs", session, "active", next)
|
||||
@@ -961,44 +1008,48 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setAll(all: string[]) {
|
||||
const session = key()
|
||||
const next = normalizeAll(all).filter((tab) => tab !== "review")
|
||||
if (!store().sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: next, active: undefined })
|
||||
} else {
|
||||
setStore("sessionTabs", session, "all", next)
|
||||
}
|
||||
batch(() => {
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: next, active: undefined })
|
||||
} else {
|
||||
setStore("sessionTabs", session, "all", next)
|
||||
}
|
||||
const preview = ephemeral.sessionTabPreview[session]
|
||||
if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined)
|
||||
})
|
||||
},
|
||||
async open(tab: string) {
|
||||
const session = key()
|
||||
const next = nextSessionTabsForOpen(store().sessionTabs[session], normalize(tab))
|
||||
setStore("sessionTabs", session, next)
|
||||
apply(
|
||||
session,
|
||||
openSessionTab(
|
||||
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
|
||||
normalize(tab),
|
||||
),
|
||||
)
|
||||
},
|
||||
previewTab(tab: string) {
|
||||
const session = key()
|
||||
apply(
|
||||
session,
|
||||
previewSessionTab(
|
||||
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
|
||||
normalize(tab),
|
||||
),
|
||||
)
|
||||
},
|
||||
close(tab: string) {
|
||||
const session = key()
|
||||
const current = store().sessionTabs[session]
|
||||
const current = store.sessionTabs[session]
|
||||
if (!current) return
|
||||
|
||||
if (tab === "review") {
|
||||
if (current.active !== tab) return
|
||||
setStore("sessionTabs", session, "active", current.all[0])
|
||||
return
|
||||
}
|
||||
|
||||
const all = current.all.filter((x) => x !== tab)
|
||||
if (current.active !== tab) {
|
||||
setStore("sessionTabs", session, "all", all)
|
||||
return
|
||||
}
|
||||
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
|
||||
batch(() => {
|
||||
setStore("sessionTabs", session, "all", all)
|
||||
setStore("sessionTabs", session, "active", next)
|
||||
})
|
||||
apply(
|
||||
session,
|
||||
closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)),
|
||||
)
|
||||
},
|
||||
move(tab: string, to: number) {
|
||||
const session = key()
|
||||
const current = store().sessionTabs[session]
|
||||
const current = store.sessionTabs[session]
|
||||
if (!current) return
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
if (index === -1) return
|
||||
|
||||
@@ -67,7 +67,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const [saved, setSaved] = persisted(
|
||||
const [saved, setSaved, , savedReady] = persisted(
|
||||
{
|
||||
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
||||
migrate,
|
||||
@@ -125,7 +125,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const scope = createMemo<State | undefined>(() => {
|
||||
const session = id()
|
||||
if (!session) return store.draft ?? store.promoting
|
||||
return saved().session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
|
||||
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -135,7 +135,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const key = handoffKey(serverSDK().scope, sdk().directory, session)
|
||||
const next = handoff.get(key)
|
||||
if (!next) return
|
||||
if (saved().session[session] !== undefined) {
|
||||
if (saved.session[session] !== undefined) {
|
||||
handoff.delete(key)
|
||||
setStore("promoting", undefined)
|
||||
return
|
||||
@@ -375,11 +375,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
model,
|
||||
agent,
|
||||
session: {
|
||||
ready: savedReady,
|
||||
reset() {
|
||||
setStore({ draft: undefined, promoting: undefined })
|
||||
},
|
||||
promote(dir: string, session: string) {
|
||||
const next = clone(snapshot())
|
||||
promote(dir: string, session: string, state?: State) {
|
||||
const next = clone(state ?? snapshot())
|
||||
if (!next) return
|
||||
const key = handoffKey(serverSDK().scope, dir, session)
|
||||
handoff.set(key, next)
|
||||
@@ -395,7 +396,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const session = id()
|
||||
if (!session) return
|
||||
if (msg.sessionID !== session) return
|
||||
if (saved().session[session] !== undefined) return
|
||||
if (saved.session[session] !== undefined) return
|
||||
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
|
||||
|
||||
setSaved("session", session, {
|
||||
@@ -409,3 +410,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
export type ModelSelection = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
@@ -28,7 +28,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
|
||||
const providers = useProviders(props.directory)
|
||||
|
||||
const [store, setStore, ready] = persisted(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model", ["model.v1"]),
|
||||
createStore<Store>({
|
||||
user: [],
|
||||
@@ -89,7 +89,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
|
||||
const visibility = createMemo(() => {
|
||||
const map = new Map<string, Visibility>()
|
||||
for (const item of store().user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
|
||||
for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
|
||||
return map
|
||||
})
|
||||
|
||||
@@ -104,12 +104,12 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
|
||||
|
||||
function update(model: ModelKey, state: Visibility) {
|
||||
const index = store().user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
||||
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
||||
if (index >= 0) {
|
||||
setStore("user", index, (current) => ({ ...current, visibility: state }))
|
||||
return
|
||||
}
|
||||
setStore("user", store().user.length, { ...model, visibility: state })
|
||||
setStore("user", store.user.length, { ...model, visibility: state })
|
||||
}
|
||||
|
||||
const visible = (model: ModelKey) => {
|
||||
@@ -128,17 +128,17 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const push = (model: ModelKey) => {
|
||||
const uniq = uniqueBy([model, ...store().recent], (x) => `${x.providerID}:${x.modelID}`)
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
|
||||
if (uniq.length > RECENT_LIMIT) uniq.pop()
|
||||
setStore("recent", uniq)
|
||||
}
|
||||
|
||||
const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
|
||||
const getVariant = (model: ModelKey) => store().variant?.[variantKey(model)]
|
||||
const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
|
||||
|
||||
const setVariant = (model: ModelKey, value: string | undefined) => {
|
||||
const key = variantKey(model)
|
||||
if (!store().variant) {
|
||||
if (!store.variant) {
|
||||
setStore("variant", { [key]: value })
|
||||
return
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
|
||||
const [recentModels] = createResource(
|
||||
async () => {
|
||||
const recent = store().recent
|
||||
const recent = store.recent
|
||||
await ready.promise
|
||||
return recent
|
||||
},
|
||||
|
||||
@@ -127,7 +127,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
const activeServer = createMemo(() => {
|
||||
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||
if (search.draftId) {
|
||||
const draft = tabs.store().find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
if (draft) return draft.server
|
||||
}
|
||||
return server.key
|
||||
@@ -222,13 +222,13 @@ function createServerNotificationState(input: {
|
||||
const currentDirectory = input.directory
|
||||
const currentSession = input.sessionID
|
||||
|
||||
const [store, setStore, ready] = persisted(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store().list))
|
||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||
|
||||
const meta = { pruned: false, disposed: false }
|
||||
|
||||
@@ -285,7 +285,7 @@ function createServerNotificationState(input: {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
meta.pruned = true
|
||||
const list = pruneNotifications(store().list)
|
||||
const list = pruneNotifications(store.list)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
||||
@@ -293,9 +293,9 @@ function createServerNotificationState(input: {
|
||||
})
|
||||
|
||||
const append = (notification: Notification) => {
|
||||
const list = pruneNotifications([...store().list, notification])
|
||||
const list = pruneNotifications([...store.list, notification])
|
||||
const keep = new Set(list)
|
||||
const removed = store().list.filter((n) => !keep.has(n))
|
||||
const removed = store.list.filter((n) => !keep.has(n))
|
||||
|
||||
batch(() => {
|
||||
if (keep.has(notification)) appendToIndex(notification)
|
||||
|
||||
@@ -59,7 +59,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
return hasPermissionPromptRules(store.config.permission)
|
||||
})
|
||||
|
||||
const [store, setStore, ready] = persisted(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
|
||||
migrate(value) {
|
||||
@@ -91,7 +91,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
const perm = childStore.config.permission
|
||||
if (typeof perm === "string" && perm === "allow") {
|
||||
const key = directoryAcceptKey(directory)
|
||||
if (store().autoAccept[key] === undefined) {
|
||||
if (store.autoAccept[key] === undefined) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
@@ -143,16 +143,16 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store().autoAccept, session, { sessionID }, directory)
|
||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
return isDirectoryAutoAccepting(store().autoAccept, directory)
|
||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store().autoAccept, session, permission, directory)
|
||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||
}
|
||||
|
||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||
|
||||
@@ -37,6 +37,9 @@ type PlatformBase = {
|
||||
/** Open a local path in a local app (desktop only) */
|
||||
openPath?(path: string, app?: string): Promise<void>
|
||||
|
||||
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
|
||||
revealPath?(path: string): Promise<boolean>
|
||||
|
||||
/** Restart the app */
|
||||
restart(): Promise<void>
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
|
||||
|
||||
describe("prompt state initialization", () => {
|
||||
test("initializes prompt text, cursor, and model together", () => {
|
||||
createRoot((dispose) => {
|
||||
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
|
||||
const prompt = createPromptState({ prompt: "hello", model })
|
||||
|
||||
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
|
||||
expect(prompt.cursor()).toBe(5)
|
||||
expect(prompt.model.current()).toEqual(model)
|
||||
expect(prompt.model.current()).not.toBe(model)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the default prompt without initial values", () => {
|
||||
createRoot((dispose) => {
|
||||
const prompt = createPromptState()
|
||||
|
||||
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
|
||||
expect(prompt.cursor()).toBeUndefined()
|
||||
expect(prompt.model.current()).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
||||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
mime?: string
|
||||
filename?: string
|
||||
url?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export type PromptModel = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string | null
|
||||
}
|
||||
|
||||
export type FileContextItem = {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type ContextItem = FileContextItem
|
||||
export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
type PromptStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
model?: PromptModel
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}
|
||||
|
||||
type InitialPrompt = {
|
||||
prompt?: string
|
||||
model?: PromptModel
|
||||
}
|
||||
|
||||
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
||||
if (!a && !b) return true
|
||||
if (!a || !b) return false
|
||||
return (
|
||||
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
||||
)
|
||||
}
|
||||
|
||||
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
||||
switch (partA.type) {
|
||||
case "text":
|
||||
return partB.type === "text" && partA.content === partB.content
|
||||
case "file":
|
||||
return (
|
||||
partB.type === "file" &&
|
||||
partA.path === partB.path &&
|
||||
partA.mime === partB.mime &&
|
||||
partA.filename === partB.filename &&
|
||||
isSelectionEqual(partA.selection, partB.selection)
|
||||
)
|
||||
case "agent":
|
||||
return partB.type === "agent" && partA.name === partB.name
|
||||
case "image":
|
||||
return partB.type === "image" && partA.id === partB.id
|
||||
}
|
||||
}
|
||||
|
||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
if (!isPartEqual(promptA[i], promptB[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: FileSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
function contextItemKey(item: ContextItem) {
|
||||
if (item.type !== "file") return item.type
|
||||
const start = item.selection?.startLine
|
||||
const end = item.selection?.endLine
|
||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||
|
||||
if (item.commentID) return `${key}:c=${item.commentID}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return key
|
||||
const digest = checksum(comment) ?? comment
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||
return item.type === "file" && !!item.comment?.trim()
|
||||
}
|
||||
|
||||
function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
|
||||
return {
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||
setStore("cursor", 0)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||
}
|
||||
|
||||
function promptStore(initial?: InitialPrompt): PromptStore {
|
||||
const text = initial?.prompt
|
||||
return {
|
||||
prompt:
|
||||
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
|
||||
cursor: text === undefined ? undefined : text.length,
|
||||
model: initial?.model ? { ...initial.model } : undefined,
|
||||
context: {
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||
const actions = createPromptActions(setStore)
|
||||
const value = {
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
model: {
|
||||
current: () => store.model,
|
||||
set: (model: PromptModel | undefined) => setStore("model", model),
|
||||
},
|
||||
context: {
|
||||
items: createMemo(() => store.context.items),
|
||||
add(item: ContextItem) {
|
||||
const key = contextItemKey(item)
|
||||
if (store.context.items.find((x) => x.key === key)) return
|
||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||
},
|
||||
removeComment(path: string, commentID: string) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||
)
|
||||
},
|
||||
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.map((item) => {
|
||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||
const value = { ...item, ...next }
|
||||
return { ...value, key: contextItemKey(value) }
|
||||
}),
|
||||
)
|
||||
},
|
||||
replaceComments(items: FileContextItem[]) {
|
||||
setStore("context", "items", (current) => [
|
||||
...current.filter((item) => !isCommentItem(item)),
|
||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
])
|
||||
},
|
||||
},
|
||||
set: actions.set,
|
||||
reset: actions.reset,
|
||||
capture: () => value,
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
|
||||
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
|
||||
return { ready, ...createPromptStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
|
||||
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
|
||||
}
|
||||
|
||||
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
||||
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
|
||||
}
|
||||
|
||||
export type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
export function createPromptReady(session: Accessor<PromptSession>) {
|
||||
return Object.defineProperty(() => session().ready(), "promise", {
|
||||
get: () => session().ready.promise,
|
||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||
}
|
||||
|
||||
export function createPromptState(initial?: InitialPrompt) {
|
||||
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
|
||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||
return {
|
||||
ready,
|
||||
...createPromptStateValue(store, setStore),
|
||||
}
|
||||
}
|
||||
@@ -1,186 +1,50 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { ServerConnection } from "./server"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { useSettings } from "./settings"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import { ServerConnection } from "./server"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { useSettings } from "./settings"
|
||||
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
type ContextItem,
|
||||
type FileContextItem,
|
||||
type Prompt,
|
||||
type PromptModel,
|
||||
type PromptScope,
|
||||
type PromptSession,
|
||||
} from "./prompt-state"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
mime?: string
|
||||
filename?: string
|
||||
url?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export type FileContextItem = {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type ContextItem = FileContextItem
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
||||
if (!a && !b) return true
|
||||
if (!a || !b) return false
|
||||
return (
|
||||
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
||||
)
|
||||
}
|
||||
|
||||
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
||||
switch (partA.type) {
|
||||
case "text":
|
||||
return partB.type === "text" && partA.content === partB.content
|
||||
case "file":
|
||||
return (
|
||||
partB.type === "file" &&
|
||||
partA.path === partB.path &&
|
||||
partA.mime === partB.mime &&
|
||||
partA.filename === partB.filename &&
|
||||
isSelectionEqual(partA.selection, partB.selection)
|
||||
)
|
||||
case "agent":
|
||||
return partB.type === "agent" && partA.name === partB.name
|
||||
case "image":
|
||||
return partB.type === "image" && partA.id === partB.id
|
||||
}
|
||||
}
|
||||
|
||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
if (!isPartEqual(promptA[i], promptB[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: FileSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
function contextItemKey(item: ContextItem) {
|
||||
if (item.type !== "file") return item.type
|
||||
const start = item.selection?.startLine
|
||||
const end = item.selection?.endLine
|
||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||
|
||||
if (item.commentID) {
|
||||
return `${key}:c=${item.commentID}`
|
||||
}
|
||||
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return key
|
||||
const digest = checksum(comment) ?? comment
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||
return item.type === "file" && !!item.comment?.trim()
|
||||
}
|
||||
|
||||
function createPromptActions(
|
||||
setStore: SetStoreFunction<{
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||
setStore("cursor", 0)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
export {
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
createPromptState,
|
||||
DEFAULT_PROMPT,
|
||||
isCommentItem,
|
||||
isPromptEqual,
|
||||
} from "./prompt-state"
|
||||
export type {
|
||||
AgentPart,
|
||||
ContentPart,
|
||||
ContextItem,
|
||||
FileAttachmentPart,
|
||||
FileContextItem,
|
||||
ImageAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
PromptScope,
|
||||
PromptSession,
|
||||
TextPart,
|
||||
} from "./prompt-state"
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_PROMPT_SESSIONS = 20
|
||||
|
||||
type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
type PromptStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}
|
||||
|
||||
type Scope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
|
||||
export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) {
|
||||
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
|
||||
if (!scope.id) return
|
||||
return (
|
||||
@@ -189,7 +53,7 @@ export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnect
|
||||
)
|
||||
}
|
||||
|
||||
function scopeKey(scope: Scope) {
|
||||
function scopeKey(scope: PromptScope) {
|
||||
if ("draftID" in scope) return `draft:${scope.draftID}`
|
||||
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
||||
}
|
||||
@@ -199,91 +63,6 @@ type PromptCacheEntry = {
|
||||
dispose: VoidFunction
|
||||
}
|
||||
|
||||
function promptTarget(serverScope: ServerScope, scope: Scope) {
|
||||
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||
}
|
||||
|
||||
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
const [store, setStore, ready] = persisted(
|
||||
promptTarget(serverScope, scope),
|
||||
createStore<PromptStore>(promptStore()),
|
||||
)
|
||||
|
||||
return { ready, ...createPromptStateValue(store(), setStore) }
|
||||
}
|
||||
|
||||
export function createPromptReady(session: Accessor<PromptSession>) {
|
||||
return Object.defineProperty(() => session().ready(), "promise", {
|
||||
get: () => session().ready.promise,
|
||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||
}
|
||||
|
||||
function promptStore(): PromptStore {
|
||||
return {
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
cursor: undefined,
|
||||
context: {
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||
const actions = createPromptActions(setStore)
|
||||
|
||||
const value = {
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
context: {
|
||||
items: createMemo(() => store.context.items),
|
||||
add(item: ContextItem) {
|
||||
const key = contextItemKey(item)
|
||||
if (store.context.items.find((x) => x.key === key)) return
|
||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||
},
|
||||
removeComment(path: string, commentID: string) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||
)
|
||||
},
|
||||
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.map((item) => {
|
||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||
const value = { ...item, ...next }
|
||||
return { ...value, key: contextItemKey(value) }
|
||||
}),
|
||||
)
|
||||
},
|
||||
replaceComments(items: FileContextItem[]) {
|
||||
setStore("context", "items", (current) => [
|
||||
...current.filter((item) => !isCommentItem(item)),
|
||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
])
|
||||
},
|
||||
},
|
||||
set: actions.set,
|
||||
reset: actions.reset,
|
||||
capture: () => value,
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function createPromptState() {
|
||||
const [store, setStore] = createStore<PromptStore>(promptStore())
|
||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||
return {
|
||||
ready,
|
||||
...createPromptStateValue(store, setStore),
|
||||
}
|
||||
}
|
||||
|
||||
export const createTabPromptState = (
|
||||
tabs: ReturnType<typeof useTabs>,
|
||||
tab: Tab,
|
||||
@@ -303,9 +82,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
|
||||
const disposeAll = () => {
|
||||
for (const entry of cache.values()) {
|
||||
entry.dispose()
|
||||
}
|
||||
for (const entry of cache.values()) entry.dispose()
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@@ -324,13 +101,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
const owner = getOwner()
|
||||
const serverKey = () =>
|
||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||
const scope = () =>
|
||||
const scope = (): PromptScope =>
|
||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||
const load = (scope: Scope) => {
|
||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store(), scope, serverKey()) : undefined
|
||||
if (current) {
|
||||
return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
}
|
||||
const load = (scope: PromptScope) => {
|
||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
||||
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
|
||||
const key = scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
@@ -354,15 +129,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(scope()))
|
||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
return {
|
||||
ready,
|
||||
capture: (scope?: Scope) => pick(scope).capture(),
|
||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||
current: () => session().current(),
|
||||
cursor: () => session().cursor(),
|
||||
dirty: () => session().dirty(),
|
||||
model: {
|
||||
current: () => session().model.current(),
|
||||
set: (model: PromptModel | undefined) => session().model.set(model),
|
||||
},
|
||||
context: {
|
||||
items: () => session().context.items(),
|
||||
add: (item: ContextItem) => session().context.add(item),
|
||||
@@ -372,8 +151,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
session().context.updateComment(path, commentID, next),
|
||||
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
||||
},
|
||||
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
|
||||
reset: (scope?: Scope) => pick(scope).reset(),
|
||||
set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
|
||||
reset: (scope?: PromptScope) => pick(scope).reset(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -260,7 +260,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
canonicalLocalServer?: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
}) => {
|
||||
const [store, setStore, ready] = persisted(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.global("server", ["server.v3"]),
|
||||
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
|
||||
@@ -276,7 +276,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
|
||||
|
||||
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||
return resolveServerList({ stored: store().list, props: props.servers })
|
||||
return resolveServerList({ stored: store.list, props: props.servers })
|
||||
})
|
||||
|
||||
const [state, setState] = createStore({
|
||||
@@ -292,11 +292,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
if (!url_) return
|
||||
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
|
||||
return batch(() => {
|
||||
const existing = store().list.findIndex((x) => url(x) === url_)
|
||||
const existing = store.list.findIndex((x) => url(x) === url_)
|
||||
if (existing !== -1) {
|
||||
setStore("list", existing, conn)
|
||||
} else {
|
||||
setStore("list", store().list.length, conn)
|
||||
setStore("list", store.list.length, conn)
|
||||
}
|
||||
setState("active", ServerConnection.key(conn))
|
||||
return conn
|
||||
@@ -305,7 +305,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
|
||||
function remove(key: ServerConnection.Key) {
|
||||
const next = nextServerAfterRemoval(allServers(), key, props.defaultServer)
|
||||
const list = store().list.filter((x) => url(x) !== key)
|
||||
const list = store.list.filter((x) => url(x) !== key)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
if (state.active === key) setState("active", next)
|
||||
@@ -318,12 +318,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
)
|
||||
|
||||
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
|
||||
const projects = createServerProjects({ scope, store: store(), setStore })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
|
||||
const projectsForServer = (key: ServerConnection.Key) => {
|
||||
const existing = projectStores.get(key)
|
||||
if (existing) return existing
|
||||
const next = createServerProjects({ scope: () => scope(key), store: store(), setStore })
|
||||
const next = createServerProjects({ scope: () => scope(key), store, setStore })
|
||||
projectStores.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
layoutTransitionState,
|
||||
maximumSunsetTimeout,
|
||||
newLayoutDesignsDefault,
|
||||
nextSunsetCheckDelay,
|
||||
resolveNewLayoutDesigns,
|
||||
} from "./settings"
|
||||
|
||||
describe("layout transition", () => {
|
||||
test("blank profiles default to the new layout", () => {
|
||||
expect(newLayoutDesignsDefault).toBe(true)
|
||||
})
|
||||
|
||||
test("hides the transition until a sunset is scheduled", () => {
|
||||
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
|
||||
})
|
||||
|
||||
test("existing profiles can switch before sunset", () => {
|
||||
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
|
||||
})
|
||||
|
||||
test("preserves explicit and default layout preferences", () => {
|
||||
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
|
||||
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
|
||||
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
|
||||
})
|
||||
|
||||
test("sunset replaces the toggle with a dismissible notice", () => {
|
||||
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
|
||||
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
|
||||
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
|
||||
})
|
||||
|
||||
test("caps checks for sunsets beyond the browser timeout limit", () => {
|
||||
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
|
||||
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
||||
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface Settings {
|
||||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
newLayoutDesigns?: boolean
|
||||
layoutTransitionEligible?: boolean
|
||||
newInterfaceNoticeDismissed?: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
@@ -52,7 +54,28 @@ export interface Settings {
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
export const newLayoutDesignsDefault = true
|
||||
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
||||
export const oldInterfaceSunset = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" ? new Date(2026, 7, 14) : null
|
||||
|
||||
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
||||
return {
|
||||
available: scheduled && eligible && !retired,
|
||||
notice: scheduled && eligible && retired && !dismissed,
|
||||
}
|
||||
}
|
||||
|
||||
export const maximumSunsetTimeout = 2_147_483_647
|
||||
|
||||
export function nextSunsetCheckDelay(sunset: number, now: number) {
|
||||
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
|
||||
}
|
||||
|
||||
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
|
||||
if (retired) return true
|
||||
return preference ?? fallback
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
@@ -152,45 +175,86 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
name: "Settings",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const [store, setStore, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
const showFileTree = withFallback(() => store().general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store().general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store().general?.showStatus, defaultSettings.general.showStatus)
|
||||
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||
const showCustomAgents = withFallback(
|
||||
() => store().general?.showCustomAgents,
|
||||
() => store.general?.showCustomAgents,
|
||||
defaultSettings.general.showCustomAgents,
|
||||
)
|
||||
const newLayoutDesigns = withFallback(() => store().general?.newLayoutDesigns, newLayoutDesignsDefault)
|
||||
const sunset = oldInterfaceSunset
|
||||
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
||||
const layoutTransitionClassified = createMemo(
|
||||
() => typeof store.general?.layoutTransitionEligible === "boolean",
|
||||
)
|
||||
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
||||
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
||||
const layoutTransition = createMemo(() =>
|
||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||
)
|
||||
const newLayoutDesigns = createMemo(() => {
|
||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||
if (!layoutTransitionClassified()) {
|
||||
return resolveNewLayoutDesigns(oldInterfaceRetired(), store.general?.newLayoutDesigns, legacyNewLayoutDesignsDefault)
|
||||
}
|
||||
return resolveNewLayoutDesigns(
|
||||
oldInterfaceRetired(),
|
||||
store.general?.newLayoutDesigns,
|
||||
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
||||
)
|
||||
})
|
||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
||||
|
||||
if (sunset && !oldInterfaceRetired()) {
|
||||
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const checkSunset = () => {
|
||||
if (Date.now() >= sunset.getTime()) {
|
||||
setOldInterfaceRetired(true)
|
||||
return
|
||||
}
|
||||
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
|
||||
}
|
||||
checkSunset()
|
||||
onCleanup(() => {
|
||||
if (timeout.current !== undefined) clearTimeout(timeout.current)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !oldInterfaceRetired()) return
|
||||
if (store.general?.newLayoutDesigns === true) return
|
||||
setStore("general", "newLayoutDesigns", true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
root.style.setProperty("--font-family-mono", monoFontFamily(store().appearance?.mono))
|
||||
root.style.setProperty("--font-family-sans", sansFontFamily(store().appearance?.sans))
|
||||
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
|
||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (store().general?.followup !== "queue") return
|
||||
if (store.general?.followup !== "queue") return
|
||||
setStore("general", "followup", "steer")
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
get current() {
|
||||
return store()
|
||||
return store
|
||||
},
|
||||
general: {
|
||||
autoSave: withFallback(() => store().general?.autoSave, defaultSettings.general.autoSave),
|
||||
autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
|
||||
setAutoSave(value: boolean) {
|
||||
setStore("general", "autoSave", value)
|
||||
},
|
||||
releaseNotes: withFallback(() => store().general?.releaseNotes, defaultSettings.general.releaseNotes),
|
||||
releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes),
|
||||
setReleaseNotes(value: boolean) {
|
||||
setStore("general", "releaseNotes", value)
|
||||
},
|
||||
followup: withFallback(
|
||||
() => (store().general?.followup === "queue" ? "steer" : store().general?.followup),
|
||||
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
|
||||
defaultSettings.general.followup,
|
||||
),
|
||||
setFollowup(value: "queue" | "steer") {
|
||||
@@ -200,7 +264,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowFileTree(value: boolean) {
|
||||
setStore("general", "showFileTree", value)
|
||||
},
|
||||
showNavigation: withFallback(() => store().general?.showNavigation, defaultSettings.general.showNavigation),
|
||||
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
|
||||
setShowNavigation(value: boolean) {
|
||||
setStore("general", "showNavigation", value)
|
||||
},
|
||||
@@ -212,26 +276,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowStatus(value: boolean) {
|
||||
setStore("general", "showStatus", value)
|
||||
},
|
||||
showTerminal: withFallback(() => store().general?.showTerminal, defaultSettings.general.showTerminal),
|
||||
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
|
||||
setShowTerminal(value: boolean) {
|
||||
setStore("general", "showTerminal", value)
|
||||
},
|
||||
showReasoningSummaries: withFallback(
|
||||
() => store().general?.showReasoningSummaries,
|
||||
() => store.general?.showReasoningSummaries,
|
||||
defaultSettings.general.showReasoningSummaries,
|
||||
),
|
||||
setShowReasoningSummaries(value: boolean) {
|
||||
setStore("general", "showReasoningSummaries", value)
|
||||
},
|
||||
shellToolPartsExpanded: withFallback(
|
||||
() => store().general?.shellToolPartsExpanded,
|
||||
() => store.general?.shellToolPartsExpanded,
|
||||
defaultSettings.general.shellToolPartsExpanded,
|
||||
),
|
||||
setShellToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "shellToolPartsExpanded", value)
|
||||
},
|
||||
editToolPartsExpanded: withFallback(
|
||||
() => store().general?.editToolPartsExpanded,
|
||||
() => store.general?.editToolPartsExpanded,
|
||||
defaultSettings.general.editToolPartsExpanded,
|
||||
),
|
||||
setEditToolPartsExpanded(value: boolean) {
|
||||
@@ -242,7 +306,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setStore("general", "showCustomAgents", value)
|
||||
},
|
||||
mobileTitlebarPosition: withFallback(
|
||||
() => store().general?.mobileTitlebarPosition,
|
||||
() => store.general?.mobileTitlebarPosition,
|
||||
defaultSettings.general.mobileTitlebarPosition,
|
||||
),
|
||||
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||
@@ -250,7 +314,18 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
},
|
||||
newLayoutDesigns,
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
|
||||
},
|
||||
layoutTransitionClassified,
|
||||
setOldLayoutEligible(eligible: boolean) {
|
||||
const current = store.general?.layoutTransitionEligible
|
||||
if (typeof current === "boolean") return
|
||||
setStore("general", "layoutTransitionEligible", eligible)
|
||||
},
|
||||
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
||||
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
||||
dismissNewInterfaceNotice() {
|
||||
setStore("general", "newInterfaceNoticeDismissed", true)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
@@ -260,25 +335,25 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
customAgents: visible(showCustomAgents),
|
||||
},
|
||||
appearance: {
|
||||
fontSize: withFallback(() => store().appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||
setFontSize(value: number) {
|
||||
setStore("appearance", "fontSize", value)
|
||||
},
|
||||
font: withFallback(() => store().appearance?.mono, defaultSettings.appearance.mono),
|
||||
font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
|
||||
setFont(value: string) {
|
||||
setStore("appearance", "mono", value.trim() ? value : "")
|
||||
},
|
||||
uiFont: withFallback(() => store().appearance?.sans, defaultSettings.appearance.sans),
|
||||
uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
|
||||
setUIFont(value: string) {
|
||||
setStore("appearance", "sans", value.trim() ? value : "")
|
||||
},
|
||||
terminalFont: withFallback(() => store().appearance?.terminal, defaultSettings.appearance.terminal),
|
||||
terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal),
|
||||
setTerminalFont(value: string) {
|
||||
setStore("appearance", "terminal", value.trim() ? value : "")
|
||||
},
|
||||
},
|
||||
keybinds: {
|
||||
get: (action: string) => store().keybinds?.[action],
|
||||
get: (action: string) => store.keybinds?.[action],
|
||||
set(action: string, keybind: string) {
|
||||
setStore("keybinds", action, keybind)
|
||||
},
|
||||
@@ -295,50 +370,50 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
autoApprove: withFallback(() => store().permissions?.autoApprove, defaultSettings.permissions.autoApprove),
|
||||
autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
|
||||
setAutoApprove(value: boolean) {
|
||||
setStore("permissions", "autoApprove", value)
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
agent: withFallback(() => store().notifications?.agent, defaultSettings.notifications.agent),
|
||||
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
||||
setAgent(value: boolean) {
|
||||
setStore("notifications", "agent", value)
|
||||
},
|
||||
permissions: withFallback(() => store().notifications?.permissions, defaultSettings.notifications.permissions),
|
||||
permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions),
|
||||
setPermissions(value: boolean) {
|
||||
setStore("notifications", "permissions", value)
|
||||
},
|
||||
errors: withFallback(() => store().notifications?.errors, defaultSettings.notifications.errors),
|
||||
errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors),
|
||||
setErrors(value: boolean) {
|
||||
setStore("notifications", "errors", value)
|
||||
},
|
||||
},
|
||||
sounds: {
|
||||
agentEnabled: withFallback(() => store().sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
|
||||
agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
|
||||
setAgentEnabled(value: boolean) {
|
||||
setStore("sounds", "agentEnabled", value)
|
||||
},
|
||||
agent: withFallback(() => store().sounds?.agent, defaultSettings.sounds.agent),
|
||||
agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
|
||||
setAgent(value: string) {
|
||||
setStore("sounds", "agent", value)
|
||||
},
|
||||
permissionsEnabled: withFallback(
|
||||
() => store().sounds?.permissionsEnabled,
|
||||
() => store.sounds?.permissionsEnabled,
|
||||
defaultSettings.sounds.permissionsEnabled,
|
||||
),
|
||||
setPermissionsEnabled(value: boolean) {
|
||||
setStore("sounds", "permissionsEnabled", value)
|
||||
},
|
||||
permissions: withFallback(() => store().sounds?.permissions, defaultSettings.sounds.permissions),
|
||||
permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
|
||||
setPermissions(value: string) {
|
||||
setStore("sounds", "permissions", value)
|
||||
},
|
||||
errorsEnabled: withFallback(() => store().sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
|
||||
errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
|
||||
setErrorsEnabled(value: boolean) {
|
||||
setStore("sounds", "errorsEnabled", value)
|
||||
},
|
||||
errors: withFallback(() => store().sounds?.errors, defaultSettings.sounds.errors),
|
||||
errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
|
||||
setErrors(value: string) {
|
||||
setStore("sounds", "errors", value)
|
||||
},
|
||||
|
||||
@@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) {
|
||||
}
|
||||
|
||||
return {
|
||||
get<T>(key: string, name: string) {
|
||||
return entries.get(key)?.get(name)?.value as T | undefined
|
||||
},
|
||||
ensure<T>(key: string, name: string, init: () => T) {
|
||||
const state = entries.get(key) ?? new Map<string, Entry>()
|
||||
if (!entries.has(key)) entries.set(key, state)
|
||||
|
||||
@@ -22,6 +22,8 @@ describe("tab memory", () => {
|
||||
})
|
||||
|
||||
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
|
||||
expect(memory.get<typeof first>("tab", "prompt")).toBe(first)
|
||||
expect(memory.get("missing", "prompt")).toBeUndefined()
|
||||
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
|
||||
|
||||
memory.remove("tab")
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
@@ -55,7 +56,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const fallback = server.key
|
||||
const [store, setStore, ready] = persisted(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.window("tabs"),
|
||||
migrate: (value: unknown) => {
|
||||
@@ -68,9 +69,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
createStore<Tab[]>([]),
|
||||
)
|
||||
const [recent, setRecent, recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
|
||||
const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore<Record<string, TabInfo>>({}))
|
||||
const [closed, setClosed, closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
|
||||
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -81,7 +82,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
let recentWrite = 0
|
||||
let recentValue: string | undefined
|
||||
|
||||
const recentKey = () => (recentWrite ? recentValue : recent().key)
|
||||
const recentKey = () => (recentWrite ? recentValue : recent.key)
|
||||
|
||||
const setRecentKey = (key: string | undefined) => {
|
||||
const write = ++recentWrite
|
||||
@@ -109,7 +110,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const removeInfo = (key: string) => {
|
||||
if (!info()[key]) return
|
||||
if (!info[key]) return
|
||||
setInfo(
|
||||
produce((draft) => {
|
||||
delete draft[key]
|
||||
@@ -122,9 +123,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
createEffect(() => {
|
||||
if (!ready() || !recentReady()) return
|
||||
const servers = new Set(server.list.map(ServerConnection.key))
|
||||
const next = store().filter((tab) => servers.has(tab.server))
|
||||
if (next.length !== store().length) {
|
||||
for (const tab of store()) {
|
||||
const next = store.filter((tab) => servers.has(tab.server))
|
||||
if (next.length !== store.length) {
|
||||
for (const tab of store) {
|
||||
if (!servers.has(tab.server)) {
|
||||
const key = tabKey(tab)
|
||||
memory.remove(key)
|
||||
@@ -133,9 +134,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}
|
||||
setStore(() => next)
|
||||
}
|
||||
if (recent().key && !next.some((tab) => tabKey(tab) === recent().key)) setRecentKey(undefined)
|
||||
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
|
||||
const keys = new Set(next.map(tabKey))
|
||||
for (const key of Object.keys(info())) {
|
||||
for (const key of Object.keys(info)) {
|
||||
if (!keys.has(key)) removeInfo(key)
|
||||
}
|
||||
})
|
||||
@@ -143,8 +144,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
createEffect(() => {
|
||||
if (!closedReady()) return
|
||||
const servers = new Set(server.list.map(ServerConnection.key))
|
||||
const next = closed().filter((entry) => servers.has(entry.tab.server))
|
||||
if (next.length !== closed().length) setClosed(() => next)
|
||||
const next = closed.filter((entry) => servers.has(entry.tab.server))
|
||||
if (next.length !== closed.length) setClosed(() => next)
|
||||
})
|
||||
|
||||
const navigateTab = (tab: Tab) => {
|
||||
@@ -154,11 +155,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
const tab = store()[index]
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||
const nextTab = nextTabAfterClose(store(), index, recentKey() === key && location.pathname !== "/")
|
||||
const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/")
|
||||
closing.add(key)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
@@ -180,7 +181,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const actions = {
|
||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||
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))
|
||||
if (existing) return existing
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
@@ -203,19 +204,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
)
|
||||
},
|
||||
draft(draftID: string) {
|
||||
const tab = store().find((item) => item.type === "draft" && item.draftID === draftID)
|
||||
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||
return tab
|
||||
},
|
||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
||||
const draftID = uuid()
|
||||
const tab = { type: "draft" as const, draftID, ...draft }
|
||||
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push({ type: "draft", draftID, ...draft })
|
||||
tabs.push(tab)
|
||||
}),
|
||||
)
|
||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||
navigate(draftHref(draftID))
|
||||
})
|
||||
},
|
||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||
@@ -228,7 +231,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
||||
// Keep the replacement and navigation atomic so /new-session never renders
|
||||
// after its backing draft tab has been removed from the store().
|
||||
// after its backing draft tab has been removed from the store.
|
||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||
const next = { type: "session" as const, ...session }
|
||||
void startTransition(() => {
|
||||
@@ -238,7 +241,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (index !== -1) tabs[index] = next
|
||||
}),
|
||||
)
|
||||
if (recent().key === `draft:${draftID}`) setRecentKey(tabKey(next))
|
||||
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
|
||||
if (active) navigateTab(next)
|
||||
})
|
||||
memory.remove(`draft:${draftID}`)
|
||||
@@ -249,7 +252,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
// Cleanup paths (missing sessions, archive, server removal) go through
|
||||
// removeTab and friends directly and are not recorded.
|
||||
closeTab(index: number) {
|
||||
const tab = store()[index]
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
|
||||
removeTab(index)
|
||||
@@ -259,12 +262,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
void closedReady.promise?.then(() => actions.reopenClosedTab())
|
||||
return
|
||||
}
|
||||
const result = takeClosedTab(closed(), store())
|
||||
if (result.stack.length === closed().length) return
|
||||
const result = takeClosedTab(closed, store)
|
||||
if (result.stack.length === closed.length) return
|
||||
setClosed(() => result.stack)
|
||||
const entry = result.entry
|
||||
if (!entry) return
|
||||
const index = Math.min(entry.index, store().length)
|
||||
const index = Math.min(entry.index, store.length)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
@@ -277,27 +280,26 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
removeSessionTab(input: Omit<SessionTab, "type">) {
|
||||
updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId]))
|
||||
const index = store().findIndex(
|
||||
const index = store.findIndex(
|
||||
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
|
||||
)
|
||||
if (index !== -1) removeTab(index)
|
||||
},
|
||||
removeServer(key: ServerConnection.Key) {
|
||||
updateClosed((stack) => stack.filter((entry) => entry.tab.server !== key))
|
||||
const drafts = store().flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||
const removed = store().filter((tab) => tab.server === key).map(tabKey)
|
||||
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||
const removed = store.filter((tab) => tab.server === key).map(tabKey)
|
||||
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
||||
for (const key of removed) memory.remove(key)
|
||||
for (const key of removed) removeInfo(key)
|
||||
const recentKey = recent().key
|
||||
if (recentKey && removed.includes(recentKey)) setRecentKey(undefined)
|
||||
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
||||
for (const draftID of drafts) removeDraftPersisted(draftID)
|
||||
if (server.key === key) navigate("/")
|
||||
},
|
||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||
const targetServer = input.server ?? server.key
|
||||
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
|
||||
const removed = store()
|
||||
const removed = store
|
||||
.filter(
|
||||
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
|
||||
)
|
||||
@@ -341,8 +343,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
else navigate("/")
|
||||
}),
|
||||
)
|
||||
const recentKey = recent().key
|
||||
if (recentKey && removed.includes(recentKey)) setRecentKey(undefined)
|
||||
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
||||
})
|
||||
for (const key of removed) memory.remove(key)
|
||||
for (const key of removed) removeInfo(key)
|
||||
@@ -350,7 +351,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
rememberSessionInfo(tab: SessionTab, session: Session) {
|
||||
const key = tabKey(tab)
|
||||
const next = { title: session.title, directory: session.directory }
|
||||
const current = info()[key]
|
||||
const current = info[key]
|
||||
if (current?.title === next.title && current.directory === next.directory) return
|
||||
setInfo(key, next)
|
||||
},
|
||||
@@ -361,7 +362,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
toggleHome(input: { home: boolean; current?: Tab }) {
|
||||
if (input.home) {
|
||||
const tab = store().find((tab) => tabKey(tab) === recentKey())
|
||||
const tab = store.find((tab) => tabKey(tab) === recentKey())
|
||||
if (tab) navigateTab(tab)
|
||||
return
|
||||
}
|
||||
@@ -375,6 +376,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
state<T>(tab: Tab, name: string, init: () => T) {
|
||||
return memory.ensure(tabKey(tab), name, init)
|
||||
},
|
||||
stateValue<T>(tab: Tab, name: string) {
|
||||
return memory.get<T>(tabKey(tab), name)
|
||||
},
|
||||
}
|
||||
|
||||
return { ...actions, store, info, ready, recentReady }
|
||||
|
||||
@@ -151,7 +151,7 @@ function createWorkspaceTerminalSession(
|
||||
) {
|
||||
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
|
||||
|
||||
const [store, setStore, ready] = persisted(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...terminalPersistTarget(scope, dir, legacy),
|
||||
migrate: migrateTerminalState,
|
||||
@@ -166,7 +166,7 @@ function createWorkspaceTerminalSession(
|
||||
|
||||
const pickNextTerminalNumber = () => {
|
||||
const existingTitleNumbers = new Set(
|
||||
store().all.flatMap((pty) => {
|
||||
store.all.flatMap((pty) => {
|
||||
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
||||
if (direct !== undefined) return [direct]
|
||||
const parsed = numberFromTitle(pty.title)
|
||||
@@ -183,10 +183,10 @@ function createWorkspaceTerminalSession(
|
||||
}
|
||||
|
||||
const removeExited = (id: string) => {
|
||||
const all = store().all
|
||||
const all = store.all
|
||||
const index = all.findIndex((x) => x.id === id)
|
||||
if (index === -1) return
|
||||
const active = store().active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store().active
|
||||
const active = store.active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store.active
|
||||
batch(() => {
|
||||
setStore("active", active)
|
||||
setStore(
|
||||
@@ -204,8 +204,8 @@ function createWorkspaceTerminalSession(
|
||||
onCleanup(unsub)
|
||||
|
||||
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
||||
const index = store().all.findIndex((x) => x.id === pty.id)
|
||||
const previous = index >= 0 ? store().all[index] : undefined
|
||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||
const previous = index >= 0 ? store.all[index] : undefined
|
||||
if (index >= 0) {
|
||||
setStore("all", index, (item) => ({ ...item, ...pty }))
|
||||
}
|
||||
@@ -217,7 +217,7 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (previous) {
|
||||
const currentIndex = store().all.findIndex((item) => item.id === pty.id)
|
||||
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
|
||||
if (currentIndex >= 0) setStore("all", currentIndex, previous)
|
||||
}
|
||||
console.error("Failed to update terminal", error)
|
||||
@@ -225,8 +225,8 @@ function createWorkspaceTerminalSession(
|
||||
}
|
||||
|
||||
const clone = async (client: DirectorySDK["client"], id: string) => {
|
||||
const index = store().all.findIndex((x) => x.id === id)
|
||||
const pty = store().all[index]
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
const next = await client.pty
|
||||
.create({
|
||||
@@ -238,7 +238,7 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
if (!next?.data) return
|
||||
|
||||
const active = store().active === pty.id
|
||||
const active = store.active === pty.id
|
||||
|
||||
batch(() => {
|
||||
setStore("all", index, {
|
||||
@@ -259,8 +259,8 @@ function createWorkspaceTerminalSession(
|
||||
|
||||
return {
|
||||
ready,
|
||||
all: createMemo(() => store().all),
|
||||
active: createMemo(() => store().active),
|
||||
all: createMemo(() => store.all),
|
||||
active: createMemo(() => store.active),
|
||||
clear() {
|
||||
batch(() => {
|
||||
setStore("active", undefined)
|
||||
@@ -280,7 +280,7 @@ function createWorkspaceTerminalSession(
|
||||
title: pty.data?.title ?? defaultTitle(nextNumber),
|
||||
titleNumber: nextNumber,
|
||||
}
|
||||
setStore("all", store().all.length, newTerminal)
|
||||
setStore("all", store.all.length, newTerminal)
|
||||
setStore("active", id)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -291,7 +291,7 @@ function createWorkspaceTerminalSession(
|
||||
update(sdk.client, pty)
|
||||
},
|
||||
trim(id: string) {
|
||||
const index = store().all.findIndex((x) => x.id === id)
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
if (index === -1) return
|
||||
setStore("all", index, (pty) => trimTerminal(pty))
|
||||
},
|
||||
@@ -309,7 +309,7 @@ function createWorkspaceTerminalSession(
|
||||
const client = sdk.client
|
||||
return {
|
||||
trim(id: string) {
|
||||
const index = store().all.findIndex((x) => x.id === id)
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
if (index === -1) return
|
||||
setStore("all", index, (pty) => trimTerminal(pty))
|
||||
},
|
||||
@@ -325,23 +325,23 @@ function createWorkspaceTerminalSession(
|
||||
setStore("active", id)
|
||||
},
|
||||
next() {
|
||||
const index = store().all.findIndex((x) => x.id === store().active)
|
||||
const index = store.all.findIndex((x) => x.id === store.active)
|
||||
if (index === -1) return
|
||||
const nextIndex = (index + 1) % store().all.length
|
||||
setStore("active", store().all[nextIndex]?.id)
|
||||
const nextIndex = (index + 1) % store.all.length
|
||||
setStore("active", store.all[nextIndex]?.id)
|
||||
},
|
||||
previous() {
|
||||
const index = store().all.findIndex((x) => x.id === store().active)
|
||||
const index = store.all.findIndex((x) => x.id === store.active)
|
||||
if (index === -1) return
|
||||
const prevIndex = index === 0 ? store().all.length - 1 : index - 1
|
||||
setStore("active", store().all[prevIndex]?.id)
|
||||
const prevIndex = index === 0 ? store.all.length - 1 : index - 1
|
||||
setStore("active", store.all[prevIndex]?.id)
|
||||
},
|
||||
async close(id: string) {
|
||||
const index = store().all.findIndex((f) => f.id === id)
|
||||
const index = store.all.findIndex((f) => f.id === id)
|
||||
if (index !== -1) {
|
||||
batch(() => {
|
||||
if (store().active === id) {
|
||||
const next = index > 0 ? store().all[index - 1]?.id : store().all[1]?.id
|
||||
if (store.active === id) {
|
||||
const next = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id
|
||||
setStore("active", next)
|
||||
}
|
||||
setStore(
|
||||
@@ -358,7 +358,7 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
},
|
||||
move(id: string, to: number) {
|
||||
const index = store().all.findIndex((f) => f.id === id)
|
||||
const index = store.all.findIndex((f) => f.id === id)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"all",
|
||||
|
||||
Vendored
+10
@@ -12,6 +12,16 @@ interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module "*.png" {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module "*.mp4" {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
export declare module "solid-js" {
|
||||
namespace JSX {
|
||||
interface Directives {
|
||||
|
||||
+158
-2
@@ -21,6 +21,9 @@ export const dict = {
|
||||
"theme.scheme.dark": "داكن",
|
||||
"command.sidebar.toggle": "تبديل الشريط الجانبي",
|
||||
"command.project.open": "فتح مشروع",
|
||||
"command.project.previous": "المشروع السابق",
|
||||
"command.project.next": "المشروع التالي",
|
||||
"command.project.index": "التبديل إلى المشروع {{index}}",
|
||||
"command.provider.connect": "اتصال بموفر",
|
||||
"command.server.switch": "تبديل الخادم",
|
||||
"command.settings.open": "فتح الإعدادات",
|
||||
@@ -39,6 +42,7 @@ export const dict = {
|
||||
"command.session.new": "جلسة جديدة",
|
||||
"command.file.open": "فتح ملف",
|
||||
"command.tab.close": "إغلاق علامة التبويب",
|
||||
"command.tab.reopenClosed": "إعادة فتح علامة التبويب المغلقة",
|
||||
"command.context.addSelection": "إضافة التحديد إلى السياق",
|
||||
"command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي",
|
||||
"command.input.focus": "التركيز على حقل الإدخال",
|
||||
@@ -193,6 +197,11 @@ export const dict = {
|
||||
"model.input.video": "فيديو",
|
||||
"model.input.pdf": "pdf",
|
||||
"model.tooltip.allows": "يسمح: {{inputs}}",
|
||||
"model.tooltip.context.label": "السياق",
|
||||
"model.tooltip.inputs": "المدخلات",
|
||||
"model.tooltip.model": "النموذج",
|
||||
"model.tooltip.provider": "الموفر",
|
||||
"model.tooltip.reasoning": "الاستنتاج",
|
||||
"model.tooltip.reasoning.allowed": "يسمح بالاستنتاج",
|
||||
"model.tooltip.reasoning.none": "بدون استنتاج",
|
||||
"model.tooltip.context": "حد السياق {{limit}}",
|
||||
@@ -202,6 +211,7 @@ export const dict = {
|
||||
"common.loading": "جارٍ التحميل",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "إلغاء",
|
||||
"common.clear": "مسح",
|
||||
"common.connect": "اتصال",
|
||||
"common.disconnect": "قطع الاتصال",
|
||||
"common.continue": "إرسال",
|
||||
@@ -218,6 +228,8 @@ export const dict = {
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc للخروج",
|
||||
"session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.",
|
||||
"session.child.backToParent": "العودة إلى الجلسة الرئيسية.",
|
||||
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
|
||||
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",
|
||||
"prompt.example.3": "إصلاح الاختبارات المعطلة",
|
||||
@@ -255,6 +267,11 @@ export const dict = {
|
||||
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
|
||||
"prompt.context.removeFile": "إزالة الملف من السياق",
|
||||
"prompt.action.attachFile": "إرفاق ملف",
|
||||
"prompt.menu.addImagesAndFiles": "إضافة ملفات والمزيد",
|
||||
"prompt.menu.imagesAndFiles": "الصور والملفات",
|
||||
"prompt.menu.commands": "الأوامر",
|
||||
"prompt.menu.context": "السياق",
|
||||
"prompt.menu.shellCommand": "أمر shell",
|
||||
"prompt.attachment.remove": "إزالة المرفق",
|
||||
"prompt.action.send": "إرسال",
|
||||
"prompt.action.stop": "توقف",
|
||||
@@ -314,6 +331,74 @@ export const dict = {
|
||||
"dialog.server.menu.delete": "حذف",
|
||||
"dialog.server.current": "الخادم الحالي",
|
||||
"dialog.server.status.default": "افتراضي",
|
||||
"wsl.server.add": "إضافة خادم WSL",
|
||||
"wsl.server.addShort": "إضافة WSL",
|
||||
"wsl.server.label": "WSL",
|
||||
"wsl.server.menu.label": "خادم WSL",
|
||||
"wsl.server.retryStart": "إعادة محاولة التشغيل",
|
||||
"wsl.server.updating": "جارٍ التحديث...",
|
||||
"wsl.onboarding.step.distro": "اختر توزيعة",
|
||||
"wsl.onboarding.step.opencode": "OpenCode",
|
||||
"wsl.onboarding.checkingRuntime": "جارٍ التحقق من WSL...",
|
||||
"wsl.onboarding.restartRequired": "يحتاج Windows إلى إعادة التشغيل لإكمال تثبيت WSL.",
|
||||
"wsl.onboarding.ready": "WSL جاهز.",
|
||||
"wsl.onboarding.required": "يلزم WSL للمتابعة.",
|
||||
"wsl.onboarding.checkingDistros": "جارٍ التحقق من التوزيعات...",
|
||||
"wsl.onboarding.installingDistro": "جارٍ تثبيت {{distro}}...",
|
||||
"wsl.onboarding.checkingDistro": "جارٍ التحقق من {{distro}}...",
|
||||
"wsl.onboarding.listingDistros": "جارٍ سرد التوزيعات...",
|
||||
"wsl.onboarding.distroReady": "{{distro}} جاهزة.",
|
||||
"wsl.onboarding.distroNotInstalled": "لم يتم تثبيت {{distro}} بعد.",
|
||||
"wsl.onboarding.openDistroOnce": "افتح {{distro}} مرة واحدة لإكمال الإعداد.",
|
||||
"wsl.onboarding.finishingDistro": "جارٍ إكمال إعداد {{distro}}.",
|
||||
"wsl.onboarding.pickDistro": "اختر توزيعة أو ثبّت واحدة أدناه.",
|
||||
"wsl.onboarding.checkingOpencode": "جارٍ التحقق من OpenCode...",
|
||||
"wsl.onboarding.checkingOpencodeIn": "جارٍ التحقق من OpenCode في {{distro}}...",
|
||||
"wsl.onboarding.updatingOpencode": "جارٍ تحديث OpenCode...",
|
||||
"wsl.onboarding.updatingOpencodeIn": "جارٍ تحديث OpenCode في {{distro}}...",
|
||||
"wsl.onboarding.updateOpencodeIn": "حدّث OpenCode في {{distro}}.",
|
||||
"wsl.onboarding.updateOpencode": "تحديث OpenCode",
|
||||
"wsl.onboarding.opencodeReadyIn": "OpenCode جاهز في {{distro}}.",
|
||||
"wsl.onboarding.opencodeReady": "OpenCode جاهز.",
|
||||
"wsl.onboarding.installOpencodeIn": "ثبّت OpenCode في {{distro}}.",
|
||||
"wsl.onboarding.installOpencode": "تثبيت OpenCode",
|
||||
"wsl.onboarding.chooseDistroFirst": "اختر توزيعة أولاً.",
|
||||
"wsl.onboarding.loadFailed": "فشل تحميل حالة WSL.",
|
||||
"wsl.onboarding.loading": "جارٍ التحميل...",
|
||||
"wsl.onboarding.installedDistros": "التوزيعات المثبتة",
|
||||
"wsl.onboarding.checkAgain": "تحقق مجددًا",
|
||||
"wsl.onboarding.distroStatus.ready": "جاهزة",
|
||||
"wsl.onboarding.distroStatus.checking": "جارٍ التحقق...",
|
||||
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode غير مثبت",
|
||||
"wsl.onboarding.distroStatus.missingTools": "bash و curl مفقودان",
|
||||
"wsl.onboarding.distroStatus.unsupported": "غير مدعومة · استخدم WSL 2",
|
||||
"wsl.onboarding.needAnotherDistro": "هل تحتاج إلى توزيعة أخرى؟",
|
||||
"wsl.onboarding.needAnotherDistroHint": "ثبّت توزيعة Linux من كتالوج WSL",
|
||||
"wsl.onboarding.wslNotInstalled.title": "WSL غير مثبت",
|
||||
"wsl.onboarding.wslNotInstalled.description":
|
||||
"يلزم WSL (نظام Windows الفرعي لـ Linux) قبل أن يتمكن OpenCode من إضافة خادم WSL",
|
||||
"wsl.onboarding.wslUnavailable.title": "WSL غير متاح",
|
||||
"wsl.onboarding.wslUnavailable.description": "تعذر على OpenCode التحقق من WSL على هذا الجهاز.",
|
||||
"wsl.onboarding.installWsl": "تثبيت WSL",
|
||||
"wsl.onboarding.windowsRestartRequired": "أعد تشغيل Windows لإكمال تثبيت WSL، ثم أعد فتح OpenCode.",
|
||||
"wsl.onboarding.next": "التالي",
|
||||
"wsl.onboarding.refresh": "تحديث",
|
||||
"wsl.onboarding.allDistrosAdded": "تمت إضافة جميع التوزيعات المثبتة بالفعل.",
|
||||
"wsl.onboarding.noDistros": "لم يتم اكتشاف أي توزيعات بعد.",
|
||||
"wsl.onboarding.install": "تثبيت",
|
||||
"wsl.onboarding.installing": "جارٍ التثبيت...",
|
||||
"wsl.onboarding.installDistro": "تثبيت توزيعة",
|
||||
"wsl.onboarding.searchDistros": "البحث عن توزيعات",
|
||||
"wsl.onboarding.wsl2Required": "يلزم WSL 2.",
|
||||
"wsl.onboarding.toolsRequired": "تحتاج هذه التوزيعة إلى bash و curl.",
|
||||
"wsl.onboarding.openTerminal": "فتح المحطة الطرفية",
|
||||
"wsl.onboarding.path": "المسار: {{path}}",
|
||||
"wsl.onboarding.notFound": "غير موجود",
|
||||
"wsl.onboarding.version": "الإصدار: {{version}}",
|
||||
"wsl.onboarding.unknown": "غير معروف",
|
||||
"wsl.onboarding.desktopVersion": "سطح المكتب {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.",
|
||||
"wsl.onboarding.adding": "جارٍ الإضافة...",
|
||||
"dialog.project.edit.title": "تحرير المشروع",
|
||||
"dialog.project.edit.name": "الاسم",
|
||||
"dialog.project.edit.icon": "أيقونة",
|
||||
@@ -370,6 +455,7 @@ export const dict = {
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.uk": "Українська",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
"toast.language.title": "لغة",
|
||||
@@ -406,10 +492,12 @@ export const dict = {
|
||||
"toast.update.action.notYet": "ليس الآن",
|
||||
"error.page.title": "حدث خطأ ما",
|
||||
"error.page.description": "حدث خطأ أثناء تحميل التطبيق.",
|
||||
"error.page.description.localServerStartup": "حدث خطأ أثناء بدء تشغيل الخادم المحلي.",
|
||||
"error.page.details.label": "تفاصيل الخطأ",
|
||||
"error.page.action.restart": "إعادة تشغيل",
|
||||
"error.page.action.report": "الإبلاغ عن الخطأ",
|
||||
"error.page.action.reported": "تم الإبلاغ عن الخطأ",
|
||||
"error.page.action.exportLogs": "تصدير السجلات",
|
||||
"error.page.action.checking": "جارٍ التحقق...",
|
||||
"error.page.action.checkUpdates": "التحقق من وجود تحديثات",
|
||||
"error.page.action.updateTo": "تحديث إلى {{version}}",
|
||||
@@ -450,16 +538,39 @@ export const dict = {
|
||||
"home.recentProjects": "المشاريع الحديثة",
|
||||
"home.empty.title": "لا توجد مشاريع حديثة",
|
||||
"home.empty.description": "ابدأ بفتح مشروع محلي",
|
||||
"home.title": "الرئيسية",
|
||||
"home.projects": "المشاريع",
|
||||
"home.project.add": "إضافة مشروع",
|
||||
"home.recentlyClosed": "المغلقة مؤخرًا",
|
||||
"home.server.collapse": "طي مشاريع الخادم",
|
||||
"home.server.expand": "توسيع مشاريع الخادم",
|
||||
"home.sessions.search.placeholder": "البحث في الجلسات",
|
||||
"home.sessions.search.placeholder.scoped": "البحث في جلسات {{scope}}",
|
||||
"home.sessions.search.sessions": "الجلسات",
|
||||
"home.sessions.search.noResults": "لم يتم العثور على جلسات لـ {{query}}",
|
||||
"home.sessions.empty": "لا يوجد شيء هنا بعد",
|
||||
"home.sessions.empty.description": "أنشئ جلسة للبدء",
|
||||
"home.sessions.group.today": "اليوم",
|
||||
"home.sessions.group.yesterday": "أمس",
|
||||
"home.sessions.group.older": "الأقدم",
|
||||
"home.providerTip": "اتصل بأكثر من 75 مزودًا لاستخدام نماذج أخرى، بما فيها Claude وGPT وGemini وغيرها",
|
||||
"session.tab.session": "جلسة",
|
||||
"session.tab.review": "مراجعة",
|
||||
"session.tab.context": "سياق",
|
||||
"session.tab.unknown": "جلسة غير معروفة",
|
||||
"session.panel.reviewAndFiles": "المراجعة والملفات",
|
||||
"session.error.notFound": "لا يمكن العثور على هذه الجلسة",
|
||||
"session.error.notFound.description": "يشير هذا التبويب إلى جلسة لم تعد موجودة على هذا الخادم.",
|
||||
"session.error.notFound.closeTab": "إغلاق علامة التبويب",
|
||||
"session.error.serverConnection": "تعذر الاتصال بهذا الخادم",
|
||||
"session.review.filesChanged": "تم تغيير {{count}} ملفات",
|
||||
"session.review.change.one": "تغيير",
|
||||
"session.review.change.other": "تغييرات",
|
||||
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
|
||||
"session.review.empty": "لا توجد تغييرات في هذه الجلسة بعد",
|
||||
"session.review.noChanges": "لا توجد تغييرات",
|
||||
"session.review.noUncommittedChanges": "لا توجد تغييرات غير مُودَعة بعد",
|
||||
"session.review.noBranchChanges": "لا توجد تغييرات في الفرع بعد",
|
||||
"session.review.noVcs": "لم يتم اكتشاف نظام التحكم في الإصدار Git، لن يتم عرض التغييرات",
|
||||
"session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة",
|
||||
"session.files.selectToOpen": "اختر ملفًا لفتحه",
|
||||
@@ -475,6 +586,10 @@ export const dict = {
|
||||
"session.todo.title": "المهام",
|
||||
"session.todo.collapse": "طي",
|
||||
"session.todo.expand": "توسيع",
|
||||
"session.question.minimize": "تصغير السؤال",
|
||||
"session.question.restore": "استعادة السؤال",
|
||||
"session.question.pending.one": "{{count}} سؤال معلق",
|
||||
"session.question.pending.other": "الأسئلة المعلقة: {{count}}",
|
||||
"session.followupDock.summary.one": "{{count}} رسالة في الانتظار",
|
||||
"session.followupDock.summary.other": "{{count}} رسائل في الانتظار",
|
||||
"session.followupDock.sendNow": "إرسال الآن",
|
||||
@@ -487,9 +602,16 @@ export const dict = {
|
||||
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
|
||||
"session.revertDock.restore": "استعادة الرسالة",
|
||||
"session.new.title": "ابنِ أي شيء",
|
||||
"session.new.project.new": "مشروع جديد",
|
||||
"session.new.project.search": "البحث عن المشاريع",
|
||||
"session.new.project.add": "إضافة مشروع",
|
||||
"session.new.worktree.main": "الفرع الرئيسي",
|
||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
||||
"session.new.workspace.runIn": "تشغيل الجلسة في",
|
||||
"session.new.workspace.triggerLocal": "محلي",
|
||||
"session.new.workspace.local": "المستودع المحلي",
|
||||
"session.new.workspace.existing": "مساحة عمل…",
|
||||
"session.new.lastModified": "آخر تعديل",
|
||||
"session.header.search.placeholder": "بحث {{project}}",
|
||||
"session.header.searchFiles": "بحث عن الملفات",
|
||||
@@ -550,6 +672,8 @@ export const dict = {
|
||||
"sidebar.project.recentSessions": "الجلسات الحديثة",
|
||||
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
|
||||
"sidebar.project.clearNotifications": "مسح الإشعارات",
|
||||
"sidebar.empty.title": "لا توجد مشاريع مفتوحة",
|
||||
"sidebar.empty.description": "افتح مشروعًا للبدء",
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "سطح المكتب",
|
||||
"settings.section.server": "الخادم",
|
||||
@@ -559,6 +683,7 @@ export const dict = {
|
||||
"settings.desktop.wsl.title": "تكامل WSL",
|
||||
"settings.desktop.wsl.description": "تشغيل خادم OpenCode داخل WSL على Windows.",
|
||||
"settings.general.section.appearance": "المظهر",
|
||||
"settings.general.section.advanced": "متقدم",
|
||||
"settings.general.section.notifications": "إشعارات النظام",
|
||||
"settings.general.section.updates": "التحديثات",
|
||||
"settings.general.section.sounds": "المؤثرات الصوتية",
|
||||
@@ -566,6 +691,11 @@ export const dict = {
|
||||
"settings.general.section.display": "شاشة العرض",
|
||||
"settings.general.row.language.title": "اللغة",
|
||||
"settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
|
||||
"settings.general.row.shell.title": "Shell المحطة الطرفية",
|
||||
"settings.general.row.shell.description":
|
||||
"اختر shell المستخدم في المحطة الطرفية. تُستخدم واجهات shell المتوافقة أيضًا في استدعاءات أدوات الوكيل.",
|
||||
"settings.general.row.shell.autoDefault": "تلقائي (افتراضي)",
|
||||
"settings.general.row.shell.terminalOnly": "المحطة الطرفية فقط",
|
||||
"settings.general.row.appearance.title": "المظهر",
|
||||
"settings.general.row.appearance.description": "تخصيص كيفية ظهور OpenCode على جهازك",
|
||||
"settings.general.row.colorScheme.title": "مخطط الألوان",
|
||||
@@ -574,14 +704,29 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
||||
"settings.general.row.font.title": "خط الكود",
|
||||
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية",
|
||||
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||
"settings.general.row.terminalFont.title": "خط الطرفية",
|
||||
"settings.general.row.terminalFont.description": "خصّص الخط المستخدم في الطرفية",
|
||||
"settings.general.row.uiFont.title": "خط الواجهة",
|
||||
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
|
||||
"settings.general.row.followup.title": "سلوك المتابعة",
|
||||
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
|
||||
"settings.general.row.followup.option.queue": "قائمة انتظار",
|
||||
"settings.general.row.followup.option.steer": "توجيه",
|
||||
"settings.general.row.showFileTree.title": "شجرة الملفات",
|
||||
"settings.general.row.showFileTree.description": "إظهار لوحة شجرة الملفات في الجلسات",
|
||||
"settings.general.row.showNavigation.title": "عناصر التحكم في التنقل",
|
||||
"settings.general.row.showNavigation.description": "إظهار زري الرجوع والتقدم في شريط عنوان سطح المكتب",
|
||||
"settings.general.row.showSearch.title": "لوحة الأوامر",
|
||||
"settings.general.row.showSearch.description": "إظهار زر البحث ولوحة الأوامر في شريط العنوان",
|
||||
"settings.general.row.showTerminal.title": "المحطة الطرفية",
|
||||
"settings.general.row.showTerminal.description": "إظهار زر المحطة الطرفية في شريط عنوان سطح المكتب",
|
||||
"settings.general.row.showStatus.title": "حالة الخادم",
|
||||
"settings.general.row.showStatus.description": "إظهار زر حالة الخادم في شريط العنوان",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
|
||||
"settings.general.row.showCustomAgents.title": "الوكلاء المخصصون",
|
||||
"settings.general.row.showCustomAgents.description": "إظهار محدد الوكلاء في محرر الرسائل",
|
||||
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج",
|
||||
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
|
||||
@@ -590,6 +735,15 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.newInterface.title": "التخطيط الجديد",
|
||||
"settings.general.row.newInterface.badge": "جديد",
|
||||
"settings.general.row.newInterface.description":
|
||||
"استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.",
|
||||
"settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد",
|
||||
"settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "رفض",
|
||||
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
|
||||
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
|
||||
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
||||
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -602,6 +756,8 @@ export const dict = {
|
||||
"settings.updates.row.check.description": "التحقق يدويًا من التحديثات وتثبيتها إذا كانت متاحة",
|
||||
"settings.updates.action.checkNow": "تحقق الآن",
|
||||
"settings.updates.action.checking": "جارٍ التحقق...",
|
||||
"settings.updates.action.downloading": "جارٍ التنزيل...",
|
||||
"settings.updates.action.installing": "جارٍ التثبيت...",
|
||||
"settings.updates.toast.latest.title": "أنت على آخر إصدار",
|
||||
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
|
||||
"sound.option.none": "بلا",
|
||||
|
||||
+163
-3
@@ -21,6 +21,9 @@ export const dict = {
|
||||
"theme.scheme.dark": "Escuro",
|
||||
"command.sidebar.toggle": "Alternar barra lateral",
|
||||
"command.project.open": "Abrir projeto",
|
||||
"command.project.previous": "Projeto anterior",
|
||||
"command.project.next": "Próximo projeto",
|
||||
"command.project.index": "Alternar para o projeto {{index}}",
|
||||
"command.provider.connect": "Conectar provedor",
|
||||
"command.server.switch": "Trocar servidor",
|
||||
"command.settings.open": "Abrir configurações",
|
||||
@@ -39,6 +42,7 @@ export const dict = {
|
||||
"command.session.new": "Nova sessão",
|
||||
"command.file.open": "Abrir arquivo",
|
||||
"command.tab.close": "Fechar aba",
|
||||
"command.tab.reopenClosed": "Reabrir aba fechada",
|
||||
"command.context.addSelection": "Adicionar seleção ao contexto",
|
||||
"command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual",
|
||||
"command.input.focus": "Focar entrada",
|
||||
@@ -193,6 +197,11 @@ export const dict = {
|
||||
"model.input.video": "vídeo",
|
||||
"model.input.pdf": "pdf",
|
||||
"model.tooltip.allows": "Permite: {{inputs}}",
|
||||
"model.tooltip.context.label": "Contexto",
|
||||
"model.tooltip.inputs": "Entradas",
|
||||
"model.tooltip.model": "Modelo",
|
||||
"model.tooltip.provider": "Provedor",
|
||||
"model.tooltip.reasoning": "Raciocínio",
|
||||
"model.tooltip.reasoning.allowed": "Permite raciocínio",
|
||||
"model.tooltip.reasoning.none": "Sem raciocínio",
|
||||
"model.tooltip.context": "Limite de contexto {{limit}}",
|
||||
@@ -202,6 +211,7 @@ export const dict = {
|
||||
"common.loading": "Carregando",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "Cancelar",
|
||||
"common.clear": "Limpar",
|
||||
"common.connect": "Conectar",
|
||||
"common.disconnect": "Desconectar",
|
||||
"common.continue": "Enviar",
|
||||
@@ -218,6 +228,8 @@ export const dict = {
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc para sair",
|
||||
"session.child.promptDisabled": "Não é possível enviar prompts em sessões de subagentes.",
|
||||
"session.child.backToParent": "Voltar à sessão principal.",
|
||||
"prompt.example.1": "Corrigir um TODO no código",
|
||||
"prompt.example.2": "Qual é a stack tecnológica deste projeto?",
|
||||
"prompt.example.3": "Corrigir testes quebrados",
|
||||
@@ -255,6 +267,11 @@ export const dict = {
|
||||
"prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
|
||||
"prompt.context.removeFile": "Remover arquivo do contexto",
|
||||
"prompt.action.attachFile": "Anexar arquivo",
|
||||
"prompt.menu.addImagesAndFiles": "Adicionar arquivos e mais",
|
||||
"prompt.menu.imagesAndFiles": "Imagens e arquivos",
|
||||
"prompt.menu.commands": "Comandos",
|
||||
"prompt.menu.context": "Contexto",
|
||||
"prompt.menu.shellCommand": "Comando shell",
|
||||
"prompt.attachment.remove": "Remover anexo",
|
||||
"prompt.action.send": "Enviar",
|
||||
"prompt.action.stop": "Parar",
|
||||
@@ -314,6 +331,75 @@ export const dict = {
|
||||
"dialog.server.menu.delete": "Excluir",
|
||||
"dialog.server.current": "Servidor atual",
|
||||
"dialog.server.status.default": "Padrão",
|
||||
"wsl.server.add": "Adicionar servidor WSL",
|
||||
"wsl.server.addShort": "Adicionar WSL",
|
||||
"wsl.server.label": "WSL",
|
||||
"wsl.server.menu.label": "Servidor WSL",
|
||||
"wsl.server.retryStart": "Tentar iniciar novamente",
|
||||
"wsl.server.updating": "Atualizando...",
|
||||
"wsl.onboarding.step.distro": "Escolher distribuição",
|
||||
"wsl.onboarding.step.opencode": "OpenCode",
|
||||
"wsl.onboarding.checkingRuntime": "Verificando o WSL...",
|
||||
"wsl.onboarding.restartRequired": "O Windows precisa ser reiniciado para concluir a instalação do WSL.",
|
||||
"wsl.onboarding.ready": "O WSL está pronto.",
|
||||
"wsl.onboarding.required": "O WSL é necessário para continuar.",
|
||||
"wsl.onboarding.checkingDistros": "Verificando distribuições...",
|
||||
"wsl.onboarding.installingDistro": "Instalando {{distro}}...",
|
||||
"wsl.onboarding.checkingDistro": "Verificando {{distro}}...",
|
||||
"wsl.onboarding.listingDistros": "Listando distribuições...",
|
||||
"wsl.onboarding.distroReady": "{{distro}} está pronta.",
|
||||
"wsl.onboarding.distroNotInstalled": "{{distro}} ainda não está instalada.",
|
||||
"wsl.onboarding.openDistroOnce": "Abra {{distro}} uma vez para concluir a configuração.",
|
||||
"wsl.onboarding.finishingDistro": "Concluindo a configuração de {{distro}}.",
|
||||
"wsl.onboarding.pickDistro": "Escolha uma distribuição ou instale uma abaixo.",
|
||||
"wsl.onboarding.checkingOpencode": "Verificando o OpenCode...",
|
||||
"wsl.onboarding.checkingOpencodeIn": "Verificando o OpenCode em {{distro}}...",
|
||||
"wsl.onboarding.updatingOpencode": "Atualizando o OpenCode...",
|
||||
"wsl.onboarding.updatingOpencodeIn": "Atualizando o OpenCode em {{distro}}...",
|
||||
"wsl.onboarding.updateOpencodeIn": "Atualize o OpenCode em {{distro}}.",
|
||||
"wsl.onboarding.updateOpencode": "Atualizar o OpenCode",
|
||||
"wsl.onboarding.opencodeReadyIn": "O OpenCode está pronto em {{distro}}.",
|
||||
"wsl.onboarding.opencodeReady": "O OpenCode está pronto.",
|
||||
"wsl.onboarding.installOpencodeIn": "Instale o OpenCode em {{distro}}.",
|
||||
"wsl.onboarding.installOpencode": "Instalar o OpenCode",
|
||||
"wsl.onboarding.chooseDistroFirst": "Escolha primeiro uma distribuição.",
|
||||
"wsl.onboarding.loadFailed": "Falha ao carregar o estado do WSL.",
|
||||
"wsl.onboarding.loading": "Carregando...",
|
||||
"wsl.onboarding.installedDistros": "Distribuições instaladas",
|
||||
"wsl.onboarding.checkAgain": "Verificar novamente",
|
||||
"wsl.onboarding.distroStatus.ready": "Pronta",
|
||||
"wsl.onboarding.distroStatus.checking": "Verificando...",
|
||||
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode não instalado",
|
||||
"wsl.onboarding.distroStatus.missingTools": "bash e curl ausentes",
|
||||
"wsl.onboarding.distroStatus.unsupported": "Sem suporte · Use o WSL 2",
|
||||
"wsl.onboarding.needAnotherDistro": "Precisa de outra distribuição?",
|
||||
"wsl.onboarding.needAnotherDistroHint": "Instale uma distribuição Linux pelo catálogo do WSL",
|
||||
"wsl.onboarding.wslNotInstalled.title": "WSL não instalado",
|
||||
"wsl.onboarding.wslNotInstalled.description":
|
||||
"O WSL (Windows Subsystem for Linux) é necessário para que o OpenCode possa adicionar um servidor WSL",
|
||||
"wsl.onboarding.wslUnavailable.title": "WSL indisponível",
|
||||
"wsl.onboarding.wslUnavailable.description": "O OpenCode não conseguiu verificar o WSL neste computador.",
|
||||
"wsl.onboarding.installWsl": "Instalar o WSL",
|
||||
"wsl.onboarding.windowsRestartRequired":
|
||||
"Reinicie o Windows para concluir a instalação do WSL e abra o OpenCode novamente.",
|
||||
"wsl.onboarding.next": "Próximo",
|
||||
"wsl.onboarding.refresh": "Atualizar",
|
||||
"wsl.onboarding.allDistrosAdded": "Todas as distribuições instaladas já foram adicionadas.",
|
||||
"wsl.onboarding.noDistros": "Nenhuma distribuição detectada ainda.",
|
||||
"wsl.onboarding.install": "Instalar",
|
||||
"wsl.onboarding.installing": "Instalando...",
|
||||
"wsl.onboarding.installDistro": "Instalar distribuição",
|
||||
"wsl.onboarding.searchDistros": "Buscar distribuições",
|
||||
"wsl.onboarding.wsl2Required": "O WSL 2 é necessário.",
|
||||
"wsl.onboarding.toolsRequired": "Esta distribuição precisa do bash e do curl.",
|
||||
"wsl.onboarding.openTerminal": "Abrir terminal",
|
||||
"wsl.onboarding.path": "Caminho: {{path}}",
|
||||
"wsl.onboarding.notFound": "não encontrado",
|
||||
"wsl.onboarding.version": "Versão: {{version}}",
|
||||
"wsl.onboarding.unknown": "desconhecido",
|
||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "A versão instalada não corresponde à versão do aplicativo para desktop.",
|
||||
"wsl.onboarding.adding": "Adicionando...",
|
||||
"dialog.project.edit.title": "Editar projeto",
|
||||
"dialog.project.edit.name": "Nome",
|
||||
"dialog.project.edit.icon": "Ícone",
|
||||
@@ -371,6 +457,7 @@ export const dict = {
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.uk": "Українська",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
"toast.language.title": "Idioma",
|
||||
@@ -407,10 +494,12 @@ export const dict = {
|
||||
"toast.update.action.notYet": "Agora não",
|
||||
"error.page.title": "Algo deu errado",
|
||||
"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.details.label": "Detalhes do Erro",
|
||||
"error.page.action.restart": "Reiniciar",
|
||||
"error.page.action.report": "Reportar erro",
|
||||
"error.page.action.reported": "Erro reportado",
|
||||
"error.page.action.exportLogs": "Exportar logs",
|
||||
"error.page.action.checking": "Verificando...",
|
||||
"error.page.action.checkUpdates": "Verificar atualizações",
|
||||
"error.page.action.updateTo": "Atualizar para {{version}}",
|
||||
@@ -453,10 +542,32 @@ export const dict = {
|
||||
"home.recentProjects": "Projetos recentes",
|
||||
"home.empty.title": "Nenhum projeto recente",
|
||||
"home.empty.description": "Comece abrindo um projeto local",
|
||||
"home.title": "Início",
|
||||
"home.projects": "Projetos",
|
||||
"home.project.add": "Adicionar projeto",
|
||||
"home.recentlyClosed": "Projetos fechados recentemente",
|
||||
"home.server.collapse": "Recolher projetos do servidor",
|
||||
"home.server.expand": "Expandir projetos do servidor",
|
||||
"home.sessions.search.placeholder": "Buscar sessões",
|
||||
"home.sessions.search.placeholder.scoped": "Buscar sessões em {{scope}}",
|
||||
"home.sessions.search.sessions": "Sessões",
|
||||
"home.sessions.search.noResults": "Nenhuma sessão encontrada para {{query}}",
|
||||
"home.sessions.empty": "Nada por aqui ainda",
|
||||
"home.sessions.empty.description": "Crie uma sessão para começar",
|
||||
"home.sessions.group.today": "Hoje",
|
||||
"home.sessions.group.yesterday": "Ontem",
|
||||
"home.sessions.group.older": "Mais antigas",
|
||||
"home.providerTip":
|
||||
"Conecte-se a mais de 75 provedores para usar outros modelos, incluindo Claude, GPT, Gemini e muito mais",
|
||||
"session.tab.session": "Sessão",
|
||||
"session.tab.review": "Revisão",
|
||||
"session.tab.context": "Contexto",
|
||||
"session.tab.unknown": "Sessão desconhecida",
|
||||
"session.panel.reviewAndFiles": "Revisão e arquivos",
|
||||
"session.error.notFound": "Esta sessão não foi encontrada",
|
||||
"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.serverConnection": "Não é possível conectar a este servidor",
|
||||
"session.review.filesChanged": "{{count}} Arquivos Alterados",
|
||||
"session.review.change.one": "Alteração",
|
||||
"session.review.change.other": "Alterações",
|
||||
@@ -466,6 +577,8 @@ export const dict = {
|
||||
"session.review.noSnapshot":
|
||||
"O rastreamento de snapshot está desabilitado na configuração, então as alterações da sessão estão indisponíveis",
|
||||
"session.review.noChanges": "Sem alterações",
|
||||
"session.review.noUncommittedChanges": "Ainda não há alterações sem commit",
|
||||
"session.review.noBranchChanges": "Ainda não há alterações na branch",
|
||||
"session.files.selectToOpen": "Selecione um arquivo para abrir",
|
||||
"session.files.all": "Todos os arquivos",
|
||||
"session.files.empty": "Nenhum arquivo",
|
||||
@@ -479,6 +592,10 @@ export const dict = {
|
||||
"session.todo.title": "Tarefas",
|
||||
"session.todo.collapse": "Recolher",
|
||||
"session.todo.expand": "Expandir",
|
||||
"session.question.minimize": "Minimizar pergunta",
|
||||
"session.question.restore": "Restaurar pergunta",
|
||||
"session.question.pending.one": "{{count}} pergunta pendente",
|
||||
"session.question.pending.other": "{{count}} perguntas pendentes",
|
||||
"session.followupDock.summary.one": "{{count}} mensagem na fila",
|
||||
"session.followupDock.summary.other": "{{count}} mensagens na fila",
|
||||
"session.followupDock.sendNow": "Enviar agora",
|
||||
@@ -491,9 +608,16 @@ export const dict = {
|
||||
"session.revertDock.expand": "Expandir mensagens revertidas",
|
||||
"session.revertDock.restore": "Restaurar mensagem",
|
||||
"session.new.title": "Crie qualquer coisa",
|
||||
"session.new.project.new": "Novo projeto",
|
||||
"session.new.project.search": "Buscar projetos",
|
||||
"session.new.project.add": "Adicionar projeto",
|
||||
"session.new.worktree.main": "Branch principal",
|
||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||
"session.new.worktree.create": "Criar novo worktree",
|
||||
"session.new.workspace.runIn": "Executar sessão em",
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Repositório local",
|
||||
"session.new.workspace.existing": "Espaço de trabalho…",
|
||||
"session.new.lastModified": "Última modificação",
|
||||
"session.header.search.placeholder": "Buscar {{project}}",
|
||||
"session.header.searchFiles": "Buscar arquivos",
|
||||
@@ -557,15 +681,18 @@ export const dict = {
|
||||
"sidebar.project.recentSessions": "Sessões recentes",
|
||||
"sidebar.project.viewAllSessions": "Ver todas as sessões",
|
||||
"sidebar.project.clearNotifications": "Limpar notificações",
|
||||
"sidebar.empty.title": "Nenhum projeto aberto",
|
||||
"sidebar.empty.description": "Abra um projeto para começar",
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "Desktop",
|
||||
"settings.section.server": "Servidor",
|
||||
"settings.tab.general": "Geral",
|
||||
"settings.tab.shortcuts": "Atalhos",
|
||||
"settings.desktop.section.wsl": "WSL",
|
||||
"settings.desktop.wsl.title": "WSL integration",
|
||||
"settings.desktop.wsl.title": "Integração com WSL",
|
||||
"settings.desktop.wsl.description": "Executar o servidor OpenCode dentro do WSL no Windows.",
|
||||
"settings.general.section.appearance": "Aparência",
|
||||
"settings.general.section.advanced": "Avançado",
|
||||
"settings.general.section.notifications": "Notificações do sistema",
|
||||
"settings.general.section.updates": "Atualizações",
|
||||
"settings.general.section.sounds": "Efeitos sonoros",
|
||||
@@ -573,6 +700,11 @@ export const dict = {
|
||||
"settings.general.section.display": "Tela",
|
||||
"settings.general.row.language.title": "Idioma",
|
||||
"settings.general.row.language.description": "Alterar o idioma de exibição do OpenCode",
|
||||
"settings.general.row.shell.title": "Shell do terminal",
|
||||
"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.",
|
||||
"settings.general.row.shell.autoDefault": "Automático (Padrão)",
|
||||
"settings.general.row.shell.terminalOnly": "somente no terminal",
|
||||
"settings.general.row.appearance.title": "Aparência",
|
||||
"settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo",
|
||||
"settings.general.row.colorScheme.title": "Esquema de cores",
|
||||
@@ -581,8 +713,8 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
||||
"settings.general.row.font.title": "Fonte de código",
|
||||
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código",
|
||||
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||
"settings.general.row.terminalFont.title": "Fonte do terminal",
|
||||
"settings.general.row.terminalFont.description": "Personalize a fonte usada no terminal",
|
||||
"settings.general.row.uiFont.title": "Fonte da interface",
|
||||
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
|
||||
"settings.general.row.followup.title": "Comportamento de acompanhamento",
|
||||
@@ -590,6 +722,22 @@ export const dict = {
|
||||
"Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
|
||||
"settings.general.row.followup.option.queue": "Fila",
|
||||
"settings.general.row.followup.option.steer": "Orientar",
|
||||
"settings.general.row.showFileTree.title": "Árvore de arquivos",
|
||||
"settings.general.row.showFileTree.description": "Mostrar o painel da árvore de arquivos nas sessões",
|
||||
"settings.general.row.showNavigation.title": "Controles de navegação",
|
||||
"settings.general.row.showNavigation.description":
|
||||
"Mostrar os botões de voltar e avançar na barra de título do desktop",
|
||||
"settings.general.row.showSearch.title": "Paleta de comandos",
|
||||
"settings.general.row.showSearch.description": "Mostrar o botão de busca e paleta de comandos na barra de título",
|
||||
"settings.general.row.showTerminal.title": "Terminal",
|
||||
"settings.general.row.showTerminal.description": "Mostrar o botão do terminal na barra de título do desktop",
|
||||
"settings.general.row.showStatus.title": "Status do servidor",
|
||||
"settings.general.row.showStatus.description": "Mostrar o botão de status do servidor na barra de título",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navegação inferior",
|
||||
"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",
|
||||
"settings.general.row.showCustomAgents.title": "Agentes personalizados",
|
||||
"settings.general.row.showCustomAgents.description": "Mostrar o seletor de agentes na área de composição",
|
||||
"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.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
|
||||
@@ -598,6 +746,16 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
|
||||
"settings.general.row.newInterface.title": "Novo layout",
|
||||
"settings.general.row.newInterface.badge": "Novo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Use as novas abas e o layout da página inicial. Alterne entre os layouts por tempo limitado.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Agora você está usando o novo layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "O layout anterior não está mais disponível",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
|
||||
"settings.general.row.pinchZoom.title": "Zoom com gesto de pinça",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom",
|
||||
"settings.general.row.wayland.title": "Usar Wayland nativo",
|
||||
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -610,6 +768,8 @@ export const dict = {
|
||||
"settings.updates.row.check.description": "Verificar atualizações manualmente e instalar se houver",
|
||||
"settings.updates.action.checkNow": "Verificar agora",
|
||||
"settings.updates.action.checking": "Verificando...",
|
||||
"settings.updates.action.downloading": "Baixando...",
|
||||
"settings.updates.action.installing": "Instalando...",
|
||||
"settings.updates.toast.latest.title": "Você está atualizado",
|
||||
"settings.updates.toast.latest.description": "Você está usando a versão mais recente do OpenCode.",
|
||||
"sound.option.none": "Nenhum",
|
||||
|
||||
+162
-2
@@ -23,6 +23,9 @@ export const dict = {
|
||||
|
||||
"command.sidebar.toggle": "Prikaži/sakrij bočnu traku",
|
||||
"command.project.open": "Otvori projekat",
|
||||
"command.project.previous": "Prethodni projekat",
|
||||
"command.project.next": "Sljedeći projekat",
|
||||
"command.project.index": "Prebaci na projekat {{index}}",
|
||||
"command.provider.connect": "Poveži provajdera",
|
||||
"command.server.switch": "Promijeni server",
|
||||
"command.settings.open": "Otvori postavke",
|
||||
@@ -45,6 +48,7 @@ export const dict = {
|
||||
"command.session.new": "Nova sesija",
|
||||
"command.file.open": "Otvori datoteku",
|
||||
"command.tab.close": "Zatvori karticu",
|
||||
"command.tab.reopenClosed": "Ponovo otvori zatvorenu karticu",
|
||||
"command.context.addSelection": "Dodaj odabir u kontekst",
|
||||
"command.context.addSelection.description": "Dodaj odabrane linije iz trenutne datoteke",
|
||||
"command.input.focus": "Fokusiraj polje za unos",
|
||||
@@ -209,6 +213,11 @@ export const dict = {
|
||||
"model.input.video": "video",
|
||||
"model.input.pdf": "pdf",
|
||||
"model.tooltip.allows": "Podržava: {{inputs}}",
|
||||
"model.tooltip.context.label": "Kontekst",
|
||||
"model.tooltip.inputs": "Ulazi",
|
||||
"model.tooltip.model": "Model",
|
||||
"model.tooltip.provider": "Provajder",
|
||||
"model.tooltip.reasoning": "Rasuđivanje",
|
||||
"model.tooltip.reasoning.allowed": "Podržava rasuđivanje",
|
||||
"model.tooltip.reasoning.none": "Bez rasuđivanja",
|
||||
"model.tooltip.context": "Limit konteksta {{limit}}",
|
||||
@@ -219,6 +228,7 @@ export const dict = {
|
||||
"common.loading": "Učitavanje",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "Otkaži",
|
||||
"common.clear": "Očisti",
|
||||
"common.connect": "Poveži",
|
||||
"common.disconnect": "Prekini vezu",
|
||||
"common.continue": "Pošalji",
|
||||
@@ -236,6 +246,8 @@ export const dict = {
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc za izlaz",
|
||||
"session.child.promptDisabled": "Sesijama podagenta nije moguće slati upite.",
|
||||
"session.child.backToParent": "Nazad na glavnu sesiju.",
|
||||
|
||||
"prompt.example.1": "Popravi TODO u bazi koda",
|
||||
"prompt.example.2": "Koji je tehnološki stack ovog projekta?",
|
||||
@@ -275,6 +287,11 @@ export const dict = {
|
||||
"prompt.context.removeActiveFile": "Ukloni aktivnu datoteku iz konteksta",
|
||||
"prompt.context.removeFile": "Ukloni datoteku iz konteksta",
|
||||
"prompt.action.attachFile": "Priloži datoteku",
|
||||
"prompt.menu.addImagesAndFiles": "Dodaj datoteke i više",
|
||||
"prompt.menu.imagesAndFiles": "Slike i datoteke",
|
||||
"prompt.menu.commands": "Komande",
|
||||
"prompt.menu.context": "Kontekst",
|
||||
"prompt.menu.shellCommand": "Shell naredba",
|
||||
"prompt.attachment.remove": "Ukloni prilog",
|
||||
"prompt.action.send": "Pošalji",
|
||||
"prompt.action.stop": "Zaustavi",
|
||||
@@ -342,6 +359,75 @@ export const dict = {
|
||||
"dialog.server.menu.delete": "Izbriši",
|
||||
"dialog.server.current": "Trenutni server",
|
||||
"dialog.server.status.default": "Podrazumijevano",
|
||||
"wsl.server.add": "Dodaj WSL server",
|
||||
"wsl.server.addShort": "Dodaj WSL",
|
||||
"wsl.server.label": "WSL",
|
||||
"wsl.server.menu.label": "WSL server",
|
||||
"wsl.server.retryStart": "Ponovi pokretanje",
|
||||
"wsl.server.updating": "Ažuriranje...",
|
||||
"wsl.onboarding.step.distro": "Odaberi distribuciju",
|
||||
"wsl.onboarding.step.opencode": "OpenCode",
|
||||
"wsl.onboarding.checkingRuntime": "Provjera WSL-a...",
|
||||
"wsl.onboarding.restartRequired": "Potrebno je ponovo pokrenuti Windows da bi se završila instalacija WSL-a.",
|
||||
"wsl.onboarding.ready": "WSL je spreman.",
|
||||
"wsl.onboarding.required": "WSL je potreban za nastavak.",
|
||||
"wsl.onboarding.checkingDistros": "Provjera distribucija...",
|
||||
"wsl.onboarding.installingDistro": "Instaliranje {{distro}}...",
|
||||
"wsl.onboarding.checkingDistro": "Provjera {{distro}}...",
|
||||
"wsl.onboarding.listingDistros": "Učitavanje liste distribucija...",
|
||||
"wsl.onboarding.distroReady": "Distribucija {{distro}} je spremna.",
|
||||
"wsl.onboarding.distroNotInstalled": "Distribucija {{distro}} još nije instalirana.",
|
||||
"wsl.onboarding.openDistroOnce": "Otvori {{distro}} jednom da završiš postavljanje.",
|
||||
"wsl.onboarding.finishingDistro": "Završavanje postavljanja za {{distro}}.",
|
||||
"wsl.onboarding.pickDistro": "Odaberi distribuciju ili instaliraj jednu ispod.",
|
||||
"wsl.onboarding.checkingOpencode": "Provjera OpenCode...",
|
||||
"wsl.onboarding.checkingOpencodeIn": "Provjera OpenCode u distribuciji {{distro}}...",
|
||||
"wsl.onboarding.updatingOpencode": "Ažuriranje OpenCode...",
|
||||
"wsl.onboarding.updatingOpencodeIn": "Ažuriranje OpenCode u distribuciji {{distro}}...",
|
||||
"wsl.onboarding.updateOpencodeIn": "Ažuriraj OpenCode u distribuciji {{distro}}.",
|
||||
"wsl.onboarding.updateOpencode": "Ažuriraj OpenCode",
|
||||
"wsl.onboarding.opencodeReadyIn": "OpenCode je spreman u distribuciji {{distro}}.",
|
||||
"wsl.onboarding.opencodeReady": "OpenCode je spreman.",
|
||||
"wsl.onboarding.installOpencodeIn": "Instaliraj OpenCode u distribuciji {{distro}}.",
|
||||
"wsl.onboarding.installOpencode": "Instaliraj OpenCode",
|
||||
"wsl.onboarding.chooseDistroFirst": "Prvo odaberi distribuciju.",
|
||||
"wsl.onboarding.loadFailed": "Nije uspjelo učitavanje WSL stanja.",
|
||||
"wsl.onboarding.loading": "Učitavanje...",
|
||||
"wsl.onboarding.installedDistros": "Instalirane distribucije",
|
||||
"wsl.onboarding.checkAgain": "Provjeri ponovo",
|
||||
"wsl.onboarding.distroStatus.ready": "Spremno",
|
||||
"wsl.onboarding.distroStatus.checking": "Provjera...",
|
||||
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode nije instaliran",
|
||||
"wsl.onboarding.distroStatus.missingTools": "Nedostaju bash i curl",
|
||||
"wsl.onboarding.distroStatus.unsupported": "Nije podržano · Koristi WSL 2",
|
||||
"wsl.onboarding.needAnotherDistro": "Treba ti druga distribucija?",
|
||||
"wsl.onboarding.needAnotherDistroHint": "Instaliraj Linux distribuciju iz WSL kataloga",
|
||||
"wsl.onboarding.wslNotInstalled.title": "WSL nije instaliran",
|
||||
"wsl.onboarding.wslNotInstalled.description":
|
||||
"WSL (Windows Subsystem for Linux) je potreban da bi OpenCode mogao dodati WSL server",
|
||||
"wsl.onboarding.wslUnavailable.title": "WSL nije dostupan",
|
||||
"wsl.onboarding.wslUnavailable.description": "OpenCode nije mogao provjeriti WSL na ovom uređaju.",
|
||||
"wsl.onboarding.installWsl": "Instaliraj WSL",
|
||||
"wsl.onboarding.windowsRestartRequired":
|
||||
"Ponovo pokreni Windows da završiš instalaciju WSL-a, zatim ponovo otvori OpenCode.",
|
||||
"wsl.onboarding.next": "Dalje",
|
||||
"wsl.onboarding.refresh": "Osvježi",
|
||||
"wsl.onboarding.allDistrosAdded": "Sve instalirane distribucije su već dodane.",
|
||||
"wsl.onboarding.noDistros": "Još nema otkrivenih distribucija.",
|
||||
"wsl.onboarding.install": "Instaliraj",
|
||||
"wsl.onboarding.installing": "Instaliranje...",
|
||||
"wsl.onboarding.installDistro": "Instaliraj distribuciju",
|
||||
"wsl.onboarding.searchDistros": "Pretraži distribucije",
|
||||
"wsl.onboarding.wsl2Required": "Potreban je WSL 2.",
|
||||
"wsl.onboarding.toolsRequired": "Ovoj distribuciji su potrebni bash i curl.",
|
||||
"wsl.onboarding.openTerminal": "Otvori terminal",
|
||||
"wsl.onboarding.path": "Putanja: {{path}}",
|
||||
"wsl.onboarding.notFound": "nije pronađeno",
|
||||
"wsl.onboarding.version": "Verzija: {{version}}",
|
||||
"wsl.onboarding.unknown": "nepoznato",
|
||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Instalirana verzija ne odgovara verziji desktop aplikacije.",
|
||||
"wsl.onboarding.adding": "Dodavanje...",
|
||||
|
||||
"dialog.project.edit.title": "Uredi projekat",
|
||||
"dialog.project.edit.name": "Naziv",
|
||||
@@ -405,6 +491,7 @@ export const dict = {
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.uk": "Українська",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
@@ -453,10 +540,12 @@ export const dict = {
|
||||
|
||||
"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.localServerStartup": "Došlo je do greške pri pokretanju lokalnog servera.",
|
||||
"error.page.details.label": "Detalji greške",
|
||||
"error.page.action.restart": "Restartuj",
|
||||
"error.page.action.report": "Prijavi grešku",
|
||||
"error.page.action.reported": "Greška prijavljena",
|
||||
"error.page.action.exportLogs": "Izvezi logove",
|
||||
"error.page.action.checking": "Provjera...",
|
||||
"error.page.action.checkUpdates": "Provjeri ažuriranja",
|
||||
"error.page.action.updateTo": "Ažuriraj na {{version}}",
|
||||
@@ -504,11 +593,33 @@ export const dict = {
|
||||
"home.recentProjects": "Nedavni projekti",
|
||||
"home.empty.title": "Nema nedavnih projekata",
|
||||
"home.empty.description": "Kreni tako što ćeš otvoriti lokalni projekat",
|
||||
"home.title": "Početna",
|
||||
"home.projects": "Projekti",
|
||||
"home.project.add": "Dodaj projekat",
|
||||
"home.recentlyClosed": "Nedavno zatvoreni projekti",
|
||||
"home.server.collapse": "Sažmi projekte servera",
|
||||
"home.server.expand": "Proširi projekte servera",
|
||||
"home.sessions.search.placeholder": "Pretraži sesije",
|
||||
"home.sessions.search.placeholder.scoped": "Pretraži sesije u {{scope}}",
|
||||
"home.sessions.search.sessions": "Sesije",
|
||||
"home.sessions.search.noResults": "Nema pronađenih sesija za {{query}}",
|
||||
"home.sessions.empty": "Ovdje još nema ničega",
|
||||
"home.sessions.empty.description": "Kreiraj sesiju za početak",
|
||||
"home.sessions.group.today": "Danas",
|
||||
"home.sessions.group.yesterday": "Jučer",
|
||||
"home.sessions.group.older": "Starije",
|
||||
"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",
|
||||
|
||||
"session.tab.session": "Sesija",
|
||||
"session.tab.review": "Pregled",
|
||||
"session.tab.context": "Kontekst",
|
||||
"session.tab.unknown": "Nepoznata sesija",
|
||||
"session.panel.reviewAndFiles": "Pregled i datoteke",
|
||||
"session.error.notFound": "Ova sesija nije pronađena",
|
||||
"session.error.notFound.description": "Ova kartica upućuje na sesiju koja više ne postoji na ovom serveru.",
|
||||
"session.error.notFound.closeTab": "Zatvori karticu",
|
||||
"session.error.serverConnection": "Nije moguće povezati se s ovim serverom",
|
||||
"session.review.filesChanged": "Izmijenjeno {{count}} datoteka",
|
||||
"session.review.change.one": "Izmjena",
|
||||
"session.review.change.other": "Izmjene",
|
||||
@@ -518,6 +629,8 @@ export const dict = {
|
||||
"session.review.noSnapshot":
|
||||
"Praćenje snimaka (snapshot) je onemogućeno u konfiguraciji, pa promjene sesije nisu dostupne",
|
||||
"session.review.noChanges": "Nema izmjena",
|
||||
"session.review.noUncommittedChanges": "Još nema nepotvrđenih promjena",
|
||||
"session.review.noBranchChanges": "Još nema promjena na grani",
|
||||
|
||||
"session.files.selectToOpen": "Odaberi datoteku za otvaranje",
|
||||
"session.files.all": "Sve datoteke",
|
||||
@@ -534,6 +647,10 @@ export const dict = {
|
||||
"session.todo.title": "Zadaci",
|
||||
"session.todo.collapse": "Sažmi",
|
||||
"session.todo.expand": "Proširi",
|
||||
"session.question.minimize": "Minimiziraj pitanje",
|
||||
"session.question.restore": "Vrati pitanje",
|
||||
"session.question.pending.one": "{{count}} pitanje na čekanju",
|
||||
"session.question.pending.other": "{{count}} pitanja na čekanju",
|
||||
"session.followupDock.summary.one": "{{count}} poruka na čekanju",
|
||||
"session.followupDock.summary.other": "{{count}} poruka na čekanju",
|
||||
"session.followupDock.sendNow": "Pošalji sada",
|
||||
@@ -547,9 +664,16 @@ export const dict = {
|
||||
"session.revertDock.restore": "Vrati poruku",
|
||||
|
||||
"session.new.title": "Napravi bilo šta",
|
||||
"session.new.project.new": "Novi projekat",
|
||||
"session.new.project.search": "Pretraži projekte",
|
||||
"session.new.project.add": "Dodaj projekat",
|
||||
"session.new.worktree.main": "Glavna grana",
|
||||
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
|
||||
"session.new.worktree.create": "Kreiraj novi worktree",
|
||||
"session.new.workspace.runIn": "Pokreni sesiju u",
|
||||
"session.new.workspace.triggerLocal": "Lokalno",
|
||||
"session.new.workspace.local": "Lokalni repozitorij",
|
||||
"session.new.workspace.existing": "Radni prostor…",
|
||||
"session.new.lastModified": "Posljednja izmjena",
|
||||
|
||||
"session.header.search.placeholder": "Pretraži {{project}}",
|
||||
@@ -618,6 +742,8 @@ export const dict = {
|
||||
"sidebar.project.recentSessions": "Nedavne sesije",
|
||||
"sidebar.project.viewAllSessions": "Prikaži sve sesije",
|
||||
"sidebar.project.clearNotifications": "Očisti obavijesti",
|
||||
"sidebar.empty.title": "Nema otvorenih projekata",
|
||||
"sidebar.empty.description": "Otvori projekat za početak",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
|
||||
@@ -630,6 +756,7 @@ export const dict = {
|
||||
"settings.desktop.wsl.description": "Pokreni OpenCode server unutar WSL-a na Windowsu.",
|
||||
|
||||
"settings.general.section.appearance": "Izgled",
|
||||
"settings.general.section.advanced": "Napredno",
|
||||
"settings.general.section.notifications": "Sistemske obavijesti",
|
||||
"settings.general.section.updates": "Ažuriranja",
|
||||
"settings.general.section.sounds": "Zvučni efekti",
|
||||
@@ -638,6 +765,11 @@ export const dict = {
|
||||
|
||||
"settings.general.row.language.title": "Jezik",
|
||||
"settings.general.row.language.description": "Promijeni jezik prikaza u OpenCode-u",
|
||||
"settings.general.row.shell.title": "Shell terminala",
|
||||
"settings.general.row.shell.description":
|
||||
"Odaberi shell koji se koristi za terminal. Kompatibilni shellovi koriste se i za pozive alata agenta.",
|
||||
"settings.general.row.shell.autoDefault": "Automatski (podrazumijevano)",
|
||||
"settings.general.row.shell.terminalOnly": "samo terminal",
|
||||
"settings.general.row.appearance.title": "Izgled",
|
||||
"settings.general.row.appearance.description": "Prilagodi kako OpenCode izgleda na tvom uređaju",
|
||||
"settings.general.row.colorScheme.title": "Šema boja",
|
||||
@@ -646,14 +778,30 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
|
||||
"settings.general.row.font.title": "Font za kod",
|
||||
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
|
||||
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||
"settings.general.row.terminalFont.title": "Font terminala",
|
||||
"settings.general.row.terminalFont.description": "Prilagodite font koji se koristi u terminalu",
|
||||
"settings.general.row.uiFont.title": "UI font",
|
||||
"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.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
|
||||
"settings.general.row.followup.option.queue": "Red čekanja",
|
||||
"settings.general.row.followup.option.steer": "Usmjeri",
|
||||
"settings.general.row.showFileTree.title": "Stablo datoteka",
|
||||
"settings.general.row.showFileTree.description": "Prikaži stablo datoteka u sesijama",
|
||||
"settings.general.row.showNavigation.title": "Kontrole navigacije",
|
||||
"settings.general.row.showNavigation.description":
|
||||
"Prikaži dugmad za nazad i naprijed u naslovnoj traci desktop aplikacije",
|
||||
"settings.general.row.showSearch.title": "Paleta komandi",
|
||||
"settings.general.row.showSearch.description": "Prikaži dugme za pretragu i paletu komandi u naslovnoj traci",
|
||||
"settings.general.row.showTerminal.title": "Terminal",
|
||||
"settings.general.row.showTerminal.description": "Prikaži dugme terminala u naslovnoj traci desktop aplikacije",
|
||||
"settings.general.row.showStatus.title": "Status servera",
|
||||
"settings.general.row.showStatus.description": "Prikaži dugme statusa servera u naslovnoj traci",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Donja navigacija",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Postavi naslovnu traku i kartice sesije na dno ekrana na mobilnim uređajima",
|
||||
"settings.general.row.showCustomAgents.title": "Prilagođeni agenti",
|
||||
"settings.general.row.showCustomAgents.description": "Prikaži izbor agenta u uređivaču poruke",
|
||||
"settings.general.row.reasoningSummaries.title": "Prikaži sažetke rasuđivanja",
|
||||
"settings.general.row.reasoningSummaries.description": "Prikaži sažetke rasuđivanja modela na vremenskoj traci",
|
||||
|
||||
@@ -663,6 +811,16 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
|
||||
"settings.general.row.newInterface.title": "Novi raspored",
|
||||
"settings.general.row.newInterface.badge": "Novo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Koristite nove kartice i raspored početne stranice. Ograničeno vrijeme možete se prebacivati između rasporeda.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Sada koristite novi raspored",
|
||||
"settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Odbaci",
|
||||
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"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.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -677,6 +835,8 @@ export const dict = {
|
||||
"settings.updates.row.check.description": "Ručno provjeri ažuriranja i instaliraj ako su dostupna",
|
||||
"settings.updates.action.checkNow": "Provjeri sada",
|
||||
"settings.updates.action.checking": "Provjera...",
|
||||
"settings.updates.action.downloading": "Preuzimanje...",
|
||||
"settings.updates.action.installing": "Instaliranje...",
|
||||
"settings.updates.toast.latest.title": "Sve je ažurno",
|
||||
"settings.updates.toast.latest.description": "Koristiš najnoviju verziju OpenCode-a.",
|
||||
"sound.option.none": "Nijedan",
|
||||
|
||||
+160
-2
@@ -23,6 +23,9 @@ export const dict = {
|
||||
|
||||
"command.sidebar.toggle": "Skift sidebjælke",
|
||||
"command.project.open": "Åbn projekt",
|
||||
"command.project.previous": "Forrige projekt",
|
||||
"command.project.next": "Næste projekt",
|
||||
"command.project.index": "Skift til projekt {{index}}",
|
||||
"command.provider.connect": "Tilslut udbyder",
|
||||
"command.server.switch": "Skift server",
|
||||
"command.settings.open": "Åbn indstillinger",
|
||||
@@ -45,6 +48,7 @@ export const dict = {
|
||||
"command.session.new": "Ny session",
|
||||
"command.file.open": "Åbn fil",
|
||||
"command.tab.close": "Luk fane",
|
||||
"command.tab.reopenClosed": "Åbn lukket fane igen",
|
||||
"command.context.addSelection": "Tilføj markering til kontekst",
|
||||
"command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil",
|
||||
"command.input.focus": "Fokuser inputfelt",
|
||||
@@ -208,6 +212,11 @@ export const dict = {
|
||||
"model.input.video": "video",
|
||||
"model.input.pdf": "pdf",
|
||||
"model.tooltip.allows": "Tillader: {{inputs}}",
|
||||
"model.tooltip.context.label": "Kontekst",
|
||||
"model.tooltip.inputs": "Input",
|
||||
"model.tooltip.model": "Model",
|
||||
"model.tooltip.provider": "Udbyder",
|
||||
"model.tooltip.reasoning": "Tænkning",
|
||||
"model.tooltip.reasoning.allowed": "Tillader tænkning",
|
||||
"model.tooltip.reasoning.none": "Ingen tænkning",
|
||||
"model.tooltip.context": "Kontekstgrænse {{limit}}",
|
||||
@@ -217,6 +226,7 @@ export const dict = {
|
||||
"common.loading": "Indlæser",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "Annuller",
|
||||
"common.clear": "Ryd",
|
||||
"common.connect": "Forbind",
|
||||
"common.disconnect": "Frakobl",
|
||||
"common.continue": "Indsend",
|
||||
@@ -234,6 +244,8 @@ export const dict = {
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc for at afslutte",
|
||||
"session.child.promptDisabled": "Der kan ikke sendes prompts til underagentsessioner.",
|
||||
"session.child.backToParent": "Tilbage til hovedsessionen.",
|
||||
|
||||
"prompt.example.1": "Ret en TODO i koden",
|
||||
"prompt.example.2": "Hvad er teknologistakken for dette projekt?",
|
||||
@@ -273,6 +285,11 @@ export const dict = {
|
||||
"prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
|
||||
"prompt.context.removeFile": "Fjern fil fra kontekst",
|
||||
"prompt.action.attachFile": "Vedhæft fil",
|
||||
"prompt.menu.addImagesAndFiles": "Tilføj filer og mere",
|
||||
"prompt.menu.imagesAndFiles": "Billeder og filer",
|
||||
"prompt.menu.commands": "Kommandoer",
|
||||
"prompt.menu.context": "Kontekst",
|
||||
"prompt.menu.shellCommand": "Shell-kommando",
|
||||
"prompt.attachment.remove": "Fjern vedhæftning",
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Stop",
|
||||
@@ -340,6 +357,75 @@ export const dict = {
|
||||
"dialog.server.menu.delete": "Slet",
|
||||
"dialog.server.current": "Nuværende server",
|
||||
"dialog.server.status.default": "Standard",
|
||||
"wsl.server.add": "Tilføj WSL-server",
|
||||
"wsl.server.addShort": "Tilføj WSL",
|
||||
"wsl.server.label": "WSL",
|
||||
"wsl.server.menu.label": "WSL-server",
|
||||
"wsl.server.retryStart": "Prøv at starte igen",
|
||||
"wsl.server.updating": "Opdaterer...",
|
||||
"wsl.onboarding.step.distro": "Vælg distribution",
|
||||
"wsl.onboarding.step.opencode": "OpenCode",
|
||||
"wsl.onboarding.checkingRuntime": "Tjekker WSL...",
|
||||
"wsl.onboarding.restartRequired": "Windows skal genstartes for at fuldføre installationen af WSL.",
|
||||
"wsl.onboarding.ready": "WSL er klar.",
|
||||
"wsl.onboarding.required": "WSL er påkrævet for at fortsætte.",
|
||||
"wsl.onboarding.checkingDistros": "Tjekker distributioner...",
|
||||
"wsl.onboarding.installingDistro": "Installerer {{distro}}...",
|
||||
"wsl.onboarding.checkingDistro": "Tjekker {{distro}}...",
|
||||
"wsl.onboarding.listingDistros": "Henter liste over distributioner...",
|
||||
"wsl.onboarding.distroReady": "{{distro}} er klar.",
|
||||
"wsl.onboarding.distroNotInstalled": "{{distro}} er ikke installeret endnu.",
|
||||
"wsl.onboarding.openDistroOnce": "Åbn {{distro}} én gang for at fuldføre opsætningen.",
|
||||
"wsl.onboarding.finishingDistro": "Fuldfører opsætningen af {{distro}}.",
|
||||
"wsl.onboarding.pickDistro": "Vælg en distribution, eller installer en nedenfor.",
|
||||
"wsl.onboarding.checkingOpencode": "Tjekker OpenCode...",
|
||||
"wsl.onboarding.checkingOpencodeIn": "Tjekker OpenCode i {{distro}}...",
|
||||
"wsl.onboarding.updatingOpencode": "Opdaterer OpenCode...",
|
||||
"wsl.onboarding.updatingOpencodeIn": "Opdaterer OpenCode i {{distro}}...",
|
||||
"wsl.onboarding.updateOpencodeIn": "Opdater OpenCode i {{distro}}.",
|
||||
"wsl.onboarding.updateOpencode": "Opdater OpenCode",
|
||||
"wsl.onboarding.opencodeReadyIn": "OpenCode er klar i {{distro}}.",
|
||||
"wsl.onboarding.opencodeReady": "OpenCode er klar.",
|
||||
"wsl.onboarding.installOpencodeIn": "Installer OpenCode i {{distro}}.",
|
||||
"wsl.onboarding.installOpencode": "Installer OpenCode",
|
||||
"wsl.onboarding.chooseDistroFirst": "Vælg først en distribution.",
|
||||
"wsl.onboarding.loadFailed": "Kunne ikke indlæse WSL-status.",
|
||||
"wsl.onboarding.loading": "Indlæser...",
|
||||
"wsl.onboarding.installedDistros": "Installerede distributioner",
|
||||
"wsl.onboarding.checkAgain": "Tjek igen",
|
||||
"wsl.onboarding.distroStatus.ready": "Klar",
|
||||
"wsl.onboarding.distroStatus.checking": "Tjekker...",
|
||||
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode er ikke installeret",
|
||||
"wsl.onboarding.distroStatus.missingTools": "Mangler bash, curl",
|
||||
"wsl.onboarding.distroStatus.unsupported": "Ikke understøttet · Brug WSL 2",
|
||||
"wsl.onboarding.needAnotherDistro": "Har du brug for en anden distribution?",
|
||||
"wsl.onboarding.needAnotherDistroHint": "Installer en Linux-distribution fra WSL-kataloget",
|
||||
"wsl.onboarding.wslNotInstalled.title": "WSL er ikke installeret",
|
||||
"wsl.onboarding.wslNotInstalled.description":
|
||||
"WSL (Windows Subsystem for Linux) er påkrævet, før OpenCode kan tilføje en WSL-server",
|
||||
"wsl.onboarding.wslUnavailable.title": "WSL er ikke tilgængelig",
|
||||
"wsl.onboarding.wslUnavailable.description": "OpenCode kunne ikke bekræfte WSL på denne maskine.",
|
||||
"wsl.onboarding.installWsl": "Installer WSL",
|
||||
"wsl.onboarding.windowsRestartRequired":
|
||||
"Genstart Windows for at fuldføre installationen af WSL, og åbn derefter OpenCode igen.",
|
||||
"wsl.onboarding.next": "Næste",
|
||||
"wsl.onboarding.refresh": "Opdater",
|
||||
"wsl.onboarding.allDistrosAdded": "Alle installerede distributioner er allerede tilføjet.",
|
||||
"wsl.onboarding.noDistros": "Ingen distributioner fundet endnu.",
|
||||
"wsl.onboarding.install": "Installer",
|
||||
"wsl.onboarding.installing": "Installerer...",
|
||||
"wsl.onboarding.installDistro": "Installer distribution",
|
||||
"wsl.onboarding.searchDistros": "Søg efter distributioner",
|
||||
"wsl.onboarding.wsl2Required": "WSL 2 er påkrævet.",
|
||||
"wsl.onboarding.toolsRequired": "Denne distribution kræver bash og curl.",
|
||||
"wsl.onboarding.openTerminal": "Åbn terminal",
|
||||
"wsl.onboarding.path": "Sti: {{path}}",
|
||||
"wsl.onboarding.notFound": "ikke fundet",
|
||||
"wsl.onboarding.version": "Version: {{version}}",
|
||||
"wsl.onboarding.unknown": "ukendt",
|
||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Den installerede version matcher ikke desktopappens version.",
|
||||
"wsl.onboarding.adding": "Tilføjer...",
|
||||
|
||||
"dialog.project.edit.title": "Rediger projekt",
|
||||
"dialog.project.edit.name": "Navn",
|
||||
@@ -403,6 +489,7 @@ export const dict = {
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.uk": "Українська",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
@@ -450,10 +537,12 @@ export const dict = {
|
||||
|
||||
"error.page.title": "Noget gik galt",
|
||||
"error.page.description": "Der opstod en fejl under indlæsning af applikationen.",
|
||||
"error.page.description.localServerStartup": "Der opstod en fejl under start af den lokale server.",
|
||||
"error.page.details.label": "Fejldetaljer",
|
||||
"error.page.action.restart": "Genstart",
|
||||
"error.page.action.report": "Rapportér fejl",
|
||||
"error.page.action.reported": "Fejl rapporteret",
|
||||
"error.page.action.exportLogs": "Eksportér logfiler",
|
||||
"error.page.action.checking": "Tjekker...",
|
||||
"error.page.action.checkUpdates": "Tjek for opdateringer",
|
||||
"error.page.action.updateTo": "Opdater til {{version}}",
|
||||
@@ -501,11 +590,33 @@ export const dict = {
|
||||
"home.recentProjects": "Seneste projekter",
|
||||
"home.empty.title": "Ingen seneste projekter",
|
||||
"home.empty.description": "Kom i gang ved at åbne et lokalt projekt",
|
||||
"home.title": "Hjem",
|
||||
"home.projects": "Projekter",
|
||||
"home.project.add": "Tilføj projekt",
|
||||
"home.recentlyClosed": "Lukket for nylig",
|
||||
"home.server.collapse": "Skjul serverprojekter",
|
||||
"home.server.expand": "Udvid serverprojekter",
|
||||
"home.sessions.search.placeholder": "Søg i sessioner",
|
||||
"home.sessions.search.placeholder.scoped": "Søg i sessioner i {{scope}}",
|
||||
"home.sessions.search.sessions": "Sessioner",
|
||||
"home.sessions.search.noResults": "Ingen sessioner fundet for {{query}}",
|
||||
"home.sessions.empty": "Intet her endnu",
|
||||
"home.sessions.empty.description": "Opret en session for at komme i gang",
|
||||
"home.sessions.group.today": "I dag",
|
||||
"home.sessions.group.yesterday": "I går",
|
||||
"home.sessions.group.older": "Ældre",
|
||||
"home.providerTip":
|
||||
"Opret forbindelse til mere end 75 udbydere for at bruge andre modeller, herunder Claude, GPT, Gemini og flere",
|
||||
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.review": "Gennemgang",
|
||||
"session.tab.context": "Kontekst",
|
||||
"session.tab.unknown": "Ukendt session",
|
||||
"session.panel.reviewAndFiles": "Gennemgang og filer",
|
||||
"session.error.notFound": "Denne session kan ikke findes",
|
||||
"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.serverConnection": "Kan ikke oprette forbindelse til denne server",
|
||||
"session.review.filesChanged": "{{count}} Filer ændret",
|
||||
"session.review.change.one": "Ændring",
|
||||
"session.review.change.other": "Ændringer",
|
||||
@@ -515,6 +626,8 @@ export const dict = {
|
||||
"session.review.noSnapshot":
|
||||
"Snapshot-sporing er deaktiveret i konfigurationen, så sessionsændringer er ikke tilgængelige",
|
||||
"session.review.noChanges": "Ingen ændringer",
|
||||
"session.review.noUncommittedChanges": "Ingen ændringer uden commit endnu",
|
||||
"session.review.noBranchChanges": "Ingen grenændringer endnu",
|
||||
"session.files.selectToOpen": "Vælg en fil at åbne",
|
||||
"session.files.all": "Alle filer",
|
||||
"session.files.empty": "Ingen filer",
|
||||
@@ -529,6 +642,10 @@ export const dict = {
|
||||
"session.todo.title": "Opgaver",
|
||||
"session.todo.collapse": "Skjul",
|
||||
"session.todo.expand": "Udvid",
|
||||
"session.question.minimize": "Minimer spørgsmål",
|
||||
"session.question.restore": "Gendan spørgsmål",
|
||||
"session.question.pending.one": "{{count}} afventende spørgsmål",
|
||||
"session.question.pending.other": "{{count}} afventende spørgsmål",
|
||||
"session.followupDock.summary.one": "{{count}} besked i kø",
|
||||
"session.followupDock.summary.other": "{{count}} beskeder i kø",
|
||||
"session.followupDock.sendNow": "Send nu",
|
||||
@@ -542,9 +659,16 @@ export const dict = {
|
||||
"session.revertDock.restore": "Gendan besked",
|
||||
|
||||
"session.new.title": "Byg hvad som helst",
|
||||
"session.new.project.new": "Nyt projekt",
|
||||
"session.new.project.search": "Søg efter projekter",
|
||||
"session.new.project.add": "Tilføj projekt",
|
||||
"session.new.worktree.main": "Hovedgren",
|
||||
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
||||
"session.new.worktree.create": "Opret nyt worktree",
|
||||
"session.new.workspace.runIn": "Kør session i",
|
||||
"session.new.workspace.triggerLocal": "Lokal",
|
||||
"session.new.workspace.local": "Lokalt repository",
|
||||
"session.new.workspace.existing": "Arbejdsområde…",
|
||||
"session.new.lastModified": "Sidst ændret",
|
||||
|
||||
"session.header.search.placeholder": "Søg {{project}}",
|
||||
@@ -614,6 +738,8 @@ export const dict = {
|
||||
"sidebar.project.recentSessions": "Seneste sessioner",
|
||||
"sidebar.project.viewAllSessions": "Vis alle sessioner",
|
||||
"sidebar.project.clearNotifications": "Ryd notifikationer",
|
||||
"sidebar.empty.title": "Ingen åbne projekter",
|
||||
"sidebar.empty.description": "Åbn et projekt for at komme i gang",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "Desktop",
|
||||
@@ -625,6 +751,7 @@ export const dict = {
|
||||
"settings.desktop.wsl.description": "Kør OpenCode-serveren inde i WSL på Windows.",
|
||||
|
||||
"settings.general.section.appearance": "Udseende",
|
||||
"settings.general.section.advanced": "Avanceret",
|
||||
"settings.general.section.notifications": "Systemmeddelelser",
|
||||
"settings.general.section.updates": "Opdateringer",
|
||||
"settings.general.section.sounds": "Lydeffekter",
|
||||
@@ -633,6 +760,11 @@ export const dict = {
|
||||
|
||||
"settings.general.row.language.title": "Sprog",
|
||||
"settings.general.row.language.description": "Ændr visningssproget for OpenCode",
|
||||
"settings.general.row.shell.title": "Terminalshell",
|
||||
"settings.general.row.shell.description":
|
||||
"Vælg den shell, der bruges i din terminal. Kompatible shells bruges også til agentens værktøjskald.",
|
||||
"settings.general.row.shell.autoDefault": "Automatisk (standard)",
|
||||
"settings.general.row.shell.terminalOnly": "kun terminal",
|
||||
"settings.general.row.appearance.title": "Udseende",
|
||||
"settings.general.row.appearance.description": "Tilpas hvordan OpenCode ser ud på din enhed",
|
||||
"settings.general.row.colorScheme.title": "Farveskema",
|
||||
@@ -641,14 +773,29 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
|
||||
"settings.general.row.font.title": "Kode-skrifttype",
|
||||
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
|
||||
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||
"settings.general.row.terminalFont.title": "Terminalskrifttype",
|
||||
"settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen",
|
||||
"settings.general.row.uiFont.title": "UI-skrifttype",
|
||||
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
|
||||
"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.option.queue": "Kø",
|
||||
"settings.general.row.followup.option.steer": "Styr",
|
||||
"settings.general.row.showFileTree.title": "Filtræ",
|
||||
"settings.general.row.showFileTree.description": "Vis filtræspanelet i sessioner",
|
||||
"settings.general.row.showNavigation.title": "Navigationsknapper",
|
||||
"settings.general.row.showNavigation.description": "Vis tilbage- og frem-knapperne i desktopappens titellinje",
|
||||
"settings.general.row.showSearch.title": "Kommandopalette",
|
||||
"settings.general.row.showSearch.description": "Vis knappen til søgning og kommandopaletten i titellinjen",
|
||||
"settings.general.row.showTerminal.title": "Terminal",
|
||||
"settings.general.row.showTerminal.description": "Vis terminalknappen i desktopappens titellinje",
|
||||
"settings.general.row.showStatus.title": "Serverstatus",
|
||||
"settings.general.row.showStatus.description": "Vis serverstatusknappen i titellinjen",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation nederst",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Placer titellinjen og sessionsfanerne nederst på skærmen på mobilenheder",
|
||||
"settings.general.row.showCustomAgents.title": "Brugerdefinerede agenter",
|
||||
"settings.general.row.showCustomAgents.description": "Vis agentvælgeren i promptfeltet",
|
||||
"settings.general.row.reasoningSummaries.title": "Vis tænkeoversigter",
|
||||
"settings.general.row.reasoningSummaries.description": "Vis model tænkeoversigter i tidslinjen",
|
||||
|
||||
@@ -657,6 +804,15 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
|
||||
"settings.general.row.newInterface.title": "Nyt layout",
|
||||
"settings.general.row.newInterface.badge": "Ny",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Brug de nye faner og startsidens layout. Du kan skifte mellem layoutene i en begrænset periode.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Du bruger nu det nye layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "Det tidligere layout er ikke længere tilgængeligt",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Afvis",
|
||||
"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.wayland.title": "Brug native Wayland",
|
||||
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -671,6 +827,8 @@ export const dict = {
|
||||
"settings.updates.row.check.description": "Tjek manuelt for opdateringer og installer, hvis tilgængelig",
|
||||
"settings.updates.action.checkNow": "Tjek nu",
|
||||
"settings.updates.action.checking": "Tjekker...",
|
||||
"settings.updates.action.downloading": "Downloader...",
|
||||
"settings.updates.action.installing": "Installerer...",
|
||||
"settings.updates.toast.latest.title": "Du er opdateret",
|
||||
"settings.updates.toast.latest.description": "Du kører den nyeste version af OpenCode.",
|
||||
|
||||
|
||||
+163
-2
@@ -25,6 +25,9 @@ export const dict = {
|
||||
"theme.scheme.dark": "Dunkel",
|
||||
"command.sidebar.toggle": "Seitenleiste umschalten",
|
||||
"command.project.open": "Projekt öffnen",
|
||||
"command.project.previous": "Vorheriges Projekt",
|
||||
"command.project.next": "Nächstes Projekt",
|
||||
"command.project.index": "Zu Projekt {{index}} wechseln",
|
||||
"command.provider.connect": "Anbieter verbinden",
|
||||
"command.server.switch": "Server wechseln",
|
||||
"command.settings.open": "Einstellungen öffnen",
|
||||
@@ -43,6 +46,7 @@ export const dict = {
|
||||
"command.session.new": "Neue Sitzung",
|
||||
"command.file.open": "Datei öffnen",
|
||||
"command.tab.close": "Tab schließen",
|
||||
"command.tab.reopenClosed": "Geschlossenen Tab wieder öffnen",
|
||||
"command.context.addSelection": "Auswahl zum Kontext hinzufügen",
|
||||
"command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen",
|
||||
"command.input.focus": "Eingabefeld fokussieren",
|
||||
@@ -198,6 +202,11 @@ export const dict = {
|
||||
"model.input.video": "Video",
|
||||
"model.input.pdf": "PDF",
|
||||
"model.tooltip.allows": "Erlaubt: {{inputs}}",
|
||||
"model.tooltip.context.label": "Kontext",
|
||||
"model.tooltip.inputs": "Eingaben",
|
||||
"model.tooltip.model": "Modell",
|
||||
"model.tooltip.provider": "Anbieter",
|
||||
"model.tooltip.reasoning": "Reasoning",
|
||||
"model.tooltip.reasoning.allowed": "Erlaubt Reasoning",
|
||||
"model.tooltip.reasoning.none": "Kein Reasoning",
|
||||
"model.tooltip.context": "Kontextlimit {{limit}}",
|
||||
@@ -207,6 +216,7 @@ export const dict = {
|
||||
"common.loading": "Laden",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "Abbrechen",
|
||||
"common.clear": "Leeren",
|
||||
"common.connect": "Verbinden",
|
||||
"common.disconnect": "Trennen",
|
||||
"common.continue": "Absenden",
|
||||
@@ -223,6 +233,8 @@ export const dict = {
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc zum Verlassen",
|
||||
"session.child.promptDisabled": "Unteragenten-Sitzungen können keine Prompts erhalten.",
|
||||
"session.child.backToParent": "Zurück zur Hauptsitzung.",
|
||||
"prompt.example.1": "Ein TODO in der Codebasis beheben",
|
||||
"prompt.example.2": "Was ist der Tech-Stack dieses Projekts?",
|
||||
"prompt.example.3": "Fehlerhafte Tests beheben",
|
||||
@@ -260,6 +272,11 @@ export const dict = {
|
||||
"prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen",
|
||||
"prompt.context.removeFile": "Datei aus dem Kontext entfernen",
|
||||
"prompt.action.attachFile": "Datei anhängen",
|
||||
"prompt.menu.addImagesAndFiles": "Dateien und mehr hinzufügen",
|
||||
"prompt.menu.imagesAndFiles": "Bilder und Dateien",
|
||||
"prompt.menu.commands": "Befehle",
|
||||
"prompt.menu.context": "Kontext",
|
||||
"prompt.menu.shellCommand": "Shell-Befehl",
|
||||
"prompt.attachment.remove": "Anhang entfernen",
|
||||
"prompt.action.send": "Senden",
|
||||
"prompt.action.stop": "Stopp",
|
||||
@@ -320,6 +337,75 @@ export const dict = {
|
||||
"dialog.server.menu.delete": "Löschen",
|
||||
"dialog.server.current": "Aktueller Server",
|
||||
"dialog.server.status.default": "Standard",
|
||||
"wsl.server.add": "WSL-Server hinzufügen",
|
||||
"wsl.server.addShort": "WSL hinzufügen",
|
||||
"wsl.server.label": "WSL",
|
||||
"wsl.server.menu.label": "WSL-Server",
|
||||
"wsl.server.retryStart": "Start erneut versuchen",
|
||||
"wsl.server.updating": "Wird aktualisiert...",
|
||||
"wsl.onboarding.step.distro": "Distribution auswählen",
|
||||
"wsl.onboarding.step.opencode": "OpenCode",
|
||||
"wsl.onboarding.checkingRuntime": "WSL wird geprüft...",
|
||||
"wsl.onboarding.restartRequired": "Windows muss neu gestartet werden, um die WSL-Installation abzuschließen.",
|
||||
"wsl.onboarding.ready": "WSL ist bereit.",
|
||||
"wsl.onboarding.required": "WSL ist erforderlich, um fortzufahren.",
|
||||
"wsl.onboarding.checkingDistros": "Distributionen werden geprüft...",
|
||||
"wsl.onboarding.installingDistro": "{{distro}} wird installiert...",
|
||||
"wsl.onboarding.checkingDistro": "{{distro}} wird geprüft...",
|
||||
"wsl.onboarding.listingDistros": "Distributionen werden aufgelistet...",
|
||||
"wsl.onboarding.distroReady": "{{distro}} ist bereit.",
|
||||
"wsl.onboarding.distroNotInstalled": "{{distro}} ist noch nicht installiert.",
|
||||
"wsl.onboarding.openDistroOnce": "Öffnen Sie {{distro}} einmal, um die Einrichtung abzuschließen.",
|
||||
"wsl.onboarding.finishingDistro": "Einrichtung von {{distro}} wird abgeschlossen.",
|
||||
"wsl.onboarding.pickDistro": "Wählen Sie eine Distribution aus oder installieren Sie unten eine.",
|
||||
"wsl.onboarding.checkingOpencode": "OpenCode wird geprüft...",
|
||||
"wsl.onboarding.checkingOpencodeIn": "OpenCode in {{distro}} wird geprüft...",
|
||||
"wsl.onboarding.updatingOpencode": "OpenCode wird aktualisiert...",
|
||||
"wsl.onboarding.updatingOpencodeIn": "OpenCode in {{distro}} wird aktualisiert...",
|
||||
"wsl.onboarding.updateOpencodeIn": "Aktualisieren Sie OpenCode in {{distro}}.",
|
||||
"wsl.onboarding.updateOpencode": "OpenCode aktualisieren",
|
||||
"wsl.onboarding.opencodeReadyIn": "OpenCode ist in {{distro}} bereit.",
|
||||
"wsl.onboarding.opencodeReady": "OpenCode ist bereit.",
|
||||
"wsl.onboarding.installOpencodeIn": "Installieren Sie OpenCode in {{distro}}.",
|
||||
"wsl.onboarding.installOpencode": "OpenCode installieren",
|
||||
"wsl.onboarding.chooseDistroFirst": "Wählen Sie zuerst eine Distribution aus.",
|
||||
"wsl.onboarding.loadFailed": "WSL-Status konnte nicht geladen werden.",
|
||||
"wsl.onboarding.loading": "Wird geladen...",
|
||||
"wsl.onboarding.installedDistros": "Installierte Distributionen",
|
||||
"wsl.onboarding.checkAgain": "Erneut prüfen",
|
||||
"wsl.onboarding.distroStatus.ready": "Bereit",
|
||||
"wsl.onboarding.distroStatus.checking": "Wird geprüft...",
|
||||
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode nicht installiert",
|
||||
"wsl.onboarding.distroStatus.missingTools": "Fehlend: bash, curl",
|
||||
"wsl.onboarding.distroStatus.unsupported": "Nicht unterstützt · WSL 2 verwenden",
|
||||
"wsl.onboarding.needAnotherDistro": "Andere Distribution benötigt?",
|
||||
"wsl.onboarding.needAnotherDistroHint": "Installieren Sie eine Linux-Distribution aus dem WSL-Katalog",
|
||||
"wsl.onboarding.wslNotInstalled.title": "WSL nicht installiert",
|
||||
"wsl.onboarding.wslNotInstalled.description":
|
||||
"WSL (Windows Subsystem for Linux) ist erforderlich, bevor OpenCode einen WSL-Server hinzufügen kann",
|
||||
"wsl.onboarding.wslUnavailable.title": "WSL nicht verfügbar",
|
||||
"wsl.onboarding.wslUnavailable.description": "OpenCode konnte WSL auf diesem Computer nicht überprüfen.",
|
||||
"wsl.onboarding.installWsl": "WSL installieren",
|
||||
"wsl.onboarding.windowsRestartRequired":
|
||||
"Starten Sie Windows neu, um die WSL-Installation abzuschließen, und öffnen Sie dann OpenCode erneut.",
|
||||
"wsl.onboarding.next": "Weiter",
|
||||
"wsl.onboarding.refresh": "Aktualisieren",
|
||||
"wsl.onboarding.allDistrosAdded": "Alle installierten Distributionen wurden bereits hinzugefügt.",
|
||||
"wsl.onboarding.noDistros": "Noch keine Distributionen erkannt.",
|
||||
"wsl.onboarding.install": "Installieren",
|
||||
"wsl.onboarding.installing": "Wird installiert...",
|
||||
"wsl.onboarding.installDistro": "Distribution installieren",
|
||||
"wsl.onboarding.searchDistros": "Distributionen durchsuchen",
|
||||
"wsl.onboarding.wsl2Required": "WSL 2 ist erforderlich.",
|
||||
"wsl.onboarding.toolsRequired": "Diese Distribution benötigt bash und curl.",
|
||||
"wsl.onboarding.openTerminal": "Terminal öffnen",
|
||||
"wsl.onboarding.path": "Pfad: {{path}}",
|
||||
"wsl.onboarding.notFound": "nicht gefunden",
|
||||
"wsl.onboarding.version": "Version: {{version}}",
|
||||
"wsl.onboarding.unknown": "unbekannt",
|
||||
"wsl.onboarding.desktopVersion": "Desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Die installierte Version entspricht nicht der Version der Desktop-App.",
|
||||
"wsl.onboarding.adding": "Wird hinzugefügt...",
|
||||
"dialog.project.edit.title": "Projekt bearbeiten",
|
||||
"dialog.project.edit.name": "Name",
|
||||
"dialog.project.edit.icon": "Icon",
|
||||
@@ -378,6 +464,7 @@ export const dict = {
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.uk": "Українська",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
"toast.language.title": "Sprache",
|
||||
@@ -414,10 +501,12 @@ export const dict = {
|
||||
"toast.update.action.notYet": "Noch nicht",
|
||||
"error.page.title": "Etwas ist schiefgelaufen",
|
||||
"error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.",
|
||||
"error.page.description.localServerStartup": "Beim Starten des lokalen Servers ist ein Fehler aufgetreten.",
|
||||
"error.page.details.label": "Fehlerdetails",
|
||||
"error.page.action.restart": "Neustart",
|
||||
"error.page.action.report": "Fehler melden",
|
||||
"error.page.action.reported": "Fehler gemeldet",
|
||||
"error.page.action.exportLogs": "Protokolle exportieren",
|
||||
"error.page.action.checking": "Prüfen...",
|
||||
"error.page.action.checkUpdates": "Nach Updates suchen",
|
||||
"error.page.action.updateTo": "Auf {{version}} aktualisieren",
|
||||
@@ -461,10 +550,33 @@ export const dict = {
|
||||
"home.recentProjects": "Letzte Projekte",
|
||||
"home.empty.title": "Keine letzten Projekte",
|
||||
"home.empty.description": "Starten Sie, indem Sie ein lokales Projekt öffnen",
|
||||
"home.title": "Startseite",
|
||||
"home.projects": "Projekte",
|
||||
"home.project.add": "Projekt hinzufügen",
|
||||
"home.recentlyClosed": "Kürzlich geschlossen",
|
||||
"home.server.collapse": "Serverprojekte einklappen",
|
||||
"home.server.expand": "Serverprojekte ausklappen",
|
||||
"home.sessions.search.placeholder": "Sitzungen durchsuchen",
|
||||
"home.sessions.search.placeholder.scoped": "Sitzungen in {{scope}} durchsuchen",
|
||||
"home.sessions.search.sessions": "Sitzungen",
|
||||
"home.sessions.search.noResults": "Keine Sitzungen für {{query}} gefunden",
|
||||
"home.sessions.empty": "Hier ist noch nichts",
|
||||
"home.sessions.empty.description": "Erstellen Sie eine Sitzung, um loszulegen",
|
||||
"home.sessions.group.today": "Heute",
|
||||
"home.sessions.group.yesterday": "Gestern",
|
||||
"home.sessions.group.older": "Älter",
|
||||
"home.providerTip":
|
||||
"Verbinde dich mit über 75 Anbietern, um weitere Modelle wie Claude, GPT, Gemini und andere zu nutzen",
|
||||
"session.tab.session": "Sitzung",
|
||||
"session.tab.review": "Überprüfung",
|
||||
"session.tab.context": "Kontext",
|
||||
"session.tab.unknown": "Unbekannte Sitzung",
|
||||
"session.panel.reviewAndFiles": "Überprüfung und Dateien",
|
||||
"session.error.notFound": "Diese Sitzung wurde nicht gefunden",
|
||||
"session.error.notFound.description":
|
||||
"Dieser Tab verweist auf eine Sitzung, die auf diesem Server nicht mehr existiert.",
|
||||
"session.error.notFound.closeTab": "Tab schließen",
|
||||
"session.error.serverConnection": "Verbindung zu diesem Server nicht möglich",
|
||||
"session.review.filesChanged": "{{count}} Dateien geändert",
|
||||
"session.review.change.one": "Änderung",
|
||||
"session.review.change.other": "Änderungen",
|
||||
@@ -474,6 +586,8 @@ export const dict = {
|
||||
"session.review.noSnapshot":
|
||||
"Snapshot-Tracking ist in der Konfiguration deaktiviert, daher sind Sitzungsänderungen nicht verfügbar",
|
||||
"session.review.noChanges": "Keine Änderungen",
|
||||
"session.review.noUncommittedChanges": "Noch keine nicht committeten Änderungen",
|
||||
"session.review.noBranchChanges": "Noch keine Branch-Änderungen",
|
||||
"session.files.selectToOpen": "Datei zum Öffnen auswählen",
|
||||
"session.files.all": "Alle Dateien",
|
||||
"session.files.empty": "Keine Dateien",
|
||||
@@ -487,6 +601,10 @@ export const dict = {
|
||||
"session.todo.title": "Aufgaben",
|
||||
"session.todo.collapse": "Einklappen",
|
||||
"session.todo.expand": "Ausklappen",
|
||||
"session.question.minimize": "Frage minimieren",
|
||||
"session.question.restore": "Frage wiederherstellen",
|
||||
"session.question.pending.one": "{{count}} ausstehende Frage",
|
||||
"session.question.pending.other": "{{count}} ausstehende Fragen",
|
||||
"session.followupDock.summary.one": "{{count}} Nachricht in der Warteschlange",
|
||||
"session.followupDock.summary.other": "{{count}} Nachrichten in der Warteschlange",
|
||||
"session.followupDock.sendNow": "Jetzt senden",
|
||||
@@ -499,9 +617,16 @@ export const dict = {
|
||||
"session.revertDock.expand": "Zurückgesetzte Nachrichten ausklappen",
|
||||
"session.revertDock.restore": "Nachricht wiederherstellen",
|
||||
"session.new.title": "Baue, was du willst",
|
||||
"session.new.project.new": "Neues Projekt",
|
||||
"session.new.project.search": "Projekte durchsuchen",
|
||||
"session.new.project.add": "Projekt hinzufügen",
|
||||
"session.new.worktree.main": "Haupt-Branch",
|
||||
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
||||
"session.new.worktree.create": "Neuen Worktree erstellen",
|
||||
"session.new.workspace.runIn": "Sitzung ausführen in",
|
||||
"session.new.workspace.triggerLocal": "Lokal",
|
||||
"session.new.workspace.local": "Lokales Repository",
|
||||
"session.new.workspace.existing": "Arbeitsbereich…",
|
||||
"session.new.lastModified": "Zuletzt geändert",
|
||||
"session.header.search.placeholder": "{{project}} durchsuchen",
|
||||
"session.header.searchFiles": "Dateien suchen",
|
||||
@@ -566,6 +691,8 @@ export const dict = {
|
||||
"sidebar.project.recentSessions": "Letzte Sitzungen",
|
||||
"sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen",
|
||||
"sidebar.project.clearNotifications": "Benachrichtigungen löschen",
|
||||
"sidebar.empty.title": "Keine Projekte geöffnet",
|
||||
"sidebar.empty.description": "Öffnen Sie ein Projekt, um loszulegen",
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "Desktop",
|
||||
"settings.section.server": "Server",
|
||||
@@ -575,6 +702,7 @@ export const dict = {
|
||||
"settings.desktop.wsl.title": "WSL-Integration",
|
||||
"settings.desktop.wsl.description": "OpenCode-Server innerhalb von WSL unter Windows ausführen.",
|
||||
"settings.general.section.appearance": "Erscheinungsbild",
|
||||
"settings.general.section.advanced": "Erweitert",
|
||||
"settings.general.section.notifications": "Systembenachrichtigungen",
|
||||
"settings.general.section.updates": "Updates",
|
||||
"settings.general.section.sounds": "Soundeffekte",
|
||||
@@ -582,6 +710,11 @@ export const dict = {
|
||||
"settings.general.section.display": "Anzeige",
|
||||
"settings.general.row.language.title": "Sprache",
|
||||
"settings.general.row.language.description": "Die Anzeigesprache für OpenCode ändern",
|
||||
"settings.general.row.shell.title": "Terminal-Shell",
|
||||
"settings.general.row.shell.description":
|
||||
"Wählen Sie die Shell für Ihr Terminal aus. Kompatible Shells werden auch für Tool-Aufrufe von Agenten verwendet.",
|
||||
"settings.general.row.shell.autoDefault": "Automatisch (Standard)",
|
||||
"settings.general.row.shell.terminalOnly": "nur Terminal",
|
||||
"settings.general.row.appearance.title": "Erscheinungsbild",
|
||||
"settings.general.row.appearance.description": "Anpassen, wie OpenCode auf Ihrem Gerät aussieht",
|
||||
"settings.general.row.colorScheme.title": "Farbschema",
|
||||
@@ -591,8 +724,8 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
|
||||
"settings.general.row.font.title": "Code-Schriftart",
|
||||
"settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen",
|
||||
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||
"settings.general.row.terminalFont.title": "Terminalschriftart",
|
||||
"settings.general.row.terminalFont.description": "Passe die im Terminal verwendete Schriftart an",
|
||||
"settings.general.row.uiFont.title": "UI-Schriftart",
|
||||
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
|
||||
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
|
||||
@@ -600,6 +733,23 @@ export const dict = {
|
||||
"Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten",
|
||||
"settings.general.row.followup.option.queue": "Warteschlange",
|
||||
"settings.general.row.followup.option.steer": "Steuern",
|
||||
"settings.general.row.showFileTree.title": "Dateibaum",
|
||||
"settings.general.row.showFileTree.description": "Dateibaum in Sitzungen anzeigen",
|
||||
"settings.general.row.showNavigation.title": "Navigationssteuerung",
|
||||
"settings.general.row.showNavigation.description":
|
||||
"Schaltflächen zum Vor- und Zurücknavigieren in der Desktop-Titelleiste anzeigen",
|
||||
"settings.general.row.showSearch.title": "Befehlspalette",
|
||||
"settings.general.row.showSearch.description":
|
||||
"Schaltfläche für Suche und Befehlspalette in der Titelleiste anzeigen",
|
||||
"settings.general.row.showTerminal.title": "Terminal",
|
||||
"settings.general.row.showTerminal.description": "Terminal-Schaltfläche in der Desktop-Titelleiste anzeigen",
|
||||
"settings.general.row.showStatus.title": "Serverstatus",
|
||||
"settings.general.row.showStatus.description": "Serverstatus-Schaltfläche in der Titelleiste anzeigen",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation unten",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Titelleiste und Sitzungs-Tabs auf Mobilgeräten am unteren Bildschirmrand platzieren",
|
||||
"settings.general.row.showCustomAgents.title": "Benutzerdefinierte Agenten",
|
||||
"settings.general.row.showCustomAgents.description": "Agentenauswahl im Eingabebereich anzeigen",
|
||||
"settings.general.row.reasoningSummaries.title": "Reasoning-Zusammenfassungen anzeigen",
|
||||
"settings.general.row.reasoningSummaries.description":
|
||||
"Zusammenfassungen des Modell-Reasonings in der Timeline anzeigen",
|
||||
@@ -609,6 +759,15 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
"settings.general.row.newInterface.title": "Neues Layout",
|
||||
"settings.general.row.newInterface.badge": "Neu",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Verwenden Sie die neuen Tabs und das Startseitenlayout. Für begrenzte Zeit können Sie zwischen den Layouts wechseln.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Sie verwenden jetzt das neue Layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "Das vorherige Layout ist nicht mehr verfügbar",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Verwerfen",
|
||||
"settings.general.row.pinchZoom.title": "Zoom per Fingergeste",
|
||||
"settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben",
|
||||
"settings.general.row.wayland.title": "Natives Wayland verwenden",
|
||||
"settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -621,6 +780,8 @@ export const dict = {
|
||||
"settings.updates.row.check.description": "Manuell nach Updates suchen und installieren, wenn verfügbar",
|
||||
"settings.updates.action.checkNow": "Jetzt prüfen",
|
||||
"settings.updates.action.checking": "Wird geprüft...",
|
||||
"settings.updates.action.downloading": "Wird heruntergeladen...",
|
||||
"settings.updates.action.installing": "Wird installiert...",
|
||||
"settings.updates.toast.latest.title": "Du bist auf dem neuesten Stand",
|
||||
"settings.updates.toast.latest.description": "Du verwendest die aktuelle Version von OpenCode.",
|
||||
"sound.option.none": "Keine",
|
||||
|
||||
@@ -287,6 +287,11 @@ export const dict = {
|
||||
"prompt.context.removeActiveFile": "Remove active file from context",
|
||||
"prompt.context.removeFile": "Remove file from context",
|
||||
"prompt.action.attachFile": "Add files",
|
||||
"prompt.menu.addImagesAndFiles": "Add files and more",
|
||||
"prompt.menu.imagesAndFiles": "Images and files",
|
||||
"prompt.menu.commands": "Commands",
|
||||
"prompt.menu.context": "Context",
|
||||
"prompt.menu.shellCommand": "Shell command",
|
||||
"prompt.attachment.remove": "Remove attachment",
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Stop",
|
||||
@@ -622,6 +627,7 @@ export const dict = {
|
||||
"home.sessions.group.today": "Today",
|
||||
"home.sessions.group.yesterday": "Yesterday",
|
||||
"home.sessions.group.older": "Older",
|
||||
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
|
||||
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.review": "Review",
|
||||
@@ -632,7 +638,7 @@ export const dict = {
|
||||
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
|
||||
"session.error.notFound.closeTab": "Close Tab",
|
||||
"session.error.serverConnection": "Can't connect to this server",
|
||||
"session.review.filesChanged": "{{count}} Files Changed",
|
||||
"session.review.filesChanged": "Files Changed {{count}}",
|
||||
"session.review.change.one": "Change",
|
||||
"session.review.change.other": "Changes",
|
||||
"session.review.loadingChanges": "Loading changes...",
|
||||
@@ -703,6 +709,9 @@ export const dict = {
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "File Explorer",
|
||||
"session.header.open.fileManager": "File Manager",
|
||||
"session.header.reveal.finder": "Reveal in Finder",
|
||||
"session.header.reveal.fileExplorer": "Reveal in File Explorer",
|
||||
"session.header.reveal.containingFolder": "Open containing folder",
|
||||
"session.header.open.app.vscode": "VS Code",
|
||||
"session.header.open.app.cursor": "Cursor",
|
||||
"session.header.open.app.zed": "Zed",
|
||||
@@ -846,9 +855,8 @@ export const dict = {
|
||||
|
||||
"settings.general.row.language.title": "Language",
|
||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||
"settings.general.row.shell.title": "Terminal Shell",
|
||||
"settings.general.row.shell.description":
|
||||
"Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
|
||||
"settings.general.row.shell.title": "Terminal shell",
|
||||
"settings.general.row.shell.description": "Shell used by the terminal and agent tools",
|
||||
"settings.general.row.shell.autoDefault": "Auto (Default)",
|
||||
"settings.general.row.shell.terminalOnly": "terminal only",
|
||||
"settings.general.row.appearance.title": "Appearance",
|
||||
@@ -880,8 +888,9 @@ export const dict = {
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
"settings.general.row.showCustomAgents.title": "Custom agents",
|
||||
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
|
||||
"settings.general.row.showCustomAgents.title": "Show agent",
|
||||
"settings.general.row.showCustomAgents.description":
|
||||
"Switch between agents in the composer. When hidden, defaults to Build agent.",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
@@ -890,8 +899,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Show edit, write, and patch tool parts expanded by default in the timeline",
|
||||
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
|
||||
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
|
||||
"settings.general.row.newInterface.title": "New layout",
|
||||
"settings.general.row.newInterface.badge": "New",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||
"settings.general.row.newInterfaceNotice.title": "You're now using new layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "The previous layout is no longer available",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Dismiss",
|
||||
"settings.general.row.pinchZoom.title": "Pinch to zoom",
|
||||
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
|
||||
|
||||
|
||||
+163
-2
@@ -23,6 +23,9 @@ export const dict = {
|
||||
|
||||
"command.sidebar.toggle": "Alternar barra lateral",
|
||||
"command.project.open": "Abrir proyecto",
|
||||
"command.project.previous": "Proyecto anterior",
|
||||
"command.project.next": "Siguiente proyecto",
|
||||
"command.project.index": "Cambiar al proyecto {{index}}",
|
||||
"command.provider.connect": "Conectar proveedor",
|
||||
"command.server.switch": "Cambiar servidor",
|
||||
"command.settings.open": "Abrir ajustes",
|
||||
@@ -45,6 +48,7 @@ export const dict = {
|
||||
"command.session.new": "Nueva sesión",
|
||||
"command.file.open": "Abrir archivo",
|
||||
"command.tab.close": "Cerrar pestaña",
|
||||
"command.tab.reopenClosed": "Reabrir pestaña cerrada",
|
||||
"command.context.addSelection": "Añadir selección al contexto",
|
||||
"command.context.addSelection.description": "Añadir las líneas seleccionadas del archivo actual",
|
||||
"command.input.focus": "Enfocar entrada",
|
||||
@@ -208,6 +212,11 @@ export const dict = {
|
||||
"model.input.video": "video",
|
||||
"model.input.pdf": "pdf",
|
||||
"model.tooltip.allows": "Permite: {{inputs}}",
|
||||
"model.tooltip.context.label": "Contexto",
|
||||
"model.tooltip.inputs": "Entradas",
|
||||
"model.tooltip.model": "Modelo",
|
||||
"model.tooltip.provider": "Proveedor",
|
||||
"model.tooltip.reasoning": "Razonamiento",
|
||||
"model.tooltip.reasoning.allowed": "Permite razonamiento",
|
||||
"model.tooltip.reasoning.none": "Sin razonamiento",
|
||||
"model.tooltip.context": "Límite de contexto {{limit}}",
|
||||
@@ -218,6 +227,7 @@ export const dict = {
|
||||
"common.loading": "Cargando",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "Cancelar",
|
||||
"common.clear": "Limpiar",
|
||||
"common.connect": "Conectar",
|
||||
"common.disconnect": "Desconectar",
|
||||
"common.continue": "Enviar",
|
||||
@@ -235,6 +245,8 @@ export const dict = {
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc para salir",
|
||||
"session.child.promptDisabled": "No se pueden enviar prompts a las sesiones de subagentes.",
|
||||
"session.child.backToParent": "Volver a la sesión principal.",
|
||||
|
||||
"prompt.example.1": "Arreglar un TODO en el código",
|
||||
"prompt.example.2": "¿Cuál es el stack tecnológico de este proyecto?",
|
||||
@@ -274,6 +286,11 @@ export const dict = {
|
||||
"prompt.context.removeActiveFile": "Eliminar archivo activo del contexto",
|
||||
"prompt.context.removeFile": "Eliminar archivo del contexto",
|
||||
"prompt.action.attachFile": "Adjuntar archivo",
|
||||
"prompt.menu.addImagesAndFiles": "Añadir archivos y más",
|
||||
"prompt.menu.imagesAndFiles": "Imágenes y archivos",
|
||||
"prompt.menu.commands": "Comandos",
|
||||
"prompt.menu.context": "Contexto",
|
||||
"prompt.menu.shellCommand": "Comando de shell",
|
||||
"prompt.attachment.remove": "Eliminar adjunto",
|
||||
"prompt.action.send": "Enviar",
|
||||
"prompt.action.stop": "Detener",
|
||||
@@ -341,6 +358,75 @@ export const dict = {
|
||||
"dialog.server.menu.delete": "Eliminar",
|
||||
"dialog.server.current": "Servidor actual",
|
||||
"dialog.server.status.default": "Predeterminado",
|
||||
"wsl.server.add": "Añadir servidor WSL",
|
||||
"wsl.server.addShort": "Añadir WSL",
|
||||
"wsl.server.label": "WSL",
|
||||
"wsl.server.menu.label": "Servidor WSL",
|
||||
"wsl.server.retryStart": "Reintentar el inicio",
|
||||
"wsl.server.updating": "Actualizando...",
|
||||
"wsl.onboarding.step.distro": "Elegir distribución",
|
||||
"wsl.onboarding.step.opencode": "OpenCode",
|
||||
"wsl.onboarding.checkingRuntime": "Comprobando WSL...",
|
||||
"wsl.onboarding.restartRequired": "Es necesario reiniciar Windows para terminar de instalar WSL.",
|
||||
"wsl.onboarding.ready": "WSL está listo.",
|
||||
"wsl.onboarding.required": "Se necesita WSL para continuar.",
|
||||
"wsl.onboarding.checkingDistros": "Comprobando distribuciones...",
|
||||
"wsl.onboarding.installingDistro": "Instalando {{distro}}...",
|
||||
"wsl.onboarding.checkingDistro": "Comprobando {{distro}}...",
|
||||
"wsl.onboarding.listingDistros": "Listando distribuciones...",
|
||||
"wsl.onboarding.distroReady": "La distribución {{distro}} está lista.",
|
||||
"wsl.onboarding.distroNotInstalled": "La distribución {{distro}} aún no está instalada.",
|
||||
"wsl.onboarding.openDistroOnce": "Abre {{distro}} una vez para completar la configuración.",
|
||||
"wsl.onboarding.finishingDistro": "Completando la configuración de {{distro}}.",
|
||||
"wsl.onboarding.pickDistro": "Elige una distribución o instala una abajo.",
|
||||
"wsl.onboarding.checkingOpencode": "Comprobando OpenCode...",
|
||||
"wsl.onboarding.checkingOpencodeIn": "Comprobando OpenCode en {{distro}}...",
|
||||
"wsl.onboarding.updatingOpencode": "Actualizando OpenCode...",
|
||||
"wsl.onboarding.updatingOpencodeIn": "Actualizando OpenCode en {{distro}}...",
|
||||
"wsl.onboarding.updateOpencodeIn": "Actualiza OpenCode en {{distro}}.",
|
||||
"wsl.onboarding.updateOpencode": "Actualizar OpenCode",
|
||||
"wsl.onboarding.opencodeReadyIn": "OpenCode está listo en {{distro}}.",
|
||||
"wsl.onboarding.opencodeReady": "OpenCode está listo.",
|
||||
"wsl.onboarding.installOpencodeIn": "Instala OpenCode en {{distro}}.",
|
||||
"wsl.onboarding.installOpencode": "Instalar OpenCode",
|
||||
"wsl.onboarding.chooseDistroFirst": "Elige primero una distribución.",
|
||||
"wsl.onboarding.loadFailed": "No se pudo cargar el estado de WSL.",
|
||||
"wsl.onboarding.loading": "Cargando...",
|
||||
"wsl.onboarding.installedDistros": "Distribuciones instaladas",
|
||||
"wsl.onboarding.checkAgain": "Comprobar de nuevo",
|
||||
"wsl.onboarding.distroStatus.ready": "Lista",
|
||||
"wsl.onboarding.distroStatus.checking": "Comprobando...",
|
||||
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode no está instalado",
|
||||
"wsl.onboarding.distroStatus.missingTools": "Faltan bash y curl",
|
||||
"wsl.onboarding.distroStatus.unsupported": "No compatible · Usa WSL 2",
|
||||
"wsl.onboarding.needAnotherDistro": "¿Necesitas otra distribución?",
|
||||
"wsl.onboarding.needAnotherDistroHint": "Instala una distribución de Linux desde el catálogo de WSL",
|
||||
"wsl.onboarding.wslNotInstalled.title": "WSL no está instalado",
|
||||
"wsl.onboarding.wslNotInstalled.description":
|
||||
"Se necesita WSL (Subsistema de Windows para Linux) para que OpenCode pueda añadir un servidor WSL",
|
||||
"wsl.onboarding.wslUnavailable.title": "WSL no disponible",
|
||||
"wsl.onboarding.wslUnavailable.description": "OpenCode no pudo comprobar WSL en este equipo.",
|
||||
"wsl.onboarding.installWsl": "Instalar WSL",
|
||||
"wsl.onboarding.windowsRestartRequired":
|
||||
"Reinicia Windows para terminar de instalar WSL y, después, vuelve a abrir OpenCode.",
|
||||
"wsl.onboarding.next": "Siguiente",
|
||||
"wsl.onboarding.refresh": "Actualizar",
|
||||
"wsl.onboarding.allDistrosAdded": "Ya se han añadido todas las distribuciones instaladas.",
|
||||
"wsl.onboarding.noDistros": "Aún no se han detectado distribuciones.",
|
||||
"wsl.onboarding.install": "Instalar",
|
||||
"wsl.onboarding.installing": "Instalando...",
|
||||
"wsl.onboarding.installDistro": "Instalar distribución",
|
||||
"wsl.onboarding.searchDistros": "Buscar distribuciones",
|
||||
"wsl.onboarding.wsl2Required": "Se necesita WSL 2.",
|
||||
"wsl.onboarding.toolsRequired": "Esta distribución necesita bash y curl.",
|
||||
"wsl.onboarding.openTerminal": "Abrir terminal",
|
||||
"wsl.onboarding.path": "Ruta: {{path}}",
|
||||
"wsl.onboarding.notFound": "no encontrado",
|
||||
"wsl.onboarding.version": "Versión: {{version}}",
|
||||
"wsl.onboarding.unknown": "desconocida",
|
||||
"wsl.onboarding.desktopVersion": "escritorio {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "La versión instalada no coincide con la versión de la aplicación de escritorio.",
|
||||
"wsl.onboarding.adding": "Añadiendo...",
|
||||
|
||||
"dialog.project.edit.title": "Editar proyecto",
|
||||
"dialog.project.edit.name": "Nombre",
|
||||
@@ -405,6 +491,7 @@ export const dict = {
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.uk": "Українська",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
@@ -453,10 +540,12 @@ export const dict = {
|
||||
|
||||
"error.page.title": "Algo salió mal",
|
||||
"error.page.description": "Ocurrió un error al cargar la aplicación.",
|
||||
"error.page.description.localServerStartup": "Ocurrió un error al iniciar el servidor local.",
|
||||
"error.page.details.label": "Detalles del error",
|
||||
"error.page.action.restart": "Reiniciar",
|
||||
"error.page.action.report": "Informar error",
|
||||
"error.page.action.reported": "Error informado",
|
||||
"error.page.action.exportLogs": "Exportar registros",
|
||||
"error.page.action.checking": "Comprobando...",
|
||||
"error.page.action.checkUpdates": "Buscar actualizaciones",
|
||||
"error.page.action.updateTo": "Actualizar a {{version}}",
|
||||
@@ -505,11 +594,33 @@ export const dict = {
|
||||
"home.recentProjects": "Proyectos recientes",
|
||||
"home.empty.title": "Sin proyectos recientes",
|
||||
"home.empty.description": "Empieza abriendo un proyecto local",
|
||||
"home.title": "Inicio",
|
||||
"home.projects": "Proyectos",
|
||||
"home.project.add": "Añadir proyecto",
|
||||
"home.recentlyClosed": "Proyectos cerrados recientemente",
|
||||
"home.server.collapse": "Contraer proyectos del servidor",
|
||||
"home.server.expand": "Expandir proyectos del servidor",
|
||||
"home.sessions.search.placeholder": "Buscar sesiones",
|
||||
"home.sessions.search.placeholder.scoped": "Buscar sesiones en {{scope}}",
|
||||
"home.sessions.search.sessions": "Sesiones",
|
||||
"home.sessions.search.noResults": "No se encontraron sesiones para {{query}}",
|
||||
"home.sessions.empty": "Aún no hay nada",
|
||||
"home.sessions.empty.description": "Crea una sesión para empezar",
|
||||
"home.sessions.group.today": "Hoy",
|
||||
"home.sessions.group.yesterday": "Ayer",
|
||||
"home.sessions.group.older": "Anteriores",
|
||||
"home.providerTip":
|
||||
"Conéctate a más de 75 proveedores para usar otros modelos, como Claude, GPT, Gemini y muchos más",
|
||||
|
||||
"session.tab.session": "Sesión",
|
||||
"session.tab.review": "Revisión",
|
||||
"session.tab.context": "Contexto",
|
||||
"session.tab.unknown": "Sesión desconocida",
|
||||
"session.panel.reviewAndFiles": "Revisión y archivos",
|
||||
"session.error.notFound": "No se encuentra esta sesión",
|
||||
"session.error.notFound.description": "Esta pestaña apunta a una sesión que ya no existe en este servidor.",
|
||||
"session.error.notFound.closeTab": "Cerrar pestaña",
|
||||
"session.error.serverConnection": "No se puede conectar a este servidor",
|
||||
"session.review.filesChanged": "{{count}} Archivos Cambiados",
|
||||
"session.review.change.one": "Cambio",
|
||||
"session.review.change.other": "Cambios",
|
||||
@@ -519,6 +630,8 @@ export const dict = {
|
||||
"session.review.noSnapshot":
|
||||
"El seguimiento de instantáneas está deshabilitado en la configuración, por lo que los cambios de sesión no están disponibles",
|
||||
"session.review.noChanges": "Sin cambios",
|
||||
"session.review.noUncommittedChanges": "Aún no hay cambios sin confirmar",
|
||||
"session.review.noBranchChanges": "Aún no hay cambios en la rama",
|
||||
|
||||
"session.files.selectToOpen": "Selecciona un archivo para abrir",
|
||||
"session.files.all": "Todos los archivos",
|
||||
@@ -535,6 +648,10 @@ export const dict = {
|
||||
"session.todo.title": "Tareas",
|
||||
"session.todo.collapse": "Contraer",
|
||||
"session.todo.expand": "Expandir",
|
||||
"session.question.minimize": "Minimizar pregunta",
|
||||
"session.question.restore": "Restaurar pregunta",
|
||||
"session.question.pending.one": "{{count}} pregunta pendiente",
|
||||
"session.question.pending.other": "{{count}} preguntas pendientes",
|
||||
"session.followupDock.summary.one": "{{count}} mensaje en cola",
|
||||
"session.followupDock.summary.other": "{{count}} mensajes en cola",
|
||||
"session.followupDock.sendNow": "Enviar ahora",
|
||||
@@ -548,9 +665,16 @@ export const dict = {
|
||||
"session.revertDock.restore": "Restaurar mensaje",
|
||||
|
||||
"session.new.title": "Construye lo que quieras",
|
||||
"session.new.project.new": "Nuevo proyecto",
|
||||
"session.new.project.search": "Buscar proyectos",
|
||||
"session.new.project.add": "Añadir proyecto",
|
||||
"session.new.worktree.main": "Rama principal",
|
||||
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
|
||||
"session.new.worktree.create": "Crear nuevo árbol de trabajo",
|
||||
"session.new.workspace.runIn": "Ejecutar sesión en",
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Repositorio local",
|
||||
"session.new.workspace.existing": "Espacio de trabajo…",
|
||||
"session.new.lastModified": "Última modificación",
|
||||
|
||||
"session.header.search.placeholder": "Buscar {{project}}",
|
||||
@@ -621,6 +745,8 @@ export const dict = {
|
||||
"sidebar.project.recentSessions": "Sesiones recientes",
|
||||
"sidebar.project.viewAllSessions": "Ver todas las sesiones",
|
||||
"sidebar.project.clearNotifications": "Borrar notificaciones",
|
||||
"sidebar.empty.title": "No hay proyectos abiertos",
|
||||
"sidebar.empty.description": "Abre un proyecto para empezar",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
|
||||
@@ -633,6 +759,7 @@ export const dict = {
|
||||
"settings.desktop.wsl.description": "Ejecutar el servidor OpenCode dentro de WSL en Windows.",
|
||||
|
||||
"settings.general.section.appearance": "Apariencia",
|
||||
"settings.general.section.advanced": "Avanzado",
|
||||
"settings.general.section.notifications": "Notificaciones del sistema",
|
||||
"settings.general.section.updates": "Actualizaciones",
|
||||
"settings.general.section.sounds": "Efectos de sonido",
|
||||
@@ -641,6 +768,11 @@ export const dict = {
|
||||
|
||||
"settings.general.row.language.title": "Idioma",
|
||||
"settings.general.row.language.description": "Cambiar el idioma de visualización para OpenCode",
|
||||
"settings.general.row.shell.title": "Shell del terminal",
|
||||
"settings.general.row.shell.description":
|
||||
"Elige el shell que se usará en el terminal. Los shells compatibles también se usarán para las llamadas a herramientas del agente.",
|
||||
"settings.general.row.shell.autoDefault": "Automático (Predeterminado)",
|
||||
"settings.general.row.shell.terminalOnly": "solo en el terminal",
|
||||
"settings.general.row.appearance.title": "Apariencia",
|
||||
"settings.general.row.appearance.description": "Personaliza cómo se ve OpenCode en tu dispositivo",
|
||||
"settings.general.row.colorScheme.title": "Esquema de color",
|
||||
@@ -649,8 +781,8 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
|
||||
"settings.general.row.font.title": "Fuente de código",
|
||||
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código",
|
||||
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||
"settings.general.row.terminalFont.title": "Fuente del terminal",
|
||||
"settings.general.row.terminalFont.description": "Personaliza la fuente utilizada en el terminal",
|
||||
"settings.general.row.uiFont.title": "Fuente de la interfaz",
|
||||
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
|
||||
"settings.general.row.followup.title": "Comportamiento de seguimiento",
|
||||
@@ -658,6 +790,23 @@ export const dict = {
|
||||
"Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola",
|
||||
"settings.general.row.followup.option.queue": "Cola",
|
||||
"settings.general.row.followup.option.steer": "Dirigir",
|
||||
"settings.general.row.showFileTree.title": "Árbol de archivos",
|
||||
"settings.general.row.showFileTree.description": "Mostrar el panel del árbol de archivos en las sesiones",
|
||||
"settings.general.row.showNavigation.title": "Controles de navegación",
|
||||
"settings.general.row.showNavigation.description":
|
||||
"Mostrar los botones para retroceder y avanzar en la barra de título de la aplicación de escritorio",
|
||||
"settings.general.row.showSearch.title": "Paleta de comandos",
|
||||
"settings.general.row.showSearch.description":
|
||||
"Mostrar el botón de búsqueda y paleta de comandos en la barra de título",
|
||||
"settings.general.row.showTerminal.title": "Terminal",
|
||||
"settings.general.row.showTerminal.description": "Mostrar el botón del terminal en la barra de título",
|
||||
"settings.general.row.showStatus.title": "Estado del servidor",
|
||||
"settings.general.row.showStatus.description": "Mostrar el botón de estado del servidor en la barra de título",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navegación inferior",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Colocar la barra de título y las pestañas de sesión en la parte inferior de la pantalla en dispositivos móviles",
|
||||
"settings.general.row.showCustomAgents.title": "Agentes personalizados",
|
||||
"settings.general.row.showCustomAgents.description": "Mostrar el selector de agentes en el editor",
|
||||
"settings.general.row.reasoningSummaries.title": "Mostrar resúmenes de razonamiento",
|
||||
"settings.general.row.reasoningSummaries.description":
|
||||
"Mostrar resúmenes del razonamiento del modelo en la línea de tiempo",
|
||||
@@ -667,6 +816,16 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
|
||||
"settings.general.row.newInterface.title": "Nuevo diseño",
|
||||
"settings.general.row.newInterface.badge": "Nuevo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Usa las nuevas pestañas y el diseño de la página de inicio. Durante un tiempo limitado, puedes cambiar entre los diseños.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Ahora estás usando el nuevo diseño",
|
||||
"settings.general.row.newInterfaceNotice.description": "El diseño anterior ya no está disponible",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
|
||||
"settings.general.row.pinchZoom.title": "Pellizcar para ampliar",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll",
|
||||
"settings.general.row.wayland.title": "Usar Wayland nativo",
|
||||
"settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -682,6 +841,8 @@ export const dict = {
|
||||
"settings.updates.row.check.description": "Buscar actualizaciones manualmente e instalarlas si hay alguna",
|
||||
"settings.updates.action.checkNow": "Buscar ahora",
|
||||
"settings.updates.action.checking": "Buscando...",
|
||||
"settings.updates.action.downloading": "Descargando...",
|
||||
"settings.updates.action.installing": "Instalando...",
|
||||
"settings.updates.toast.latest.title": "Estás al día",
|
||||
"settings.updates.toast.latest.description": "Estás usando la última versión de OpenCode.",
|
||||
"sound.option.none": "Ninguno",
|
||||
|
||||
+164
-2
@@ -21,6 +21,9 @@ export const dict = {
|
||||
"theme.scheme.dark": "Sombre",
|
||||
"command.sidebar.toggle": "Basculer la barre latérale",
|
||||
"command.project.open": "Ouvrir un projet",
|
||||
"command.project.previous": "Projet précédent",
|
||||
"command.project.next": "Projet suivant",
|
||||
"command.project.index": "Passer au projet {{index}}",
|
||||
"command.provider.connect": "Connecter un fournisseur",
|
||||
"command.server.switch": "Changer de serveur",
|
||||
"command.settings.open": "Ouvrir les paramètres",
|
||||
@@ -39,6 +42,7 @@ export const dict = {
|
||||
"command.session.new": "Nouvelle session",
|
||||
"command.file.open": "Ouvrir un fichier",
|
||||
"command.tab.close": "Fermer l'onglet",
|
||||
"command.tab.reopenClosed": "Rouvrir l'onglet fermé",
|
||||
"command.context.addSelection": "Ajouter la sélection au contexte",
|
||||
"command.context.addSelection.description": "Ajouter les lignes sélectionnées du fichier actuel",
|
||||
"command.input.focus": "Focus sur l'entrée",
|
||||
@@ -193,6 +197,11 @@ export const dict = {
|
||||
"model.input.video": "vidéo",
|
||||
"model.input.pdf": "pdf",
|
||||
"model.tooltip.allows": "Autorise : {{inputs}}",
|
||||
"model.tooltip.context.label": "Contexte",
|
||||
"model.tooltip.inputs": "Entrées",
|
||||
"model.tooltip.model": "Modèle",
|
||||
"model.tooltip.provider": "Fournisseur",
|
||||
"model.tooltip.reasoning": "Raisonnement",
|
||||
"model.tooltip.reasoning.allowed": "Autorise le raisonnement",
|
||||
"model.tooltip.reasoning.none": "Sans raisonnement",
|
||||
"model.tooltip.context": "Limite de contexte {{limit}}",
|
||||
@@ -202,6 +211,7 @@ export const dict = {
|
||||
"common.loading": "Chargement",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "Annuler",
|
||||
"common.clear": "Effacer",
|
||||
"common.connect": "Connecter",
|
||||
"common.disconnect": "Déconnecter",
|
||||
"common.continue": "Soumettre",
|
||||
@@ -218,6 +228,8 @@ export const dict = {
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc pour quitter",
|
||||
"session.child.promptDisabled": "Les sessions de sous-agents ne peuvent pas recevoir de messages.",
|
||||
"session.child.backToParent": "Revenir à la session principale.",
|
||||
"prompt.example.1": "Corriger un TODO dans la base de code",
|
||||
"prompt.example.2": "Quelle est la pile technique de ce projet ?",
|
||||
"prompt.example.3": "Réparer les tests échoués",
|
||||
@@ -255,6 +267,11 @@ export const dict = {
|
||||
"prompt.context.removeActiveFile": "Retirer le fichier actif du contexte",
|
||||
"prompt.context.removeFile": "Retirer le fichier du contexte",
|
||||
"prompt.action.attachFile": "Joindre un fichier",
|
||||
"prompt.menu.addImagesAndFiles": "Ajouter des fichiers et plus encore",
|
||||
"prompt.menu.imagesAndFiles": "Images et fichiers",
|
||||
"prompt.menu.commands": "Commandes",
|
||||
"prompt.menu.context": "Contexte",
|
||||
"prompt.menu.shellCommand": "Commande shell",
|
||||
"prompt.attachment.remove": "Supprimer la pièce jointe",
|
||||
"prompt.action.send": "Envoyer",
|
||||
"prompt.action.stop": "Arrêter",
|
||||
@@ -315,6 +332,75 @@ export const dict = {
|
||||
"dialog.server.menu.delete": "Supprimer",
|
||||
"dialog.server.current": "Serveur actuel",
|
||||
"dialog.server.status.default": "Défaut",
|
||||
"wsl.server.add": "Ajouter un serveur WSL",
|
||||
"wsl.server.addShort": "Ajouter WSL",
|
||||
"wsl.server.label": "WSL",
|
||||
"wsl.server.menu.label": "Serveur WSL",
|
||||
"wsl.server.retryStart": "Réessayer de démarrer",
|
||||
"wsl.server.updating": "Mise à jour...",
|
||||
"wsl.onboarding.step.distro": "Choisir une distribution",
|
||||
"wsl.onboarding.step.opencode": "OpenCode",
|
||||
"wsl.onboarding.checkingRuntime": "Vérification de WSL...",
|
||||
"wsl.onboarding.restartRequired": "Windows doit redémarrer pour terminer l'installation de WSL.",
|
||||
"wsl.onboarding.ready": "WSL est prêt.",
|
||||
"wsl.onboarding.required": "WSL est requis pour continuer.",
|
||||
"wsl.onboarding.checkingDistros": "Vérification des distributions...",
|
||||
"wsl.onboarding.installingDistro": "Installation de {{distro}}...",
|
||||
"wsl.onboarding.checkingDistro": "Vérification de {{distro}}...",
|
||||
"wsl.onboarding.listingDistros": "Récupération de la liste des distributions...",
|
||||
"wsl.onboarding.distroReady": "{{distro}} est prête.",
|
||||
"wsl.onboarding.distroNotInstalled": "{{distro}} n'est pas encore installée.",
|
||||
"wsl.onboarding.openDistroOnce": "Ouvrez {{distro}} une fois pour terminer la configuration.",
|
||||
"wsl.onboarding.finishingDistro": "Finalisation de la configuration de {{distro}}.",
|
||||
"wsl.onboarding.pickDistro": "Choisissez une distribution ou installez-en une ci-dessous.",
|
||||
"wsl.onboarding.checkingOpencode": "Vérification d'OpenCode...",
|
||||
"wsl.onboarding.checkingOpencodeIn": "Vérification d'OpenCode dans {{distro}}...",
|
||||
"wsl.onboarding.updatingOpencode": "Mise à jour d'OpenCode...",
|
||||
"wsl.onboarding.updatingOpencodeIn": "Mise à jour d'OpenCode dans {{distro}}...",
|
||||
"wsl.onboarding.updateOpencodeIn": "Mettez à jour OpenCode dans {{distro}}.",
|
||||
"wsl.onboarding.updateOpencode": "Mettre à jour OpenCode",
|
||||
"wsl.onboarding.opencodeReadyIn": "OpenCode est prêt dans {{distro}}.",
|
||||
"wsl.onboarding.opencodeReady": "OpenCode est prêt.",
|
||||
"wsl.onboarding.installOpencodeIn": "Installez OpenCode dans {{distro}}.",
|
||||
"wsl.onboarding.installOpencode": "Installer OpenCode",
|
||||
"wsl.onboarding.chooseDistroFirst": "Choisissez d'abord une distribution.",
|
||||
"wsl.onboarding.loadFailed": "Impossible de charger l'état de WSL.",
|
||||
"wsl.onboarding.loading": "Chargement...",
|
||||
"wsl.onboarding.installedDistros": "Distributions installées",
|
||||
"wsl.onboarding.checkAgain": "Vérifier à nouveau",
|
||||
"wsl.onboarding.distroStatus.ready": "Prête",
|
||||
"wsl.onboarding.distroStatus.checking": "Vérification...",
|
||||
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode non installé",
|
||||
"wsl.onboarding.distroStatus.missingTools": "bash et curl manquants",
|
||||
"wsl.onboarding.distroStatus.unsupported": "Non prise en charge · Utilisez WSL 2",
|
||||
"wsl.onboarding.needAnotherDistro": "Besoin d'une autre distribution ?",
|
||||
"wsl.onboarding.needAnotherDistroHint": "Installez une distribution Linux depuis le catalogue WSL",
|
||||
"wsl.onboarding.wslNotInstalled.title": "WSL n'est pas installé",
|
||||
"wsl.onboarding.wslNotInstalled.description":
|
||||
"WSL (Windows Subsystem for Linux) est requis avant qu'OpenCode puisse ajouter un serveur WSL",
|
||||
"wsl.onboarding.wslUnavailable.title": "WSL indisponible",
|
||||
"wsl.onboarding.wslUnavailable.description": "OpenCode n'a pas pu vérifier WSL sur cet appareil.",
|
||||
"wsl.onboarding.installWsl": "Installer WSL",
|
||||
"wsl.onboarding.windowsRestartRequired":
|
||||
"Redémarrez Windows pour terminer l'installation de WSL, puis rouvrez OpenCode.",
|
||||
"wsl.onboarding.next": "Suivant",
|
||||
"wsl.onboarding.refresh": "Actualiser",
|
||||
"wsl.onboarding.allDistrosAdded": "Toutes les distributions installées ont déjà été ajoutées.",
|
||||
"wsl.onboarding.noDistros": "Aucune distribution détectée pour l'instant.",
|
||||
"wsl.onboarding.install": "Installer",
|
||||
"wsl.onboarding.installing": "Installation...",
|
||||
"wsl.onboarding.installDistro": "Installer une distribution",
|
||||
"wsl.onboarding.searchDistros": "Rechercher des distributions",
|
||||
"wsl.onboarding.wsl2Required": "WSL 2 est requis.",
|
||||
"wsl.onboarding.toolsRequired": "Cette distribution nécessite bash et curl.",
|
||||
"wsl.onboarding.openTerminal": "Ouvrir le terminal",
|
||||
"wsl.onboarding.path": "Chemin : {{path}}",
|
||||
"wsl.onboarding.notFound": "introuvable",
|
||||
"wsl.onboarding.version": "Version : {{version}}",
|
||||
"wsl.onboarding.unknown": "inconnue",
|
||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "La version installée ne correspond pas à celle de l'application desktop.",
|
||||
"wsl.onboarding.adding": "Ajout...",
|
||||
"dialog.project.edit.title": "Modifier le projet",
|
||||
"dialog.project.edit.name": "Nom",
|
||||
"dialog.project.edit.icon": "Icône",
|
||||
@@ -373,6 +459,7 @@ export const dict = {
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.uk": "Українська",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
"toast.language.title": "Langue",
|
||||
@@ -410,10 +497,12 @@ export const dict = {
|
||||
"toast.update.action.notYet": "Pas encore",
|
||||
"error.page.title": "Quelque chose s'est mal passé",
|
||||
"error.page.description": "Une erreur s'est produite lors du chargement de l'application.",
|
||||
"error.page.description.localServerStartup": "Une erreur s'est produite lors du démarrage du serveur local.",
|
||||
"error.page.details.label": "Détails de l'erreur",
|
||||
"error.page.action.restart": "Redémarrer",
|
||||
"error.page.action.report": "Signaler l'erreur",
|
||||
"error.page.action.reported": "Erreur signalée",
|
||||
"error.page.action.exportLogs": "Exporter les journaux",
|
||||
"error.page.action.checking": "Vérification...",
|
||||
"error.page.action.checkUpdates": "Vérifier les mises à jour",
|
||||
"error.page.action.updateTo": "Mettre à jour vers {{version}}",
|
||||
@@ -458,16 +547,40 @@ export const dict = {
|
||||
"home.recentProjects": "Projets récents",
|
||||
"home.empty.title": "Aucun projet récent",
|
||||
"home.empty.description": "Commencez par ouvrir un projet local",
|
||||
"home.title": "Accueil",
|
||||
"home.projects": "Projets",
|
||||
"home.project.add": "Ajouter un projet",
|
||||
"home.recentlyClosed": "Récemment fermés",
|
||||
"home.server.collapse": "Réduire les projets du serveur",
|
||||
"home.server.expand": "Développer les projets du serveur",
|
||||
"home.sessions.search.placeholder": "Rechercher des sessions",
|
||||
"home.sessions.search.placeholder.scoped": "Rechercher les sessions dans {{scope}}",
|
||||
"home.sessions.search.sessions": "Sessions",
|
||||
"home.sessions.search.noResults": "Aucune session trouvée pour {{query}}",
|
||||
"home.sessions.empty": "Rien ici pour l'instant",
|
||||
"home.sessions.empty.description": "Créez une session pour commencer",
|
||||
"home.sessions.group.today": "Aujourd'hui",
|
||||
"home.sessions.group.yesterday": "Hier",
|
||||
"home.sessions.group.older": "Plus anciennes",
|
||||
"home.providerTip":
|
||||
"Connectez-vous à plus de 75 fournisseurs pour utiliser d’autres modèles, notamment Claude, GPT, Gemini, etc.",
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.review": "Revue",
|
||||
"session.tab.context": "Contexte",
|
||||
"session.tab.unknown": "Session inconnue",
|
||||
"session.panel.reviewAndFiles": "Revue et fichiers",
|
||||
"session.error.notFound": "Cette session est introuvable",
|
||||
"session.error.notFound.description": "Cet onglet pointe vers une session qui n'existe plus sur ce serveur.",
|
||||
"session.error.notFound.closeTab": "Fermer l'onglet",
|
||||
"session.error.serverConnection": "Impossible de se connecter à ce serveur",
|
||||
"session.review.filesChanged": "{{count}} fichiers modifiés",
|
||||
"session.review.change.one": "Modification",
|
||||
"session.review.change.other": "Modifications",
|
||||
"session.review.loadingChanges": "Chargement des modifications...",
|
||||
"session.review.empty": "Aucune modification dans cette session pour l'instant",
|
||||
"session.review.noChanges": "Aucune modification",
|
||||
"session.review.noUncommittedChanges": "Aucune modification non validée pour l'instant",
|
||||
"session.review.noBranchChanges": "Aucune modification de branche pour l'instant",
|
||||
"session.review.noVcs": "Aucun système de contrôle de version Git détecté, modifications non affichées",
|
||||
"session.review.noSnapshot":
|
||||
"Le suivi des instantanés est désactivé dans la configuration, les modifications de session sont donc indisponibles",
|
||||
@@ -484,6 +597,10 @@ export const dict = {
|
||||
"session.todo.title": "Tâches",
|
||||
"session.todo.collapse": "Réduire",
|
||||
"session.todo.expand": "Développer",
|
||||
"session.question.minimize": "Réduire la question",
|
||||
"session.question.restore": "Restaurer la question",
|
||||
"session.question.pending.one": "{{count}} question en attente",
|
||||
"session.question.pending.other": "{{count}} questions en attente",
|
||||
"session.followupDock.summary.one": "{{count}} message en file d'attente",
|
||||
"session.followupDock.summary.other": "{{count}} messages en file d'attente",
|
||||
"session.followupDock.sendNow": "Envoyer maintenant",
|
||||
@@ -496,9 +613,16 @@ export const dict = {
|
||||
"session.revertDock.expand": "Développer les messages annulés",
|
||||
"session.revertDock.restore": "Restaurer le message",
|
||||
"session.new.title": "Créez ce que vous voulez",
|
||||
"session.new.project.new": "Nouveau projet",
|
||||
"session.new.project.search": "Rechercher des projets",
|
||||
"session.new.project.add": "Ajouter un projet",
|
||||
"session.new.worktree.main": "Branche principale",
|
||||
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
||||
"session.new.worktree.create": "Créer un nouvel arbre de travail",
|
||||
"session.new.workspace.runIn": "Exécuter la session dans",
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Dépôt local",
|
||||
"session.new.workspace.existing": "Espace de travail…",
|
||||
"session.new.lastModified": "Dernière modification",
|
||||
"session.header.search.placeholder": "Rechercher {{project}}",
|
||||
"session.header.searchFiles": "Rechercher des fichiers",
|
||||
@@ -564,6 +688,8 @@ export const dict = {
|
||||
"sidebar.project.recentSessions": "Sessions récentes",
|
||||
"sidebar.project.viewAllSessions": "Voir toutes les sessions",
|
||||
"sidebar.project.clearNotifications": "Effacer les notifications",
|
||||
"sidebar.empty.title": "Aucun projet ouvert",
|
||||
"sidebar.empty.description": "Ouvrez un projet pour commencer",
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "Bureau",
|
||||
"settings.section.server": "Serveur",
|
||||
@@ -573,6 +699,7 @@ export const dict = {
|
||||
"settings.desktop.wsl.title": "Intégration WSL",
|
||||
"settings.desktop.wsl.description": "Exécuter le serveur OpenCode dans WSL sur Windows.",
|
||||
"settings.general.section.appearance": "Apparence",
|
||||
"settings.general.section.advanced": "Avancé",
|
||||
"settings.general.section.notifications": "Notifications système",
|
||||
"settings.general.section.updates": "Mises à jour",
|
||||
"settings.general.section.sounds": "Effets sonores",
|
||||
@@ -580,6 +707,11 @@ export const dict = {
|
||||
"settings.general.section.display": "Affichage",
|
||||
"settings.general.row.language.title": "Langue",
|
||||
"settings.general.row.language.description": "Changer la langue d'affichage pour OpenCode",
|
||||
"settings.general.row.shell.title": "Shell du terminal",
|
||||
"settings.general.row.shell.description":
|
||||
"Choisissez le shell utilisé par votre terminal. Les shells compatibles sont également utilisés pour les appels d'outils de l'agent.",
|
||||
"settings.general.row.shell.autoDefault": "Auto (par défaut)",
|
||||
"settings.general.row.shell.terminalOnly": "terminal uniquement",
|
||||
"settings.general.row.appearance.title": "Apparence",
|
||||
"settings.general.row.appearance.description": "Personnaliser l'apparence d'OpenCode sur votre appareil",
|
||||
"settings.general.row.colorScheme.title": "Schéma de couleurs",
|
||||
@@ -588,8 +720,8 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
|
||||
"settings.general.row.font.title": "Police de code",
|
||||
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code",
|
||||
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||
"settings.general.row.terminalFont.title": "Police du terminal",
|
||||
"settings.general.row.terminalFont.description": "Personnalisez la police utilisée dans le terminal",
|
||||
"settings.general.row.uiFont.title": "Police de l'interface",
|
||||
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
|
||||
"settings.general.row.followup.title": "Comportement de suivi",
|
||||
@@ -597,6 +729,24 @@ export const dict = {
|
||||
"Choisissez si les messages de suivi dirigent immédiatement ou attendent dans une file d'attente",
|
||||
"settings.general.row.followup.option.queue": "File d'attente",
|
||||
"settings.general.row.followup.option.steer": "Diriger",
|
||||
"settings.general.row.showFileTree.title": "Arborescence des fichiers",
|
||||
"settings.general.row.showFileTree.description": "Afficher le panneau d'arborescence des fichiers dans les sessions",
|
||||
"settings.general.row.showNavigation.title": "Commandes de navigation",
|
||||
"settings.general.row.showNavigation.description":
|
||||
"Afficher les boutons précédent et suivant dans la barre de titre de l'application desktop",
|
||||
"settings.general.row.showSearch.title": "Palette de commandes",
|
||||
"settings.general.row.showSearch.description":
|
||||
"Afficher le bouton de recherche et de palette de commandes dans la barre de titre",
|
||||
"settings.general.row.showTerminal.title": "Terminal",
|
||||
"settings.general.row.showTerminal.description":
|
||||
"Afficher le bouton du terminal dans la barre de titre de l'application desktop",
|
||||
"settings.general.row.showStatus.title": "État du serveur",
|
||||
"settings.general.row.showStatus.description": "Afficher le bouton d'état du serveur dans la barre de titre",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation en bas",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Placer la barre de titre et les onglets de session en bas de l'écran sur mobile",
|
||||
"settings.general.row.showCustomAgents.title": "Agents personnalisés",
|
||||
"settings.general.row.showCustomAgents.description": "Afficher le sélecteur d'agent dans la zone de saisie",
|
||||
"settings.general.row.reasoningSummaries.title": "Afficher les résumés de raisonnement",
|
||||
"settings.general.row.reasoningSummaries.description":
|
||||
"Afficher les résumés de raisonnement du modèle dans la chronologie",
|
||||
@@ -606,6 +756,16 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
|
||||
"settings.general.row.newInterface.title": "Nouvelle mise en page",
|
||||
"settings.general.row.newInterface.badge": "Nouveau",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Utilisez les nouveaux onglets et la mise en page de l'accueil. Vous pouvez alterner entre les mises en page pendant une durée limitée.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Vous utilisez maintenant la nouvelle mise en page",
|
||||
"settings.general.row.newInterfaceNotice.description": "La mise en page précédente n'est plus disponible",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Ignorer",
|
||||
"settings.general.row.pinchZoom.title": "Pincer pour zoomer",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer",
|
||||
"settings.general.row.wayland.title": "Utiliser Wayland natif",
|
||||
"settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -618,6 +778,8 @@ export const dict = {
|
||||
"settings.updates.row.check.description": "Vérifier manuellement les mises à jour et installer si disponible",
|
||||
"settings.updates.action.checkNow": "Vérifier maintenant",
|
||||
"settings.updates.action.checking": "Vérification...",
|
||||
"settings.updates.action.downloading": "Téléchargement...",
|
||||
"settings.updates.action.installing": "Installation...",
|
||||
"settings.updates.toast.latest.title": "Vous êtes à jour",
|
||||
"settings.updates.toast.latest.description": "Vous utilisez la dernière version d'OpenCode.",
|
||||
"sound.option.none": "Aucun",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user