mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-22 16:47:34 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
211ce5e9f8 | ||
|
|
3858b11bf9 |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +175,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,7 +201,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -214,7 +214,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -250,7 +250,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -277,7 +277,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -294,7 +294,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -318,7 +318,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -338,7 +338,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -406,10 +406,10 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
"electron-updater": "6.8.9",
|
||||
"lighthouse": "13.4.1",
|
||||
@@ -436,7 +436,7 @@
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "44.4.3",
|
||||
"electron": "42.10.1",
|
||||
"electron-builder": "26.15.7",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
@@ -455,7 +455,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -492,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +508,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -527,7 +527,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -540,7 +540,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -554,7 +554,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -569,7 +569,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -608,7 +608,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -638,7 +638,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -653,7 +653,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -677,7 +677,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -698,7 +698,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -720,7 +720,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -755,7 +755,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -775,7 +775,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -809,7 +809,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -828,7 +828,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -874,7 +874,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -888,7 +888,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -923,7 +923,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -958,7 +958,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -991,7 +991,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1032,7 +1032,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -3880,13 +3880,13 @@
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron": ["electron@44.4.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-LTpSFTB40qVCXIX5xMo+cgHI/Jjkbjw7VpB26PccEbroqOn72LBukeaDwPVo1fBYzSzs0c9iPuAucFCO7Tw81Q=="],
|
||||
"electron": ["electron@42.10.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-ITc1HPeoDzsxCCaH6MFsN67Nq2nUiJf5N9pBWxzhUpFfKJF0IwiAkKT3emg1lPbvC4OfFE+Cdwe4vhgdOZ1YKg=="],
|
||||
|
||||
"electron-builder": ["electron-builder@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.7", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-DBpaNzxsPs1BvEblzFoNriSbzsBqDCy/gseIngeEhYzQG1IxfB7Hvc2tBBVmpWE2BTQGP9J1RrAvDT+Vc/uAxg=="],
|
||||
|
||||
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-B4uvn2NzFSuf084udWqugludFull6CRJiWe2dLzMnZLl6G5hdAGk0fsBMGlBSpKjvQCJn8IPc+S7OnJ+GXqwLA=="],
|
||||
|
||||
"electron-context-menu": ["electron-context-menu@5.0.0", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-rgFpRtwY0/rhsRCoz9rE6VM4WueEsLbIpca7ucOhERVrbGB2dQrxa9xwBNLplU54jRgPuv6nTEbxoplE5bzy2A=="],
|
||||
"electron-context-menu": ["electron-context-menu@4.1.2", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-9xYTUV0oRqKL50N9W71IrXNdVRB0LuBp3R1zkUdUc2wfIa2/QZwYYj5RLuO7Tn7ZSLVIaO3X6u+EIBK+cBvzrQ=="],
|
||||
|
||||
"electron-dl": ["electron-dl@4.0.0", "", { "dependencies": { "ext-name": "^5.0.0", "pupa": "^3.1.0", "unused-filename": "^4.0.1" } }, "sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-/jah4P2a0aGJNJ0aMdlFEbGHAXx33lqxHbc7UmpLFDg=",
|
||||
"aarch64-linux": "sha256-L3SoZ24qNXicsE2FK6LATQjOmjPxY679RjugrUyO/1Y=",
|
||||
"aarch64-darwin": "sha256-pI9NT8KWUPi+JCk6DYMqIAYmBqTbi13uL4VdNV3WS6Y=",
|
||||
"x86_64-darwin": "sha256-rMAGhTTz46KA5Ya7E5J0af7Bn1QzTDhTaNfNJm8qfsw="
|
||||
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
|
||||
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
|
||||
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
|
||||
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -19,13 +19,6 @@ export function mountBrowserPane() {
|
||||
session: "Alpha",
|
||||
mounted: true,
|
||||
visible: true,
|
||||
url: undefined as string | undefined,
|
||||
loading: false,
|
||||
generation: 0,
|
||||
delayNavigation: false,
|
||||
pendingURL: undefined as string | undefined,
|
||||
loadErrors: {} as Record<string, string | undefined>,
|
||||
error: undefined as string | undefined,
|
||||
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
|
||||
})
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
@@ -54,33 +47,13 @@ export function mountBrowserPane() {
|
||||
opened: () => !!registrations.get(store.session),
|
||||
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
|
||||
tabs: () => tabs.filter((tab) => tab.title === store.session),
|
||||
active: () => {
|
||||
const tab = tabs.find((tab) => tab.title === store.session) ?? tabs[0]
|
||||
return {
|
||||
...tab,
|
||||
url: store.url ?? tab.url,
|
||||
loading: store.loading,
|
||||
generation: store.generation,
|
||||
loadError: store.loadErrors[store.session],
|
||||
}
|
||||
},
|
||||
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
|
||||
registration: () => registrations.get(store.session),
|
||||
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
|
||||
error: () => undefined,
|
||||
suspended: () => false,
|
||||
close: () => undefined,
|
||||
open: () => undefined,
|
||||
command: (command) => {
|
||||
setStore("error", undefined)
|
||||
if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined)
|
||||
if (command.type === "navigate") {
|
||||
if (store.delayNavigation) {
|
||||
setStore("pendingURL", command.url)
|
||||
return
|
||||
}
|
||||
setStore({ url: command.url, loading: false, generation: store.generation + 1 })
|
||||
}
|
||||
if (command.type === "stop") setStore("loading", false)
|
||||
},
|
||||
command: () => undefined,
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -91,32 +64,6 @@ export function mountBrowserPane() {
|
||||
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
|
||||
</For>
|
||||
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStore({ loading: true, generation: store.generation + 1, loadErrors: { [store.session]: undefined } })
|
||||
}
|
||||
>
|
||||
Load current page
|
||||
</button>
|
||||
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
|
||||
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
|
||||
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
|
||||
Block navigation
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStore({
|
||||
url: store.pendingURL,
|
||||
pendingURL: undefined,
|
||||
loading: false,
|
||||
generation: store.generation + 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
Complete navigation
|
||||
</button>
|
||||
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
|
||||
</nav>
|
||||
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
|
||||
@@ -57,153 +57,3 @@ story("hides the native view immediately while the pane stays mounted", async ({
|
||||
await toggle.click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
|
||||
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Loading page", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await root.getByRole("button", { name: "Stop", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("keeps the submitted URL visible until the browser reports navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
|
||||
await address.fill("https://unsubmitted.example/")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
})
|
||||
|
||||
story("restores the current URL when a submitted navigation is blocked", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://blocked.example/")
|
||||
await address.press("Enter")
|
||||
await expect(address).toHaveValue("https://blocked.example/")
|
||||
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
|
||||
await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible()
|
||||
await expect(address).toHaveValue("https://alpha.example/")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Failed page", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
|
||||
"https://alpha.example/",
|
||||
)
|
||||
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
|
||||
await root.getByRole("button", { name: "Reload", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("returns a failed tab to the empty state when an empty URL is submitted", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Failed page", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("")
|
||||
await root.getByRole("button", { name: "Load current page", exact: true }).click()
|
||||
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("selects the full URL when the address field gains focus", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.click()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
|
||||
await address.pressSequentially("https://example.com/")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await address.focus()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
|
||||
await address.press("ArrowRight")
|
||||
await address.click()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
|
||||
})
|
||||
|
||||
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
|
||||
await root.getByRole("button", { name: "Load current page", exact: true }).click()
|
||||
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
})
|
||||
|
||||
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("https://alpha.example/")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("shows question navigation shortcuts", async ({ mount, page }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const shortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘⏎" : "Ctrl+⏎"))
|
||||
const next = component.getByRole("button", { name: "Next", exact: true })
|
||||
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
|
||||
await next.click()
|
||||
await expect(component.getByRole("button", { name: "Submit", exact: true })).toContainText(shortcut)
|
||||
const back = component.getByRole("button", { name: "Back", exact: true })
|
||||
const backShortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘[" : "Alt+←"))
|
||||
await expect(back).toHaveText("Back")
|
||||
await back.hover()
|
||||
await expect(page.getByRole("tooltip")).toContainText(backShortcut)
|
||||
await page.keyboard.press(
|
||||
await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+[" : "Alt+ArrowLeft")),
|
||||
)
|
||||
await expect(next).toBeVisible()
|
||||
})
|
||||
@@ -158,10 +158,6 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
story(`fits narrow and wide layouts in ${direction}, ${theme}`, async ({ mount, page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 900, height: 900 })
|
||||
const component = await mount("settings-timeline-detail--interactive", { globals: { direction, theme } })
|
||||
await expect(component.locator('[data-slot="timeline-detail-scale"]')).toHaveCSS("margin-top", "16px")
|
||||
const advanced = component.locator('[data-slot="timeline-detail-advanced"]')
|
||||
await expect(advanced).toHaveCSS("margin-top", "0px")
|
||||
await expect(advanced).toHaveCSS("padding-top", "8px")
|
||||
await expect(component.locator('[data-slot="timeline-detail-summary"]')).toHaveCSS(
|
||||
"color",
|
||||
await component
|
||||
@@ -181,46 +177,28 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
),
|
||||
)
|
||||
if (theme === "light") {
|
||||
await expect(track.locator("span").last()).toHaveCSS("background-image", "none")
|
||||
await expect(track.locator("span").first()).toHaveCSS("background-image", "none")
|
||||
await expect(track).toHaveCSS(
|
||||
"--timeline-detail-marker-background",
|
||||
await track.evaluate((element) => getComputedStyle(element).getPropertyValue("--v2-grey-500").trim()),
|
||||
)
|
||||
}
|
||||
if (theme === "dark") {
|
||||
await expect(track.locator("span").last()).not.toHaveCSS("background-image", "none")
|
||||
await expect(track.locator("span").first()).not.toHaveCSS("background-image", "none")
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`timeline-${theme}-${direction}.png`) })
|
||||
const list = component.locator('[data-slot="timeline-detail-list"]')
|
||||
const columns = component.locator('[data-slot="timeline-detail-columns"]')
|
||||
await expect(component.locator('[data-slot="timeline-detail-categories"]')).toHaveCSS("margin-top", "0px")
|
||||
const advancedTrigger = component.getByRole("button", { name: "Advanced", exact: true })
|
||||
expect(
|
||||
await advancedTrigger.evaluate((element) => {
|
||||
const trigger = element.getBoundingClientRect()
|
||||
const heading = document
|
||||
.querySelector('[data-slot="timeline-detail-columns"] > :nth-child(2)')!
|
||||
.getBoundingClientRect()
|
||||
return Math.abs(trigger.y + trigger.height / 2 - (heading.y + heading.height / 2))
|
||||
}),
|
||||
).toBeLessThan(1)
|
||||
expect(
|
||||
await list.evaluate((element) => {
|
||||
const list = element.getBoundingClientRect()
|
||||
const trigger = document
|
||||
.querySelector('[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"]')!
|
||||
.getBoundingClientRect()
|
||||
return list.y - trigger.bottom
|
||||
}),
|
||||
).toBe(8)
|
||||
for (const [column, field] of [
|
||||
[2, "placement"],
|
||||
[3, "details"],
|
||||
] as const) {
|
||||
const heading = await columns.locator(`> :nth-child(${column})`).evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.x + rect.width / 2
|
||||
})
|
||||
const heading = await component
|
||||
.locator(`[data-slot="timeline-detail-columns"] > :nth-child(${column})`)
|
||||
.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.x + rect.width / 2
|
||||
})
|
||||
const toggle = await component
|
||||
.locator(`[data-category="shell"][data-field="${field}"] [data-slot="switch-control"]`)
|
||||
.evaluate((element) => {
|
||||
@@ -229,7 +207,7 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
})
|
||||
expect(Math.abs(heading - toggle)).toBeLessThan(1)
|
||||
}
|
||||
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "8px")
|
||||
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "12px")
|
||||
for (const width of [900, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeVisible()
|
||||
@@ -249,7 +227,6 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
|
||||
const slider = component.getByRole("slider", { name: "Timeline detail" })
|
||||
const track = component.locator('[data-slot="timeline-detail-track"]')
|
||||
await expect(slider).not.toHaveCSS("cursor", "pointer")
|
||||
expect(await track.evaluate((element) => element.getBoundingClientRect().width)).toBe(
|
||||
await component
|
||||
.locator('[data-slot="timeline-detail-scale"]')
|
||||
@@ -260,11 +237,6 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
for (const position of [0, 1, 2, 3, 4]) {
|
||||
if (position > 0) await slider.press("ArrowUp")
|
||||
await expect(track).toHaveCSS("--timeline-detail-progress", `${position * 25}%`)
|
||||
await expect(track.locator("span[data-selected]")).toHaveCount(position + 1)
|
||||
await expect(track.locator("span").nth(position)).toHaveCSS(
|
||||
"background-color",
|
||||
await track.evaluate((element) => getComputedStyle(element, "::before").backgroundColor),
|
||||
)
|
||||
const fill = await track.evaluate((element) => {
|
||||
const style = getComputedStyle(element, "::before")
|
||||
return {
|
||||
|
||||
@@ -17,12 +17,14 @@ const milestones = [
|
||||
"electronSpawnStarted",
|
||||
"debugEndpointReady",
|
||||
"electronStarted",
|
||||
"layersReady",
|
||||
"windowVisible",
|
||||
"serviceEnsureStarted",
|
||||
"serviceSpawnRequested",
|
||||
"serviceReady",
|
||||
"backgroundLoadingReady",
|
||||
"rendererViteConnected",
|
||||
"rendererInitializationStarted",
|
||||
"rendererInitializationReady",
|
||||
"windowVisible",
|
||||
"homeReady",
|
||||
] as const
|
||||
const phases = [
|
||||
@@ -126,12 +128,14 @@ const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
|
||||
{ name: "electronSpawnStarted", text: "starting electron app..." },
|
||||
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
|
||||
{ name: "electronStarted", text: "app starting" },
|
||||
{ name: "layersReady", text: "layers ready" },
|
||||
{ name: "windowVisible", text: "main window visible" },
|
||||
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
|
||||
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
|
||||
{ name: "serviceReady", text: "v2 CLI background service ready" },
|
||||
{ name: "backgroundLoadingReady", text: "loading task finished" },
|
||||
{ name: "rendererViteConnected", text: "[vite] connected." },
|
||||
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
|
||||
{ name: "rendererInitializationReady", text: "server ready" },
|
||||
{ name: "windowVisible", text: "main window visible" },
|
||||
]
|
||||
|
||||
async function createColdProfile() {
|
||||
@@ -400,12 +404,14 @@ function requireMilestones(observed: Partial<Record<Milestone, number>>) {
|
||||
electronSpawnStarted: get("electronSpawnStarted"),
|
||||
debugEndpointReady: get("debugEndpointReady"),
|
||||
electronStarted: get("electronStarted"),
|
||||
layersReady: get("layersReady"),
|
||||
windowVisible: get("windowVisible"),
|
||||
serviceEnsureStarted: get("serviceEnsureStarted"),
|
||||
serviceSpawnRequested: get("serviceSpawnRequested"),
|
||||
serviceReady: get("serviceReady"),
|
||||
backgroundLoadingReady: get("backgroundLoadingReady"),
|
||||
rendererViteConnected: get("rendererViteConnected"),
|
||||
rendererInitializationStarted: get("rendererInitializationStarted"),
|
||||
rendererInitializationReady: get("rendererInitializationReady"),
|
||||
windowVisible: get("windowVisible"),
|
||||
homeReady: get("homeReady"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,14 @@ describe("desktop startup benchmark", () => {
|
||||
["electronSpawnStarted", "starting electron app..."],
|
||||
["debugEndpointReady", "DevTools listening on ws://"],
|
||||
["electronStarted", "app starting"],
|
||||
["layersReady", "layers ready"],
|
||||
["windowVisible", "main window visible"],
|
||||
["serviceEnsureStarted", "starting v2 background service"],
|
||||
["serviceSpawnRequested", "v2 CLI background service starting"],
|
||||
["serviceReady", "v2 CLI background service ready"],
|
||||
["backgroundLoadingReady", "loading task finished"],
|
||||
["rendererViteConnected", "[vite] connected."],
|
||||
["rendererInitializationStarted", "awaiting server ready"],
|
||||
["rendererInitializationReady", "server ready"],
|
||||
["windowVisible", "main window visible"],
|
||||
] as const
|
||||
cases.forEach(([milestone, line]) => {
|
||||
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
|
||||
@@ -56,12 +58,14 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
|
||||
electronSpawnStarted: 7,
|
||||
debugEndpointReady: 8,
|
||||
electronStarted: 9,
|
||||
layersReady: 10,
|
||||
windowVisible: 11,
|
||||
serviceEnsureStarted: 12,
|
||||
serviceSpawnRequested: 13,
|
||||
serviceReady: 14,
|
||||
rendererViteConnected: 15,
|
||||
serviceEnsureStarted: 10,
|
||||
serviceSpawnRequested: 11,
|
||||
serviceReady: 12,
|
||||
backgroundLoadingReady: 13,
|
||||
rendererViteConnected: 14,
|
||||
rendererInitializationStarted: 15,
|
||||
rendererInitializationReady: 16,
|
||||
windowVisible: 17,
|
||||
homeReady: commandToHomeReadyMs,
|
||||
}
|
||||
return {
|
||||
@@ -76,8 +80,8 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
|
||||
electronStartup: 2,
|
||||
serviceSpawnWait: 1,
|
||||
serviceProcessStartup: 1,
|
||||
rendererStartup: commandToHomeReadyMs - 15,
|
||||
visibleWindowToHome: commandToHomeReadyMs - 11,
|
||||
rendererStartup: commandToHomeReadyMs - 14,
|
||||
visibleWindowToHome: commandToHomeReadyMs - 17,
|
||||
},
|
||||
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ for (const field of [
|
||||
expect(patches).toEqual(field.patches)
|
||||
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "General", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: project.name, exact: true }).click()
|
||||
if (field.name === "color") {
|
||||
await expect(settings.getByRole("button", { name: "Select orange color", exact: true })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
|
||||
@@ -71,7 +71,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
|
||||
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
|
||||
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
|
||||
for (const width of [1520, 1280, 1800]) {
|
||||
for (const width of [1520, 1280, 1600]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
|
||||
await page.clock.runFor(100)
|
||||
@@ -84,7 +84,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
|
||||
await expect(content).not.toHaveCSS("translate", shifted)
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
|
||||
await expect(content).toHaveCSS("translate", shifted)
|
||||
|
||||
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
|
||||
@@ -113,8 +112,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(summary).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 1800, height: 900 })
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
|
||||
await expect(content).toHaveCSS("translate", "none")
|
||||
await expect(content).toHaveCSS("translate", "0px")
|
||||
await expect(summary).toBeVisible()
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
|
||||
@@ -99,21 +99,6 @@ test("settings has its own route and returns through app history", async ({ page
|
||||
await expect(home).toHaveAttribute("aria-pressed", "true")
|
||||
})
|
||||
|
||||
test("settings page survives refresh", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const appearance = settings.getByRole("tab", { name: "Appearance", exact: true })
|
||||
|
||||
await appearance.click()
|
||||
await expect(page).toHaveURL("/settings?tab=appearance")
|
||||
await expect(appearance).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(appearance).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page).toHaveURL("/settings?tab=appearance")
|
||||
})
|
||||
|
||||
test("single-server settings expose scoped pages without a server picker", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeVisible()
|
||||
@@ -131,7 +116,7 @@ test("single-server settings expose scoped pages without a server picker", async
|
||||
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
|
||||
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
|
||||
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
|
||||
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "10px")
|
||||
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
|
||||
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
|
||||
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
|
||||
@@ -149,52 +134,10 @@ test("project settings open as a nested autosaving view", async ({ page }) => {
|
||||
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
|
||||
|
||||
await expect(settings.getByRole("button", { name: "Back to projects", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "General", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Settings demo", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Worktrees", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Scripts", exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByTitle(directory)).toHaveCount(0)
|
||||
const projectIcon = settings.locator('.settings-tab-header [data-component="project-avatar-v2"]')
|
||||
await expect(projectIcon).toHaveCSS("width", "32px")
|
||||
const projectOptions = settings
|
||||
.locator(".settings-tab-header")
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
await expect(projectOptions).toHaveCSS("width", "28px")
|
||||
await expect
|
||||
.poll(async () =>
|
||||
new Set(
|
||||
await Promise.all(
|
||||
[
|
||||
settings.getByRole("button", { name: "Back to projects", exact: true }),
|
||||
settings.getByRole("heading", { name: "Settings demo", exact: true }),
|
||||
projectIcon,
|
||||
projectOptions,
|
||||
].map((item) =>
|
||||
item.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return Math.round(bounds.top + bounds.height / 2)
|
||||
}),
|
||||
),
|
||||
),
|
||||
).size,
|
||||
)
|
||||
.toBe(1)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const back = await settings
|
||||
.getByRole("button", { name: "Back to projects", exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top)
|
||||
const general = await settings
|
||||
.getByRole("tab", { name: "General", exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top)
|
||||
return Math.round(general - back)
|
||||
})
|
||||
.toBe(72)
|
||||
await projectOptions.click()
|
||||
const projectMenu = page.getByRole("menu")
|
||||
await expect(projectMenu.getByRole("menuitem")).toHaveText(["Clear notifications", "Close"])
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(projectMenu).toBeHidden()
|
||||
|
||||
const name = settings.getByRole("textbox", { name: "Project name", exact: true })
|
||||
const saved = page.waitForRequest(
|
||||
@@ -203,8 +146,7 @@ test("project settings open as a nested autosaving view", async ({ page }) => {
|
||||
await name.fill("Renamed project")
|
||||
await name.blur()
|
||||
expect((await saved).postDataJSON()).toEqual({ name: "Renamed project" })
|
||||
await expect(settings.getByRole("tab", { name: "General", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("heading", { name: "Renamed project", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Renamed project", exact: true })).toBeVisible()
|
||||
|
||||
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
|
||||
const scriptSaved = page.waitForRequest(
|
||||
@@ -215,17 +157,6 @@ test("project settings open as a nested autosaving view", async ({ page }) => {
|
||||
expect((await scriptSaved).postDataJSON()).toEqual({ commands: { start: "bun install" } })
|
||||
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(
|
||||
(url) =>
|
||||
url.pathname === "/settings" &&
|
||||
url.searchParams.get("server") === "http://127.0.0.1:4096" &&
|
||||
url.searchParams.get("project") === directory &&
|
||||
url.searchParams.get("tab") === "workspaces",
|
||||
)
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
@@ -295,7 +226,7 @@ test("recording a new session shortcut stays in settings until recording finishe
|
||||
await page.keyboard.press("Control+t")
|
||||
|
||||
await expect(binding).toHaveText("Ctrl+T")
|
||||
await expect(page).toHaveURL("/settings?tab=shortcuts")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(page.locator("[data-titlebar-tab]")).toHaveCount(0)
|
||||
await page.keyboard.press("Control+t")
|
||||
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
||||
|
||||
@@ -5,7 +5,7 @@ for (const colorScheme of ["light", "dark"] as const) {
|
||||
test.describe(colorScheme, () => {
|
||||
test.use({ colorScheme, contextOptions: { reducedMotion: "reduce" } })
|
||||
|
||||
test("project list actions and edges stay inside the settings scrollport", async ({ page }, info) => {
|
||||
test("project card edges stay inside the settings scrollport", async ({ page }, info) => {
|
||||
const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => ({
|
||||
id: `project-${index}`,
|
||||
name,
|
||||
@@ -20,18 +20,10 @@ for (const colorScheme of ["light", "dark"] as const) {
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
fileList: () => [],
|
||||
})
|
||||
await page.route("**/api/project", (route) =>
|
||||
route.fulfill({ json: projects, headers: { "access-control-allow-origin": "*" } }),
|
||||
)
|
||||
await page.route("**/api/project/project-0", async (route) => {
|
||||
const update = route.request().postDataJSON()
|
||||
await route.fulfill({
|
||||
json: { ...projects[0], ...update },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await page.addInitScript((projects) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
@@ -48,76 +40,18 @@ for (const colorScheme of ["light", "dark"] as const) {
|
||||
const panel = settings.getByRole("tabpanel")
|
||||
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("Playground", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Add project", exact: true })).toBeVisible()
|
||||
const list = panel.getByRole("list")
|
||||
await expect(list.getByRole("listitem")).toHaveCount(4)
|
||||
const projectButton = panel.getByRole("button", { name: "rebase", exact: true })
|
||||
const projectCard = list.getByRole("listitem").filter({ hasText: "rebase" })
|
||||
const projectName = projectButton.getByText("rebase", { exact: true })
|
||||
await expect(projectName).toHaveCSS("font-weight", "530")
|
||||
await expect(projectName).toHaveCSS("line-height", "20px")
|
||||
await expect(projectButton.getByText("/projects/rebase", { exact: true })).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() =>
|
||||
list.evaluate((group) => {
|
||||
const icons = Array.from(group.querySelectorAll('[data-component="project-avatar-v2"]'))
|
||||
const first = icons.at(0)?.getBoundingClientRect()
|
||||
const last = icons.at(-1)?.getBoundingClientRect()
|
||||
const bounds = group.getBoundingClientRect()
|
||||
if (!first || !last) return []
|
||||
return [first.top - bounds.top, bounds.bottom - last.bottom, first.left - bounds.left]
|
||||
}),
|
||||
)
|
||||
.toEqual([16, 16, 16])
|
||||
await expect
|
||||
.poll(() =>
|
||||
list
|
||||
.locator(".settings-project-row-shell")
|
||||
.filter({ hasText: "rebase" })
|
||||
.evaluate((row) => {
|
||||
const group = row.parentElement
|
||||
if (!group) return 0
|
||||
return (
|
||||
row.getBoundingClientRect().left -
|
||||
group.getBoundingClientRect().left +
|
||||
Number.parseFloat(getComputedStyle(row, "::after").insetInlineStart)
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBe(16)
|
||||
const chevron = projectButton.locator('svg:has(use[href="#opencode-v2-icon-chevron-right"])')
|
||||
await expect(chevron).toHaveCount(0)
|
||||
const hoverColor = await projectCard.evaluate((element) => {
|
||||
const probe = document.createElement("div")
|
||||
probe.style.backgroundColor = "var(--v2-background-bg-layer-02)"
|
||||
element.append(probe)
|
||||
const color = getComputedStyle(probe).backgroundColor
|
||||
probe.remove()
|
||||
return color
|
||||
})
|
||||
await projectCard.hover()
|
||||
await expect(projectCard).toHaveCSS("background-color", hoverColor)
|
||||
const more = projectCard.getByRole("button", { name: "More options", exact: true })
|
||||
await more.click()
|
||||
const menu = page.getByRole("menu")
|
||||
await expect(menu.getByRole("menuitem")).toHaveText(["Edit", "Rename", "Clear notifications", "Close"])
|
||||
await menu.getByRole("menuitem", { name: "Close", exact: true }).hover()
|
||||
await expect(projectCard).toHaveCSS("background-color", hoverColor)
|
||||
await expect(menu.getByRole("separator")).toHaveCount(1)
|
||||
await menu.getByRole("menuitem", { name: "Edit", exact: true }).click()
|
||||
await expect(panel.getByRole("heading", { name: "rebase", exact: true })).toBeVisible()
|
||||
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
|
||||
await expect(panel.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
|
||||
for (const width of [1280, 1050, 960, 720, 600]) {
|
||||
await page.setViewportSize({ width, height: 720 })
|
||||
await page.mouse.move(0, 0)
|
||||
await page.screenshot({ path: info.outputPath(`projects-${width}.png`), animations: "disabled" })
|
||||
// Each project card stays fully inside every horizontal clip ancestor.
|
||||
// Raised cards paint a half-pixel border outside their box. The scrollport
|
||||
// must leave room for that border and the soft shadow on both sides.
|
||||
await expect
|
||||
.poll(() =>
|
||||
projectCard.evaluate((row) => {
|
||||
panel.getByText("rebase", { exact: true }).evaluate((label) => {
|
||||
const row = label.parentElement!.parentElement!
|
||||
const bounds = row.getBoundingClientRect()
|
||||
const clips = []
|
||||
for (let parent = row.parentElement; parent; parent = parent.parentElement) {
|
||||
@@ -139,45 +73,11 @@ for (const colorScheme of ["light", "dark"] as const) {
|
||||
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
|
||||
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
|
||||
|
||||
const secondProject = panel.getByRole("button", { name: "dinocms", exact: true })
|
||||
await list
|
||||
.getByRole("listitem")
|
||||
.filter({ hasText: "dinocms" })
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
.click()
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
|
||||
await expect(secondProject).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 260 })
|
||||
await panel.getByText("rebase", { exact: true }).hover()
|
||||
await page.mouse.wheel(0, 400)
|
||||
await expect(panel.getByText("Playground", { exact: true })).toBeInViewport({ ratio: 1 })
|
||||
await expect(panel.getByRole("heading", { name: "Projects", exact: true })).toBeInViewport({ ratio: 1 })
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await more.click()
|
||||
await menu.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const rename = panel.getByRole("textbox", { name: "Rename", exact: true })
|
||||
await expect(rename).toBeFocused()
|
||||
await expect(rename).toHaveValue("rebase")
|
||||
await expect(rename).toHaveCSS("border-radius", "0px")
|
||||
await rename.fill("Renamed project")
|
||||
const renamed = page.waitForRequest(
|
||||
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/project-0",
|
||||
)
|
||||
await rename.press("Enter")
|
||||
expect((await renamed).postDataJSON()).toEqual({ name: "Renamed project" })
|
||||
await expect(panel.getByRole("button", { name: "Renamed project", exact: true })).toBeVisible()
|
||||
|
||||
await panel.getByRole("button", { name: "Add project", exact: true }).click()
|
||||
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
|
||||
await picker.getByRole("combobox").fill("/projects/added")
|
||||
await picker.getByRole("combobox").press("Enter")
|
||||
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
|
||||
await expect(selectFolder).toBeEnabled()
|
||||
await selectFolder.click()
|
||||
await expect(picker).toBeHidden()
|
||||
await expect(panel.getByRole("heading", { name: "added", exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,19 +30,6 @@ function projectList(count: number) {
|
||||
}))
|
||||
}
|
||||
|
||||
async function persistProjects(page: Page, projects: ReturnType<typeof projectList>) {
|
||||
await page.evaluate((projects) => {
|
||||
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
...value,
|
||||
projects: { ...value.projects, local: projects.map((project) => ({ worktree: project.canonical })) },
|
||||
}),
|
||||
)
|
||||
}, projects)
|
||||
}
|
||||
|
||||
function ui(page: Page) {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
return {
|
||||
@@ -63,14 +50,6 @@ test.use({ viewport: { width: 1280, height: 900 } })
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, config)
|
||||
await page.route("https://api.github.com/**", (route) => route.fulfill({ json: [] }))
|
||||
await page.addInitScript((directory) => {
|
||||
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
|
||||
if (value.projects?.local) return
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ ...value, projects: { ...value.projects, local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
}, directory)
|
||||
await page.goto("/")
|
||||
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
@@ -177,7 +156,7 @@ test("empty queries keep the closing quote beside the ellipsis while typing and
|
||||
await expect(status).toHaveText('No results for "zzzzzzzzzzx"')
|
||||
})
|
||||
|
||||
test("Models and Shortcuts leave focus on navigation", async ({ page }) => {
|
||||
test("Models and Shortcuts autofocus their filters on normal navigation", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
for (const entry of [
|
||||
{ tab: "Models", search: "Search models" },
|
||||
@@ -185,7 +164,7 @@ test("Models and Shortcuts leave focus on navigation", async ({ page }) => {
|
||||
{ tab: "Models", search: "Search models" },
|
||||
]) {
|
||||
await view.settings.getByRole("tab", { name: entry.tab, exact: true }).click()
|
||||
await expect(view.settings.getByRole("searchbox", { name: entry.search, exact: true })).not.toBeFocused()
|
||||
await expect(view.settings.getByRole("searchbox", { name: entry.search, exact: true })).toBeFocused()
|
||||
}
|
||||
await view.search.fill("shortcuts")
|
||||
const result = view.results.getByRole("option", { name: "Keyboard shortcuts", exact: true })
|
||||
@@ -193,172 +172,6 @@ test("Models and Shortcuts leave focus on navigation", async ({ page }) => {
|
||||
await expect(result).toBeFocused()
|
||||
})
|
||||
|
||||
test("Shortcuts search keeps focus while filtering", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search shortcuts", exact: true })
|
||||
await search.press("p")
|
||||
await expect(search).toBeFocused()
|
||||
await page.keyboard.type("alette")
|
||||
await expect(search).toHaveValue("palette")
|
||||
await expect(view.settings.getByText("Command palette", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("section searches share controls and hierarchical Escape behavior", async ({ page }) => {
|
||||
const projects = projectList(8)
|
||||
await page.route("**/api/project", (route) => route.fulfill({ json: projects }))
|
||||
await persistProjects(page, projects)
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
|
||||
for (const [index, entry] of [
|
||||
{ tab: "Projects", label: "Search projects", query: "missing-project" },
|
||||
{ tab: "Shortcuts", label: "Search shortcuts", query: "missing-shortcut" },
|
||||
{ tab: "Models", label: "Search models", query: "missing-model" },
|
||||
].entries()) {
|
||||
if (index > 0) {
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(view.settings).toBeVisible()
|
||||
}
|
||||
await view.settings.getByRole("tab", { name: entry.tab, exact: true }).click()
|
||||
const field = view.settings.locator('[data-component="settings-filter-search"]')
|
||||
const search = field.getByRole("searchbox", { name: entry.label, exact: true })
|
||||
await expect(field).toHaveCount(1)
|
||||
await expect(field.locator('[data-slot="text-input-v2-leading-icon"]')).toBeVisible()
|
||||
await expect(search).not.toBeFocused()
|
||||
|
||||
await page.keyboard.type(entry.query)
|
||||
await expect(search).toBeFocused()
|
||||
await expect(search).toHaveValue(entry.query)
|
||||
await expect(view.settings.getByText(`No results for "${entry.query}"`, { exact: true })).toBeVisible()
|
||||
const clear = field.getByRole("button", { name: "Clear", exact: true })
|
||||
await expect(clear).toHaveAttribute("data-variant", "clear")
|
||||
await clear.click()
|
||||
await expect(search).toHaveValue("")
|
||||
await expect(search).toBeFocused()
|
||||
|
||||
await search.fill(`${entry.query} `.repeat(20))
|
||||
await search.press("End")
|
||||
await expect(search).toHaveAttribute("data-overflow-start", "true")
|
||||
await search.press("Home")
|
||||
await expect(search).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(search).toHaveAttribute("data-overflow-end", "true")
|
||||
await search.press("Escape")
|
||||
await expect(search).toHaveValue("")
|
||||
await expect(search).toBeFocused()
|
||||
await expect(view.settings).toBeVisible()
|
||||
|
||||
await search.press("Escape")
|
||||
await expect(search).not.toBeFocused()
|
||||
await expect(view.settings).toBeFocused()
|
||||
await expect(view.settings).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(view.settings).toBeHidden()
|
||||
await expect(page).toHaveURL("/")
|
||||
}
|
||||
})
|
||||
|
||||
for (const count of [7, 8]) {
|
||||
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
|
||||
const inventory = projectList(count)
|
||||
await page.route("**/api/project", (route) => route.fulfill({ json: inventory }))
|
||||
await persistProjects(page, inventory)
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(count)
|
||||
await view.search.clear()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
|
||||
await expect(projects).toHaveCount(count)
|
||||
if (count === 7) {
|
||||
await expect(search).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
await expect(search).not.toBeFocused()
|
||||
await search.fill(" CODE 06 ")
|
||||
await expect(projects).toHaveCount(1)
|
||||
await expect(projects).toHaveAccessibleName("OpenCode 06")
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill("missing-project")
|
||||
await expect(projects).toHaveCount(0)
|
||||
await expect(view.settings.getByText('No results for "missing-project"', { exact: true })).toBeVisible()
|
||||
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(projects).toHaveCount(count)
|
||||
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(search).not.toBeFocused()
|
||||
await search.fill("OpenCode 06")
|
||||
await projects.click()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("Projects shows an add action in its empty state", async ({ page }) => {
|
||||
await page.route("**/api/project", (route) => route.fulfill({ json: [] }))
|
||||
await persistProjects(page, [])
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(view.settings.getByText("No projects yet", { exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByText("Create your first project to get started", { exact: true })).toBeVisible()
|
||||
const emptyCard = view.settings.locator('[data-component="settings-project-empty-card"]')
|
||||
await expect(emptyCard).toHaveCSS("border-radius", "8px")
|
||||
await expect(emptyCard).toHaveCSS("padding-top", "96px")
|
||||
await expect(emptyCard).toHaveCSS("padding-bottom", "96px")
|
||||
const emptyBackground = await emptyCard.evaluate((element) => {
|
||||
const probe = document.createElement("div")
|
||||
probe.style.background =
|
||||
"color-mix(in oklab, var(--v2-background-bg-base) 50%, var(--v2-background-bg-layer-01))"
|
||||
element.append(probe)
|
||||
const color = getComputedStyle(probe).backgroundColor
|
||||
probe.remove()
|
||||
return color
|
||||
})
|
||||
await expect(emptyCard).toHaveCSS("background-color", emptyBackground)
|
||||
const add = view.settings.getByRole("button", { name: "Add project", exact: true })
|
||||
await expect(add).toHaveCount(1)
|
||||
await add.click()
|
||||
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
|
||||
await expect(picker).toBeVisible()
|
||||
await picker.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(picker).toBeHidden()
|
||||
})
|
||||
|
||||
test("a single project uses the full grouped hover surface", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
const list = view.settings.getByRole("list")
|
||||
const project = list.getByRole("listitem")
|
||||
await expect(project).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() =>
|
||||
project.evaluate((item) => {
|
||||
const row = item.getBoundingClientRect()
|
||||
const group = item.parentElement?.parentElement?.getBoundingClientRect()
|
||||
if (!group) return []
|
||||
return [row.top - group.top, row.left - group.left, group.right - row.right, group.bottom - row.bottom]
|
||||
}),
|
||||
)
|
||||
.toEqual([0, 0, 0, 0])
|
||||
})
|
||||
|
||||
test("Projects search renders with a qualifying persisted inventory", async ({ page }) => {
|
||||
const projects = projectList(8)
|
||||
await page.route("**/api/project", (route) => route.fulfill({ json: projects }))
|
||||
await persistProjects(page, projects)
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(search).toBeVisible()
|
||||
await expect(search).not.toBeFocused()
|
||||
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
|
||||
})
|
||||
|
||||
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
|
||||
@@ -394,13 +207,11 @@ test("qualified project results preserve query and selection on return", async (
|
||||
})
|
||||
|
||||
test("returning from a project restores a scrolled result list", async ({ page }) => {
|
||||
const projects = projectList(30)
|
||||
await page.route("**/api/project", (route) =>
|
||||
route.fulfill({
|
||||
json: projects,
|
||||
json: projectList(30),
|
||||
}),
|
||||
)
|
||||
await persistProjects(page, projects)
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("OpenCode")
|
||||
@@ -502,17 +313,14 @@ test("multi-server results navigate to the named server and hide search in neste
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
})
|
||||
})
|
||||
await page.addInitScript((server) => {
|
||||
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
...value,
|
||||
list: [{ type: "http", displayName: "Build server", http: { url: server } }],
|
||||
projects: { ...value.projects, [server]: [{ worktree: "/remote/opencode", expanded: true }] },
|
||||
}),
|
||||
)
|
||||
}, server)
|
||||
await page.addInitScript(
|
||||
(server) =>
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ list: [{ type: "http", displayName: "Build server", http: { url: server } }] }),
|
||||
),
|
||||
server,
|
||||
)
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("MCPs")
|
||||
@@ -527,13 +335,6 @@ test("multi-server results navigate to the named server and hide search in neste
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
|
||||
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
|
||||
await view.search.fill("Build server projects")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByRole("button", { name: "Add project", exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByRole("list").getByRole("listitem")).toHaveCount(1)
|
||||
await expect(view.settings.getByRole("button", { name: "OpenCode", exact: true })).toBeVisible()
|
||||
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
|
||||
await view.search.fill("Build server OpenCode name")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(1)
|
||||
await view.results.getByRole("option").click()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -5,8 +5,6 @@ import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
|
||||
import { createEffect, createMemo, startTransition } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { addProjects } from "./projects/add"
|
||||
|
||||
export function createHomeController() {
|
||||
const layout = useLayout()
|
||||
@@ -43,24 +41,6 @@ export function createHomeController() {
|
||||
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
|
||||
void ctx.sync.worktrees.list(id).then(() => ctx.sync.worktrees.refresh(id))
|
||||
})
|
||||
createEffect(() => {
|
||||
// The project list is empty until the server store hydrates; clearing the restored
|
||||
// selection against it would persist the loss.
|
||||
if (!servers.hydrated()) return
|
||||
const current = selection()
|
||||
const directory = current.directory
|
||||
if (!directory) return
|
||||
const conn = servers.visible.find((conn) => ServerConnection.key(conn) === current.server)
|
||||
if (!conn) return
|
||||
if (
|
||||
global
|
||||
.ensureServerCtx(conn)
|
||||
.projects.list()
|
||||
.some((project) => pathKey(project.worktree) === pathKey(directory))
|
||||
)
|
||||
return
|
||||
setSelection({ server: current.server })
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
layout.home.setSelection(next)
|
||||
@@ -119,8 +99,23 @@ export function createHomeController() {
|
||||
setSelection(toggleHomeProjectSelection(selection(), key, directory))
|
||||
},
|
||||
add: (conn: ServerConnection.Any, directories: string[]) => {
|
||||
const directory = addProjects(global.ensureServerCtx(conn), directories)
|
||||
const directory = directories[0]
|
||||
if (!directory) return
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
directories.forEach((item) => {
|
||||
if (ctx.projects.list().some((project) => project.worktree === item)) return
|
||||
const location = { directory: item }
|
||||
void ctx.sdk.api.file
|
||||
.list({ path: ".", location })
|
||||
.then(async (files) => {
|
||||
// TODO: Initialize empty directories when V2 exposes a native Git init API.
|
||||
return ctx.sdk.api.location.get({ location }).then((result) => result.project)
|
||||
})
|
||||
.then((project) => ctx.sync.child(item, { bootstrap: false })[1]("project", project.id))
|
||||
.catch(() => undefined)
|
||||
ctx.projects.open(item)
|
||||
})
|
||||
ctx.projects.touch(directory)
|
||||
setSelection({ server: ServerConnection.key(conn), directory })
|
||||
},
|
||||
openNewSession: () => {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ServerCtx } from "@/runtime/server/runtime"
|
||||
|
||||
export function addProjects(context: ServerCtx, directories: string[]) {
|
||||
const directory = directories[0]
|
||||
if (!directory) return
|
||||
|
||||
directories.forEach((item) => {
|
||||
if (context.projects.list().some((project) => project.worktree === item)) return
|
||||
const location = { directory: item }
|
||||
void context.sdk.api.file
|
||||
.list({ path: ".", location })
|
||||
.then(() => context.sdk.api.location.get({ location }))
|
||||
.then((value) => context.sync.child(item, { bootstrap: false })[1]("project", value.project.id))
|
||||
.catch(() => undefined)
|
||||
context.projects.open(item)
|
||||
})
|
||||
context.projects.touch(directory)
|
||||
return directory
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { type LocalProject } from "@/shell/state/layout"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { closeHomeProject, displayName, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers"
|
||||
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
@@ -17,7 +17,6 @@ import type { HomeController } from "../model"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { SessionTransfer } from "@opencode/schema/session-transfer"
|
||||
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
|
||||
import { revealProject } from "./reveal"
|
||||
|
||||
export const HomeServersSchema = Schema.Struct({
|
||||
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
|
||||
@@ -44,7 +43,7 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
}
|
||||
|
||||
function canRevealProject(conn: ServerConnection.Any) {
|
||||
return platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(conn)
|
||||
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
|
||||
}
|
||||
|
||||
function choose(conn: ServerConnection.Any) {
|
||||
@@ -168,27 +167,13 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
},
|
||||
canReveal: canRevealProject,
|
||||
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
if (!platform.revealPath || !canRevealProject(conn)) return
|
||||
const context = global.ensureServerCtx(conn)
|
||||
void revealProject({
|
||||
directory: project.worktree,
|
||||
reveal: platform.revealPath,
|
||||
remove: context.projects.remove,
|
||||
})
|
||||
.then((revealed) => {
|
||||
if (revealed) return
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("home.project.missing.title"),
|
||||
description: language.t("home.project.missing.description", { name: displayName(project) }),
|
||||
})
|
||||
})
|
||||
.catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
if (!platform.openPath || !canRevealProject(conn)) return
|
||||
platform.openPath(project.worktree).catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
utility: {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { revealProject } from "./reveal"
|
||||
|
||||
describe("reveal project", () => {
|
||||
test("keeps a project when its directory is revealed", async () => {
|
||||
const removed: string[] = []
|
||||
expect(
|
||||
await revealProject({
|
||||
directory: "/projects/current",
|
||||
reveal: async () => true,
|
||||
remove: (directory) => removed.push(directory),
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(removed).toEqual([])
|
||||
})
|
||||
|
||||
test("removes a project when its directory no longer exists", async () => {
|
||||
const removed: string[] = []
|
||||
expect(
|
||||
await revealProject({
|
||||
directory: "/projects/missing",
|
||||
reveal: async () => false,
|
||||
remove: (directory) => removed.push(directory),
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(removed).toEqual(["/projects/missing"])
|
||||
})
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
export async function revealProject(input: {
|
||||
directory: string
|
||||
reveal: (directory: string) => Promise<boolean>
|
||||
remove: (directory: string) => void
|
||||
}) {
|
||||
if (await input.reveal(input.directory)) return true
|
||||
input.remove(input.directory)
|
||||
return false
|
||||
}
|
||||
@@ -65,14 +65,14 @@
|
||||
}
|
||||
|
||||
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
|
||||
When they overlap, center the conversation in the space that remains beside the summary. */
|
||||
@container session-chat (min-width: 1320px) and (width < 1640px) {
|
||||
Move only as far as needed; wider panels already have enough space in the margin. */
|
||||
@container session-chat (min-width: 1320px) {
|
||||
[data-slot="session-chat-panel"][data-summary-open="true"]
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
|
||||
--session-summary-translate: -160px;
|
||||
--session-summary-translate: min(0px, calc(50cqi - 820px));
|
||||
|
||||
&:dir(rtl) {
|
||||
--session-summary-translate: 160px;
|
||||
--session-summary-translate: max(0px, calc(820px - 50cqi));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,16 +625,6 @@ export const dict = {
|
||||
"toast.file.loadFailed.title": "Failed to load file",
|
||||
"file.error.notFound": "File not found: {{name}}",
|
||||
"toast.file.listFailed.title": "Failed to list files",
|
||||
"file.view.preview": "Preview",
|
||||
"file.view.source": "Source",
|
||||
"file.view.openInBrowser": "Open in browser",
|
||||
"file.view.binary": "Binary file · {{size}}",
|
||||
"file.view.table.rows.one": "{{count}} row",
|
||||
"file.view.table.rows.other": "{{count}} rows",
|
||||
"file.view.table.columns.one": "{{count}} column",
|
||||
"file.view.table.columns.other": "{{count}} columns",
|
||||
"file.view.table.truncated": "Showing the first {{shown}} of {{total}} rows.",
|
||||
"file.view.fontSample": "Sphinx of black quartz, judge my vow.",
|
||||
|
||||
"toast.context.noLineSelection.title": "No line selection",
|
||||
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
|
||||
@@ -732,8 +722,6 @@ export const dict = {
|
||||
"home.projects": "Projects",
|
||||
"home.projects.all": "All projects",
|
||||
"home.project.add": "Add project",
|
||||
"home.project.missing.title": "Project folder not found",
|
||||
"home.project.missing.description": "{{name}} was removed because its folder no longer exists.",
|
||||
"home.recentlyClosed": "Recently closed",
|
||||
"home.server.collapse": "Collapse server projects",
|
||||
"home.server.expand": "Expand server projects",
|
||||
@@ -918,10 +906,6 @@ export const dict = {
|
||||
"session.browser.replaced": "Browser control moved to another desktop window.",
|
||||
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
|
||||
"session.browser.address.placeholder": "Enter URL",
|
||||
"session.browser.empty.title": "Enter URL",
|
||||
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
|
||||
"session.browser.failed.title": "URL can't be reached",
|
||||
"session.browser.failed.description": "Check the URL and your connection, then try again.",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
@@ -1084,10 +1068,8 @@ export const dict = {
|
||||
"settings.server.preferences.websearch.title": "Third-party search",
|
||||
"settings.server.preferences.websearch.description": "Select the search provider agents use to search the web",
|
||||
"settings.projects.title": "Projects",
|
||||
"settings.projects.description": "Manage projects and their settings",
|
||||
"settings.projects.description": "Manage project settings on this server",
|
||||
"settings.projects.empty": "No projects found",
|
||||
"settings.projects.empty.title": "No projects yet",
|
||||
"settings.projects.empty.description": "Create your first project to get started",
|
||||
"settings.projects.search.placeholder": "Search projects",
|
||||
"settings.projects.server.all": "All servers",
|
||||
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Codec } from "./codec"
|
||||
|
||||
// Type-level checks: these compile only if inference matches what `typeof schema.Type` gave callers.
|
||||
test("struct types infer optional and required fields", () => {
|
||||
const s = Codec.struct({ id: Codec.string, tab: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
|
||||
const value: typeof s.Type = { id: "x" }
|
||||
const tab: string | undefined = value.tab
|
||||
const n: number | undefined = value.n
|
||||
const t = Codec.transform(s, { decode: (old) => old.tab ?? old.id, encode: (v) => ({ id: v }) })
|
||||
const out: string | Codec.Invalid = t.decode({ id: "a" })
|
||||
const onlyOptional = Codec.struct({ tab: Codec.optional(Codec.string) })
|
||||
const empty: typeof onlyOptional.Type = {}
|
||||
const maybe: string | undefined = empty.tab
|
||||
const viaTransform = Codec.transform(onlyOptional, { decode: (old) => old.tab, encode: (tab) => ({ tab }) })
|
||||
expect([tab, n, out, maybe, viaTransform.decode({})]).toEqual([undefined, undefined, "a", undefined, undefined])
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Codec } from "./codec"
|
||||
|
||||
describe("Codec", () => {
|
||||
test("primitives reject the wrong shape and finite numbers only", () => {
|
||||
expect(Codec.string.decode("a")).toBe("a")
|
||||
expect(Codec.string.decode(1)).toBe(Codec.INVALID)
|
||||
expect(Codec.number.decode(1.5)).toBe(1.5)
|
||||
expect(Codec.number.decode(Number.NaN)).toBe(Codec.INVALID)
|
||||
expect(Codec.nonNegativeInt.decode(-1)).toBe(Codec.INVALID)
|
||||
expect(Codec.literals(["a", "b"]).decode("c")).toBe(Codec.INVALID)
|
||||
expect(Codec.literal("x").decode("x")).toBe("x")
|
||||
})
|
||||
|
||||
test("struct keeps optional fields absent and rejects invalid required ones", () => {
|
||||
const codec = Codec.struct({ id: Codec.string, title: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
|
||||
expect(codec.decode({ id: "1" })).toEqual({ id: "1" })
|
||||
expect(codec.decode({ id: "1", title: "t", n: "bad" })).toEqual({ id: "1", title: "t" })
|
||||
expect(codec.decode({ id: "1", title: 3 })).toBe(Codec.INVALID)
|
||||
expect(codec.decode({ title: "t" })).toBe(Codec.INVALID)
|
||||
expect(codec.decode([])).toBe(Codec.INVALID)
|
||||
expect(codec.encode({ id: "1" })).toEqual({ id: "1" })
|
||||
const value: typeof codec.Type = { id: "1", title: undefined }
|
||||
expect(value.id).toBe("1")
|
||||
})
|
||||
|
||||
test("lenient collections recover what they can", () => {
|
||||
const items = Codec.lenientArray(Codec.struct({ id: Codec.string }))
|
||||
expect(items.decode([{ id: "a" }, { id: 1 }, "x", { id: "b" }])).toEqual([{ id: "a" }, { id: "b" }])
|
||||
expect(items.decode("nope")).toEqual([])
|
||||
expect(Codec.array(Codec.string).decode(["a", 1])).toBe(Codec.INVALID)
|
||||
const map = Codec.lenientRecord(Codec.boolean)
|
||||
expect(map.decode({ a: true, b: "x" })).toEqual({})
|
||||
expect(map.decode({ a: true })).toEqual({ a: true })
|
||||
})
|
||||
|
||||
test("union, transform and fallback compose", () => {
|
||||
const session = Codec.struct({ type: Codec.literal("session"), id: Codec.string })
|
||||
const draft = Codec.struct({ type: Codec.literal("draft"), directory: Codec.string })
|
||||
const tab = Codec.union([session, draft])
|
||||
expect(tab.decode({ type: "draft", directory: "/x" })).toEqual({ type: "draft", directory: "/x" })
|
||||
expect(tab.decode({ type: "other" })).toBe(Codec.INVALID)
|
||||
const upper = Codec.transform(Codec.string, { decode: (s) => s.toUpperCase(), encode: (s) => s.toLowerCase() })
|
||||
expect(upper.decode("ab")).toBe("AB")
|
||||
expect(upper.encode("AB")).toBe("ab")
|
||||
const safe = Codec.fallback(Codec.number, () => 7)
|
||||
expect(safe.decode("x")).toBe(7)
|
||||
expect(safe.decode(undefined)).toBe(7)
|
||||
expect(safe.decode(2)).toBe(2)
|
||||
})
|
||||
|
||||
test("brand constructs and decodes as its base", () => {
|
||||
const Key = Codec.brand<"ServerConnection.Key">()
|
||||
const key = Key.make("http://a")
|
||||
expect(Key.decode(key)).toBe(key)
|
||||
expect(Key.decode(3)).toBe(Codec.INVALID)
|
||||
})
|
||||
|
||||
test("withInitial recovers field by field and merges new defaults", () => {
|
||||
const layout = Codec.struct({
|
||||
sidebar: Codec.struct({ opened: Codec.boolean, width: Codec.number }),
|
||||
theme: Codec.lenientOptional(Codec.literals(["light", "dark"])),
|
||||
})
|
||||
const initial: typeof layout.Type = { sidebar: { opened: true, width: 240 } }
|
||||
const codec = Codec.fromJsonString(Codec.withInitial(layout, initial))
|
||||
expect(codec.decode(JSON.stringify({ sidebar: { opened: false, width: "wide" }, theme: "dark" }))).toEqual({
|
||||
sidebar: { opened: false, width: 240 },
|
||||
theme: "dark",
|
||||
})
|
||||
expect(codec.decode(JSON.stringify({ sidebar: 5 }))).toEqual(initial)
|
||||
expect(codec.decode("{not json")).toBe(Codec.INVALID)
|
||||
expect(JSON.parse(codec.encode({ sidebar: { opened: true, width: 1 } }))).toEqual({ sidebar: { opened: true, width: 1 } })
|
||||
})
|
||||
|
||||
test("migrate reads the old shape first", () => {
|
||||
const current = Codec.struct({ tabs: Codec.array(Codec.string) })
|
||||
const previous = Codec.struct({ tab: Codec.optional(Codec.string) })
|
||||
const read = Codec.transform(previous, {
|
||||
decode: (old) => ({ tabs: old.tab ? [old.tab] : [] }),
|
||||
encode: (value) => ({ tab: value.tabs[0] }),
|
||||
})
|
||||
const codec = Codec.withInitial(Codec.migrate(current, read), { tabs: [] })
|
||||
expect(codec.decode({ tab: "a" })).toEqual({ tabs: ["a"] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
export * as Codec from "./codec"
|
||||
|
||||
// Plain codecs for persisted state. They replace Effect Schema in the renderer's initial module
|
||||
// graph, where Effect's own module initialisation was the single largest startup cost that was not
|
||||
// rendering. Semantics mirror the Persistence helpers: decoding never throws, `INVALID` marks a
|
||||
// value that cannot be recovered, and the lenient combinators recover what they can.
|
||||
|
||||
export const INVALID: unique symbol = Symbol.for("opencode/persistence/codec/invalid")
|
||||
export type Invalid = typeof INVALID
|
||||
|
||||
const tag: unique symbol = Symbol.for("opencode/persistence/codec")
|
||||
|
||||
export interface Of<T, E = unknown> {
|
||||
readonly [tag]: true
|
||||
/** Phantom: `typeof codec.Type` is the decoded type, as with Effect schemas. */
|
||||
readonly Type: T
|
||||
readonly Encoded: E
|
||||
readonly optional?: boolean
|
||||
decode(input: unknown): T | Invalid
|
||||
encode(value: T): E
|
||||
}
|
||||
|
||||
export type Any = Of<any, any>
|
||||
export type Type<C extends Any> = C["Type"]
|
||||
|
||||
export function isCodec(value: unknown): value is Any {
|
||||
return typeof value === "object" && value !== null && tag in value
|
||||
}
|
||||
|
||||
export function make<T, E = unknown>(decode: (input: unknown) => T | Invalid, encode: (value: T) => E): Of<T, E> {
|
||||
return { [tag]: true, decode, encode } as Of<T, E>
|
||||
}
|
||||
|
||||
export function is<T>(codec: Of<T>, input: unknown): input is T {
|
||||
return codec.decode(input) !== INVALID
|
||||
}
|
||||
|
||||
export function decodeOption<T>(codec: Of<T>, input: unknown): T | undefined {
|
||||
const value = codec.decode(input)
|
||||
return value === INVALID ? undefined : value
|
||||
}
|
||||
|
||||
export function decodeOrThrow<T>(codec: Of<T>, input: unknown): T {
|
||||
const value = codec.decode(input)
|
||||
if (value === INVALID) throw new Error("Value does not match its codec")
|
||||
return value
|
||||
}
|
||||
|
||||
const identity = <T>(value: T) => value
|
||||
|
||||
export const string: Of<string, string> = make((v) => (typeof v === "string" ? v : INVALID), identity)
|
||||
export const boolean: Of<boolean, boolean> = make((v) => (typeof v === "boolean" ? v : INVALID), identity)
|
||||
export const unknown: Of<unknown, unknown> = make((v) => v, identity)
|
||||
/** Finite numbers only: NaN and infinities are not JSON and never valid state. */
|
||||
export const number: Of<number, number> = make((v) => (typeof v === "number" && Number.isFinite(v) ? v : INVALID), identity)
|
||||
export const int: Of<number, number> = make((v) => (typeof v === "number" && Number.isInteger(v) ? v : INVALID), identity)
|
||||
export const nonNegativeInt: Of<number, number> = make(
|
||||
(v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : INVALID),
|
||||
identity,
|
||||
)
|
||||
|
||||
export function literal<const L extends string | number | boolean | null>(value: L): Of<L, L> {
|
||||
return make((v) => (v === value ? value : INVALID), identity)
|
||||
}
|
||||
|
||||
export function literals<const L extends ReadonlyArray<string | number | boolean | null>>(values: L): Of<L[number], L[number]> {
|
||||
const set = new Set<unknown>(values)
|
||||
return make((v) => (set.has(v) ? (v as L[number]) : INVALID), identity)
|
||||
}
|
||||
|
||||
/** A string carrying a nominal brand, with the constructor Effect's `Schema.brand` gave callers. */
|
||||
export function brand<B extends string>(): Of<string & { readonly [K in B]: B }, string> & {
|
||||
make(value: string): string & { readonly [K in B]: B }
|
||||
} {
|
||||
return Object.assign(make<string & { readonly [K in B]: B }, string>((v) => (typeof v === "string" ? (v as never) : INVALID), identity), {
|
||||
make: (value: string) => value as never,
|
||||
})
|
||||
}
|
||||
|
||||
export function nullOr<T, E>(codec: Of<T, E>): Of<T | null, E | null> {
|
||||
return make((v) => (v === null ? null : codec.decode(v)), (v) => (v === null ? null : codec.encode(v)))
|
||||
}
|
||||
|
||||
export function undefinedOr<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> {
|
||||
return make((v) => (v === undefined ? undefined : codec.decode(v)), (v) => (v === undefined ? undefined : codec.encode(v)))
|
||||
}
|
||||
|
||||
/** A struct field that may be absent. Present but invalid values make the struct invalid. */
|
||||
export function optional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
|
||||
return { ...undefinedOr(codec), optional: true } as never
|
||||
}
|
||||
|
||||
/** A struct field that may be absent, and whose invalid values are dropped rather than rejected. */
|
||||
export function lenientOptional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
|
||||
return {
|
||||
...make<T | undefined, E | undefined>(
|
||||
(v) => {
|
||||
if (v === undefined) return undefined
|
||||
const value = codec.decode(v)
|
||||
return value === INVALID ? undefined : value
|
||||
},
|
||||
(v) => (v === undefined ? undefined : codec.encode(v)),
|
||||
),
|
||||
optional: true,
|
||||
} as never
|
||||
}
|
||||
|
||||
type Fields = Record<string, Any>
|
||||
type OptionalKeys<F extends Fields> = { [K in keyof F]: F[K] extends { optional: true } ? K : never }[keyof F]
|
||||
type RequiredKeys<F extends Fields> = Exclude<keyof F, OptionalKeys<F>>
|
||||
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
||||
export type StructType<F extends Fields> = Simplify<
|
||||
{ [K in RequiredKeys<F>]: F[K]["Type"] } & { [K in OptionalKeys<F>]?: F[K]["Type"] }
|
||||
>
|
||||
export type StructEncoded<F extends Fields> = Simplify<
|
||||
{ [K in RequiredKeys<F>]: F[K]["Encoded"] } & { [K in OptionalKeys<F>]?: F[K]["Encoded"] }
|
||||
>
|
||||
|
||||
export interface Struct<F extends Fields> extends Of<StructType<F>, StructEncoded<F>> {
|
||||
readonly fields: F
|
||||
}
|
||||
|
||||
export function struct<const F extends Fields>(fields: F): Struct<F> {
|
||||
const entries = Object.entries(fields)
|
||||
return {
|
||||
...make<StructType<F>, StructEncoded<F>>(
|
||||
(input) => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
|
||||
const record = input as Record<string, unknown>
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, codec] of entries) {
|
||||
const present = Object.hasOwn(record, key)
|
||||
if (!present && codec.optional) continue
|
||||
const value = codec.decode(record[key])
|
||||
if (value === INVALID) return INVALID
|
||||
if (value !== undefined || present) out[key] = value
|
||||
}
|
||||
return out as StructType<F>
|
||||
},
|
||||
(value) => {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, codec] of entries) {
|
||||
const field = (value as Record<string, unknown>)[key]
|
||||
if (field === undefined && !Object.hasOwn(value as object, key)) continue
|
||||
out[key] = codec.encode(field)
|
||||
}
|
||||
return out as StructEncoded<F>
|
||||
},
|
||||
),
|
||||
fields,
|
||||
}
|
||||
}
|
||||
|
||||
export function array<T, E>(codec: Of<T, E>): Of<T[], E[]> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (!Array.isArray(input)) return INVALID
|
||||
const out: T[] = []
|
||||
for (const item of input) {
|
||||
const value = codec.decode(item)
|
||||
if (value === INVALID) return INVALID
|
||||
out.push(value)
|
||||
}
|
||||
return out
|
||||
},
|
||||
(value) => value.map((item) => codec.encode(item)),
|
||||
)
|
||||
}
|
||||
|
||||
/** Keeps the items that decode and drops the rest, like `Persistence.array`. */
|
||||
export function lenientArray<T, E>(codec: Of<T, E>): Of<T[], E[]> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (!Array.isArray(input)) return []
|
||||
return input.flatMap((item) => {
|
||||
const value = codec.decode(item)
|
||||
return value === INVALID ? [] : [value]
|
||||
})
|
||||
},
|
||||
(value) => value.map((item) => codec.encode(item)),
|
||||
)
|
||||
}
|
||||
|
||||
export function record<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
|
||||
const out: Record<string, T> = {}
|
||||
for (const [key, item] of Object.entries(input)) {
|
||||
const value = codec.decode(item)
|
||||
if (value === INVALID) return INVALID
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
},
|
||||
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
|
||||
)
|
||||
}
|
||||
|
||||
/** An invalid record becomes empty rather than failing the whole store, like `Persistence.record`. */
|
||||
export function lenientRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
|
||||
const strict = record(codec)
|
||||
return make(
|
||||
(input) => {
|
||||
const value = strict.decode(input)
|
||||
return value === INVALID ? {} : value
|
||||
},
|
||||
strict.encode,
|
||||
)
|
||||
}
|
||||
|
||||
export function union<const C extends ReadonlyArray<Any>>(codecs: C): Of<C[number]["Type"], C[number]["Encoded"]> {
|
||||
return make(
|
||||
(input) => {
|
||||
for (const codec of codecs) {
|
||||
const value = codec.decode(input)
|
||||
if (value !== INVALID) return value
|
||||
}
|
||||
return INVALID
|
||||
},
|
||||
(value) => {
|
||||
// Encode with the first member that accepts the value's shape; members are disjoint in practice.
|
||||
for (const codec of codecs) if (codec.decode(value) !== INVALID) return codec.encode(value)
|
||||
return value as C[number]["Encoded"]
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Maps a decoded value into another shape, the replacement for `decodeTo` + `SchemaGetter.transform`. */
|
||||
export function transform<T, E, T2>(
|
||||
codec: Of<T, E>,
|
||||
options: { decode: (value: T) => T2; encode: (value: T2) => T },
|
||||
): Of<T2, E> {
|
||||
return make(
|
||||
(input) => {
|
||||
const value = codec.decode(input)
|
||||
return value === INVALID ? INVALID : options.decode(value)
|
||||
},
|
||||
(value) => codec.encode(options.encode(value)),
|
||||
)
|
||||
}
|
||||
|
||||
/** Invalid and missing values become `value()`, like `Persistence.fallback`. */
|
||||
export function fallback<T, E>(codec: Of<T, E>, value: () => NoInfer<T>): Of<T, E> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (input === undefined) return value()
|
||||
const decoded = codec.decode(input)
|
||||
return decoded === INVALID ? value() : decoded
|
||||
},
|
||||
codec.encode,
|
||||
)
|
||||
}
|
||||
|
||||
export function fromJsonString<T, E>(codec: Of<T, E>): Of<T, string> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (typeof input !== "string") return INVALID
|
||||
try {
|
||||
return codec.decode(JSON.parse(input))
|
||||
} catch {
|
||||
return INVALID
|
||||
}
|
||||
},
|
||||
(value) => JSON.stringify(codec.encode(value)),
|
||||
)
|
||||
}
|
||||
|
||||
export type Decoder = Pick<Of<unknown>, "decode">
|
||||
export type Migrated<C extends Any> = { readonly current: C; readonly read: Decoder }
|
||||
|
||||
/** Older stored shapes go through `read` first; `current` describes what the store holds today. */
|
||||
export function migrate<C extends Any>(current: C, read: Decoder): Migrated<C> {
|
||||
return { current, read }
|
||||
}
|
||||
|
||||
function isMigrated<C extends Any>(definition: C | Migrated<C>): definition is Migrated<C> {
|
||||
return !isCodec(definition) && "current" in definition
|
||||
}
|
||||
|
||||
// Stored values recover field by field against the initial value: an object's valid fields are
|
||||
// kept, invalid or missing ones take their initial counterpart, and the result is merged over the
|
||||
// initial so new fields appear with their defaults. Mirrors `Persistence.withInitial`.
|
||||
export function withInitial<C extends Any>(definition: C | Migrated<C>, initial: Type<C>): Of<Type<C>, unknown> {
|
||||
const codec = isMigrated(definition) ? definition.current : definition
|
||||
const read = isMigrated(definition) ? definition.read : unknown
|
||||
return make(
|
||||
(input) => {
|
||||
const stored = read.decode(input)
|
||||
if (stored === INVALID) return INVALID
|
||||
return merge(initial, recover(codec, stored, initial))
|
||||
},
|
||||
(value) => codec.encode(value),
|
||||
)
|
||||
}
|
||||
|
||||
function recover(codec: Any, value: unknown, initial: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
if ("fields" in codec && isObject(value)) {
|
||||
const fields = (codec as Struct<Fields>).fields
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).flatMap(([name, field]) => {
|
||||
const defaults = isObject(initial) ? initial[name] : undefined
|
||||
const next = recover(field, value[name], defaults)
|
||||
if (next === undefined && !Object.hasOwn(value, name) && defaults === undefined) return []
|
||||
return [[name, next]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
const decoded = codec.decode(value)
|
||||
return decoded === INVALID ? initial : decoded
|
||||
}
|
||||
|
||||
function merge(initial: unknown, value: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
if (!isObject(initial) || !isObject(value)) return value
|
||||
return Object.fromEntries(
|
||||
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
|
||||
)
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Option, Schema } from "effect"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
|
||||
import { Codec } from "./codec"
|
||||
import { persistStore } from "./persist"
|
||||
import { Persistence } from "./schema"
|
||||
|
||||
@@ -472,25 +473,57 @@ export function removePersisted(
|
||||
}
|
||||
}
|
||||
|
||||
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
type Definition<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any> =
|
||||
| S
|
||||
| Persistence.Migrated<Extract<S, Schema.ConstraintCodec<object, unknown>>>
|
||||
| Codec.Migrated<Extract<S, Codec.Any>>
|
||||
|
||||
// Persisted stores are moving from Effect Schema to the plain codecs in ./codec so the renderer
|
||||
// stops paying for Effect at startup; both are accepted while the migration is underway.
|
||||
function serializer<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
|
||||
definition: Definition<S>,
|
||||
initial: S["Type"],
|
||||
) {
|
||||
if (Codec.isCodec(definition) || (!("current" in definition) ? false : Codec.isCodec(definition.current))) {
|
||||
const codec = Codec.withInitial(definition as Codec.Any | Codec.Migrated<Codec.Any>, initial)
|
||||
const json = Codec.fromJsonString(codec)
|
||||
return {
|
||||
decode: (raw: string) => Codec.decodeOption(json, raw) as S["Type"] | undefined,
|
||||
deserialize: (raw: unknown) => Codec.decodeOrThrow(json, raw) as S["Type"],
|
||||
serialize: (value: S["Type"]) => json.encode(value),
|
||||
encode: (value: S["Type"]) => codec.encode(value),
|
||||
initial: Codec.decodeOrThrow(codec, codec.encode(initial)) as S["Type"],
|
||||
}
|
||||
}
|
||||
const schema = definition as Schema.ConstraintCodec<object, unknown> | Persistence.Migrated<Schema.ConstraintCodec<object, unknown>>
|
||||
const initialized = Persistence.withInitial(schema, initial as object)
|
||||
const json = Schema.fromJsonString(initialized)
|
||||
const decode = Schema.decodeUnknownOption(json)
|
||||
return {
|
||||
decode: (raw: string) => Option.getOrUndefined(decode(raw)) as S["Type"] | undefined,
|
||||
deserialize: Schema.decodeUnknownSync(json) as (raw: unknown) => S["Type"],
|
||||
serialize: Schema.encodeSync(json) as (value: S["Type"]) => string,
|
||||
encode: Schema.encodeSync(initialized) as (value: S["Type"]) => unknown,
|
||||
initial: Schema.decodeUnknownSync(Schema.toType(initialized))(initial as object) as S["Type"],
|
||||
}
|
||||
}
|
||||
|
||||
export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
|
||||
target: string | PersistTarget,
|
||||
schema: S | Persistence.Migrated<S>,
|
||||
schema: Definition<S>,
|
||||
initial: NoInfer<S["Type"]>,
|
||||
platformOverride?: Platform,
|
||||
): PersistedWithReady<S["Type"]> {
|
||||
const platform = platformOverride ?? usePlatform()
|
||||
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
|
||||
|
||||
const initialized = Persistence.withInitial(schema, initial)
|
||||
const json = Schema.fromJsonString(initialized)
|
||||
const decode = Schema.decodeUnknownOption(json)
|
||||
const encode = Schema.encodeSync(initialized)
|
||||
const serialize = Schema.encodeSync(json)
|
||||
const codec = serializer<S>(schema, initial)
|
||||
const { encode, serialize } = codec
|
||||
const normalize = (raw: string) => {
|
||||
const value = decode(raw)
|
||||
if (Option.isSome(value)) return serialize(value.value)
|
||||
const value = codec.decode(raw)
|
||||
if (value !== undefined) return serialize(value)
|
||||
}
|
||||
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
|
||||
const store = createStore<S["Type"]>(codec.initial)
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||
const draft = config.draft ? platform.draftStore : undefined
|
||||
const prefix = `${config.storage ?? "default"}:`
|
||||
@@ -602,7 +635,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
name: config.key,
|
||||
storage,
|
||||
serialize,
|
||||
deserialize: Schema.decodeUnknownSync(json),
|
||||
deserialize: codec.deserialize,
|
||||
sync: channel ? messageSync(channel) : undefined,
|
||||
// Drafts take the encoded document itself so large text is externalized without the store
|
||||
// re-parsing the serialized form on every save.
|
||||
|
||||
@@ -19,7 +19,6 @@ export type BrowserPaneCommand = Browser.Action
|
||||
export type BrowserPaneState = Browser.State | null
|
||||
export type BrowserPaneEvent =
|
||||
| { type: "focus"; tabID: Browser.TabID }
|
||||
| { type: "preview"; path: string }
|
||||
| { type: "state"; state: BrowserPaneState; error?: string }
|
||||
|
||||
export type BrowserPaneRegistration = {
|
||||
|
||||
@@ -46,9 +46,6 @@ type PlatformBase = {
|
||||
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
|
||||
revealPath?(path: string): Promise<boolean>
|
||||
|
||||
/** Return the local paths that still exist as directories; paths that cannot be checked are kept (desktop only) */
|
||||
checkDirectories?(paths: string[]): Promise<string[]>
|
||||
|
||||
/** Restart the app */
|
||||
restart(): Promise<void>
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Brand } from "effect"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
|
||||
// The server key's brand is shared with the Effect schema in ./persistence.ts (type only, so this
|
||||
// module loads nothing of Effect), letting stores port to plain codecs one at a time.
|
||||
export type ServerKey = string & Brand.Brand<"ServerConnection.Key">
|
||||
|
||||
export const ServerKey: Codec.Of<ServerKey, string> & { make(value: string): ServerKey } = Object.assign(
|
||||
Codec.make<ServerKey, string>((v) => (typeof v === "string" ? (v as ServerKey) : Codec.INVALID), (v) => v),
|
||||
{ make: (value: string) => value as ServerKey },
|
||||
)
|
||||
@@ -200,7 +200,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
canonicalLocalServer?: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
}) => {
|
||||
const [store, setStore, _, hydrated] = persisted(
|
||||
const [store, setStore, _] = persisted(
|
||||
{
|
||||
...Persist.global("server"),
|
||||
sync: true,
|
||||
@@ -258,9 +258,6 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
get visible() {
|
||||
return visibleServers()
|
||||
},
|
||||
// Named to avoid the context `ready` gate: consumers that derive from persisted project
|
||||
// state wait on this, but the provider must not block first render on storage.
|
||||
hydrated,
|
||||
isHidden(key: ServerConnection.Key) {
|
||||
return store.hidden[key] ?? false
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
|
||||
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { useServerHealth } from "@/runtime/server/health"
|
||||
@@ -14,7 +14,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { createDesktopData } from "./data"
|
||||
import { ModelState } from "./persistence"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "./errors"
|
||||
import { useSettings } from "@/settings/model"
|
||||
@@ -110,7 +109,6 @@ function createServerController(
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const source = createData({
|
||||
@@ -172,27 +170,6 @@ function createServerController(
|
||||
const isLocal =
|
||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||
|
||||
// Only the desktop can stat a local server's project directories without a server round-trip.
|
||||
// Each directory is checked once per app run; the list is tracked so persisted projects are
|
||||
// checked after the store hydrates.
|
||||
const checked = new Set<string>()
|
||||
createEffect(() => {
|
||||
if (!isLocal || !platform.checkDirectories) return
|
||||
const directories = projects
|
||||
.list()
|
||||
.map((project) => project.worktree)
|
||||
.filter((directory) => !checked.has(pathKey(directory)))
|
||||
if (directories.length === 0) return
|
||||
directories.forEach((directory) => checked.add(pathKey(directory)))
|
||||
void platform
|
||||
.checkDirectories(directories)
|
||||
.then((available) => {
|
||||
const keep = new Set(available.map(pathKey))
|
||||
batch(() => directories.filter((directory) => !keep.has(pathKey(directory))).forEach(projects.remove))
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
|
||||
return {
|
||||
data,
|
||||
sdk,
|
||||
|
||||
@@ -33,8 +33,6 @@ export type FileContent = {
|
||||
}
|
||||
encoding?: "base64"
|
||||
mimeType?: string
|
||||
/** On-disk size when the bytes themselves are not retained. */
|
||||
size?: number
|
||||
}
|
||||
|
||||
export type Path = {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { batch, createEffect, createMemo, createRoot, getOwner, on, onCleanup, runWithOwner } from "solid-js"
|
||||
import { createEffect, createMemo, getOwner, on, onCleanup, runWithOwner } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { useServer } from "@/runtime/server/current"
|
||||
import type { SessionStateKey } from "@/runtime/server/scope"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
|
||||
import { sessionBrowserTab } from "@/shell/state/session-tabs"
|
||||
import { useCurrentRoute } from "@/shell/state/layout"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createBrowserConnection, type BrowserConnectionState } from "./connection"
|
||||
|
||||
@@ -36,14 +35,13 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const shellTabs = useTabs()
|
||||
const layout = useLayout()
|
||||
const route = useCurrentRoute()
|
||||
const owner = getOwner()
|
||||
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
|
||||
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
|
||||
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
|
||||
const live = new Map<string, Live>()
|
||||
const preview = new Map<string, Set<(path: string) => void>>()
|
||||
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
|
||||
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
|
||||
const enabled = createMemo(
|
||||
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
|
||||
@@ -89,14 +87,11 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
enabled,
|
||||
supported: (server: Server) => !unsupported[server.key],
|
||||
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
|
||||
attach(server: Server, sessionID: string, sessionKey: SessionStateKey) {
|
||||
attach(server: Server, sessionID: string) {
|
||||
const id = key(server, sessionID)
|
||||
if (live.has(id)) return
|
||||
const pane = platform.browserPane
|
||||
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
|
||||
// Focus requests write to the owning session's layout even while another shell tab is routed,
|
||||
// so the Review pane and browser tab are already selected when the user returns to it.
|
||||
const tabs = createRoot((dispose) => ({ dispose, layout: layout.tabs(sessionKey) }), owner)
|
||||
const connection = createBrowserConnection({
|
||||
pane,
|
||||
// Resolve the current port at every wake, including after sidecar replacement.
|
||||
@@ -105,15 +100,7 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
sessionID,
|
||||
endpoint: { ...server.conn.http, url: server.ctx.sdk.url },
|
||||
}),
|
||||
focus: (tabID) => {
|
||||
const tab = sessionBrowserTab(tabID)
|
||||
batch(() => {
|
||||
shellTabs.setPane(findSessionTab(shellTabs.store, server.key, sessionID), "review", true)
|
||||
if (!tabs.layout.all().includes(tab)) tabs.layout.setAll([...tabs.layout.all(), tab])
|
||||
tabs.layout.setActive(tab)
|
||||
})
|
||||
},
|
||||
preview: (path) => preview.get(id)?.forEach((listener) => listener(path)),
|
||||
focus: (tabID) => focus.get(id)?.forEach((listener) => listener(tabID)),
|
||||
change: (state) => {
|
||||
if (state.error === "browser.pane.unsupported") {
|
||||
setUnsupported(server.key, true)
|
||||
@@ -151,18 +138,17 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
entry.dispose = () => {
|
||||
unsubscribe?.forEach((dispose) => dispose())
|
||||
connection.dispose()
|
||||
tabs.dispose()
|
||||
}
|
||||
},
|
||||
/** Agent requests to show a file in this session's Review pane. */
|
||||
onPreview(server: Server, sessionID: string, listener: (path: string) => void) {
|
||||
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
|
||||
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
|
||||
const id = key(server, sessionID)
|
||||
const listeners = preview.get(id) ?? new Set()
|
||||
const listeners = focus.get(id) ?? new Set()
|
||||
listeners.add(listener)
|
||||
preview.set(id, listeners)
|
||||
focus.set(id, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (!listeners.size) preview.delete(id)
|
||||
if (!listeners.size) focus.delete(id)
|
||||
}
|
||||
},
|
||||
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
|
||||
|
||||
@@ -28,12 +28,10 @@ function fixture() {
|
||||
commands: Browser.Action[]
|
||||
}[] = []
|
||||
const endpoint = { url: "http://localhost:4096" }
|
||||
const previews: string[] = []
|
||||
const connection = createBrowserConnection({
|
||||
target: () => ({ serverKey: "browser-test", sessionID: "ses_browser", endpoint: { ...endpoint } }),
|
||||
change: (state) => states.push(state),
|
||||
focus: () => {},
|
||||
preview: (path) => previews.push(path),
|
||||
pane: {
|
||||
register(target, emit) {
|
||||
const call = { target, emit, closed: false, commands: [] as Browser.Action[] }
|
||||
@@ -52,21 +50,9 @@ function fixture() {
|
||||
})
|
||||
connection.wake()
|
||||
calls[0].emit({ type: "state", state: browser })
|
||||
return { connection, calls, states, endpoint, previews }
|
||||
return { connection, calls, states, endpoint }
|
||||
}
|
||||
|
||||
test("preview requests reach the session without touching connection state", () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
const before = app.states.length
|
||||
app.calls[0].emit({ type: "preview", path: "docs/report.pdf" })
|
||||
expect(app.previews).toEqual(["docs/report.pdf"])
|
||||
expect(app.states).toHaveLength(before)
|
||||
} finally {
|
||||
app.connection.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("suspension retains tabs and reconnects once on demand using the current endpoint", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,6 @@ export function createBrowserConnection(input: {
|
||||
target: () => BrowserPaneTarget
|
||||
change: (state: BrowserConnectionState) => void
|
||||
focus: (tabID: Browser.TabID) => void
|
||||
preview: (path: string) => void
|
||||
}) {
|
||||
const state: BrowserConnectionState = { browser: null, suspended: false }
|
||||
let disposed = false
|
||||
@@ -34,7 +33,6 @@ export function createBrowserConnection(input: {
|
||||
(event) => {
|
||||
if (disposed || state.registration !== registration) return
|
||||
if (event.type === "focus") return input.focus(event.tabID)
|
||||
if (event.type === "preview") return input.preview(event.path)
|
||||
if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") {
|
||||
blocked = true
|
||||
registration.close()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { batch, createEffect, createMemo, on } from "solid-js"
|
||||
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -33,6 +33,13 @@ export function createSessionBrowser(session: SessionModel) {
|
||||
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
|
||||
[],
|
||||
)
|
||||
const focus = (tabID: Browser.TabID) => {
|
||||
session.layout.view().reviewPanel.open()
|
||||
const tabs = session.layout.tabs()
|
||||
const key = sessionBrowserTab(tabID)
|
||||
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
|
||||
tabs.setActive(key)
|
||||
}
|
||||
const command = (command: BrowserPaneCommand) => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
@@ -59,7 +66,8 @@ export function createSessionBrowser(session: SessionModel) {
|
||||
createEffect(() => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
if (attachments.enabled()) attachments.attach(server, sessionID, session.layout.sessionKey())
|
||||
if (attachments.enabled()) attachments.attach(server, sessionID)
|
||||
onCleanup(attachments.onFocus(server, sessionID, focus))
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
|
||||
@@ -20,18 +20,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const command = useCommand()
|
||||
const state = props.browser.active
|
||||
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
|
||||
const failed = () => !!state()?.loadError
|
||||
const registration = props.browser.registration
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
address: "",
|
||||
editing: false,
|
||||
submitted: false,
|
||||
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
|
||||
navigating: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
const empty = () => !address() && !state()?.loading && !store.navigating
|
||||
let surface: HTMLDivElement | undefined
|
||||
let addressDisplay: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
@@ -48,7 +43,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
title: language.t("command.browser.reload"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "f5",
|
||||
disabled: !props.visible || !address(),
|
||||
disabled: !props.visible || !state(),
|
||||
onSelect: () => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
|
||||
@@ -76,9 +71,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const top = Math.round(rect.top * zoom)
|
||||
const right = Math.round(rect.right * zoom)
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
// The desktop page hides blank and loading documents itself; only hide here
|
||||
// while the pane shows its own empty or failed state over the surface.
|
||||
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
|
||||
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
|
||||
// The cutout exposes the app backdrop outside the rounded Review card,
|
||||
// not the browser surface inside it.
|
||||
const color = getComputedStyle(
|
||||
@@ -114,25 +107,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
createEffect(on([() => state()?.id, address], () => !store.editing && setStore("address", address())))
|
||||
// Any reported movement, including a rejected or blocked request, ends the submitted navigation.
|
||||
createEffect(
|
||||
on(
|
||||
[() => state()?.id, () => state()?.generation, () => state()?.loading, () => props.browser.error()],
|
||||
() => setStore("navigating", false),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
// A blocked or rejected submission leaves the page where it was; show that page's URL again.
|
||||
createEffect(
|
||||
on(
|
||||
() => props.browser.error(),
|
||||
(error) => {
|
||||
if (error && !store.editing) setStore("address", address())
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
createEffect(() => !store.editing && setStore("address", address()))
|
||||
createEffect(
|
||||
on(registration, (current) => {
|
||||
// Session routes can change before this pane unmounts. Hide the registration
|
||||
@@ -148,8 +123,6 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
() => store.visible,
|
||||
() => props.visible,
|
||||
() => state()?.id,
|
||||
empty,
|
||||
failed,
|
||||
registration,
|
||||
],
|
||||
() => {
|
||||
@@ -184,7 +157,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
|
||||
return (
|
||||
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-3 border-b border-v2-border-border-muted">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
|
||||
@@ -220,7 +193,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()?.loading && !address()}
|
||||
disabled={!state()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
@@ -239,13 +212,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const tab = state()
|
||||
const url = store.address.trim()
|
||||
if (!tab) return
|
||||
if (url || failed()) {
|
||||
setStore({ submitted: true, address: url, navigating: true })
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: url || "about:blank" })
|
||||
}
|
||||
event.currentTarget.querySelector("input")?.blur()
|
||||
if (tab && store.address.trim())
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
@@ -256,14 +224,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={(event) => {
|
||||
setStore("editing", true)
|
||||
event.currentTarget.select()
|
||||
}}
|
||||
onClick={(event) => event.currentTarget.select()}
|
||||
onBlur={() =>
|
||||
setStore({ editing: false, address: store.submitted ? store.address : address(), submitted: false })
|
||||
}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: address() })}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
onScroll={(event) => {
|
||||
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
|
||||
@@ -281,27 +243,12 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={props.browser.error() && !failed()}>
|
||||
<Show when={props.browser.error()}>
|
||||
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
|
||||
{props.browser.error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
|
||||
<Show when={(empty() || failed()) && !props.browser.suspended()}>
|
||||
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
|
||||
<div
|
||||
dir="auto"
|
||||
class="flex size-full flex-col items-center justify-center gap-2 p-6 pb-[200px] text-center text-text-weak"
|
||||
>
|
||||
<Icon name="globe" size="large" class="mb-2 shrink-0" />
|
||||
<div class="text-[13px] font-medium leading-[var(--line-height-compact)] text-text-strong">
|
||||
{language.t(failed() ? "session.browser.failed.title" : "session.browser.empty.title")}
|
||||
</div>
|
||||
<div class="text-13-regular leading-[var(--line-height-base)]">
|
||||
{language.t(failed() ? "session.browser.failed.description" : "session.browser.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.browser.suspended()}>
|
||||
<p class="px-6 text-center text-13-regular text-v2-text-text-subtle" role="status">
|
||||
{language.t("session.browser.suspended")}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/* The control defaults to a fixed 232px with equal segments; here it hugs its two labels. */
|
||||
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item"] {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
/* Shrink-wrapped segments round the label box down a fraction; the ellipsis overflow then clips glyph edges. */
|
||||
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item-label"] {
|
||||
overflow: visible;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"] {
|
||||
--artifact-check: color-mix(in oklch, var(--v2-text-text-base) 5%, transparent);
|
||||
background-color: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-checker] {
|
||||
background-image:
|
||||
linear-gradient(45deg, var(--artifact-check) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--artifact-check) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--artifact-check) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--artifact-check) 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
}
|
||||
|
||||
[data-slot="artifact-media"] {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 0 0 0.5px var(--v2-border-border-base),
|
||||
0 12px 32px -12px color-mix(in oklch, var(--v2-text-text-base) 25%, transparent);
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-zoom="fit"] [data-slot="artifact-media"] {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-zoom="fit"][data-overflow] [data-slot="artifact-media"] {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-zoom="actual"] [data-slot="artifact-media"] {
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] th,
|
||||
[data-slot="artifact-table"] td {
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid var(--v2-border-border-muted);
|
||||
border-inline-end: 1px solid var(--v2-border-border-muted);
|
||||
white-space: pre;
|
||||
max-width: 40ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: start;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] tbody tr:hover td {
|
||||
background: color-mix(in oklch, var(--v2-text-text-base) 3%, transparent);
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] td[data-index] {
|
||||
color: var(--v2-text-text-faint);
|
||||
text-align: end;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { Markdown } from "@opencode/session-ui/markdown"
|
||||
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
|
||||
import { getDirectory, getFilename } from "@opencode/util/path"
|
||||
import type { FileContent } from "@/runtime/server/types"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import {
|
||||
artifactKind,
|
||||
blobUrlFromContent,
|
||||
contentBytes,
|
||||
parseDelimited,
|
||||
resolveArtifactPath,
|
||||
type ArtifactKind,
|
||||
} from "@/workspaces/files/artifact"
|
||||
import { useArtifactOpener } from "@/session/files/open-artifact"
|
||||
import "./artifact-view.css"
|
||||
|
||||
type ArtifactMode = "preview" | "source"
|
||||
|
||||
/** Facts a viewer learns from the decoded media, shown in the toolbar. */
|
||||
type ArtifactInfo = { width?: number; height?: number; duration?: number; rows?: number; columns?: number }
|
||||
|
||||
type MediaProps = {
|
||||
path: string
|
||||
content: FileContent
|
||||
onInfo: (info: ArtifactInfo) => void
|
||||
/** The browser could not decode the bytes; the host falls back to the binary placeholder. */
|
||||
onError: () => void
|
||||
}
|
||||
|
||||
/** Kinds that render a preview from their text and can toggle back to highlighted source. */
|
||||
const previewableKinds = new Set<ArtifactKind>(["svg", "html", "markdown", "mermaid", "table"])
|
||||
|
||||
/**
|
||||
* Renders a loaded non-text file: media, documents, and data get a dedicated viewer with a toolbar;
|
||||
* previewable text kinds can switch to `source`, which the host supplies (its code view).
|
||||
*/
|
||||
export function ArtifactView(props: { path: string; content: FileContent; cacheKey?: string; source: JSX.Element }) {
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({
|
||||
mode: "preview" as ArtifactMode,
|
||||
info: {} as ArtifactInfo,
|
||||
// Media the browser could not decode falls back to the binary placeholder.
|
||||
undecodable: false,
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => props.content,
|
||||
() => setState({ mode: "preview", info: {}, undecodable: false }),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const kind = createMemo<ArtifactKind | "binary">(() => {
|
||||
if (props.content.type === "binary" && !props.content.mimeType) return "binary"
|
||||
if (state.undecodable) return "binary"
|
||||
return artifactKind(props.path)
|
||||
})
|
||||
const previewable = createMemo(() => {
|
||||
const value = kind()
|
||||
return value !== "binary" && previewableKinds.has(value)
|
||||
})
|
||||
const meta = createMemo(() => {
|
||||
const info = state.info
|
||||
return [
|
||||
info.width && info.height ? `${info.width} × ${info.height}` : undefined,
|
||||
info.duration ? formatDuration(info.duration) : undefined,
|
||||
info.rows !== undefined ? language.plural("file.view.table.rows", Math.max(0, info.rows - 1)) : undefined,
|
||||
info.columns !== undefined ? language.plural("file.view.table.columns", info.columns) : undefined,
|
||||
formatBytes(language.intl(), contentBytes(props.content)),
|
||||
].filter((item): item is string => !!item)
|
||||
})
|
||||
|
||||
const media = { onInfo: (info: ArtifactInfo) => setState("info", info), onError: () => setState("undecodable", true) }
|
||||
const rendered = () => (
|
||||
<ScrollView class="min-h-0 flex-1">
|
||||
<Show
|
||||
when={kind() === "markdown"}
|
||||
fallback={<ArtifactMermaid text={props.content.content} cacheKey={props.cacheKey} />}
|
||||
>
|
||||
<ArtifactMarkdown path={props.path} text={props.content.content} cacheKey={props.cacheKey} />
|
||||
</Show>
|
||||
</ScrollView>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArtifactToolbar
|
||||
mode={state.mode}
|
||||
onModeChange={previewable() ? (mode) => setState("mode", mode) : undefined}
|
||||
meta={meta()}
|
||||
actions={
|
||||
<Show when={kind() === "html"}>
|
||||
<OpenInBrowserButton path={props.path} />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<Show when={!previewable() || state.mode === "preview"} fallback={props.source}>
|
||||
<Switch>
|
||||
<Match when={kind() === "image" || kind() === "svg"}>
|
||||
<ArtifactImage path={props.path} content={props.content} {...media} />
|
||||
</Match>
|
||||
<Match when={kind() === "video"}>
|
||||
<ArtifactVideo path={props.path} content={props.content} {...media} />
|
||||
</Match>
|
||||
<Match when={kind() === "audio"}>
|
||||
<ArtifactAudio path={props.path} content={props.content} {...media} />
|
||||
</Match>
|
||||
<Match when={kind() === "pdf" || kind() === "html"}>
|
||||
<ArtifactFrame path={props.path} content={props.content} kind={kind() === "pdf" ? "pdf" : "html"} />
|
||||
</Match>
|
||||
<Match when={kind() === "font"}>
|
||||
<ArtifactFont path={props.path} content={props.content} />
|
||||
</Match>
|
||||
<Match when={kind() === "table"}>
|
||||
<ArtifactTable path={props.path} text={props.content.content} onInfo={media.onInfo} />
|
||||
</Match>
|
||||
<Match when={kind() === "markdown" || kind() === "mermaid"}>{rendered()}</Match>
|
||||
<Match when={kind() === "binary"}>
|
||||
<ArtifactBinary path={props.path} size={formatBytes(language.intl(), contentBytes(props.content))} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(locale: string, bytes: number) {
|
||||
const units = ["byte", "kilobyte", "megabyte", "gigabyte"] as const
|
||||
const index = Math.min(units.length - 1, bytes > 0 ? Math.floor(Math.log10(bytes) / 3) : 0)
|
||||
const value = bytes / 1000 ** index
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "unit",
|
||||
unit: units[index],
|
||||
// "short" bytes render as the singular "byte"; the long form pluralizes correctly.
|
||||
unitDisplay: index === 0 ? "long" : "short",
|
||||
maximumFractionDigits: value >= 100 || index === 0 ? 0 : 1,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const total = Math.round(seconds)
|
||||
const minutes = Math.floor(total / 60)
|
||||
return `${minutes}:${String(total % 60).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function ArtifactToolbar(props: {
|
||||
mode?: ArtifactMode
|
||||
onModeChange?: (mode: ArtifactMode) => void
|
||||
meta: string[]
|
||||
actions?: JSX.Element
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div data-slot="artifact-toolbar" class="flex h-10 shrink-0 items-center gap-3 px-4">
|
||||
<Show when={props.onModeChange}>
|
||||
<SegmentedControl
|
||||
value={props.mode ?? "preview"}
|
||||
onChange={(value) => {
|
||||
if (value === "preview" || value === "source") props.onModeChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SegmentedControlItem value="preview">{language.t("file.view.preview")}</SegmentedControlItem>
|
||||
<SegmentedControlItem value="source">{language.t("file.view.source")}</SegmentedControlItem>
|
||||
</SegmentedControl>
|
||||
</Show>
|
||||
<div class="ms-auto flex min-w-0 items-center gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2 text-12-regular text-text-weak">
|
||||
<For each={props.meta}>
|
||||
{(item, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<span aria-hidden class="text-text-faint">
|
||||
·
|
||||
</span>
|
||||
</Show>
|
||||
<span class="truncate tabular-nums">{item}</span>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{props.actions}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OpenInBrowserButton(props: { path: string }) {
|
||||
const language = useLanguage()
|
||||
const artifacts = useArtifactOpener()
|
||||
return (
|
||||
<Show when={artifacts.canOpenInBrowser(props.path)}>
|
||||
<Button size="small" variant="ghost" icon="globe" onClick={() => artifacts.openInBrowser(props.path)}>
|
||||
{language.t("file.view.openInBrowser")}
|
||||
</Button>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function createBlobUrl(content: () => FileContent) {
|
||||
return createMemo(() => {
|
||||
const value = blobUrlFromContent(content())
|
||||
onCleanup(() => URL.revokeObjectURL(value))
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
/** Images and SVG previews: fit the pane, click to inspect at 1:1 when the image is larger. */
|
||||
function ArtifactImage(props: MediaProps) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
const [state, setState] = createStore({ zoom: "fit" as "fit" | "actual", overflow: false, width: 0, height: 0 })
|
||||
let stage: HTMLDivElement | undefined
|
||||
const measure = () => {
|
||||
if (!stage) return
|
||||
setState("overflow", state.width > stage.clientWidth - 48 || state.height > stage.clientHeight - 48)
|
||||
}
|
||||
createResizeObserver(
|
||||
() => stage,
|
||||
() => measure(),
|
||||
)
|
||||
createEffect(() => {
|
||||
url()
|
||||
setState({ zoom: "fit", overflow: false })
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={stage}
|
||||
data-slot="artifact-stage"
|
||||
data-checker
|
||||
data-zoom={state.zoom}
|
||||
data-overflow={state.overflow || undefined}
|
||||
class="relative min-h-0 flex-1 overflow-auto"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0 flex items-center justify-center p-6": state.zoom === "fit",
|
||||
"flex min-h-full min-w-full w-max items-center justify-center p-6": state.zoom === "actual",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
data-slot="artifact-media"
|
||||
src={url()}
|
||||
alt={getFilename(props.path)}
|
||||
draggable={false}
|
||||
onError={() => props.onError()}
|
||||
onLoad={(event) => {
|
||||
const image = event.currentTarget
|
||||
setState({ width: image.naturalWidth, height: image.naturalHeight })
|
||||
props.onInfo({ width: image.naturalWidth, height: image.naturalHeight })
|
||||
measure()
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!state.overflow && state.zoom === "fit") return
|
||||
setState("zoom", state.zoom === "fit" ? "actual" : "fit")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactVideo(props: MediaProps) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
return (
|
||||
<div data-slot="artifact-stage" data-zoom="fit" class="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div class="absolute inset-0 flex items-center justify-center p-6">
|
||||
<video
|
||||
data-slot="artifact-media"
|
||||
class="w-full bg-black"
|
||||
controls
|
||||
preload="metadata"
|
||||
playsinline
|
||||
onError={() => props.onError()}
|
||||
src={url()}
|
||||
onLoadedMetadata={(event) => {
|
||||
const video = event.currentTarget
|
||||
props.onInfo({ width: video.videoWidth, height: video.videoHeight, duration: video.duration })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactAudio(props: MediaProps) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
return (
|
||||
<div data-slot="artifact-stage" class="relative min-h-0 flex-1 overflow-auto">
|
||||
<div class="absolute inset-0 flex items-center justify-center p-6">
|
||||
<div class="flex w-full max-w-lg flex-col items-center gap-5 rounded-xl border border-v2-border-border-muted bg-v2-background-bg-base px-8 py-8 shadow-[var(--v2-elevation-raised)]">
|
||||
<div class="flex size-14 items-center justify-center rounded-full bg-v2-background-bg-layer-02">
|
||||
<FileIcon node={{ path: props.path, type: "file" }} class="size-7" />
|
||||
</div>
|
||||
<div class="max-w-full truncate text-14-medium text-text-strong">{getFilename(props.path)}</div>
|
||||
<audio
|
||||
class="w-full"
|
||||
onError={() => props.onError()}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={url()}
|
||||
onLoadedMetadata={(event) => props.onInfo({ duration: event.currentTarget.duration })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactFrame(props: { path: string; content: FileContent; kind: "pdf" | "html" }) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
// PDF Open Parameters: start with the thumbnail pane closed and the page fitted to the pane width.
|
||||
const src = () => (props.kind === "pdf" ? `${url()}#navpanes=0&view=FitH` : url())
|
||||
return (
|
||||
<iframe
|
||||
class="block h-full w-full flex-1 border-0 bg-white"
|
||||
title={getFilename(props.path)}
|
||||
src={src()}
|
||||
// The PDF viewer is Chromium's own and does not run in a sandboxed frame. HTML runs as an
|
||||
// opaque origin: no app storage, cookies, or credentialed requests reach it.
|
||||
sandbox={props.kind === "html" ? "allow-scripts allow-popups allow-forms allow-modals" : undefined}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactMarkdown(props: { path: string; text: string; cacheKey?: string }) {
|
||||
const parent = useMarkdown()
|
||||
const artifacts = useArtifactOpener()
|
||||
// getDirectory yields "/" for a root-level file, which would make relative links absolute.
|
||||
const dir = createMemo(() => (props.path.includes("/") || props.path.includes("\\") ? getDirectory(props.path) : ""))
|
||||
// Absolute references bypass the file's directory; relative ones resolve against it.
|
||||
const resolve = (href: string) => (/^([a-z]:)?\//i.test(href) ? href : (resolveArtifactPath(dir(), href) ?? href))
|
||||
return (
|
||||
<MarkdownProvider
|
||||
readImage={(src, signal) => parent?.readImage?.(resolve(src), signal) ?? Promise.resolve(undefined)}
|
||||
openLocalFile={(href) => artifacts.open(href, dir())}
|
||||
>
|
||||
<div class="mx-auto w-full max-w-3xl px-8 py-6">
|
||||
<Markdown text={props.text} cacheKey={props.cacheKey} class="select-text" />
|
||||
</div>
|
||||
</MarkdownProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Mermaid sources render through the same fenced-block pipeline the timeline uses. */
|
||||
function ArtifactMermaid(props: { text: string; cacheKey?: string }) {
|
||||
return (
|
||||
<div class="mx-auto w-full max-w-4xl px-8 py-6">
|
||||
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} class="select-text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactTable(props: { path: string; text: string; onInfo: (info: ArtifactInfo) => void }) {
|
||||
const language = useLanguage()
|
||||
const parsed = createMemo(() => parseDelimited(props.text, props.path.toLowerCase().endsWith(".tsv") ? "\t" : ","))
|
||||
createEffect(() => props.onInfo({ rows: parsed().total, columns: parsed().columns }))
|
||||
// Pad the header to the widest row so no data column is dropped.
|
||||
const header = () => Array.from({ length: parsed().columns }, (_, index) => parsed().rows[0]?.[index] ?? "")
|
||||
const body = () => parsed().rows.slice(1)
|
||||
return (
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table data-slot="artifact-table" class="min-w-full text-13-regular text-text-base">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-index />
|
||||
<For each={header()}>{(cell) => <th>{cell}</th>}</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={body()}>
|
||||
{(row, index) => (
|
||||
<tr>
|
||||
<td data-index>{index() + 1}</td>
|
||||
<For each={header()}>{(_, column) => <td>{row[column()] ?? ""}</td>}</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={parsed().total > parsed().rows.length}>
|
||||
<div class="px-4 py-3 text-12-regular text-text-weak">
|
||||
{language.t("file.view.table.truncated", { shown: parsed().rows.length - 1, total: parsed().total - 1 })}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const specimenSizes = [12, 16, 24, 40, 64]
|
||||
|
||||
function ArtifactFont(props: { path: string; content: FileContent }) {
|
||||
const language = useLanguage()
|
||||
const url = createBlobUrl(() => props.content)
|
||||
const family = createMemo(() => `artifact-${Math.random().toString(36).slice(2)}`)
|
||||
createEffect(() => {
|
||||
const face = new FontFace(family(), `url(${url()})`)
|
||||
document.fonts.add(face)
|
||||
void face.load().catch(() => undefined)
|
||||
onCleanup(() => document.fonts.delete(face))
|
||||
})
|
||||
return (
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-6 px-8 py-8" style={{ "font-family": `"${family()}"` }}>
|
||||
<div class="text-text-strong" style={{ "font-size": "56px", "line-height": "1.1" }}>
|
||||
{getFilename(props.path).replace(/\.[^.]+$/, "")}
|
||||
</div>
|
||||
<div class="break-all text-text-base" style={{ "font-size": "22px", "line-height": "1.4" }}>
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
||||
<br />
|
||||
abcdefghijklmnopqrstuvwxyz
|
||||
<br />
|
||||
0123456789 !?&@#%(){}[]
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 border-t border-v2-border-border-muted pt-6">
|
||||
<For each={specimenSizes}>
|
||||
{(size) => (
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="w-8 shrink-0 text-12-regular text-text-faint tabular-nums"
|
||||
style={{ "font-family": "var(--font-family-mono)" }}
|
||||
>
|
||||
{size}
|
||||
</span>
|
||||
<span class="text-text-base" style={{ "font-size": `${size}px`, "line-height": "1.25" }}>
|
||||
{language.t("file.view.fontSample")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactBinary(props: { path: string; size: string }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div data-slot="artifact-stage" class="relative min-h-0 flex-1">
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<FileIcon node={{ path: props.path, type: "file" }} class="size-8 text-text-weak" />
|
||||
<div class="text-14-medium text-text-strong">{getFilename(props.path)}</div>
|
||||
<div class="text-13-regular text-text-weak">{language.t("file.view.binary", { size: props.size })}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -11,9 +11,8 @@ import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { artifactKind } from "@/workspaces/files/artifact"
|
||||
import { ArtifactView } from "@/session/files/artifact-view"
|
||||
import { useComments } from "@/composer/comments"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
@@ -206,11 +205,6 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
})
|
||||
const contents = createMemo(() => state()?.content?.content ?? "")
|
||||
const cacheKey = createMemo(() => sampledChecksum(contents()))
|
||||
// Plain text keeps the code view; every other kind is rendered by ArtifactView.
|
||||
const artifact = createMemo(() => {
|
||||
const content = state()?.content
|
||||
return content?.type === "binary" || artifactKind(path() ?? "") !== "text"
|
||||
})
|
||||
const selectedLines = createMemo<SelectedLineRange | null>(() => {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
@@ -439,42 +433,39 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
}}
|
||||
search={search}
|
||||
class="select-text"
|
||||
// Media and previews have their own viewers below; the code view only ever shows text.
|
||||
media={{ mode: "off" }}
|
||||
media={{
|
||||
mode: "auto",
|
||||
path: path(),
|
||||
current: state()?.content,
|
||||
onLoad: scrollSync.queueRestore,
|
||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||
if (args.kind !== "svg") return
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
})
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
// The code view scrolls inside ScrollView so line state and scroll position persist per tab.
|
||||
const codeView = (source: string) => (
|
||||
<ScrollView class="min-h-0 flex-1" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
|
||||
{renderFile(source)}
|
||||
</ScrollView>
|
||||
)
|
||||
|
||||
const content = () => (
|
||||
<div class="mt-3 relative h-full min-h-0 flex flex-col">
|
||||
<Switch>
|
||||
<Match when={state()?.loaded ? state()?.content : undefined}>
|
||||
{(value) => (
|
||||
<Show when={artifact()} fallback={codeView(value().content)}>
|
||||
<ArtifactView
|
||||
path={path() ?? ""}
|
||||
content={value()}
|
||||
cacheKey={cacheKey()}
|
||||
source={codeView(value().content)}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}…</div>
|
||||
</Match>
|
||||
<Match when={state()?.notFound ? state()?.name : undefined}>
|
||||
{(name) => <div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>}
|
||||
</Match>
|
||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||
</Switch>
|
||||
<div class="mt-3 relative h-full min-h-0">
|
||||
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}…</div>
|
||||
</Match>
|
||||
<Match when={state()?.notFound ? state()?.name : undefined}>
|
||||
{(name) => (
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||
</Switch>
|
||||
</ScrollView>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { createEffect, onCleanup, type ParentProps } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
|
||||
import { useBrowserAttachments } from "@/session/browser/attachments"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { artifactKind, resolveArtifactPath } from "@/workspaces/files/artifact"
|
||||
import { encodeFilePath } from "@/workspaces/files/path"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createOpenSessionFileTab } from "@/session/helpers"
|
||||
import type { createSessionBrowser } from "@/session/browser/model"
|
||||
|
||||
/** Routes local links in timeline markdown to the artifact opener while keeping image loading. */
|
||||
export function ArtifactMarkdownProvider(props: ParentProps) {
|
||||
const markdown = useMarkdown()
|
||||
const artifacts = useArtifactOpener()
|
||||
return (
|
||||
<MarkdownProvider readImage={markdown?.readImage} openLocalFile={(path) => artifacts.open(path)}>
|
||||
{props.children}
|
||||
</MarkdownProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens files the agent references as side-panel tabs, inside or outside the workspace, or as
|
||||
* a browser tab for HTML when the desktop can load the file directly.
|
||||
*/
|
||||
export const { use: useArtifactOpener, provider: ArtifactOpenerProvider } = createSimpleContext({
|
||||
name: "ArtifactOpener",
|
||||
init: (props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) => {
|
||||
const file = useFile()
|
||||
const server = useServer()
|
||||
const location = useWorkspaceLocation()
|
||||
const attachments = useBrowserAttachments()
|
||||
const { tabs, view } = useSessionLayout()
|
||||
|
||||
const root = () => location().directory.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
|
||||
/**
|
||||
* Turn a link into a path `useFile` can load: workspace-relative when it is under the root,
|
||||
* otherwise absolute. Relative links resolve against `base`; ones that climb past the root
|
||||
* become absolute too, so a `../../shared/report.pdf` still opens.
|
||||
*/
|
||||
const resolve = (href: string, base?: string) => {
|
||||
// Agents cite locations as path:line or path:line:col; the file is what opens.
|
||||
const value = href.replaceAll("\\", "/").replace(/:\d+(?::\d+)?$/, "")
|
||||
if (/^[a-z]:\//i.test(value) || value.startsWith("/")) return file.normalize(value)
|
||||
const relative = resolveArtifactPath(base ?? "", value)
|
||||
if (relative !== undefined) return file.normalize(relative)
|
||||
// Climbing past the workspace root: resolve from the referencing folder's absolute location.
|
||||
const dir = base ? `${root()}/${base.replace(/\/+$/, "")}` : root()
|
||||
return file.normalize(resolveArtifactPath(dir, value) ?? value)
|
||||
}
|
||||
|
||||
const showTab = createOpenSessionFileTab({
|
||||
normalizeTab: (tab) => tab,
|
||||
openTab: (tab) => tabs().open(tab),
|
||||
pathFromTab: file.pathFromTab,
|
||||
loadFile: () => undefined,
|
||||
openReviewPanel: () => {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
},
|
||||
setActive: (tab) => tabs().setActive(tab),
|
||||
})
|
||||
|
||||
// Inline paths are guessed from text, so confirm the file exists before a tab appears for it.
|
||||
const openTab = (path: string) => {
|
||||
void file.load(path).then(() => {
|
||||
if (file.get(path)?.loaded) showTab(file.tab(path))
|
||||
})
|
||||
}
|
||||
|
||||
// The desktop's own sidecar shares this disk, and its browser pane accepts file:// URLs inside the
|
||||
// session workspace only. Forwarded loopback servers do not qualify, matching the desktop policy.
|
||||
const canOpenInBrowser = (path?: string) =>
|
||||
ServerConnection.builtin(server.conn) &&
|
||||
props.browser.available() &&
|
||||
props.browser.attached() &&
|
||||
(path === undefined || !file.absolute(path))
|
||||
|
||||
const openInBrowser = (path: string) => {
|
||||
props.browser.command({ type: "tabs.open", url: `file://${encodeFilePath(`${root()}/${path}`)}` })
|
||||
}
|
||||
|
||||
/** Open `href` as referenced from `base` (a workspace-relative directory, "" for the root). */
|
||||
const open = (href: string, base?: string) => {
|
||||
const path = resolve(href, base)
|
||||
if (!path) return
|
||||
if (artifactKind(path) === "html" && canOpenInBrowser(path)) return openInBrowser(path)
|
||||
openTab(path)
|
||||
}
|
||||
|
||||
// The agent's browser.preview tool arrives through the desktop browser pane attachment.
|
||||
createEffect(() => {
|
||||
const sessionID = props.session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
onCleanup(attachments.onPreview(server, sessionID, (path) => open(path)))
|
||||
})
|
||||
|
||||
return { canOpenInBrowser, openInBrowser, open }
|
||||
},
|
||||
})
|
||||
@@ -173,9 +173,7 @@ export function SessionFileBrowserTab(props: {
|
||||
<SessionFilePanelV2Empty>
|
||||
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" class="mb-2" />
|
||||
<div class="text-[13px] font-medium leading-[13px] text-text-strong">
|
||||
{language.t("command.file.open")}
|
||||
</div>
|
||||
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
|
||||
</div>
|
||||
</SessionFilePanelV2Empty>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { children, createMemo, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
@@ -71,19 +69,9 @@ export function SortableTab(props: {
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onTabClose(props.tab)
|
||||
}}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
<Tabs.CloseButton
|
||||
class="h-5 w-5"
|
||||
onClick={() => props.onTabClose(props.tab)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -137,7 +137,6 @@ export function useSessionModel() {
|
||||
tabs: layout.tabs,
|
||||
view: layout.view,
|
||||
tabKey: layout.tabKey,
|
||||
sessionKey: layout.sessionKey,
|
||||
},
|
||||
ownership: createSessionOwnership(layout.sessionKey),
|
||||
tabs: {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { DockPrompt } from "@opencode/session-ui/dock-prompt"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { useSpring } from "@opencode/ui/motion-spring"
|
||||
@@ -14,10 +13,8 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ScopedKey } from "@/runtime/server/scope"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
|
||||
const cache = new Map<string, { tab: number; answers: string[][]; custom: string[]; customOn: boolean[] }>()
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
type QuestionField = FormStringField | FormMultiselectField
|
||||
|
||||
@@ -73,7 +70,6 @@ function Option(props: {
|
||||
export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => void }> = (props) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
|
||||
|
||||
const questions = createMemo(() =>
|
||||
@@ -265,8 +261,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
}))
|
||||
|
||||
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
|
||||
const submitShortcut = () => (IS_MAC ? "⌘⏎" : `${language.t("common.key.ctrl")}+⏎`)
|
||||
const backShortcut = () => (IS_MAC ? "⌘[" : `${language.t("common.key.alt")}+←`)
|
||||
|
||||
const reply = (answer: FormAnswer) => {
|
||||
if (sending()) return
|
||||
@@ -357,16 +351,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
return
|
||||
}
|
||||
|
||||
const previous = IS_MAC
|
||||
? event.metaKey && !event.ctrlKey && !event.altKey && event.key === "["
|
||||
: event.altKey && !event.ctrlKey && !event.metaKey && event.key === "ArrowLeft"
|
||||
if (previous) {
|
||||
if (event.repeat) return
|
||||
event.preventDefault()
|
||||
back()
|
||||
return
|
||||
}
|
||||
|
||||
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
|
||||
if (mod && event.key === "Enter") {
|
||||
if (event.repeat) return
|
||||
@@ -477,19 +461,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
command.register("session.question.back", () => [
|
||||
{
|
||||
id: "session.question.back",
|
||||
title: language.t("ui.common.back"),
|
||||
keybind: IS_MAC ? "mod+[" : "alt+arrowleft",
|
||||
hidden: true,
|
||||
// Stay registered while sending so the shortcut does not fall through to history navigation.
|
||||
disabled: store.tab <= 0,
|
||||
when: () => store.tab > 0,
|
||||
onSelect: back,
|
||||
},
|
||||
])
|
||||
|
||||
const minimize = () => {
|
||||
if (sending()) return
|
||||
setStore("editing", false)
|
||||
@@ -547,25 +518,9 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<>
|
||||
{language.t("ui.common.back")}
|
||||
<span class="opacity-60">{backShortcut()}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={back}
|
||||
aria-keyshortcuts={IS_MAC ? "Meta+[" : "Alt+ArrowLeft"}
|
||||
>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button variant="neutral" size="large" disabled={sending()} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant={last() ? "submit" : "neutral"}
|
||||
@@ -575,9 +530,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
<span data-slot="question-submit-shortcut" aria-hidden="true" class="text-11-medium opacity-60">
|
||||
{submitShortcut()}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -155,9 +155,6 @@ export function createSessionReview(input: {
|
||||
const count = () => diffs().length
|
||||
const hasChanges = () => count() > 0
|
||||
const ready = () => {
|
||||
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
|
||||
const project = input.session.project()
|
||||
if (project && !project.vcs) return true
|
||||
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
|
||||
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
|
||||
const language = useLanguage()
|
||||
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
|
||||
const noGit = () => props.review.noGit()
|
||||
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
|
||||
const text = () => {
|
||||
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
|
||||
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
|
||||
@@ -282,7 +282,7 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
|
||||
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
|
||||
const language = useLanguage()
|
||||
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
|
||||
const noGit = () => props.review.noGit()
|
||||
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
|
||||
@@ -38,7 +38,6 @@ import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
import { createTimelineCache } from "./timeline/cache"
|
||||
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -51,26 +50,14 @@ const SessionSummaryPanel = lazy(async () => {
|
||||
})
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
// The timeline cache captures its owner when created, so link handling must be provided above it.
|
||||
const browser = createSessionBrowser(props.session)
|
||||
return (
|
||||
<ArtifactOpenerProvider session={props.session} browser={browser}>
|
||||
<ArtifactMarkdownProvider>
|
||||
<SessionScreenContent session={props.session} browser={browser} />
|
||||
</ArtifactMarkdownProvider>
|
||||
</ArtifactOpenerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionScreenContent(props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) {
|
||||
const session = props.session
|
||||
const browser = props.browser
|
||||
const server = useServer()
|
||||
const detailsProject = createMemo(() => {
|
||||
const info = session.data.info()
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const browser = createSessionBrowser(session)
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const timelineSearch = createTimelineSearchController({
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { For, Show, createMemo, onCleanup } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import {
|
||||
@@ -14,8 +17,6 @@ import {
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsSearchEmpty } from "@/settings/search-empty"
|
||||
import { SettingsSearchField } from "@/settings/search-field"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
@@ -339,7 +340,7 @@ export function createKeybindSettingsController(
|
||||
}
|
||||
}
|
||||
|
||||
export function SettingsKeybinds(props: { active?: boolean }) {
|
||||
export function SettingsKeybinds(props: { active?: boolean; autofocus?: boolean }) {
|
||||
const command = useCommand()
|
||||
const settings = useSettings()
|
||||
const controller = createKeybindSettingsController({
|
||||
@@ -350,6 +351,7 @@ export function SettingsKeybinds(props: { active?: boolean }) {
|
||||
return (
|
||||
<SettingsKeybindsView
|
||||
visible={props.active}
|
||||
autofocus={props.autofocus}
|
||||
groups={controller.catalog.groups}
|
||||
filtered={controller.catalog.filtered}
|
||||
title={controller.catalog.title}
|
||||
@@ -364,6 +366,7 @@ export function SettingsKeybinds(props: { active?: boolean }) {
|
||||
|
||||
function SettingsKeybindsView(props: {
|
||||
visible?: boolean
|
||||
autofocus?: boolean
|
||||
groups: KeybindGroup[]
|
||||
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||
title: (id: string) => string
|
||||
@@ -374,6 +377,20 @@ function SettingsKeybindsView(props: {
|
||||
onReset: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
let search: HTMLInputElement | undefined
|
||||
createEffect(
|
||||
on(
|
||||
() => props.visible ?? true,
|
||||
(visible) => {
|
||||
if (!visible) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (props.visible !== false && props.autofocus !== false && search?.isConnected)
|
||||
search.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
},
|
||||
),
|
||||
)
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const filtered = createMemo(() => props.filtered(store.filter))
|
||||
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
|
||||
@@ -390,12 +407,31 @@ function SettingsKeybindsView(props: {
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</Button>
|
||||
</div>
|
||||
<SettingsSearchField
|
||||
value={store.filter}
|
||||
active={props.visible ?? true}
|
||||
onInput={(value) => setStore("filter", value)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
/>
|
||||
<div class="settings-tab-search">
|
||||
<TextInput
|
||||
ref={search}
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={store.filter}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-tab-search-clear"
|
||||
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setStore("filter", "")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-shortcuts settings-section-stack">
|
||||
@@ -406,29 +442,27 @@ function SettingsKeybindsView(props: {
|
||||
<h3 class="settings-section-title">{language.t(groupKey[group])}</h3>
|
||||
<SettingsList>
|
||||
<For each={filtered().get(group) ?? []}>
|
||||
{(id) => {
|
||||
const binding = props.keybind(id)
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<span>{props.title(id)}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant={binding ? "ghost" : "ghost-muted"}
|
||||
data-keybind-id={id}
|
||||
data-expanded={props.active === id ? "" : undefined}
|
||||
onClick={() => props.onCapture(id)}
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<span>{props.title(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-keybind-id={id}
|
||||
classList={{
|
||||
"settings-keybind-button": true,
|
||||
"settings-keybind-button--active": props.active === id,
|
||||
}}
|
||||
onClick={() => props.onCapture(id)}
|
||||
>
|
||||
<Show
|
||||
when={props.active === id}
|
||||
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||
>
|
||||
<Show
|
||||
when={props.active === id}
|
||||
fallback={binding || language.t("settings.shortcuts.unassigned")}
|
||||
>
|
||||
{language.t("settings.shortcuts.pressKeys")}
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
{language.t("settings.shortcuts.pressKeys")}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</div>
|
||||
@@ -436,8 +470,9 @@ function SettingsKeybindsView(props: {
|
||||
)}
|
||||
</For>
|
||||
<Show when={store.filter && !hasResults()}>
|
||||
<div class="settings-tab-search-empty">
|
||||
<SettingsSearchEmpty query={store.filter} />
|
||||
<div class="settings-shortcuts-status">
|
||||
<span>{language.t("settings.shortcuts.search.empty")}</span>
|
||||
<span class="settings-shortcuts-status-filter">"{store.filter}"</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useFilteredList } from "@opencode/ui/hooks"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { type Component, createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -11,8 +14,6 @@ import { popularProviders } from "@/providers/catalog/providers"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import { SettingsSearchEmpty } from "@/settings/search-empty"
|
||||
import { SettingsSearchField } from "@/settings/search-field"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
@@ -23,10 +24,24 @@ export const ModelProvidersSchema = Schema.Struct({
|
||||
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
|
||||
})
|
||||
|
||||
export const SettingsModels: Component<{ active?: boolean }> = (props) => {
|
||||
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
const serverSdk = useServerSDK()
|
||||
let search: HTMLInputElement | undefined
|
||||
createEffect(
|
||||
on(
|
||||
() => props.active ?? true,
|
||||
(active) => {
|
||||
if (!active) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (props.active !== false && props.autofocus !== false && search?.isConnected)
|
||||
search.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
},
|
||||
),
|
||||
)
|
||||
const [store, setStore] = persisted(
|
||||
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
|
||||
ModelProvidersSchema,
|
||||
@@ -64,12 +79,31 @@ export const SettingsModels: Component<{ active?: boolean }> = (props) => {
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsSearchField
|
||||
value={list.filter()}
|
||||
active={props.active ?? true}
|
||||
onInput={list.onInput}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
/>
|
||||
<div class="settings-tab-search">
|
||||
<TextInput
|
||||
ref={search}
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={list.filter()}
|
||||
onInput={(event) => list.onInput(event.currentTarget.value)}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
/>
|
||||
<Show when={list.filter()}>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-tab-search-clear"
|
||||
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => list.clear()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-models">
|
||||
@@ -85,14 +119,12 @@ export const SettingsModels: Component<{ active?: boolean }> = (props) => {
|
||||
<Show
|
||||
when={list.flat().length > 0}
|
||||
fallback={
|
||||
<Show
|
||||
when={list.filter()}
|
||||
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
|
||||
>
|
||||
<div class="settings-tab-search-empty">
|
||||
<SettingsSearchEmpty query={list.filter()} />
|
||||
</div>
|
||||
</Show>
|
||||
<div class="settings-models-status">
|
||||
<span>{language.t("dialog.model.empty")}</span>
|
||||
<Show when={list.filter()}>
|
||||
<span class="settings-models-status-filter">"{list.filter()}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseSettingsView, settingsViewUrl, type SettingsView } from "./route"
|
||||
|
||||
describe("settings route", () => {
|
||||
test("keeps root preferences at the canonical route", () => {
|
||||
expect(settingsViewUrl({ type: "root", tab: "general" })).toBe("/settings")
|
||||
expect(parseSettingsView("", false)).toEqual({ type: "root", tab: "general", subtab: undefined })
|
||||
})
|
||||
|
||||
test("round trips root, server, and project pages", () => {
|
||||
const root: SettingsView = { type: "root", tab: "appearance" }
|
||||
const server: SettingsView = { type: "server", server: "wsl:Debian", tab: "models" }
|
||||
const project: SettingsView = {
|
||||
type: "project",
|
||||
server: "local",
|
||||
project: "C:\\work folder",
|
||||
parent: "server",
|
||||
tab: "extensions",
|
||||
subtab: "skills",
|
||||
}
|
||||
|
||||
expect(parseSettingsView(new URL(settingsViewUrl(root), "http://localhost").search, false)).toEqual({
|
||||
...root,
|
||||
subtab: undefined,
|
||||
})
|
||||
expect(parseSettingsView(new URL(settingsViewUrl(server), "http://localhost").search, true)).toEqual({
|
||||
...server,
|
||||
subtab: undefined,
|
||||
})
|
||||
expect(parseSettingsView(new URL(settingsViewUrl(project), "http://localhost").search, true)).toEqual(project)
|
||||
})
|
||||
|
||||
test("derives project ancestry and keeps search reveal state transient", () => {
|
||||
expect(
|
||||
parseSettingsView("?server=local&project=%2Fwork&tab=extensions&subtab=plugins", false, {
|
||||
target: "settings-plugin",
|
||||
searchActivation: 2,
|
||||
}),
|
||||
).toEqual({
|
||||
type: "project",
|
||||
server: "local",
|
||||
project: "/work",
|
||||
parent: "root",
|
||||
tab: "extensions",
|
||||
subtab: "plugins",
|
||||
target: "settings-plugin",
|
||||
searchActivation: 2,
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back for invalid scope and tab combinations", () => {
|
||||
expect(parseSettingsView("?tab=unknown", false)).toEqual({ type: "root", tab: "general" })
|
||||
expect(parseSettingsView("?project=%2Fwork&tab=extensions", false)).toEqual({ type: "root", tab: "general" })
|
||||
expect(parseSettingsView("?server=local&tab=about", true)).toEqual({ type: "root", tab: "general" })
|
||||
expect(parseSettingsView("?tab=toString", false)).toEqual({ type: "root", tab: "general" })
|
||||
expect(parseSettingsView("?tab=extensions&subtab=toString", false)).toEqual({
|
||||
type: "root",
|
||||
tab: "extensions",
|
||||
subtab: undefined,
|
||||
})
|
||||
expect(parseSettingsView("?tab=extensions&subtab=lsps", false)).toEqual({
|
||||
type: "root",
|
||||
tab: "extensions",
|
||||
subtab: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
export type SettingsRootTab =
|
||||
| "general"
|
||||
| "appearance"
|
||||
| "notifications"
|
||||
| "shortcuts"
|
||||
| "projects"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
| "models"
|
||||
| "extensions"
|
||||
| "servers"
|
||||
| "experimental"
|
||||
| "about"
|
||||
|
||||
export type SettingsServerTab = "general" | "projects" | "workspaces" | "providers" | "models" | "extensions"
|
||||
export type SettingsProjectTab = "general" | "workspaces" | "extensions"
|
||||
|
||||
export type SettingsView = (
|
||||
| { type: "root"; tab: SettingsRootTab }
|
||||
| { type: "server"; server: string; tab: SettingsServerTab }
|
||||
| {
|
||||
type: "project"
|
||||
server: string
|
||||
project: string
|
||||
tab: SettingsProjectTab
|
||||
parent: "root" | "server"
|
||||
}
|
||||
) & {
|
||||
target?: string
|
||||
subtab?: "mcps" | "plugins" | "skills" | "lsps"
|
||||
searchActivation?: number
|
||||
}
|
||||
|
||||
export type SettingsTransientView = Pick<SettingsView, "target" | "searchActivation">
|
||||
|
||||
const rootTabs: Record<SettingsRootTab, true> = {
|
||||
general: true,
|
||||
appearance: true,
|
||||
notifications: true,
|
||||
shortcuts: true,
|
||||
projects: true,
|
||||
workspaces: true,
|
||||
providers: true,
|
||||
models: true,
|
||||
extensions: true,
|
||||
servers: true,
|
||||
experimental: true,
|
||||
about: true,
|
||||
}
|
||||
const serverTabs: Record<SettingsServerTab, true> = {
|
||||
general: true,
|
||||
projects: true,
|
||||
workspaces: true,
|
||||
providers: true,
|
||||
models: true,
|
||||
extensions: true,
|
||||
}
|
||||
const projectTabs: Record<SettingsProjectTab, true> = {
|
||||
general: true,
|
||||
workspaces: true,
|
||||
extensions: true,
|
||||
}
|
||||
const subtabs: Record<NonNullable<SettingsView["subtab"]>, true> = {
|
||||
mcps: true,
|
||||
plugins: true,
|
||||
skills: true,
|
||||
lsps: true,
|
||||
}
|
||||
|
||||
export function parseSettingsView(
|
||||
search: string,
|
||||
multipleServers: boolean,
|
||||
transient?: SettingsTransientView,
|
||||
): SettingsView {
|
||||
const params = new URLSearchParams(search)
|
||||
const tab = params.get("tab") ?? "general"
|
||||
const server = params.get("server")
|
||||
const project = params.get("project")
|
||||
const subtab = params.get("subtab")
|
||||
const nested = subtab && isSubtab(subtab) && tab === "extensions" ? subtab : undefined
|
||||
|
||||
if (project && server && isProjectTab(tab)) {
|
||||
return {
|
||||
type: "project",
|
||||
server,
|
||||
project,
|
||||
parent: multipleServers ? "server" : "root",
|
||||
tab,
|
||||
subtab: nested,
|
||||
...transient,
|
||||
}
|
||||
}
|
||||
if (!project && server && isServerTab(tab))
|
||||
return { type: "server", server, tab, subtab: nested === "lsps" ? undefined : nested, ...transient }
|
||||
if (!project && !server && isRootTab(tab))
|
||||
return { type: "root", tab, subtab: nested === "lsps" ? undefined : nested, ...transient }
|
||||
return { type: "root", tab: "general", ...transient }
|
||||
}
|
||||
|
||||
export function settingsViewUrl(view: SettingsView) {
|
||||
const params = new URLSearchParams()
|
||||
if (view.type !== "root") params.set("server", view.server)
|
||||
if (view.type === "project") params.set("project", view.project)
|
||||
if (view.tab !== "general") params.set("tab", view.tab)
|
||||
if (view.tab === "extensions" && view.subtab) params.set("subtab", view.subtab)
|
||||
const search = params.toString()
|
||||
return search ? `/settings?${search}` : "/settings"
|
||||
}
|
||||
|
||||
export function isRootTab(value: string): value is SettingsRootTab {
|
||||
return Object.hasOwn(rootTabs, value)
|
||||
}
|
||||
|
||||
export function isServerTab(value: string): value is SettingsServerTab {
|
||||
return Object.hasOwn(serverTabs, value)
|
||||
}
|
||||
|
||||
export function isProjectTab(value: string): value is SettingsProjectTab {
|
||||
return Object.hasOwn(projectTabs, value)
|
||||
}
|
||||
|
||||
function isSubtab(value: string): value is NonNullable<SettingsView["subtab"]> {
|
||||
return Object.hasOwn(subtabs, value)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { createEffect, on, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import "@/settings/search.css"
|
||||
|
||||
export function SettingsSearchField(props: {
|
||||
value: string
|
||||
placeholder: string
|
||||
active: boolean
|
||||
onInput: (value: string) => void
|
||||
}) {
|
||||
const [state, setState] = createStore({ overflow: { start: false, end: false } })
|
||||
let root: HTMLDivElement | undefined
|
||||
let input: HTMLInputElement | undefined
|
||||
const updateOverflow = () => {
|
||||
if (!input) return
|
||||
const offset = Math.abs(input.scrollLeft)
|
||||
setState("overflow", {
|
||||
start: offset > 1,
|
||||
end: input.scrollWidth - input.clientWidth - offset > 1,
|
||||
})
|
||||
}
|
||||
const clear = () => {
|
||||
props.onInput("")
|
||||
input?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
createEffect(on(() => props.value, updateOverflow))
|
||||
onMount(() => {
|
||||
const screen = root?.closest<HTMLElement>(".settings-screen")
|
||||
if (!screen) return
|
||||
makeEventListener(screen, "keydown", (event) => {
|
||||
if (
|
||||
!props.active ||
|
||||
event.defaultPrevented ||
|
||||
event.isComposing ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.altKey ||
|
||||
event.key.length !== 1 ||
|
||||
!event.key.trim() ||
|
||||
(event.target instanceof Element && event.target.closest("input, textarea, select, [contenteditable=true]"))
|
||||
)
|
||||
return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const value = `${props.value}${event.key}`
|
||||
props.onInput(value)
|
||||
input?.focus({ preventScroll: true })
|
||||
queueMicrotask(() => input?.setSelectionRange(value.length, value.length))
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={root} class="settings-tab-search settings-filter-search" data-component="settings-filter-search">
|
||||
<TextInput
|
||||
ref={(element) => {
|
||||
input = element
|
||||
createResizeObserver(element, updateOverflow)
|
||||
}}
|
||||
type="search"
|
||||
appearance="base"
|
||||
leadingIcon={<Icon name="magnifying-glass" size="small" />}
|
||||
value={props.value}
|
||||
data-overflow-start={state.overflow.start}
|
||||
data-overflow-end={state.overflow.end}
|
||||
onScroll={updateOverflow}
|
||||
onInput={(event) => props.onInput(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (props.value) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
event.currentTarget.blur()
|
||||
event.currentTarget.closest<HTMLElement>(".settings-screen")?.focus({ preventScroll: true })
|
||||
}}
|
||||
placeholder={props.placeholder}
|
||||
aria-label={props.placeholder}
|
||||
showClearButton={!!props.value}
|
||||
clearIcon="circle-xmark"
|
||||
onClearClick={clear}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,12 +15,12 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:is(.settings-search, .settings-filter-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
|
||||
color: var(--v2-icon-icon-faint);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:is(.settings-search, .settings-filter-search)
|
||||
:is(.settings-search, .settings-projects-search)
|
||||
[data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) {
|
||||
background: transparent;
|
||||
color: var(--v2-icon-icon-base);
|
||||
@@ -38,19 +38,19 @@
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
:is(.settings-search, .settings-filter-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] {
|
||||
--mask-start: 16px;
|
||||
}
|
||||
|
||||
:is(.settings-search, .settings-filter-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] {
|
||||
--mask-end: 16px;
|
||||
}
|
||||
|
||||
:is(.settings-search, .settings-filter-search) [data-slot="text-input-v2-input"]:dir(rtl) {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"]:dir(rtl) {
|
||||
--mask-direction: to left;
|
||||
}
|
||||
|
||||
:is(.settings-search, .settings-filter-search)
|
||||
:is(.settings-search, .settings-projects-search)
|
||||
[data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
|
||||
mask-image: linear-gradient(
|
||||
var(--mask-direction, to right),
|
||||
|
||||
@@ -77,8 +77,6 @@
|
||||
}
|
||||
|
||||
.settings-nav > .settings-back {
|
||||
position: relative;
|
||||
inset-block-start: -20px;
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
|
||||
@@ -113,8 +111,8 @@
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"]:not(:has([data-selected])):hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
.settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"]:hover {
|
||||
background: var(--v2-background-bg-layer-03);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,106 +803,78 @@
|
||||
padding-inline-end: 28px;
|
||||
}
|
||||
|
||||
.settings-filter-search [data-component="text-input-v2"] {
|
||||
.settings-projects-search [data-component="text-input-v2"] {
|
||||
height: 36px;
|
||||
background: color-mix(in oklab, var(--v2-background-bg-layer-02) 60%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-filter-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) {
|
||||
.settings-projects-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-filter-search [data-slot="text-input-v2-input"] {
|
||||
.settings-projects-search [data-slot="text-input-v2-input"] {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.settings-filter-search [data-slot="text-input-v2-leading-icon"] {
|
||||
.settings-projects-search [data-slot="text-input-v2-leading-icon"] {
|
||||
padding-inline-start: 16px;
|
||||
}
|
||||
|
||||
.settings-filter-search [data-slot="text-input-v2-value"] {
|
||||
gap: 8px;
|
||||
.settings-projects-search [data-slot="text-input-v2-value"] {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-filter-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
|
||||
.settings-projects-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
|
||||
margin-inline-end: -4px;
|
||||
}
|
||||
|
||||
.settings-tab-search-empty {
|
||||
.settings-projects-empty {
|
||||
width: 60%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.settings-tab-search-empty .settings-search-empty {
|
||||
.settings-projects-empty .settings-search-empty {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.settings-tab-search-empty .settings-search-empty-quoted {
|
||||
.settings-projects-empty .settings-search-empty-quoted {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.settings-project-empty-card {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background: color-mix(
|
||||
in oklab,
|
||||
var(--v2-background-bg-base) 50%,
|
||||
var(--v2-background-bg-layer-01)
|
||||
);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
}
|
||||
|
||||
.settings-project-list {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
.settings-project-row {
|
||||
position: relative;
|
||||
margin-inline: 1px;
|
||||
border-radius: 8px;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
}
|
||||
|
||||
.settings-project-row-shell {
|
||||
position: relative;
|
||||
margin-inline: 4px;
|
||||
padding-block: 4px;
|
||||
}
|
||||
|
||||
.settings-project-row-shell:not(:last-child)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 12px;
|
||||
bottom: 0;
|
||||
height: 0.5px;
|
||||
background: var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-project-row-shell:only-child {
|
||||
margin-inline: 0;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.settings-project-row-shell:only-child .settings-project-card {
|
||||
height: 48px;
|
||||
padding-inline: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.settings-project-card {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-inline: 12px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 120ms;
|
||||
}
|
||||
|
||||
.settings-project-card:hover,
|
||||
.settings-project-card:focus-within,
|
||||
.settings-project-card[data-menu="true"] {
|
||||
.settings-project-row:hover {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-project-row-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 73px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
border-radius: inherit;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.settings-project-row-name {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-tab-search-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -1062,6 +1032,61 @@
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
.settings-keybind-button {
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 8px;
|
||||
margin: -6px -8px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.05px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings:
|
||||
"tnum" on,
|
||||
"lnum" on;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-keybind-button:hover {
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-keybind-button:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-keybind-button--active {
|
||||
color: var(--v2-text-text-faint);
|
||||
border-radius: 2px;
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-shortcuts-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-shortcuts-status-filter {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-servers-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1077,6 +1102,10 @@
|
||||
padding-block: 20px;
|
||||
}
|
||||
|
||||
.settings-server-connection .settings-servers-lead {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-servers-row:not(:last-child) {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
@@ -279,7 +280,7 @@ function RootSettings() {
|
||||
<SettingsNotifications />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="shortcuts" class="settings-panel">
|
||||
<SettingsKeybinds active={surface.view().tab === "shortcuts"} />
|
||||
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="experimental" class="settings-panel">
|
||||
<SettingsExperimental />
|
||||
@@ -293,7 +294,6 @@ function RootSettings() {
|
||||
<Tabs.Content value="projects" class="settings-panel">
|
||||
<SettingsProjects
|
||||
server={server}
|
||||
active={surface.view().tab === "projects"}
|
||||
onOpenProject={(project) =>
|
||||
surface.openProject({
|
||||
server: ServerConnection.key(server),
|
||||
@@ -312,7 +312,7 @@ function RootSettings() {
|
||||
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="models" class="settings-panel">
|
||||
<SettingsModels active={surface.view().tab === "models"} />
|
||||
<SettingsModels active={surface.view().tab === "models"} autofocus={!surface.search.state.selected} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="extensions" class="settings-panel">
|
||||
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
|
||||
@@ -376,7 +376,6 @@ function ServerSettings(props: { entry: SettingsServer }) {
|
||||
<Tabs.Content value="projects" class="settings-panel">
|
||||
<SettingsProjects
|
||||
server={server}
|
||||
active={surface.view().tab === "projects"}
|
||||
onOpenProject={(project) =>
|
||||
surface.openProject({
|
||||
server: props.entry.key,
|
||||
@@ -419,8 +418,10 @@ function ProjectSettings(props: { server: ServerConnection.Any; project: LocalPr
|
||||
{
|
||||
items: nestedProjectTabs.map((item) => ({
|
||||
...item,
|
||||
label: language.t(item.label),
|
||||
onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined,
|
||||
get label() {
|
||||
return item.value === "general" ? displayName(props.project) : language.t(item.label)
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
@@ -439,7 +440,6 @@ function ProjectSettings(props: { server: ServerConnection.Any; project: LocalPr
|
||||
server={props.server}
|
||||
project={props.project}
|
||||
onOpenServer={() => surface.replaceServer(ServerConnection.key(props.server))}
|
||||
onClose={() => surface.back()}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="workspaces" class="settings-panel">
|
||||
|
||||
@@ -5,20 +5,79 @@ import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useLayout, type LayoutRoute } from "@/shell/state/layout"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettingsServers } from "./servers/inventory"
|
||||
import {
|
||||
isProjectTab,
|
||||
isRootTab,
|
||||
isServerTab,
|
||||
parseSettingsView,
|
||||
settingsViewUrl,
|
||||
type SettingsProjectTab,
|
||||
type SettingsRootTab,
|
||||
type SettingsServerTab,
|
||||
type SettingsTransientView,
|
||||
type SettingsView,
|
||||
} from "./route"
|
||||
|
||||
export type { SettingsProjectTab, SettingsRootTab, SettingsServerTab, SettingsView } from "./route"
|
||||
export type SettingsRootTab =
|
||||
| "general"
|
||||
| "appearance"
|
||||
| "notifications"
|
||||
| "shortcuts"
|
||||
| "projects"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
| "models"
|
||||
| "extensions"
|
||||
| "servers"
|
||||
| "experimental"
|
||||
| "about"
|
||||
|
||||
export type SettingsServerTab = "general" | "projects" | "workspaces" | "providers" | "models" | "extensions"
|
||||
export type SettingsProjectTab = "general" | "workspaces" | "extensions"
|
||||
|
||||
export type SettingsView = (
|
||||
| { type: "root"; tab: SettingsRootTab }
|
||||
| { type: "server"; server: string; tab: SettingsServerTab }
|
||||
| {
|
||||
type: "project"
|
||||
server: string
|
||||
project: string
|
||||
tab: SettingsProjectTab
|
||||
parent: "root" | "server"
|
||||
}
|
||||
) & {
|
||||
target?: string
|
||||
subtab?: "mcps" | "plugins" | "skills" | "lsps"
|
||||
searchActivation?: number
|
||||
}
|
||||
|
||||
const rootTabs: Record<SettingsRootTab, true> = {
|
||||
general: true,
|
||||
appearance: true,
|
||||
notifications: true,
|
||||
shortcuts: true,
|
||||
projects: true,
|
||||
workspaces: true,
|
||||
providers: true,
|
||||
models: true,
|
||||
extensions: true,
|
||||
servers: true,
|
||||
experimental: true,
|
||||
about: true,
|
||||
}
|
||||
const serverTabs: Record<SettingsServerTab, true> = {
|
||||
general: true,
|
||||
projects: true,
|
||||
workspaces: true,
|
||||
providers: true,
|
||||
models: true,
|
||||
extensions: true,
|
||||
}
|
||||
const projectTabs: Record<SettingsProjectTab, true> = {
|
||||
general: true,
|
||||
workspaces: true,
|
||||
extensions: true,
|
||||
}
|
||||
|
||||
function isRootTab(value: string): value is SettingsRootTab {
|
||||
return value in rootTabs
|
||||
}
|
||||
|
||||
function isServerTab(value: string): value is SettingsServerTab {
|
||||
return value in serverTabs
|
||||
}
|
||||
|
||||
function isProjectTab(value: string): value is SettingsProjectTab {
|
||||
return value in projectTabs
|
||||
}
|
||||
|
||||
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
|
||||
name: "SettingsSurface",
|
||||
@@ -29,11 +88,11 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
|
||||
const command = useCommand()
|
||||
const servers = useSettingsServers()
|
||||
const location = useLocation<{
|
||||
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; view?: SettingsTransientView }
|
||||
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; view: SettingsView }
|
||||
}>()
|
||||
const open = () => layout.route().type === "settings"
|
||||
const source = () => location.state?.settings?.route ?? { type: "home" as const }
|
||||
const view = () => parseSettingsView(location.search, servers().length > 1, location.state?.settings?.view)
|
||||
const view = (): SettingsView => location.state?.settings?.view ?? { type: "root", tab: "general" }
|
||||
const [search, setSearch] = createStore({
|
||||
query: "",
|
||||
origin: undefined as SettingsView | undefined,
|
||||
@@ -45,17 +104,12 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
|
||||
})
|
||||
let focus: HTMLElement | undefined
|
||||
|
||||
const show = (view: SettingsView) => {
|
||||
const show = (view: SettingsView, replace: boolean) => {
|
||||
const route = layout.route()
|
||||
if (route.type !== "settings" && document.activeElement instanceof HTMLElement) focus = document.activeElement
|
||||
navigate(settingsViewUrl(view), {
|
||||
replace: route.type === "settings",
|
||||
state: {
|
||||
settings: {
|
||||
route: route.type === "settings" ? source() : route,
|
||||
view: { target: view.target, searchActivation: view.searchActivation },
|
||||
},
|
||||
},
|
||||
navigate("/settings", {
|
||||
replace,
|
||||
state: { settings: { route: route.type === "settings" ? source() : route, view } },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,7 +148,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
|
||||
},
|
||||
open(destination: SettingsView, id: string) {
|
||||
batch(() => {
|
||||
show({ ...destination, searchActivation: search.activation + 1 })
|
||||
show({ ...destination, searchActivation: search.activation + 1 }, true)
|
||||
setSearch({ selected: id, highlighted: id, expanded: false, activation: search.activation + 1 })
|
||||
})
|
||||
},
|
||||
@@ -103,27 +157,30 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
|
||||
},
|
||||
back() {
|
||||
if (!search.query.trim() || !search.selected || !search.origin) return false
|
||||
show(search.origin)
|
||||
show(search.origin, true)
|
||||
setSearch({ selected: "", expanded: true })
|
||||
return true
|
||||
},
|
||||
},
|
||||
open(tab: SettingsRootTab = "general") {
|
||||
show({ type: "root", tab })
|
||||
show({ type: "root", tab }, open())
|
||||
},
|
||||
openServer(server: string, tab: SettingsServerTab = "general") {
|
||||
show({ type: "server", server, tab })
|
||||
show({ type: "server", server, tab }, false)
|
||||
},
|
||||
replaceServer(server: string, tab: SettingsServerTab = "general") {
|
||||
show({ type: "server", server, tab })
|
||||
show({ type: "server", server, tab }, true)
|
||||
},
|
||||
openProject(input: { server: string; project: string; tab?: SettingsProjectTab }) {
|
||||
show({
|
||||
type: "project",
|
||||
...input,
|
||||
parent: servers().length > 1 ? "server" : "root",
|
||||
tab: input.tab ?? "general",
|
||||
})
|
||||
show(
|
||||
{
|
||||
type: "project",
|
||||
...input,
|
||||
parent: servers().length > 1 ? "server" : "root",
|
||||
tab: input.tab ?? "general",
|
||||
},
|
||||
false,
|
||||
)
|
||||
},
|
||||
select(tab: string) {
|
||||
const current = view()
|
||||
@@ -135,10 +192,10 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
|
||||
: current.type === "project" && isProjectTab(tab)
|
||||
? { ...current, tab }
|
||||
: current
|
||||
show({ ...next, target: undefined, subtab: undefined })
|
||||
show({ ...next, target: undefined, subtab: undefined }, true)
|
||||
},
|
||||
subtab(subtab: SettingsView["subtab"]) {
|
||||
show({ ...view(), subtab, target: undefined })
|
||||
show({ ...view(), subtab, target: undefined }, true)
|
||||
},
|
||||
back() {
|
||||
const current = view()
|
||||
@@ -150,7 +207,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
|
||||
current.type === "server" || current.parent === "root"
|
||||
? { type: "root", tab: current.type === "server" ? "general" : "projects" }
|
||||
: { type: "server", server: current.server, tab: "projects" }
|
||||
show(parent)
|
||||
show(parent, true)
|
||||
},
|
||||
close() {
|
||||
if (open()) command.trigger("common.goBack")
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
[data-slot="timeline-detail-scale"] {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
margin-top: 16px;
|
||||
margin-top: 24px;
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
@@ -58,10 +58,6 @@
|
||||
border-radius: 50%;
|
||||
background: var(--timeline-detail-marker-background);
|
||||
|
||||
&[data-selected] {
|
||||
background: var(--v2-background-bg-accent);
|
||||
}
|
||||
|
||||
&:dir(rtl) {
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
@@ -78,6 +74,7 @@
|
||||
margin: 0;
|
||||
margin-inline: -8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus-visible {
|
||||
@@ -130,7 +127,7 @@
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-advanced"] {
|
||||
margin-top: 0;
|
||||
margin-top: 20px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
@@ -236,7 +233,7 @@
|
||||
[data-slot="timeline-detail-activity"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
|
||||
> label {
|
||||
@@ -260,24 +257,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@container timeline-detail (min-width: 311px) {
|
||||
[data-component="timeline-detail-control"]
|
||||
[data-slot="timeline-detail-advanced"]:has(> [data-slot="collapsible-trigger"][aria-expanded="true"]) {
|
||||
> [data-slot="collapsible-trigger"] {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
> [data-slot="collapsible-content"] {
|
||||
margin-top: -24px;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-columns"] {
|
||||
min-height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="timeline-detail-control"] {
|
||||
--timeline-detail-track-background: var(--v2-background-bg-layer-03);
|
||||
--timeline-detail-marker-background:
|
||||
|
||||
@@ -51,12 +51,7 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
|
||||
style={{ "--timeline-detail-progress": `${(position() / (presets.length - 1)) * 100}%` }}
|
||||
>
|
||||
<For each={presets}>
|
||||
{(_, index) => (
|
||||
<span
|
||||
data-selected={index() <= position() ? "" : undefined}
|
||||
style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
{(_, index) => <span style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }} />}
|
||||
</For>
|
||||
</div>
|
||||
<input
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Show, type Component } from "solid-js"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { displayName, errorMessage } from "@/shell/layout/helpers"
|
||||
import { fileManagerApp } from "@/home/projects/file-manager"
|
||||
import { revealProject } from "@/home/projects/reveal"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
|
||||
export const ProjectOptions: Component<{
|
||||
server: ServerConnection.Any
|
||||
project: LocalProject
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onEdit?: () => void
|
||||
onRename?: () => void
|
||||
onClose?: () => void
|
||||
class?: string
|
||||
size?: "small" | "normal" | "large"
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const platform = usePlatform()
|
||||
const context = () => global.ensureServerCtx(props.server)
|
||||
const canReveal = () =>
|
||||
platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(props.server)
|
||||
const unseen = () =>
|
||||
[props.project.worktree, ...(props.project.sandboxes ?? [])].reduce(
|
||||
(total, directory) => total + context().notification.project.unseenCount(directory),
|
||||
0,
|
||||
)
|
||||
const reveal = () => {
|
||||
if (!platform.revealPath || !canReveal()) return
|
||||
void revealProject({
|
||||
directory: props.project.worktree,
|
||||
reveal: platform.revealPath,
|
||||
remove: context().projects.remove,
|
||||
})
|
||||
.then((revealed) => {
|
||||
if (revealed) return
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("home.project.missing.title"),
|
||||
description: language.t("home.project.missing.description", { name: displayName(props.project) }),
|
||||
})
|
||||
})
|
||||
.catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const clearNotifications = () => {
|
||||
const notification = context().notification
|
||||
const directories = [props.project.worktree, ...(props.project.sandboxes ?? [])]
|
||||
directories
|
||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
}
|
||||
const close = () => {
|
||||
context().projects.close(props.project.worktree)
|
||||
props.onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
gutter={4}
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
variant="ghost-muted"
|
||||
size={props.size ?? "small"}
|
||||
class={props.class}
|
||||
icon={<Icon name="outline-dots" />}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Show when={props.onEdit} keyed>
|
||||
{(edit) => <Menu.Item onSelect={edit}>{language.t("common.edit")}</Menu.Item>}
|
||||
</Show>
|
||||
<Show when={props.onRename} keyed>
|
||||
{(rename) => <Menu.Item onSelect={rename}>{language.t("common.rename")}</Menu.Item>}
|
||||
</Show>
|
||||
<Show when={canReveal()}>
|
||||
<Menu.Item onSelect={reveal}>{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item disabled={unseen() === 0} onSelect={clearNotifications}>
|
||||
{language.t("sidebar.project.clearNotifications")}
|
||||
</Menu.Item>
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={close}>{language.t("common.close")}</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Show } from "solid-js"
|
||||
import { Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { InlineInput } from "@opencode/ui/inline-input"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { displayName, errorMessage } from "@/shell/layout/helpers"
|
||||
import { fileManagerApp } from "@/home/projects/file-manager"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { ProjectOptions } from "./project-options"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
|
||||
export function SettingsProjectRow(props: {
|
||||
project: LocalProject
|
||||
@@ -17,13 +20,21 @@ export function SettingsProjectRow(props: {
|
||||
onOpen: (project: LocalProject) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const global = useGlobal()
|
||||
const [store, setStore] = createStore({
|
||||
menu: false,
|
||||
menu: undefined as { x: number; y: number } | undefined,
|
||||
editor: undefined as { draft: string; saving: boolean } | undefined,
|
||||
})
|
||||
let row: HTMLDivElement | undefined
|
||||
let button: HTMLButtonElement | undefined
|
||||
let input: HTMLInputElement | undefined
|
||||
let outside = false
|
||||
const openMenu = (x: number, y: number) => {
|
||||
if (!row) return
|
||||
const bounds = row.getBoundingClientRect()
|
||||
setStore("menu", { x: x - bounds.left, y: y - bounds.top })
|
||||
}
|
||||
const openEditor = () => {
|
||||
setStore("editor", { draft: displayName(props.project), saving: false })
|
||||
requestAnimationFrame(() => {
|
||||
@@ -53,11 +64,11 @@ export function SettingsProjectRow(props: {
|
||||
: Promise.resolve(context.sync.project.meta(props.project.worktree, { name: value }))
|
||||
)
|
||||
.then(() => true)
|
||||
.catch((cause: unknown) => {
|
||||
.catch((error: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
return false
|
||||
})
|
||||
@@ -69,26 +80,29 @@ export function SettingsProjectRow(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="settings-project-row-shell">
|
||||
<div
|
||||
role="listitem"
|
||||
data-component="settings-project-card"
|
||||
data-menu={store.menu ? "true" : undefined}
|
||||
class="settings-project-card"
|
||||
>
|
||||
<Show
|
||||
when={!store.editor}
|
||||
fallback={
|
||||
<div class="flex h-full min-w-0 flex-1 items-center gap-2">
|
||||
<ProjectIcon project={props.project} class="shrink-0" />
|
||||
<div
|
||||
ref={row}
|
||||
data-component="settings-project-row"
|
||||
class="settings-project-row group"
|
||||
onContextMenu={(event) => {
|
||||
if (store.editor) return
|
||||
event.preventDefault()
|
||||
openMenu(event.clientX, event.clientY)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={!store.editor}
|
||||
fallback={
|
||||
<div class="settings-project-row-content">
|
||||
<ProjectRowContent project={props.project}>
|
||||
<InlineInput
|
||||
ref={input}
|
||||
aria-label={language.t("common.rename")}
|
||||
dir="auto"
|
||||
value={store.editor?.draft ?? ""}
|
||||
disabled={store.editor?.saving}
|
||||
class="w-full text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none"
|
||||
style={{ "--inline-input-shadow": "none", "border-radius": "0", "text-align": "start" }}
|
||||
class="settings-project-row-name w-full outline-none"
|
||||
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
|
||||
onInput={(event) => setStore("editor", "draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -105,31 +119,112 @@ export function SettingsProjectRow(props: {
|
||||
}}
|
||||
onBlur={closeEditor}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</ProjectRowContent>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
ref={button}
|
||||
type="button"
|
||||
aria-label={displayName(props.project)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={!!store.menu}
|
||||
class="settings-project-row-content"
|
||||
onClick={() => props.onOpen(props.project)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
|
||||
event.preventDefault()
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
openMenu(bounds.left + 12, bounds.bottom)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
ref={button}
|
||||
type="button"
|
||||
aria-label={displayName(props.project)}
|
||||
class="flex h-full min-w-0 flex-1 items-center gap-2 rounded-[4px] bg-transparent text-start focus-visible:outline-none focus-visible:[box-shadow:inset_0_0_0_1px_var(--v2-border-border-focus)]"
|
||||
onClick={() => props.onOpen(props.project)}
|
||||
>
|
||||
<ProjectIcon project={props.project} class="shrink-0" />
|
||||
<bdi class="truncate text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
{displayName(props.project)}
|
||||
</bdi>
|
||||
</button>
|
||||
<ProjectOptions
|
||||
server={props.server}
|
||||
project={props.project}
|
||||
open={store.menu}
|
||||
onOpenChange={(open) => setStore("menu", open)}
|
||||
onEdit={() => props.onOpen(props.project)}
|
||||
onRename={openEditor}
|
||||
<ProjectRowContent project={props.project}>
|
||||
<bdi class="settings-project-row-name truncate">{displayName(props.project)}</bdi>
|
||||
</ProjectRowContent>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
size="small"
|
||||
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</Show>
|
||||
<Menu
|
||||
modal={false}
|
||||
placement="bottom-start"
|
||||
gutter={2}
|
||||
open={!!store.menu}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setStore("menu", undefined)
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as="span"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
class="pointer-events-none absolute size-px"
|
||||
style={{ left: `${store.menu?.x ?? 0}px`, top: `${store.menu?.y ?? 0}px` }}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
onInteractOutside={() => {
|
||||
outside = true
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
const restore = !outside && !store.editor
|
||||
outside = false
|
||||
if (restore) requestAnimationFrame(() => button?.focus())
|
||||
}}
|
||||
>
|
||||
<Menu.Item onSelect={openEditor}>{language.t("common.rename")}</Menu.Item>
|
||||
<Show
|
||||
when={platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)}
|
||||
>
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
if (!platform.openPath) return
|
||||
void platform.openPath(props.project.worktree).catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}}
|
||||
>
|
||||
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Separator />
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
const next = row?.nextElementSibling ?? row?.previousElementSibling
|
||||
global.ensureServerCtx(props.server).projects.close(props.project.worktree)
|
||||
requestAnimationFrame(() => next?.querySelector("button")?.focus())
|
||||
}}
|
||||
>
|
||||
{language.t("common.close")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectRowContent(props: { project: LocalProject; children: JSX.Element }) {
|
||||
return (
|
||||
<span class="flex items-start gap-2.5 min-w-0 flex-1">
|
||||
<ProjectIcon project={props.project} class="shrink-0" />
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
{props.children}
|
||||
<bdi
|
||||
dir="ltr"
|
||||
class="text-11-regular leading-[var(--line-height-compact)] text-v2-text-text-muted truncate"
|
||||
title={props.project.worktree}
|
||||
>
|
||||
{props.project.worktree}
|
||||
</bdi>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
import { useSettingsServers } from "@/settings/servers/inventory"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { ProjectOptions } from "./project-options"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import { createEditProjectModel } from "./project-model"
|
||||
@@ -19,7 +18,6 @@ export const SettingsProjectGeneral: Component<{
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
onOpenServer: () => void
|
||||
onClose: () => void
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const model = createEditProjectModel(props)
|
||||
@@ -29,29 +27,16 @@ export const SettingsProjectGeneral: Component<{
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<ProjectIcon
|
||||
project={props.project}
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
icon={{
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: model.store.iconOverride,
|
||||
}}
|
||||
class="!size-8 shrink-0 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[16px]"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h2 class="settings-tab-title truncate">
|
||||
<bdi dir="auto">{model.store.name || displayName(props.project)}</bdi>
|
||||
</h2>
|
||||
<Show when={servers().length > 1}>
|
||||
<button type="button" class="project-settings-server-link" onClick={() => props.onOpenServer()}>
|
||||
<bdi dir="auto">{serverName(props.server) || ServerConnection.key(props.server)}</bdi>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h2 class="settings-tab-title truncate">
|
||||
<bdi dir="auto">{model.store.name || displayName(props.project)}</bdi>
|
||||
</h2>
|
||||
<Show when={servers().length > 1}>
|
||||
<button type="button" class="project-settings-server-link" onClick={() => props.onOpenServer()}>
|
||||
<bdi dir="auto">{serverName(props.server) || ServerConnection.key(props.server)}</bdi>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<ProjectOptions server={props.server} project={props.project} size="large" onClose={props.onClose} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
import { Show, createEffect, createMemo, type Component } from "solid-js"
|
||||
import { Show, createEffect, createMemo, on, type Component } from "solid-js"
|
||||
import { Key } from "@solid-primitives/keyed"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { displayName, homeProjectDirectories } from "@/shell/layout/helpers"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
import { addProjects } from "@/home/projects/add"
|
||||
import { settingsProjects } from "../servers/inventory"
|
||||
import { SettingsSearchEmpty } from "../search-empty"
|
||||
import { SettingsSearchField } from "../search-field"
|
||||
import { settingsProjects } from "../servers/inventory"
|
||||
import { SettingsProjectRow } from "./project-row"
|
||||
import "@/settings/search.css"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
export const SettingsProjects: Component<{
|
||||
server: ServerConnection.Any
|
||||
active: boolean
|
||||
onOpenProject: (project: LocalProject) => void
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const context = createMemo(() => global.ensureServerCtx(props.server))
|
||||
const projects = createMemo(() => settingsProjects(context()))
|
||||
const [store, setStore] = createStore({ filter: "", overflow: { start: false, end: false } })
|
||||
let search: HTMLInputElement | undefined
|
||||
const updateOverflow = () => {
|
||||
if (!search) return
|
||||
const offset = Math.abs(search.scrollLeft)
|
||||
setStore("overflow", {
|
||||
start: offset > 1,
|
||||
end: search.scrollWidth - search.clientWidth - offset > 1,
|
||||
})
|
||||
}
|
||||
createEffect(on(() => store.filter, updateOverflow))
|
||||
const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server)))
|
||||
const searchable = createMemo(() => projects().length > 7)
|
||||
const filtered = createMemo(() => {
|
||||
const query = searchable() ? store.filter.trim().toLowerCase() : ""
|
||||
@@ -36,23 +41,6 @@ export const SettingsProjects: Component<{
|
||||
createEffect(() => {
|
||||
if (!searchable()) setStore("filter", "")
|
||||
})
|
||||
const addProject = () =>
|
||||
pickDirectory({
|
||||
server: props.server,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: (result) => {
|
||||
const directories = homeProjectDirectories(result)
|
||||
const directory = addProjects(context(), directories)
|
||||
if (!directory) return
|
||||
if (directories.length > 1) return
|
||||
const project = context()
|
||||
.projects.list()
|
||||
.find((item) => item.worktree === directory)
|
||||
if (!project) return
|
||||
props.onOpenProject(project)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -62,19 +50,36 @@ export const SettingsProjects: Component<{
|
||||
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
|
||||
</div>
|
||||
<Show when={projects().length > 0}>
|
||||
<Button variant="ghost-muted" icon="plus" onClick={addProject}>
|
||||
{language.t("home.project.add")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={searchable()}>
|
||||
<SettingsSearchField
|
||||
value={store.filter}
|
||||
active={props.active}
|
||||
onInput={(value) => setStore("filter", value)}
|
||||
placeholder={language.t("settings.projects.search.placeholder")}
|
||||
/>
|
||||
<div class="settings-tab-search settings-projects-search">
|
||||
<TextInput
|
||||
ref={(element) => {
|
||||
search = element
|
||||
createResizeObserver(element, updateOverflow)
|
||||
}}
|
||||
type="search"
|
||||
appearance="base"
|
||||
leadingIcon={<Icon name="magnifying-glass" size="small" />}
|
||||
value={store.filter}
|
||||
data-overflow-start={store.overflow.start}
|
||||
data-overflow-end={store.overflow.end}
|
||||
onScroll={updateOverflow}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
placeholder={language.t("settings.projects.search.placeholder")}
|
||||
aria-label={language.t("settings.projects.search.placeholder")}
|
||||
showClearButton={!!store.filter}
|
||||
clearIcon="circle-xmark"
|
||||
onClearClick={() => {
|
||||
setStore("filter", "")
|
||||
search?.focus({ preventScroll: true })
|
||||
}}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -83,41 +88,20 @@ export const SettingsProjects: Component<{
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<Show
|
||||
when={!store.filter.trim() && projects().length === 0}
|
||||
when={store.filter.trim()}
|
||||
fallback={
|
||||
<Show
|
||||
when={store.filter.trim()}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="settings-tab-search-empty">
|
||||
<SettingsSearchEmpty query={store.filter} />
|
||||
</div>
|
||||
</Show>
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-component="settings-project-empty-card"
|
||||
class="settings-project-empty-card flex flex-col items-center gap-2 py-24 text-center"
|
||||
>
|
||||
<Icon name="folder" size="large" class="mb-2 text-v2-icon-icon-muted" />
|
||||
<div class="text-13-medium text-v2-text-text-base">
|
||||
{language.t("settings.projects.empty.title")}
|
||||
</div>
|
||||
<div class="text-13-regular text-v2-text-text-muted">
|
||||
{language.t("settings.projects.empty.description")}
|
||||
</div>
|
||||
<Button variant="neutral" icon="plus" class="mt-6" onClick={addProject}>
|
||||
{language.t("home.project.add")}
|
||||
</Button>
|
||||
<div class="settings-projects-empty">
|
||||
<SettingsSearchEmpty query={store.filter} />
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div role="list" class="settings-project-list">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<Key each={filtered()} by="worktree">
|
||||
{(project) => (
|
||||
<SettingsProjectRow project={project()} server={props.server} onOpen={props.onOpenProject} />
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
|
||||
|
||||
@@ -11,21 +10,21 @@ test("settings has its own layout route", () => {
|
||||
})
|
||||
|
||||
describe("layout persistence", () => {
|
||||
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
const schema = Codec.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
|
||||
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
|
||||
|
||||
test("uses supplied initial preferences after legacy migration", () => {
|
||||
const initial = initialLayout(ServerConnection.Key.make("remote"))
|
||||
initial.sidebar.width = 420
|
||||
initial.fileTree.width = 300
|
||||
initial.review.panelOpened = true
|
||||
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initial))
|
||||
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(layoutPersistence, initial), input)
|
||||
expect(restore({})).toEqual(initial)
|
||||
expect(restore({ sidebar: { width: "bad" } }).sidebar.width).toBe(420)
|
||||
expect(restore({ fileTree: { width: 260 } }).fileTree.width).toBe(200)
|
||||
expect(restore({ fileTree: {} }).fileTree.width).toBe(300)
|
||||
expect(restore({ review: {}, fileTree: { opened: false } }).review.panelOpened).toBe(false)
|
||||
expect(() => Schema.decodeUnknownSync(layoutSchema)({})).toThrow()
|
||||
expect(() => Codec.decodeOrThrow(layoutSchema, {})).toThrow()
|
||||
})
|
||||
|
||||
test("restores shipped defaults for missing and invalid fields", () => {
|
||||
@@ -56,8 +55,8 @@ describe("layout persistence", () => {
|
||||
expect(value.sidebar).toEqual({ opened: false, width: 344, workspaces: {}, workspacesDefault: true })
|
||||
expect(value.review).toEqual({ diffStyle: "split", panelOpened: true })
|
||||
expect(value.fileTree).toEqual({ opened: true, width: 200, tab: "changes" })
|
||||
expect(Schema.encodeSync(schema)(value)).toEqual(value)
|
||||
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
|
||||
expect(schema.encode(value)).toEqual(value)
|
||||
expect(decode(schema.encode(value))).toEqual(value)
|
||||
expect(decode({ fileTree: { opened: true } }).review.panelOpened).toBe(false)
|
||||
})
|
||||
|
||||
@@ -169,3 +168,4 @@ describe("pruneSessionKeys", () => {
|
||||
expect(drop).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,63 +1,58 @@
|
||||
export * as TabStorage from "./schema"
|
||||
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { ServerKey } from "@/runtime/server/persistence"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { ServerKey } from "@/runtime/server/key"
|
||||
|
||||
export { ServerKey }
|
||||
|
||||
export const Session = Persistence.struct({
|
||||
type: Schema.Literal("session"),
|
||||
export const Session = Codec.struct({
|
||||
type: Codec.literal("session"),
|
||||
server: ServerKey,
|
||||
sessionId: Schema.String,
|
||||
routeSessionId: Persistence.optional(Schema.String),
|
||||
routeParentId: Persistence.optional(Schema.String),
|
||||
sessionId: Codec.string,
|
||||
routeSessionId: Codec.lenientOptional(Codec.string),
|
||||
routeParentId: Codec.lenientOptional(Codec.string),
|
||||
})
|
||||
|
||||
export const Draft = Persistence.struct({
|
||||
type: Schema.Literal("draft"),
|
||||
draftID: Schema.String,
|
||||
export const Draft = Codec.struct({
|
||||
type: Codec.literal("draft"),
|
||||
draftID: Codec.string,
|
||||
server: ServerKey,
|
||||
directory: Schema.String,
|
||||
worktree: Persistence.optional(Schema.String),
|
||||
branch: Persistence.optional(Schema.String),
|
||||
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
|
||||
directory: Codec.string,
|
||||
worktree: Codec.lenientOptional(Codec.string),
|
||||
branch: Codec.lenientOptional(Codec.string),
|
||||
mcp: Codec.lenientOptional(Codec.struct({ target: Codec.string, states: Codec.lenientRecord(Codec.boolean) })),
|
||||
})
|
||||
|
||||
const SessionCodec = Session.pipe(
|
||||
Schema.decodeTo(Schema.toType(Session), {
|
||||
decode: SchemaGetter.transform((tab) => ({
|
||||
type: tab.type,
|
||||
server: tab.server,
|
||||
sessionId: tab.sessionId,
|
||||
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
|
||||
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
|
||||
: {}),
|
||||
})),
|
||||
encode: SchemaGetter.transform((tab) => tab),
|
||||
// A stored route that only repeats the session id carries nothing; drop it and its parent.
|
||||
const SessionCodec = Codec.transform(Session, {
|
||||
decode: (tab) => ({
|
||||
type: tab.type,
|
||||
server: tab.server,
|
||||
sessionId: tab.sessionId,
|
||||
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
|
||||
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
|
||||
: {}),
|
||||
}),
|
||||
encode: (tab) => tab,
|
||||
})
|
||||
|
||||
export const Tab = Codec.union([Session, Draft])
|
||||
export const Tabs = Codec.lenientArray(Codec.union([SessionCodec, Draft]))
|
||||
export const Recent = Codec.struct({
|
||||
key: Codec.optional(Codec.string),
|
||||
})
|
||||
export const Info = Codec.struct({
|
||||
title: Codec.optional(Codec.string),
|
||||
directory: Codec.optional(Codec.string),
|
||||
})
|
||||
export const Infos = Codec.record(Info)
|
||||
export const Panes = Codec.record(
|
||||
Codec.struct({
|
||||
terminal: Codec.optional(Codec.boolean),
|
||||
review: Codec.optional(Codec.boolean),
|
||||
terminalHeight: Codec.optional(Codec.number),
|
||||
sessionWidth: Codec.optional(Codec.number),
|
||||
}),
|
||||
)
|
||||
|
||||
export const Tab = Schema.Union([Session, Draft])
|
||||
export const Tabs = Persistence.array(Schema.Union([SessionCodec, Draft]))
|
||||
export const Recent = Persistence.struct({
|
||||
key: Schema.optional(Schema.String),
|
||||
})
|
||||
export const Info = Persistence.struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
directory: Schema.optional(Schema.String),
|
||||
})
|
||||
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
|
||||
export const Panes = Schema.Record(
|
||||
Schema.String,
|
||||
Schema.mutableKey(
|
||||
Persistence.struct({
|
||||
terminal: Schema.optional(Schema.Boolean),
|
||||
review: Schema.optional(Schema.Boolean),
|
||||
terminalHeight: Schema.optional(Schema.Finite),
|
||||
sessionWidth: Schema.optional(Schema.Finite),
|
||||
}),
|
||||
),
|
||||
)
|
||||
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
|
||||
export const Closed = Persistence.array(ClosedTab)
|
||||
export const ClosedTab = Codec.struct({ tab: SessionCodec, index: Codec.nonNegativeInt })
|
||||
export const Closed = Codec.lenientArray(ClosedTab)
|
||||
|
||||
@@ -3,13 +3,12 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createTabMemory } from "./memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
|
||||
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
|
||||
import { Schema } from "effect"
|
||||
import { TabStorage } from "./schema"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
|
||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||
const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, []))
|
||||
const decodeTabs = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Tabs, []), input))
|
||||
|
||||
function sessionTab(sessionId: string): SessionTab {
|
||||
return { type: "session", server, sessionId }
|
||||
@@ -26,7 +25,7 @@ describe("tab migration", () => {
|
||||
}
|
||||
const restored = decodeTabs([legacy, draft])
|
||||
expect(restored).toEqual([legacy, draft])
|
||||
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
|
||||
expect(decodeTabs(TabStorage.Tabs.encode(restored))).toEqual([legacy, draft])
|
||||
})
|
||||
|
||||
test("drops null and malformed persisted tabs", () => {
|
||||
@@ -64,13 +63,13 @@ describe("tab migration", () => {
|
||||
draft,
|
||||
])
|
||||
expect(tabs).toEqual([sessionTab("root"), draft])
|
||||
expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs)
|
||||
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs)
|
||||
expect(TabStorage.Tabs.encode(tabs)).toEqual(tabs)
|
||||
expect(decodeTabs(TabStorage.Tabs.encode(tabs))).toEqual(tabs)
|
||||
})
|
||||
|
||||
test("salvages valid closed session tabs", () => {
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([
|
||||
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Closed, []), input))([
|
||||
{ tab: sessionTab("a"), index: 1 },
|
||||
{ tab: sessionTab("b"), index: -1 },
|
||||
{ tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 },
|
||||
@@ -81,16 +80,16 @@ describe("tab migration", () => {
|
||||
|
||||
test("validates auxiliary tab state", () => {
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }),
|
||||
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Recent, { key: undefined }), input))({ key: 1 }),
|
||||
).toEqual({ key: undefined })
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({})
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({})
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({
|
||||
expect(Codec.decodeOrThrow(TabStorage.Infos, {})).toEqual({})
|
||||
expect(Codec.decodeOrThrow(TabStorage.Panes, {})).toEqual({})
|
||||
expect(Codec.decodeOrThrow(TabStorage.Infos, { tab: { title: "Title", directory: "/project" } })).toEqual({
|
||||
tab: { title: "Title", directory: "/project" },
|
||||
})
|
||||
const panes = Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(Schema.encodeSync(TabStorage.Panes)(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(() => Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: "yes" } })).toThrow()
|
||||
const panes = Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(TabStorage.Panes.encode(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(() => Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: "yes" } })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -209,3 +208,5 @@ describe("closed tab stack", () => {
|
||||
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -79,16 +79,4 @@ describe("titlebar history", () => {
|
||||
expect(back.to.url).toBe("/")
|
||||
expect(forwardPath(applyPath(back.state, back.to))?.to.state).toEqual({ tab: "models" })
|
||||
})
|
||||
|
||||
test("settings pages replace one history entry", () => {
|
||||
const initial = applyPath(history(), { url: "/settings" })
|
||||
const models = applyPath(initial, { url: "/settings?tab=models" })
|
||||
const project = applyPath(models, { url: "/settings?server=local&project=%2Fwork&tab=extensions" })
|
||||
|
||||
expect(project.stack.map((entry) => entry.url)).toEqual([
|
||||
"/",
|
||||
"/settings?server=local&project=%2Fwork&tab=extensions",
|
||||
])
|
||||
expect(backPath(project)?.to.url).toBe("/")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,9 +21,8 @@ export function applyPath(
|
||||
}
|
||||
|
||||
const active = state.stack[state.index]
|
||||
const sameUrl = current.url === active.url
|
||||
if (sameUrl || (isSettingsPath(current.url) && isSettingsPath(active.url))) {
|
||||
if (sameUrl && !state.action && current.state === active.state) return state
|
||||
if (current.url === active.url) {
|
||||
if (!state.action && current.state === active.state) return state
|
||||
return {
|
||||
...state,
|
||||
stack: state.stack.map((entry, index) => (index === state.index ? current : entry)),
|
||||
@@ -36,10 +35,6 @@ export function applyPath(
|
||||
return pushPath(state, current, max)
|
||||
}
|
||||
|
||||
function isSettingsPath(url: string) {
|
||||
return url.split(/[?#]/, 1)[0] === "/settings"
|
||||
}
|
||||
|
||||
export function pushPath(state: TitlebarHistory, path: HistoryLocation, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
|
||||
const stack = state.stack.slice(0, state.index + 1).concat(path)
|
||||
const next = trimHistory(stack, stack.length - 1, max)
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-titlebar-tab],
|
||||
[data-titlebar-tab-action] {
|
||||
[data-titlebar-tab] {
|
||||
--tab-base: var(--v2-background-bg-deep);
|
||||
--tab-overlay: transparent;
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
@@ -69,14 +68,6 @@
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-action]:is(:hover, :focus-visible) {
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-action]:is(:active, [data-state="pressed"]) {
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
}
|
||||
|
||||
@@ -351,10 +351,9 @@ export function Titlebar(props: {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-home"
|
||||
data-state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base data-[state=pressed]:text-v2-text-text-base"
|
||||
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
|
||||
onClick={toggleHome}
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
@@ -651,9 +650,8 @@ export function Titlebar(props: {
|
||||
{homeButton(true)}
|
||||
<button
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
artifactKind,
|
||||
bytesToBase64,
|
||||
contentBytes,
|
||||
fileContentFromBytes,
|
||||
MAX_MEDIA_BYTES,
|
||||
parseDelimited,
|
||||
resolveArtifactPath,
|
||||
} from "./artifact"
|
||||
|
||||
describe("artifactKind", () => {
|
||||
test.each([
|
||||
["shot.PNG", "image"],
|
||||
["logo.svg", "svg"],
|
||||
["song.mp3", "audio"],
|
||||
["demo.mp4", "video"],
|
||||
["clip.webm", "video"],
|
||||
["paper.pdf", "pdf"],
|
||||
["out/index.html", "html"],
|
||||
["README.md", "markdown"],
|
||||
["flow.mmd", "mermaid"],
|
||||
["data.csv", "table"],
|
||||
["data.tsv", "table"],
|
||||
["Inter.woff2", "font"],
|
||||
["src/app.ts", "text"],
|
||||
["Makefile", "text"],
|
||||
[".env", "text"],
|
||||
["archive.tar.gz", "text"],
|
||||
] as const)("classifies %s as %s", (path, kind) => {
|
||||
expect(artifactKind(path)).toBe(kind)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fileContentFromBytes", () => {
|
||||
test("keeps media as base64 with a mime type", () => {
|
||||
const content = fileContentFromBytes("a.png", new Uint8Array([137, 80, 78, 71]))
|
||||
expect(content).toEqual({ type: "binary", content: "iVBORw==", encoding: "base64", mimeType: "image/png" })
|
||||
})
|
||||
|
||||
test("decodes text and svg with a mime type", () => {
|
||||
expect(fileContentFromBytes("a.svg", new TextEncoder().encode("<svg/>"))).toEqual({
|
||||
type: "text",
|
||||
content: "<svg/>",
|
||||
mimeType: "image/svg+xml",
|
||||
})
|
||||
expect(fileContentFromBytes("a.ts", new TextEncoder().encode("const a = 1"))).toEqual({
|
||||
type: "text",
|
||||
content: "const a = 1",
|
||||
mimeType: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps only the size of media above the cap", () => {
|
||||
const content = fileContentFromBytes("big.mp4", new Uint8Array(MAX_MEDIA_BYTES + 1))
|
||||
expect(content).toEqual({ type: "binary", content: "", size: MAX_MEDIA_BYTES + 1 })
|
||||
})
|
||||
|
||||
test("marks unknown binaries without keeping bytes", () => {
|
||||
expect(fileContentFromBytes("a.bin", new Uint8Array([1, 0, 2]))).toEqual({ type: "binary", content: "", size: 3 })
|
||||
})
|
||||
|
||||
test("encodes large buffers in chunks", () => {
|
||||
const bytes = new Uint8Array(70_000).fill(65)
|
||||
expect(bytesToBase64(bytes)).toBe(Buffer.from(bytes).toString("base64"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("contentBytes", () => {
|
||||
test("recovers byte counts from base64 and text", () => {
|
||||
expect(contentBytes({ type: "binary", content: "iVBORw==", encoding: "base64" })).toBe(4)
|
||||
expect(contentBytes({ type: "binary", content: "iVBORwA=", encoding: "base64" })).toBe(5)
|
||||
expect(contentBytes({ type: "text", content: "héllo" })).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseDelimited", () => {
|
||||
test("handles quotes, embedded delimiters, newlines, and CRLF", () => {
|
||||
const parsed = parseDelimited('name,note\r\n"Smith, J","says ""hi""\nand more"\nplain,\n', ",")
|
||||
expect(parsed.rows).toEqual([
|
||||
["name", "note"],
|
||||
["Smith, J", 'says "hi"\nand more'],
|
||||
["plain", ""],
|
||||
])
|
||||
expect(parsed.total).toBe(3)
|
||||
expect(parsed.columns).toBe(2)
|
||||
})
|
||||
|
||||
test("counts rows past the limit without keeping them", () => {
|
||||
const parsed = parseDelimited("a\tb\n1\t2\n3\t4\n5\t6", "\t", 2)
|
||||
expect(parsed.rows).toHaveLength(2)
|
||||
expect(parsed.total).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveArtifactPath", () => {
|
||||
test.each([
|
||||
["docs", "guide.md", "docs/guide.md"],
|
||||
["docs", "./img/a.png", "docs/img/a.png"],
|
||||
["docs/api", "../index.md", "docs/index.md"],
|
||||
["", "src/app.ts", "src/app.ts"],
|
||||
["docs", "sub\\win.md", "docs/sub/win.md"],
|
||||
["", "docs/guide.md", "docs/guide.md"],
|
||||
["/tmp/notes/", "../out/a.pdf", "/tmp/out/a.pdf"],
|
||||
["C:/tmp/notes/", "img.png", "C:/tmp/notes/img.png"],
|
||||
["/repo", "../shared/report.pdf", "/shared/report.pdf"],
|
||||
])("resolves %s + %s", (base, href, expected) => {
|
||||
expect(resolveArtifactPath(base, href)).toBe(expected)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["docs", "../../etc/passwd"],
|
||||
["", "../x"],
|
||||
["docs", "/abs/path"],
|
||||
])("rejects %s + %s", (base, href) => {
|
||||
expect(resolveArtifactPath(base, href)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,211 +0,0 @@
|
||||
import type { FileContent } from "@/runtime/server/types"
|
||||
|
||||
export type ArtifactKind =
|
||||
| "image"
|
||||
| "svg"
|
||||
| "audio"
|
||||
| "video"
|
||||
| "pdf"
|
||||
| "html"
|
||||
| "markdown"
|
||||
| "mermaid"
|
||||
| "table"
|
||||
| "font"
|
||||
| "text"
|
||||
|
||||
const mimes = new Map([
|
||||
["png", "image/png"],
|
||||
["jpg", "image/jpeg"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["gif", "image/gif"],
|
||||
["webp", "image/webp"],
|
||||
["avif", "image/avif"],
|
||||
["bmp", "image/bmp"],
|
||||
["ico", "image/x-icon"],
|
||||
["tif", "image/tiff"],
|
||||
["tiff", "image/tiff"],
|
||||
["heic", "image/heic"],
|
||||
["svg", "image/svg+xml"],
|
||||
["mp3", "audio/mpeg"],
|
||||
["wav", "audio/wav"],
|
||||
["ogg", "audio/ogg"],
|
||||
["oga", "audio/ogg"],
|
||||
["m4a", "audio/mp4"],
|
||||
["aac", "audio/aac"],
|
||||
["flac", "audio/flac"],
|
||||
["opus", "audio/ogg"],
|
||||
["weba", "audio/webm"],
|
||||
["mp4", "video/mp4"],
|
||||
["m4v", "video/mp4"],
|
||||
["webm", "video/webm"],
|
||||
["mov", "video/quicktime"],
|
||||
["ogv", "video/ogg"],
|
||||
["mkv", "video/x-matroska"],
|
||||
["pdf", "application/pdf"],
|
||||
["html", "text/html"],
|
||||
["htm", "text/html"],
|
||||
["md", "text/markdown"],
|
||||
["markdown", "text/markdown"],
|
||||
["mdx", "text/markdown"],
|
||||
["mmd", "text/vnd.mermaid"],
|
||||
["mermaid", "text/vnd.mermaid"],
|
||||
["csv", "text/csv"],
|
||||
["tsv", "text/tab-separated-values"],
|
||||
["ttf", "font/ttf"],
|
||||
["otf", "font/otf"],
|
||||
["woff", "font/woff"],
|
||||
["woff2", "font/woff2"],
|
||||
])
|
||||
|
||||
export function artifactExtension(path: string) {
|
||||
const name = path.split(/[\\/]/).pop() ?? ""
|
||||
const index = name.lastIndexOf(".")
|
||||
if (index <= 0) return ""
|
||||
return name.slice(index + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function artifactMime(path: string) {
|
||||
return mimes.get(artifactExtension(path))
|
||||
}
|
||||
|
||||
export function artifactKind(path: string): ArtifactKind {
|
||||
const mime = artifactMime(path)
|
||||
if (!mime) return "text"
|
||||
if (mime === "image/svg+xml") return "svg"
|
||||
if (mime === "application/pdf") return "pdf"
|
||||
if (mime === "text/html") return "html"
|
||||
if (mime === "text/markdown") return "markdown"
|
||||
if (mime === "text/vnd.mermaid") return "mermaid"
|
||||
if (mime === "text/csv" || mime === "text/tab-separated-values") return "table"
|
||||
if (mime.startsWith("image/")) return "image"
|
||||
if (mime.startsWith("audio/")) return "audio"
|
||||
if (mime.startsWith("font/")) return "font"
|
||||
return "video"
|
||||
}
|
||||
|
||||
/** Kinds whose bytes are kept as base64 so media elements can play them without a text round trip. */
|
||||
const binaryKinds = new Set<ArtifactKind>(["image", "audio", "video", "pdf", "font"])
|
||||
|
||||
/** Text files never contain NUL; a NUL in the first 8 KiB marks an unknown binary. */
|
||||
function isBinaryBytes(bytes: Uint8Array) {
|
||||
return bytes.subarray(0, 8192).includes(0)
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array) {
|
||||
const parts: string[] = []
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
parts.push(String.fromCharCode(...bytes.subarray(index, index + 0x8000)))
|
||||
}
|
||||
return btoa(parts.join(""))
|
||||
}
|
||||
|
||||
/** Media above this stays a placeholder: base64 encoding on the main thread and the LRU budget both suffer. */
|
||||
export const MAX_MEDIA_BYTES = 25 * 1024 * 1024
|
||||
|
||||
export function fileContentFromBytes(path: string, bytes: Uint8Array): FileContent {
|
||||
const kind = artifactKind(path)
|
||||
const mimeType = artifactMime(path)
|
||||
if (binaryKinds.has(kind)) {
|
||||
if (bytes.length > MAX_MEDIA_BYTES) return { type: "binary", content: "", size: bytes.length }
|
||||
return { type: "binary", content: bytesToBase64(bytes), encoding: "base64", mimeType }
|
||||
}
|
||||
// Unknown binaries keep no bytes: the viewer only shows a placeholder for them.
|
||||
if (kind === "text" && isBinaryBytes(bytes)) return { type: "binary", content: "", size: bytes.length }
|
||||
return { type: "text", content: new TextDecoder().decode(bytes), mimeType }
|
||||
}
|
||||
|
||||
/** Approximate on-disk size of loaded content. */
|
||||
export function contentBytes(content: FileContent) {
|
||||
if (content.size !== undefined) return content.size
|
||||
if (content.encoding === "base64") {
|
||||
const padding = content.content.endsWith("==") ? 2 : content.content.endsWith("=") ? 1 : 0
|
||||
return Math.floor((content.content.length * 3) / 4) - padding
|
||||
}
|
||||
return new TextEncoder().encode(content.content).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RFC 4180 style delimited text. Quoted fields may contain the delimiter, newlines, and
|
||||
* doubled quotes. Rows beyond `limit` are counted but not returned.
|
||||
*/
|
||||
export function parseDelimited(text: string, delimiter: string, limit = 1000) {
|
||||
const rows: string[][] = []
|
||||
let row: string[] = []
|
||||
let field = ""
|
||||
let quoted = false
|
||||
let total = 0
|
||||
const endRow = () => {
|
||||
row.push(field)
|
||||
field = ""
|
||||
const blank = row.length === 1 && row[0] === ""
|
||||
if (!blank) {
|
||||
total++
|
||||
if (rows.length < limit) rows.push(row)
|
||||
}
|
||||
row = []
|
||||
}
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const char = text[index]!
|
||||
if (quoted) {
|
||||
if (char !== '"') {
|
||||
field += char
|
||||
continue
|
||||
}
|
||||
if (text[index + 1] === '"') {
|
||||
field += '"'
|
||||
index++
|
||||
continue
|
||||
}
|
||||
quoted = false
|
||||
continue
|
||||
}
|
||||
if (char === '"' && field === "") {
|
||||
quoted = true
|
||||
continue
|
||||
}
|
||||
if (char === delimiter) {
|
||||
row.push(field)
|
||||
field = ""
|
||||
continue
|
||||
}
|
||||
if (char === "\r") continue
|
||||
if (char === "\n") {
|
||||
endRow()
|
||||
continue
|
||||
}
|
||||
field += char
|
||||
}
|
||||
if (field !== "" || row.length > 0) endRow()
|
||||
const columns = rows.reduce((max, current) => Math.max(max, current.length), 0)
|
||||
return { rows, total, columns }
|
||||
}
|
||||
|
||||
/** Build a blob URL from loaded content. Callers revoke it when the viewer unmounts. */
|
||||
export function blobUrlFromContent(content: FileContent) {
|
||||
const type = content.mimeType ?? "application/octet-stream"
|
||||
if (content.encoding !== "base64") return URL.createObjectURL(new Blob([content.content], { type }))
|
||||
const raw = atob(content.content)
|
||||
const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0))
|
||||
return URL.createObjectURL(new Blob([bytes], { type }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative link against a directory. A relative base yields a workspace-relative path and
|
||||
* an absolute base an absolute one; undefined when the link climbs past the base's root.
|
||||
*/
|
||||
export function resolveArtifactPath(base: string, href: string) {
|
||||
const target = href.replaceAll("\\", "/")
|
||||
if (target.startsWith("/")) return undefined
|
||||
const dir = base.replaceAll("\\", "/")
|
||||
const segments = [...dir.split("/").filter(Boolean)]
|
||||
for (const segment of target.split("/")) {
|
||||
if (!segment || segment === ".") continue
|
||||
if (segment !== "..") {
|
||||
segments.push(segment)
|
||||
continue
|
||||
}
|
||||
if (segments.length === 0) return undefined
|
||||
segments.pop()
|
||||
}
|
||||
return `${dir.startsWith("/") ? "/" : ""}${segments.join("/")}`
|
||||
}
|
||||
@@ -5,12 +5,11 @@ import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { getDirectory, getFilename } from "@opencode/util/path"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { createPathHelpers } from "./path"
|
||||
import { fileContentFromBytes } from "./artifact"
|
||||
import {
|
||||
approxBytes,
|
||||
evictContentLru,
|
||||
@@ -186,17 +185,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
|
||||
setLoading(file)
|
||||
|
||||
// Files outside the workspace are read from their own directory, like markdown images.
|
||||
// The trailing separator from getDirectory keeps "/" and "C:/" valid, like readLocalImage.
|
||||
const request = path.absolute(file)
|
||||
? { path: getFilename(file), location: { directory: getDirectory(file) } }
|
||||
: { path: file, location: { directory } }
|
||||
const promise = serverSDK.api.file
|
||||
.read(request)
|
||||
.read({ path: file, location: { directory } })
|
||||
.then((data) => {
|
||||
if (scope() !== directory) return
|
||||
const content = fileContentFromBytes(file, data)
|
||||
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
|
||||
setLoaded(file, content)
|
||||
|
||||
if (!content) return
|
||||
touchFileContent(file, approxBytes(content))
|
||||
evictContent(new Set([file]))
|
||||
})
|
||||
@@ -285,7 +281,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
return {
|
||||
ready: () => view().ready(),
|
||||
normalize: path.normalize,
|
||||
absolute: path.absolute,
|
||||
tab: path.tab,
|
||||
pathFromTab: path.pathFromTab,
|
||||
tree: {
|
||||
|
||||
@@ -21,24 +21,6 @@ describe("file path helpers", () => {
|
||||
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
||||
})
|
||||
|
||||
test("keeps files outside the workspace absolute and round-trips them through tabs", () => {
|
||||
const posix = createPathHelpers(() => "/repo")
|
||||
expect(posix.normalize("/tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
|
||||
expect(posix.absolute("/tmp/out/report.pdf")).toBe(true)
|
||||
expect(posix.absolute("src/app.ts")).toBe(false)
|
||||
expect(posix.tab("/tmp/out/report.pdf")).toBe("file:///tmp/out/report.pdf")
|
||||
expect(posix.pathFromTab("file:///tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
|
||||
expect(posix.normalize("/repository/x.ts")).toBe("/repository/x.ts")
|
||||
|
||||
const windows = createPathHelpers(() => "C:\\repo")
|
||||
expect(windows.normalize("C:\\tmp\\font.ttf")).toBe("C:\\tmp\\font.ttf")
|
||||
expect(windows.normalize("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
|
||||
expect(windows.absolute("C:/tmp/font.ttf")).toBe(true)
|
||||
expect(windows.tab("C:/tmp/font.ttf")).toBe("file:///C:/tmp/font.ttf")
|
||||
expect(windows.pathFromTab("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
|
||||
expect(windows.pathFromTab("file:///C:/repo/src/app.ts")).toBe("src/app.ts")
|
||||
})
|
||||
|
||||
test("normalizes Windows directory separators", () => {
|
||||
const path = createPathHelpers(() => "C:\\repo")
|
||||
expect(path.normalizeDir("frontend\\")).toBe("frontend")
|
||||
|
||||
@@ -105,11 +105,7 @@ export function createPathHelpers(scope: () => string) {
|
||||
const normalize = (input: string) => {
|
||||
const root = scope()
|
||||
|
||||
// file:///C:/dir becomes /C:/dir once the protocol is gone; restore the drive form.
|
||||
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(
|
||||
/^[/\\]([A-Za-z]:)/,
|
||||
"$1",
|
||||
)
|
||||
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
|
||||
|
||||
// Separator-agnostic prefix stripping for Cygwin/native Windows compatibility
|
||||
// Only case-insensitive on Windows (drive letter or UNC paths)
|
||||
@@ -120,21 +116,20 @@ export function createPathHelpers(scope: () => string) {
|
||||
canonPath.startsWith(canonRoot) &&
|
||||
(canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/")
|
||||
) {
|
||||
// Slice from original path to preserve native separators, then drop the separator itself.
|
||||
path = path.slice(root.length).replace(/^[/\\]/, "")
|
||||
// Slice from original path to preserve native separators
|
||||
path = path.slice(root.length)
|
||||
}
|
||||
|
||||
if (path.startsWith("./") || path.startsWith(".\\")) {
|
||||
path = path.slice(2)
|
||||
}
|
||||
|
||||
// An absolute path that is not under the root stays absolute; it is a file outside the workspace.
|
||||
if (path.startsWith("/") || path.startsWith("\\")) {
|
||||
path = path.slice(1)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/** Whether a normalized path points outside the workspace root. */
|
||||
const absolute = (path: string) => /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("/") || path.startsWith("\\\\")
|
||||
|
||||
const tab = (input: string) => {
|
||||
const path = normalize(input)
|
||||
return `file://${encodeFilePath(path)}`
|
||||
@@ -154,7 +149,6 @@ export function createPathHelpers(scope: () => string) {
|
||||
|
||||
return {
|
||||
normalize,
|
||||
absolute,
|
||||
tab,
|
||||
pathFromTab,
|
||||
normalizeDir,
|
||||
|
||||
+1
-21
@@ -1,5 +1,4 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { createRequire } from "node:module"
|
||||
import solidPlugin from "vite-plugin-solid"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "url"
|
||||
@@ -18,24 +17,6 @@ if (tailwindGenerate && typeof tailwindHotUpdate === "function") {
|
||||
}
|
||||
}
|
||||
|
||||
// The markdown worker imports these directly, so they are served unbundled to keep worker startup
|
||||
// stable. Vite applies `exclude` to every import inside a pre-bundle too, which would leave a bare
|
||||
// `import "marked"` in mermaid's chunk that the browser cannot resolve from this package.
|
||||
const workerDeps = ["@shikijs/stream", "marked", "marked-shiki", "remend"]
|
||||
|
||||
/** @type {import("rolldown").Plugin} */
|
||||
const bundleNestedWorkerDeps = {
|
||||
name: "opencode-desktop:bundle-nested-worker-deps",
|
||||
resolveId(id, importer) {
|
||||
if (!importer || !workerDeps.includes(id) || !importer.includes("node_modules")) return
|
||||
try {
|
||||
return createRequire(importer).resolve(id)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
@@ -63,9 +44,8 @@ export default [
|
||||
format: "es",
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: workerDeps,
|
||||
exclude: ["@shikijs/stream", "marked", "marked-shiki", "remend"],
|
||||
include: ["@opencode/session-ui > mermaid", "@opencode/session-ui > mermaid > katex"],
|
||||
rolldownOptions: { plugins: [bundleNestedWorkerDeps] },
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -5,77 +5,34 @@ import { Runtime } from "../../framework/runtime"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { handlePromptErrors } from "../../ui/prompt"
|
||||
import { OPENCODE_VERSION } from "../../version"
|
||||
import { stripVTControlCharacters } from "node:util"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.upgrade,
|
||||
Effect.fn("cli.upgrade")(
|
||||
function* (input) {
|
||||
intro("Upgrade")
|
||||
const updater = yield* Updater.Service
|
||||
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
|
||||
if (!method)
|
||||
return yield* Effect.fail(
|
||||
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
|
||||
)
|
||||
|
||||
log.info(`Using method: ${method}`)
|
||||
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
|
||||
const version = target.trim().replace(/^v/, "")
|
||||
if (version === OPENCODE_VERSION) {
|
||||
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
|
||||
outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
log.info(`From ${OPENCODE_VERSION} → ${version}`)
|
||||
const progress = spinner()
|
||||
progress.start("Upgrading...")
|
||||
yield* updater.upgrade(method, target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
|
||||
Effect.fn("cli.upgrade")(function* (input) {
|
||||
intro("Upgrade")
|
||||
const updater = yield* Updater.Service
|
||||
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
|
||||
if (!method)
|
||||
return yield* Effect.fail(
|
||||
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
|
||||
)
|
||||
|
||||
log.info(`Using method: ${method}`)
|
||||
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
|
||||
const version = target.trim().replace(/^v/, "")
|
||||
if (version === OPENCODE_VERSION) {
|
||||
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
|
||||
outro("Done")
|
||||
},
|
||||
(effect) =>
|
||||
handlePromptErrors(
|
||||
effect.pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof Updater.UpgradeError ? new Error(formatUpgradeError(error), { cause: error }) : error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
return
|
||||
}
|
||||
|
||||
log.info(`From ${OPENCODE_VERSION} → ${version}`)
|
||||
const progress = spinner()
|
||||
progress.start("Upgrading...")
|
||||
yield* updater.upgrade(method, target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
|
||||
)
|
||||
outro("Done")
|
||||
}, handlePromptErrors),
|
||||
)
|
||||
|
||||
function formatUpgradeError(error: Updater.UpgradeError) {
|
||||
const clean = (value: string) => stripVTControlCharacters(value).replaceAll("\r", "").trim()
|
||||
const line = (value: string) => clean(value).replace(/\s+/g, " ")
|
||||
const detail = wrap(clean(error.detail), Math.max(24, (process.stdout.columns ?? 80) - 7)).join("\n")
|
||||
return [
|
||||
line(error.title),
|
||||
"",
|
||||
detail,
|
||||
...(error.command ? ["", field("Command", line(error.command))] : []),
|
||||
field("Retry", line(error.retry)),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function field(label: string, value: string) {
|
||||
const prefix = label.padEnd(9)
|
||||
const width = Math.max(24, Math.min(61, (process.stdout.columns ?? 80) - prefix.length - 10))
|
||||
const lines = wrap(value, width)
|
||||
return lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`).join("\n")
|
||||
}
|
||||
|
||||
function wrap(value: string, width: number) {
|
||||
return value.split("\n").flatMap((source) => {
|
||||
if (source.length <= width) return [source]
|
||||
return source.split(/\s+/).reduce<string[]>((result, word) => {
|
||||
const index = result.length - 1
|
||||
if (index < 0 || result[index].length + word.length + 1 > width) result.push(word)
|
||||
else result[index] += ` ${word}`
|
||||
return result
|
||||
}, [])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { getErrorReported } from "effect/Runtime"
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Observability } from "@opencode/util/observability"
|
||||
@@ -131,15 +130,5 @@ Effect.gen(function* () {
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
|
||||
// runMain's default reporter logs the fatal cause to stdout. Write it to stderr instead: the
|
||||
// desktop and `Service.ensure` only capture stderr from `serve --service`, so this is the only
|
||||
// channel through which a startup failure's reason reaches the user.
|
||||
Effect.tapCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return
|
||||
if (!getErrorReported(Cause.squash(cause))) return
|
||||
process.stderr.write(Cause.pretty(cause) + "\n")
|
||||
}),
|
||||
),
|
||||
NodeRuntime.runMain({ disableErrorReporting: true }),
|
||||
NodeRuntime.runMain,
|
||||
)
|
||||
|
||||
@@ -5,9 +5,7 @@ import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } fro
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { stripVTControlCharacters } from "node:util"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
|
||||
|
||||
@@ -15,82 +13,10 @@ export type Method = (typeof methods)[number]
|
||||
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
|
||||
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
|
||||
|
||||
export class UpgradeError extends Error {
|
||||
readonly title: string
|
||||
readonly detail: string
|
||||
readonly command?: string
|
||||
readonly retry: string
|
||||
|
||||
constructor(
|
||||
input: {
|
||||
readonly title: string
|
||||
readonly detail: string
|
||||
readonly command?: string
|
||||
readonly retry: string
|
||||
},
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(input.detail, options)
|
||||
this.name = "UpgradeError"
|
||||
this.title = input.title
|
||||
this.detail = input.detail
|
||||
this.command = input.command
|
||||
this.retry = input.retry
|
||||
}
|
||||
}
|
||||
|
||||
const decodeVpPackages = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
|
||||
)
|
||||
|
||||
const installNames: Record<Method, string> = {
|
||||
curl: "The OpenCode installer",
|
||||
npm: "npm",
|
||||
pnpm: "pnpm",
|
||||
bun: "Bun",
|
||||
yarn: "Yarn",
|
||||
vp: "Vite+",
|
||||
brew: "Homebrew",
|
||||
}
|
||||
|
||||
function conciseDetail(input: string) {
|
||||
const lines = stripVTControlCharacters(input)
|
||||
.trim()
|
||||
.replaceAll("\r", "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim().length > 0)
|
||||
const tail = lines.slice(-12).join("\n")
|
||||
const clipped = tail.length > 2_000
|
||||
const detail = clipped ? `…${tail.slice(-1_999)}` : tail
|
||||
if (!detail) return
|
||||
if (lines.length <= 12 && !clipped) return detail
|
||||
return `${detail}\n\nOutput shortened to the last 12 lines.`
|
||||
}
|
||||
|
||||
function errorDetail(cause: unknown): string {
|
||||
if (cause instanceof AppProcess.AppProcessError) {
|
||||
const stderr = conciseDetail(cause.stderr ?? "")
|
||||
if (stderr) return stderr
|
||||
if (cause.cause !== undefined) return errorDetail(cause.cause)
|
||||
return cause.message
|
||||
}
|
||||
if (cause instanceof Error) {
|
||||
const detail = cause.cause === undefined ? undefined : errorDetail(cause.cause)
|
||||
if (!detail || detail === cause.message) return cause.message
|
||||
return `${cause.message}: ${detail}`
|
||||
}
|
||||
return errorMessage(cause)
|
||||
}
|
||||
|
||||
function resultDetail(result: { code: number; stdout: string; stderr: string }) {
|
||||
return (
|
||||
conciseDetail(result.stderr) ??
|
||||
conciseDetail(result.stdout) ??
|
||||
`The command exited with code ${result.code} without any error output.`
|
||||
)
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
@@ -98,9 +24,9 @@ export interface Interface {
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
readonly removal: (
|
||||
method: Method,
|
||||
) => { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> } | undefined
|
||||
readonly removal: (method: Method) =>
|
||||
| { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> }
|
||||
| undefined
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -163,6 +89,7 @@ const make = Effect.gen(function* () {
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})),
|
||||
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -192,11 +119,7 @@ const make = Effect.gen(function* () {
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) =>
|
||||
exec(check.command).pipe(
|
||||
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
|
||||
Effect.map((result) => ({ check, result })),
|
||||
),
|
||||
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => {
|
||||
@@ -221,7 +144,11 @@ const make = Effect.gen(function* () {
|
||||
return {
|
||||
command,
|
||||
run: exec(command, "5 minutes").pipe(
|
||||
Effect.flatMap((result) => (result.code === 0 ? Effect.void : Effect.fail(new Error(resultDetail(result))))),
|
||||
Effect.flatMap((result) =>
|
||||
result.code === 0
|
||||
? Effect.void
|
||||
: Effect.fail(new Error(result.stderr.trim() || `Failed to uninstall with ${method}`)),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -236,44 +163,14 @@ const make = Effect.gen(function* () {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
},
|
||||
),
|
||||
catch: (cause) =>
|
||||
new UpgradeError(
|
||||
{
|
||||
title: "Could not check for OpenCode updates",
|
||||
detail: errorDetail(cause),
|
||||
retry: "Check your network, then run opencode upgrade again.",
|
||||
},
|
||||
{ cause },
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
})
|
||||
if (!response.ok)
|
||||
return yield* Effect.fail(
|
||||
new UpgradeError({
|
||||
title: "Could not check for OpenCode updates",
|
||||
detail: `The update service returned HTTP ${response.status}.`,
|
||||
retry: "Try again in a few minutes.",
|
||||
}),
|
||||
)
|
||||
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
|
||||
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) =>
|
||||
new UpgradeError(
|
||||
{
|
||||
title: "Could not read the OpenCode update information",
|
||||
detail: errorDetail(cause),
|
||||
retry: "Try again in a few minutes.",
|
||||
},
|
||||
{ cause },
|
||||
),
|
||||
catch: (cause) => new Error("Failed to read update information", { cause }),
|
||||
})
|
||||
if (!data.metadata?.package)
|
||||
return yield* Effect.fail(
|
||||
new UpgradeError({
|
||||
title: "Could not read the OpenCode update information",
|
||||
detail: "The update service returned incomplete release information.",
|
||||
retry: "Try again in a few minutes.",
|
||||
}),
|
||||
)
|
||||
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
|
||||
return { package: data.metadata.package, version: data.version }
|
||||
})
|
||||
|
||||
@@ -288,40 +185,6 @@ const make = Effect.gen(function* () {
|
||||
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const runUpgrade = (input: {
|
||||
readonly method: Method
|
||||
readonly command: string[]
|
||||
readonly displayCommand?: string[]
|
||||
readonly title?: string
|
||||
readonly retry?: string
|
||||
}) => {
|
||||
const failure = (detail: string, cause?: unknown) =>
|
||||
new UpgradeError(
|
||||
{
|
||||
title: input.title ?? `${installNames[input.method]} could not install OpenCode`,
|
||||
detail,
|
||||
command: (input.displayCommand ?? input.command).join(" "),
|
||||
retry: input.retry ?? "Fix the issue above, then run opencode upgrade again.",
|
||||
},
|
||||
cause === undefined ? undefined : { cause },
|
||||
)
|
||||
return exec(input.command, "5 minutes").pipe(
|
||||
Effect.flatMap((result) =>
|
||||
result.code === 0 ? Effect.succeed(result) : Effect.fail(failure(resultDetail(result))),
|
||||
),
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof UpgradeError
|
||||
? cause
|
||||
: failure(
|
||||
cause instanceof AppProcess.AppProcessError && cause.stderr === undefined && cause.cause === undefined
|
||||
? `Failed to update with ${input.method}`
|
||||
: errorDetail(cause),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
@@ -349,54 +212,31 @@ const make = Effect.gen(function* () {
|
||||
? ["vp", "install", "-g", "--force", target]
|
||||
: ["vp", "update", "-g", target],
|
||||
}
|
||||
yield* Effect.scoped(
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
if (method === "bun") {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* temporaryDirectory("update-")
|
||||
return yield* runUpgrade({
|
||||
method,
|
||||
command: ["bun", "install", "--global", "--trust", "--cache-dir", cache, target],
|
||||
displayCommand: ["bun", "install", "--global", "--trust", target],
|
||||
})
|
||||
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* temporaryDirectory("update-")
|
||||
const installer = path.join(directory, "install")
|
||||
yield* runUpgrade({
|
||||
method,
|
||||
command: ["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
displayCommand: ["curl", "-fsSL", "https://opencode.ai/v2/install"],
|
||||
title: "Could not download the OpenCode installer",
|
||||
retry: "Check your network, then run opencode upgrade again.",
|
||||
})
|
||||
return yield* runUpgrade({
|
||||
method,
|
||||
command: ["bash", installer, "--version", version, "--no-modify-path"],
|
||||
displayCommand: ["opencode", "upgrade", version, "--method", "curl"],
|
||||
title: "The OpenCode installer failed",
|
||||
})
|
||||
const download = yield* exec(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
"5 minutes",
|
||||
)
|
||||
if (download.code !== 0) return download
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
if (method === "brew") return yield* runUpgrade({ method, command: ["brew", "upgrade", packageName] })
|
||||
return yield* runUpgrade({ method, command: commands[method] })
|
||||
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof UpgradeError
|
||||
? cause
|
||||
: new UpgradeError(
|
||||
{
|
||||
title: "Could not prepare the OpenCode upgrade",
|
||||
detail: errorDetail(cause),
|
||||
retry: "Fix the issue above, then run opencode upgrade again.",
|
||||
},
|
||||
{ cause },
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
if (result.code === 0) return
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
const inspect = Effect.fnUntraced(function* () {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -24,7 +24,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
and Map, RegExp, and generators serialize as `{}`. A bare `undefined` result is `null`.
|
||||
Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot
|
||||
be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set
|
||||
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own enumerable }`, a
|
||||
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a
|
||||
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
|
||||
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
|
||||
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
|
||||
@@ -38,11 +38,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
10,000,000 elements (`Array(n)`, `length =`, `Array.from`, `split`, `matchAll`, `concat`, `flat`; below the JS
|
||||
maximum of 2^32 - 1), and 10,000 pending promises at once. Exceeding one throws a `RangeError`. A single regular
|
||||
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
|
||||
- [x] A trailing comma after a rest parameter is a syntax error, with or without `"use strict"`.
|
||||
- [x] A program that begins with `"use strict"` rejects `yield` as an identifier and duplicate parameter names at
|
||||
parse time. Without it, `yield` is an ordinary binding.
|
||||
- [ ] Duplicate parameter names in non-strict code throw when the function is called, instead of binding the last
|
||||
parameter as JavaScript does.
|
||||
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
|
||||
rest parameter are accepted unless the program itself begins with `"use strict"`.
|
||||
|
||||
## Values and literals
|
||||
|
||||
@@ -97,8 +94,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `for`, `while`, and `do...while`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, Uint8Arrays, built-in iterators, custom
|
||||
synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other
|
||||
non-objects iterate nothing. An un-awaited promise throws rather than iterating.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
- [x] `throw` with arbitrary values.
|
||||
@@ -112,8 +108,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
## Functions and callbacks
|
||||
|
||||
- [x] Function declarations, function expressions, and arrow functions.
|
||||
- [x] Synchronous and `async` functions. A line break between `function` and the name is allowed, as in JavaScript;
|
||||
a line break between `async` and `function` is not an async function.
|
||||
- [x] Synchronous and `async` functions.
|
||||
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
|
||||
- [x] A call depth limit of 10000: deeper nesting throws a catchable `RangeError: Maximum call stack size exceeded`
|
||||
at the overflowing call instead of running until the timeout. Callbacks invoked by built-ins count below the
|
||||
@@ -148,6 +143,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
`Array.prototype.push.name === "push"`).
|
||||
- [ ] A named function expression's name is not bound inside its own body.
|
||||
- [ ] Redeclaring a function in the same scope is rejected; in JavaScript the last declaration wins.
|
||||
- [ ] A line terminator between `async function` and the function name.
|
||||
- [ ] Generator and async generator functions evaluate parameter defaults and destructuring at the first `next()`
|
||||
rather than at the call, so their errors are not thrown synchronously.
|
||||
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
|
||||
@@ -184,8 +180,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
|
||||
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
|
||||
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present. `Error.isError`
|
||||
is true for every Error value.
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
|
||||
@@ -275,8 +270,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `Object.is` for supported data values.
|
||||
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
|
||||
and plain-object results.
|
||||
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString` (calls the value's
|
||||
`toString`, as in JS), `valueOf`, `hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
|
||||
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString`, `valueOf`,
|
||||
`hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
|
||||
|
||||
## Arrays
|
||||
|
||||
@@ -290,14 +285,12 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
`lastIndexOf`.
|
||||
- [x] Aggregation: `reduce` and `reduceRight`.
|
||||
- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`.
|
||||
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, `join`, and `toLocaleString` (each element's
|
||||
`toLocaleString`, holes and nullish elements as empty strings).
|
||||
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`.
|
||||
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
|
||||
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
|
||||
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
|
||||
`[opaque reference]`, serialize to `{}`, and cannot be passed to extensions. Every built-in collection iterator
|
||||
shares one prototype. JavaScript gives each collection its own; the difference is not observable here because
|
||||
`Object.getPrototypeOf` is not exposed.
|
||||
shares one prototype, which is only observable through `getPrototypeOf`.
|
||||
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
|
||||
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
|
||||
- [x] `Array.prototype.toSpliced`.
|
||||
@@ -310,13 +303,12 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
form, like `JSON.stringify`.
|
||||
- [ ] Argument coercion for `indexOf`, `lastIndexOf`, `includes`, `fill`, `flat`, `copyWithin`, and the `join`
|
||||
separator: JavaScript applies ToIntegerOrInfinity/ToString (including `valueOf`, strings, and `undefined`), the
|
||||
interpreter requires numbers and strings. `indexOf()` and `lastIndexOf()` with no argument already search for
|
||||
`undefined`; `includes()` still requires a value.
|
||||
interpreter requires numbers and strings; `includes()`/`indexOf()` with no argument should search for
|
||||
`undefined`.
|
||||
|
||||
## Strings
|
||||
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`, and the `toLocaleLowerCase`/`toLocaleUpperCase`
|
||||
aliases, which ignore their locale argument and apply the default Unicode casing.
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
|
||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
@@ -340,8 +332,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Coercion functions: `Number`, `parseInt`, and `parseFloat`.
|
||||
- [x] Number predicates/parsers: `Number.isInteger`, `Number.isFinite`, `Number.isNaN`, `Number.isSafeInteger`,
|
||||
`Number.parseInt`, and `Number.parseFloat`.
|
||||
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, `valueOf`, and `toLocaleString`, which
|
||||
always formats as `en-US` (`"1,234.5"`) so output does not depend on the host.
|
||||
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, and `valueOf`.
|
||||
- [x] Number constants: `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `NaN`,
|
||||
`POSITIVE_INFINITY`, and `NEGATIVE_INFINITY`.
|
||||
- [x] Math constants: `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT2`, and `SQRT1_2`.
|
||||
@@ -354,8 +345,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
use their epoch time) and reject opaque runtime references as data errors.
|
||||
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
|
||||
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
|
||||
example `Math.sum is not a function.` or `search(...).catch is not a function.` Unknown `Promise` statics keep
|
||||
their descriptive error.
|
||||
example `Math.sum is not a function.` Unknown `Promise` statics keep their descriptive error.
|
||||
- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number
|
||||
elements without coercion.
|
||||
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
|
||||
@@ -389,8 +379,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
`TimeClip` behavior.
|
||||
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
|
||||
- [x] `toDateString` and `toTimeString` in the host's local timezone.
|
||||
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
|
||||
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
|
||||
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
|
||||
- [ ] Date setters and multi-argument construction coerce object arguments through `valueOf`/`toString` and surface
|
||||
their throws.
|
||||
@@ -416,7 +404,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
|
||||
- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity.
|
||||
- [x] `new Map()` from synchronous iterables of entries.
|
||||
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, `forEach`, `getOrInsert`, and `getOrInsertComputed`.
|
||||
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] `new Set()` from synchronous iterables.
|
||||
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()`
|
||||
@@ -437,8 +425,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `new URLSearchParams()` from query strings, data objects, synchronous iterables of pairs, and URLSearchParams.
|
||||
- [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`,
|
||||
`entries`, `[Symbol.iterator]`, `toString`, and `size`.
|
||||
- [x] URL values are their href in `JSON.stringify` and at the host boundary. URLSearchParams are `{}` in
|
||||
`JSON.stringify` and their query string at the host boundary.
|
||||
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
|
||||
|
||||
## Uint8Array
|
||||
|
||||
@@ -483,8 +470,7 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
|
||||
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
errors cross as errors with their name, message, `cause`, and own enumerable data, and a `__proto__` key is
|
||||
dropped. Functions, generators,
|
||||
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
|
||||
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
|
||||
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
|
||||
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
|
||||
@@ -509,10 +495,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
|
||||
an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators.
|
||||
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. `message` is an own
|
||||
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` for a plain error. The
|
||||
result boundary still emits `{ name, message, ...own enumerable }`, so a field such as `code` crosses. `cause` is
|
||||
non-enumerable: an extension Error carries it, and this JSON form does not. Errors have no `stack`; the diagnostic
|
||||
carries a 1-based line and column in the submitted source instead.
|
||||
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` while the host still
|
||||
receives `{ name, message }`. Errors have no `stack`; the diagnostic carries the source location instead.
|
||||
- [x] `instanceof` against any constructor with a `prototype`, including every built-in and `Function`.
|
||||
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
|
||||
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
|
||||
@@ -521,9 +505,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
short orientation to the supported subset; this matrix is the full reference.
|
||||
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
|
||||
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
|
||||
Error-shaped value in `catch`, rejection handlers, and `Promise.allSettled` reasons. It always has `name` and
|
||||
`message`, plus `cause` and own data when the failure carried them. This is deliberate: the program should
|
||||
handle a failure the same way regardless of where it originated.
|
||||
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
This is deliberate: the program should handle a failure the same way regardless of where it originated.
|
||||
- [x] Failures raised by the interpreter are `TypeError`s unless JavaScript names them otherwise (`RangeError`,
|
||||
`ReferenceError`, `SyntaxError`, `URIError`), so `e instanceof TypeError` and `e.constructor === TypeError`
|
||||
hold. Unsupported syntax reached at runtime is a `SyntaxError`; awaited tool failures stay plain `Error`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.12",
|
||||
"version": "2.0.10",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect, Exit } from "effect"
|
||||
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { primitivePrototype } from "./intrinsics.js"
|
||||
import { typeError } from "./model.js"
|
||||
import { Callable, get, Native, DateObj, Obj } from "./objects.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
@@ -44,13 +43,6 @@ export const toPrimitive = <R>(
|
||||
})
|
||||
}
|
||||
|
||||
/** Invoke(value, name): calls the method the value would find through its prototype. */
|
||||
export const invoke = <R>(ctx: Interpreter<R>, value: unknown, name: string, label: string) => {
|
||||
const target = value instanceof Obj ? value : primitivePrototype(ctx.builtins, value)
|
||||
if (target === undefined) throw typeError(`${label} called on null or undefined.`)
|
||||
return ctx.call(get(target, name), value, [])
|
||||
}
|
||||
|
||||
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: unknown) =>
|
||||
Effect.map(toPrimitive(ctx, value, "string"), coerceToString)
|
||||
|
||||
|
||||
@@ -168,7 +168,6 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
|
||||
methods(builtins, prototype, [
|
||||
["toString", 0, (thisValue) => errorToString(receiver(Obj, thisValue, "Error.prototype.toString"))],
|
||||
])
|
||||
methods(builtins, ctor, [["isError", 1, (_, args) => args[0] instanceof ErrorObj]])
|
||||
}
|
||||
return ctor
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
} from "./model.js"
|
||||
import { checkStringLength } from "./limits.js"
|
||||
import { locate, materialize } from "./errors.js"
|
||||
import { type Builtins, primitivePrototype } from "./intrinsics.js"
|
||||
import type { Builtins } from "./intrinsics.js"
|
||||
import { globals } from "./globals.js"
|
||||
import {
|
||||
assign,
|
||||
@@ -110,25 +110,20 @@ const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefin
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Native engines name the callee (`search(...).catch is not a function`), including call chains. Returns
|
||||
// undefined when a link cannot be named, so a chain is either named completely or not at all.
|
||||
const calleeDescription = (node: Expression | Super | undefined): string | undefined => {
|
||||
if (node?.type === "Identifier") return node.name
|
||||
if (node?.type === "CallExpression") {
|
||||
const target = calleeDescription(node.callee)
|
||||
return target === undefined ? undefined : `${target}(...)`
|
||||
const calleeDescription = (callee: Expression | Super | undefined): string => {
|
||||
if (callee?.type === "Identifier") return callee.name
|
||||
if (callee?.type === "MemberExpression") {
|
||||
const object = callee.object
|
||||
const property = callee.property
|
||||
const key =
|
||||
!callee.computed && property.type === "Identifier"
|
||||
? property.name
|
||||
: property.type === "Literal" && typeof property.value === "string"
|
||||
? property.value
|
||||
: undefined
|
||||
if (object.type === "Identifier" && key !== undefined) return `${object.name}.${key}`
|
||||
}
|
||||
if (node?.type !== "MemberExpression") return undefined
|
||||
const property = node.property
|
||||
const key =
|
||||
!node.computed && property.type === "Identifier"
|
||||
? property.name
|
||||
: property.type === "Literal" && typeof property.value === "string"
|
||||
? property.value
|
||||
: undefined
|
||||
if (key === undefined) return undefined
|
||||
const object = calleeDescription(node.object)
|
||||
return object === undefined ? undefined : `${object}.${key}`
|
||||
return "The called value"
|
||||
}
|
||||
|
||||
// OrdinaryHasInstance: walk the left operand's chain looking for the constructor's `prototype`.
|
||||
@@ -1310,7 +1305,7 @@ class Frame<R> {
|
||||
// `new` itself is supported, so a non-constructible callee is a TypeError like JS rather than
|
||||
// unsupported syntax. Built-ins like Number are real constructors in JS, so do not claim
|
||||
// otherwise; say `new` is unsupported for them and point at the plain call.
|
||||
const name = calleeDescription(node.callee) ?? "The called value"
|
||||
const name = calleeDescription(node.callee)
|
||||
const message =
|
||||
callee instanceof Fn
|
||||
? `${name} cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.`
|
||||
@@ -1613,7 +1608,7 @@ class Frame<R> {
|
||||
if (callable instanceof Native) {
|
||||
return yield* self.native(() => (callable as Native<R>).call(thisValue, args), node)
|
||||
}
|
||||
throw typeError(`${calleeDescription(callee) ?? "The called value"} is not a function.`, callee ?? node)
|
||||
throw typeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2076,14 +2071,16 @@ class Frame<R> {
|
||||
|
||||
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
|
||||
|
||||
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
|
||||
// Primitives read through their wrapper prototype without being boxed; strings own length and indexes.
|
||||
const builtins = self.ctx.builtins
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return { value: objectValue.length }
|
||||
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
|
||||
if (index !== undefined) return { value: objectValue[index] }
|
||||
return { target: builtins.String, key, receiver: objectValue }
|
||||
}
|
||||
const proto = primitivePrototype(self.ctx.builtins, objectValue)
|
||||
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
|
||||
if (typeof objectValue === "number") return { target: builtins.Number, key, receiver: objectValue }
|
||||
if (typeof objectValue === "boolean") return { target: builtins.Boolean, key, receiver: objectValue }
|
||||
|
||||
if (objectValue === null || objectValue === undefined) {
|
||||
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
|
||||
|
||||
@@ -52,14 +52,6 @@ export const createErrorValue = (prototype: Obj, message: string | undefined): E
|
||||
return value
|
||||
}
|
||||
|
||||
/** The prototype a primitive reads its methods from without being boxed; none for null, undefined, and symbols. */
|
||||
export const primitivePrototype = (builtins: Builtins, value: unknown): Obj | undefined => {
|
||||
if (typeof value === "string") return builtins.String
|
||||
if (typeof value === "number") return builtins.Number
|
||||
if (typeof value === "boolean") return builtins.Boolean
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const createBuiltins = (): Builtins => {
|
||||
const object = new Obj(null)
|
||||
// Function.prototype is itself callable and returns undefined.
|
||||
|
||||
@@ -86,10 +86,9 @@ export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null
|
||||
|
||||
// Acorn lines are 1-based and its columns are 0-based. Diagnostics use 1-based columns of the submitted source.
|
||||
export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({
|
||||
line: node.loc?.start.line ?? 1,
|
||||
column: (node.loc?.start.column ?? 0) + 1,
|
||||
line: Math.max(1, (node.loc?.start.line ?? 2) - 1),
|
||||
column: Math.max(1, (node.loc?.start.column ?? 4) - 3),
|
||||
})
|
||||
|
||||
export const formatLocation = (node?: AstNode): string => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { checkArrayLength, checkStringLength, MAX_ARRAY_LENGTH } from "../interp
|
||||
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
|
||||
import { define, get, hidden, Arr, GeneratorObj, IteratorObj, Obj } from "../interpreter/objects.js"
|
||||
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { compareText } from "../tool-runtime.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
@@ -340,19 +340,6 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return target
|
||||
},
|
||||
],
|
||||
[
|
||||
"toLocaleString",
|
||||
0,
|
||||
(thisValue) =>
|
||||
Effect.map(
|
||||
Effect.forEach(self(thisValue, "toLocaleString").items, (item) =>
|
||||
item === null || item === undefined
|
||||
? Effect.succeed("")
|
||||
: Effect.map(invoke(ctx, item, "toLocaleString", "Array.prototype.toLocaleString"), coerceToString),
|
||||
),
|
||||
(parts) => parts.join(","),
|
||||
),
|
||||
],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").items.keys())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").items.values())],
|
||||
[
|
||||
|
||||
@@ -145,29 +145,6 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return target
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsert",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsert").map
|
||||
if (!target.has(args[0])) target.set(args[0], args[1])
|
||||
return target.get(args[0])
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsertComputed",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsertComputed").map
|
||||
const apply = applyCollectionCallback(ctx, args[1], "Map.getOrInsertComputed")
|
||||
if (target.has(args[0])) return target.get(args[0])
|
||||
// The callback sees the stored key (-0 is +0) and its result wins over anything it inserted itself.
|
||||
return Effect.map(apply([args[0] === 0 ? 0 : args[0]]), (value) => {
|
||||
target.set(args[0], value)
|
||||
return value
|
||||
})
|
||||
},
|
||||
],
|
||||
["delete", 1, (thisValue, args) => self(thisValue, "delete").map.delete(args[0])],
|
||||
[
|
||||
"clear",
|
||||
|
||||
@@ -105,13 +105,6 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["toTimeString", 0, (thisValue) => new Date(self(thisValue, "toTimeString").time).toTimeString()],
|
||||
["toUTCString", 0, (thisValue) => new Date(self(thisValue, "toUTCString").time).toUTCString()],
|
||||
["toGMTString", 0, (thisValue) => new Date(self(thisValue, "toGMTString").time).toUTCString()],
|
||||
...(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"] as const).map(
|
||||
(name): Method => [
|
||||
name,
|
||||
0,
|
||||
(thisValue) => new Date(self(thisValue, name).time)[name]("en-US", { timeZone: "UTC" }),
|
||||
],
|
||||
),
|
||||
...getters.map((name): Method => [name, 0, (thisValue) => new Date(self(thisValue, name).time)[name]()]),
|
||||
...setters.map(
|
||||
([name, length]): Method => [
|
||||
|
||||
@@ -50,7 +50,6 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
}
|
||||
methods(builtins, builtins.Number, [
|
||||
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum("toFixed", args[0]))],
|
||||
["toLocaleString", 0, (thisValue) => self(thisValue, "toLocaleString").toLocaleString("en-US")],
|
||||
[
|
||||
"toExponential",
|
||||
1,
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
set,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { invoke, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { preserveConsumerError } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { groupBy } from "./collections.js"
|
||||
@@ -196,7 +196,7 @@ export const objectGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
true,
|
||||
],
|
||||
["toString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
|
||||
["toLocaleString", 0, (thisValue) => invoke(ctx, thisValue, "toString", "Object.prototype.toLocaleString")],
|
||||
["toLocaleString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
|
||||
[
|
||||
"valueOf",
|
||||
0,
|
||||
|
||||
@@ -150,8 +150,6 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
simple("valueOf", 0, (value) => value),
|
||||
simple("toLowerCase", 0, (value) => value.toLowerCase()),
|
||||
simple("toUpperCase", 0, (value) => value.toUpperCase()),
|
||||
simple("toLocaleLowerCase", 0, (value) => value.toLowerCase()),
|
||||
simple("toLocaleUpperCase", 0, (value) => value.toUpperCase()),
|
||||
simple("trim", 0, (value) => value.trim()),
|
||||
simple("trimStart", 0, (value) => value.trimStart()),
|
||||
simple("trimLeft", 0, (value) => value.trimStart()),
|
||||
|
||||
@@ -192,8 +192,6 @@ export type Prepared<R = never> = {
|
||||
|
||||
export type SearchEntry = {
|
||||
readonly description: ToolDescription
|
||||
/** The path split into words, so `zones` matches `get_zones` as a word rather than as a substring of `timezones`. */
|
||||
readonly pathWords: ReadonlyArray<string>
|
||||
readonly searchText: string
|
||||
}
|
||||
|
||||
@@ -249,7 +247,6 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
||||
(total, forms) =>
|
||||
total +
|
||||
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
|
||||
(forms.some((form) => entry.pathWords.includes(form)) ? 12 : 0) +
|
||||
(forms.some((form) => path.includes(form)) ? 8 : 0) +
|
||||
(forms.some((form) => description.includes(form)) ? 4 : 0) +
|
||||
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
|
||||
@@ -284,7 +281,6 @@ export const searchSignature = (() => {
|
||||
|
||||
const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
|
||||
description: describeTool(visible),
|
||||
pathWords: tokenize(visible.path),
|
||||
searchText: [
|
||||
visible.path,
|
||||
visible.tool.description,
|
||||
|
||||
@@ -1039,32 +1039,6 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("a query term that is a whole word of the path outranks a substring of it", async () => {
|
||||
const simple = (description: string) =>
|
||||
Tool.make({
|
||||
description,
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
// Declared so that alphabetical order would put the substring match first.
|
||||
cloudflare: { get_timezones: simple("List timezones"), get_zones: simple("List zones") },
|
||||
},
|
||||
})
|
||||
|
||||
const ranked = await Effect.runPromise(runtime.execute(`return search({ query: "zones" })`))
|
||||
expect(ranked.ok).toBe(true)
|
||||
if (ranked.ok) {
|
||||
const value = ranked.value as { items: Array<{ path: string }> }
|
||||
expect(value.items.map((item) => item.path)).toStrictEqual([
|
||||
"tools.cloudflare.get_zones",
|
||||
"tools.cloudflare.get_timezones",
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test("a plural query term matches singular-only tool text", async () => {
|
||||
const simple = (description: string) =>
|
||||
Tool.make({
|
||||
|
||||
@@ -22,16 +22,6 @@ describe("source syntax", () => {
|
||||
})
|
||||
|
||||
describe("error identity", () => {
|
||||
test("Error.isError is true for every Error value and nothing else", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const caught = (() => { try { null.foo } catch (error) { return error } })()
|
||||
return [Error.isError(new Error("x")), Error.isError(new RangeError("x")), Error.isError(caught),
|
||||
Error.isError({ name: "Error", message: "x" }), Error.isError("Error"), Error.isError(null)]
|
||||
`),
|
||||
).toEqual([true, true, true, false, false, false])
|
||||
})
|
||||
|
||||
test("awaiting the same rejected promise twice yields the same error object", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
@@ -105,24 +95,6 @@ describe("uncaught program throws", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("source locations", () => {
|
||||
test("uses the submitted line and 1-based column", async () => {
|
||||
const failure = await error("const value = 1\nreturn value()")
|
||||
expect(failure.location).toEqual({ line: 2, column: 8 })
|
||||
expect(failure.message).toBe("TypeError: value is not a function. (line 2, col 8)")
|
||||
})
|
||||
|
||||
test("names a missing method on a call instead of the previous line", async () => {
|
||||
const failure = await error(`// Try search with different namespaces
|
||||
for (const ns of ["github", "tools.github", "tools", ""]) {
|
||||
const s = await search({query: "star", namespace: ns, limit: 100}).catch(e=>({items:[],error:String(e)}));
|
||||
return s
|
||||
}`)
|
||||
expect(failure.location).toEqual({ line: 3, column: 19 })
|
||||
expect(failure.message).toBe("TypeError: search(...).catch is not a function. (line 3, col 19)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("host errors escaping built-ins", () => {
|
||||
test("become the same-named program error", async () => {
|
||||
expect(
|
||||
@@ -135,7 +107,7 @@ describe("host errors escaping built-ins", () => {
|
||||
test("report the location of the call that raised them", async () => {
|
||||
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
|
||||
})
|
||||
|
||||
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
|
||||
@@ -143,13 +115,13 @@ describe("host errors escaping built-ins", () => {
|
||||
})
|
||||
|
||||
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
|
||||
"(line 1, col 14)",
|
||||
"(line 1, col 10)",
|
||||
)
|
||||
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
|
||||
"(line 1, col 12)",
|
||||
"(line 1, col 8)",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -162,7 +134,7 @@ describe("host errors escaping built-ins", () => {
|
||||
|
||||
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
|
||||
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -181,7 +153,7 @@ describe("call depth", () => {
|
||||
test("uncaught overflow reports the call that overflowed", async () => {
|
||||
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
|
||||
})
|
||||
|
||||
test("the limit is 10000 nested calls", async () => {
|
||||
|
||||
@@ -502,12 +502,6 @@ describe("CodeMode-specific array behavior", () => {
|
||||
expect(err.message).toContain("circular")
|
||||
})
|
||||
|
||||
test("indexOf and lastIndexOf with no argument search for undefined", async () => {
|
||||
expect(await value(`return [1, undefined, 3].indexOf()`)).toBe(1)
|
||||
expect(await value(`return [1, undefined, 3].lastIndexOf()`)).toBe(1)
|
||||
expect(await value(`return [1, 2, 3].indexOf()`)).toBe(-1)
|
||||
})
|
||||
|
||||
test("keys/values/entries return iterators usable with for...of and spread", async () => {
|
||||
expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
|
||||
expect(await value(`return [...["x","y"].values()]`)).toEqual(["x", "y"])
|
||||
@@ -946,12 +940,6 @@ describe("coercion parity: unknown static members read as undefined", () => {
|
||||
expect(await value(`try { JSON.rawJSON("1") } catch (e) { return e.message }`)).toBe(
|
||||
"JSON.rawJSON is not a function.",
|
||||
)
|
||||
expect(await value(`try { search({ query: "star" }).catch(() => 1) } catch (e) { return e.message }`)).toBe(
|
||||
"search(...).catch is not a function.",
|
||||
)
|
||||
expect(
|
||||
await value(`const foo = () => ({ bar: () => ({}) }); try { foo().bar().baz() } catch (e) { return e.message }`),
|
||||
).toBe("foo(...).bar(...).baz is not a function.")
|
||||
})
|
||||
|
||||
test("built-ins are objects on a real prototype chain", async () => {
|
||||
@@ -973,17 +961,6 @@ describe("coercion parity: unknown static members read as undefined", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("async function line breaks", () => {
|
||||
test("a line break between function and the name is an async function", async () => {
|
||||
expect(await value(`async function\nfoo() { return 1 }\nreturn await foo()`)).toBe(1)
|
||||
})
|
||||
|
||||
test("a line break between async and function is not an async function", async () => {
|
||||
const failure = await error(`async\nfunction foo() { return 1 }\nreturn foo()`)
|
||||
expect(failure.message).toContain("Unknown identifier 'async'")
|
||||
})
|
||||
})
|
||||
|
||||
describe("functions are objects", () => {
|
||||
test("name follows NamedEvaluation and length counts required parameters", async () => {
|
||||
expect(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user