Compare commits

..
Author SHA1 Message Date
Kit Langton 5acc917591 refactor(test/cli): simplify pass on tier-A + prebuild
Applies findings from the third simplify pass:

1. \`cliArgv\` is a module-level const, not a function — prebuiltCli is
   read once at module init and never mutated, so the per-spawn function
   allocation was pure overhead.

2. Help-snapshot failures surface via \`Effect.fail\` instead of \`throw\`,
   symmetric with the \`Effect.fail\` already inside the partition above.
   Keeps the failure typed in the Effect channel rather than as a defect.

3. \`prebuild-test-cli.ts\` skips the build when the binary is already
   newer than every file in src/ — saves the 2.8s rebuild cost on every
   subsequent invocation. Pass --force to bypass.

4. \`prebuild-test-cli.ts\` verifies the built binary is executable before
   symlinking — catches a silently-failed build leaving stale output
   instead of letting tests fail with confusing exec errors later.

Verified: 331/331 CLI tests pass; typecheck clean; skip-if-fresh + --force
behave as documented.
2026-05-19 11:36:15 -04:00
Kit Langton 5bc6a7f6d0 test(cli): opt-in pre-built binary for ~3x faster subprocess spawns
\`bun run --conditions=browser src/index.ts\` pays ~15s of JIT + plugin
init + DB migration per subprocess spawn in isolation mode. A pre-built
binary cuts that to ~5s — most of which is now the SQLite \`:memory:\`
migration that runs regardless of execution mode.

Adds \`script/prebuild-test-cli.ts\` which wraps the existing build.ts
with \`--single --skip-embed-web-ui --skip-install\`, then symlinks the
platform-specific output to \`dist/test-cli/bin/opencode\` so the
harness has a stable path.

The harness (test/lib/cli-process.ts) reads OPENCODE_TEST_CLI_PATH and
spawns the binary directly when set; falls back to dev mode otherwise.
Strictly opt-in — default behavior, CI, and local iteration are
unchanged. Anyone who wants the speedup runs:

  bun script/prebuild-test-cli.ts
  export OPENCODE_TEST_CLI_PATH="\$PWD/dist/test-cli/bin/opencode"
  bun test test/cli/

Measured locally:
  Dev mode (default):  29.9s  (331 tests)
  Binary mode:         22.1s  (-26%, after one-time 2.8s build)

The win compounds as more subprocess tests are added — every new test
that hits DB migration saves ~10s vs dev mode.
2026-05-19 11:36:15 -04:00
401 changed files with 5495 additions and 48475 deletions
+2 -2
View File
@@ -4,8 +4,8 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = { const TEAM = {
tui: ["kommander", "simonklee"], tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"], desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"], core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom", "starptech"], inference: ["fwang", "MrMushrooooom"],
windows: ["Hona"], windows: ["Hona"],
} as const } as const
-8
View File
@@ -4,14 +4,6 @@
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. - Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
## Commits and PR Titles
Use conventional commit-style messages and PR titles: `type(scope): summary`.
Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes are optional; use the affected package or area when helpful, e.g. `core`, `opencode`, `tui`, `app`, `desktop`, `sdk`, or `plugin`.
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
## Style Guide ## Style Guide
### General Principles ### General Principles
+43 -64
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.15.6", "version": "1.15.5",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -253,7 +253,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -305,23 +305,9 @@
"@parcel/watcher-win32-x64": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1",
}, },
}, },
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.5",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -351,7 +337,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -367,7 +353,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -380,7 +366,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2", "@smithy/util-utf8": "4.2.2",
@@ -398,7 +384,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.15.6", "version": "1.15.5",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -536,7 +522,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
@@ -552,9 +538,9 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.2.15", "@opentui/core": ">=0.2.14",
"@opentui/keymap": ">=0.2.15", "@opentui/keymap": ">=0.2.14",
"@opentui/solid": ">=0.2.15", "@opentui/solid": ">=0.2.14",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
@@ -574,7 +560,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -589,7 +575,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -624,7 +610,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -673,7 +659,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -729,18 +715,17 @@
}, },
"catalog": { "catalog": {
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@effect/opentelemetry": "4.0.0-beta.66", "@effect/opentelemetry": "4.0.0-beta.65",
"@effect/platform-node": "4.0.0-beta.66", "@effect/platform-node": "4.0.0-beta.65",
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.10", "@lydell/node-pty": "1.2.0-beta.10",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.15", "@opentui/core": "0.2.14",
"@opentui/keymap": "0.2.15", "@opentui/keymap": "0.2.14",
"@opentui/solid": "0.2.15", "@opentui/solid": "0.2.14",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@@ -762,9 +747,9 @@
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-rc.2", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.66", "effect": "4.0.0-beta.65",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -1123,13 +1108,11 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.66", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.66" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-LU3ejAzJS+4P+Qtfn9ULnsGcIPmx1tUUB2ZswFRL+EolD8US7zMljHTwGuQRUBJOjDwt7wFCMN5AR512vdY8FQ=="], "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.65", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.65" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-0CD2fSsXrDM7FP2WFkbGJO1DwMqWR3UKHh6oBDXPHAPA+RsJSKoh3pLQsbQfldLuKnhOy87Bv0v9r9IdrIHCQw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.66", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.66", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.66", "ioredis": "^5.7.0" } }, "sha512-s/0RgaQFuszzdorRnX1PwEQNnSOi+JgMJo3zEe9O2NR3sosMhTr0Uk+1AF6bUOI9uJ2CPT3KpTIIU7q5/TpOkg=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.65", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.65", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.65", "ioredis": "^5.7.0" } }, "sha512-QQy3KRcMwP0TngQdfQGl2u1zp03B7k7DuF5SNS8aZhD0dDBpKZpCwFad1ODY5qdY3ycPgMwBwKRRK7y/aw0C9w=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.66", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-+ymrhBnESv/hmn5SKTe2//IY9Ox/hGPeoogEWhW47ZGyhFI5eMYFxdEUBa+3IAV05rrBzrxON9lynu68n0DM7w=="], "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.65", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-3rY8F3WLEax6Hj08GI/OvDIH+KqjfxH7RM2bAMfgR75NgRmwDtny1P49PtPkoRjH5dcdtThThtsvE4X9OTZkpQ=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.66", "", { "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-UYsrAb/5T0ZRypeN9Kmv3/ZInibGCjM6dtoiAWtfG+xKyuq8N05wmuVCXB0+XgVmUBxDWjw/S1fu4ivS0vZVuw=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -1559,8 +1542,6 @@
"@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"],
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
@@ -1611,23 +1592,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="], "@opentui/core": ["@opentui/core@0.2.14", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.14", "@opentui/core-darwin-x64": "0.2.14", "@opentui/core-linux-arm64": "0.2.14", "@opentui/core-linux-x64": "0.2.14", "@opentui/core-win32-arm64": "0.2.14", "@opentui/core-win32-x64": "0.2.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-17YCr3BqM9mhi/DdNVM+omgmrKQNIl0G5RzoaTFOHe4+OAhG+W3iooYi+WdsekJWSUOEwZqDRz0QBTZhOtgZsQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iS4NZQkOKX2EP5rsNjDcU7inDLcKhPaSBn8ENjDXKx2smOh7p/rgM2qlEaiLI3njtL784QoF+nxTzSXbEI6+Jw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-ft4ZwYHCV0VtRMwQtHH5mAgwqRLHEXP26DWcwtCZWDEHDvghClBR0cj9UZLH5JAKn/j7ds5hZDCCZz+nUiEHYA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-t/EKD4+rlzWuwYAa6NzGCmiBOHvF+hzjNwExj+dnSqX5wK7TU+VHl+N2iYUl4VhhJK94kPP6BnrF5GcHnZGFLg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Lvqmd92UZ+KZVnr0xU0jYj4XqnCSsBQJHS/FpYkJgZSAN7/4NmPlgMvQXIGW8a3BcFaGRKe8LuGpqM2E4oaX0Q=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jnuud29daaEoZNEp80dxUDLyUcwLr+g6SruHPyyWerOe7J10JE1ihJNkDlXLT7T49xdBGYRQlRkuNGwRfZWx5A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-2ZUNh7yaAMUwAOK8oFEO28qXqPFrWPGGD0KHK1Gp97Th9XuVZniMLtbkbrFtDRh15+PVj7MyrG0N967W7rLr1A=="],
"@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="], "@opentui/keymap": ["@opentui/keymap@0.2.14", "", { "dependencies": { "@opentui/core": "0.2.14" }, "peerDependencies": { "@opentui/react": "0.2.14", "@opentui/solid": "0.2.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Jd4F3S98D8bJcr41jk7KcsFwwQTA0GCNKb+LoDMkPwv00k+NV6XcrXPB3QlNeM/JrVbe55pyGaT/ynZklGYHRw=="],
"@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="], "@opentui/solid": ["@opentui/solid@0.2.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-tSWiiwdh/J+crkjwHgd26HXAlJHYocwkN/eUoqSH3+Y/uO3c3qRnbzQz7zzds+j4XDQU/8e9QcZshYLvQT9c7w=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2993,9 +2974,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-rc.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TRxUmj1wDA2QCt3GvuhfamvIa66wJ7+MzSxBMKkpRtYScjHTumT9BE+x6daSzuEacSrPEuUH5/cW1uo5RkoPIg=="], "drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="],
"drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="], "drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -3009,7 +2990,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.66", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw=="], "effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5697,8 +5678,6 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
+2 -9
View File
@@ -2,7 +2,6 @@ import { SECRET } from "./secret"
import { domain } from "./stage" import { domain } from "./stage"
const description = "Managed by SST (Don't edit in Honeycomb UI)" const description = "Managed by SST (Don't edit in Honeycomb UI)"
const alertsDisabled = $app.stage !== "production"
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", { const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`, name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
@@ -80,7 +79,7 @@ IF(
filters, filters,
}, },
], ],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 150), DIV($FAILED, $TOTAL), 0)" }], formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 200), DIV($FAILED, $TOTAL), 0)" }],
timeRange: 900, timeRange: 900,
}).json }).json
} }
@@ -123,7 +122,7 @@ const providerHttpErrorsQuery = () => {
}, },
], ],
formulas: [ formulas: [
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 150), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
], ],
timeRange: 900, timeRange: 900,
}).json }).json
@@ -160,7 +159,6 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
name: "Increased Model HTTP Errors [Go]", name: "Increased Model HTTP Errors [Go]",
description, description,
disabled: alertsDisabled,
queryJson: modelHttpErrorsQuery("go"), queryJson: modelHttpErrorsQuery("go"),
alertType: "on_change", alertType: "on_change",
frequency: 300, frequency: 300,
@@ -180,7 +178,6 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
name: "Increased Model HTTP Errors [Zen]", name: "Increased Model HTTP Errors [Zen]",
description, description,
disabled: alertsDisabled,
queryJson: modelHttpErrorsQuery("zen"), queryJson: modelHttpErrorsQuery("zen"),
alertType: "on_change", alertType: "on_change",
frequency: 300, frequency: 300,
@@ -200,7 +197,6 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
new honeycomb.Trigger("LowModelTpsGo", { new honeycomb.Trigger("LowModelTpsGo", {
name: "Low Model TPS [Go]", name: "Low Model TPS [Go]",
description, description,
disabled: alertsDisabled,
queryJson: modelLowTpsQuery("go"), queryJson: modelLowTpsQuery("go"),
alertType: "on_change", alertType: "on_change",
frequency: 600, frequency: 600,
@@ -220,7 +216,6 @@ new honeycomb.Trigger("LowModelTpsGo", {
new honeycomb.Trigger("LowModelTpsZen", { new honeycomb.Trigger("LowModelTpsZen", {
name: "Low Model TPS [Zen]", name: "Low Model TPS [Zen]",
description, description,
disabled: alertsDisabled,
queryJson: modelLowTpsQuery("zen"), queryJson: modelLowTpsQuery("zen"),
alertType: "on_change", alertType: "on_change",
frequency: 600, frequency: 600,
@@ -240,7 +235,6 @@ new honeycomb.Trigger("LowModelTpsZen", {
new honeycomb.Trigger("IncreasedProviderHttpErrors", { new honeycomb.Trigger("IncreasedProviderHttpErrors", {
name: "Increased Provider HTTP Errors", name: "Increased Provider HTTP Errors",
description, description,
disabled: alertsDisabled,
queryJson: providerHttpErrorsQuery(), queryJson: providerHttpErrorsQuery(),
alertType: "on_change", alertType: "on_change",
frequency: 300, frequency: 300,
@@ -260,7 +254,6 @@ new honeycomb.Trigger("IncreasedProviderHttpErrors", {
new honeycomb.Trigger("IncreasedFreeTierRequests", { new honeycomb.Trigger("IncreasedFreeTierRequests", {
name: "Increased Free Tier Requests", name: "Increased Free Tier Requests",
description, description,
disabled: alertsDisabled,
queryJson: honeycomb.getQuerySpecificationOutput({ queryJson: honeycomb.getQuerySpecificationOutput({
calculations: [{ op: "COUNT" }], calculations: [{ op: "COUNT" }],
filters: [ filters: [
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-kCSAVPQgJROcvnnwf0Cn6PuYL25hYgTasJeBJlmnFgQ=", "x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=",
"aarch64-linux": "sha256-prY27Ek2QhW+4OvBJ3bHHkUDoLTA4mD3KQmOQqSbAuo=", "aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=",
"aarch64-darwin": "sha256-0yIqnnjreVHTgGZLrKFpT9Cc2B2LNfmYcRByaCu7tiU=", "aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=",
"x86_64-darwin": "sha256-n+urvMRozB9nO5D3qyCweSa5HExFk1YGEzOt2445LEE=" "x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg="
} }
} }
+8 -9
View File
@@ -28,17 +28,16 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/opentelemetry": "4.0.0-beta.66", "@effect/opentelemetry": "4.0.0-beta.65",
"@effect/platform-node": "4.0.0-beta.66", "@effect/platform-node": "4.0.0-beta.65",
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.15", "@opentui/core": "0.2.14",
"@opentui/keymap": "0.2.15", "@opentui/keymap": "0.2.14",
"@opentui/solid": "0.2.15", "@opentui/solid": "0.2.14",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
@@ -54,9 +53,9 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-rc.2", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.66", "effect": "4.0.0-beta.65",
"ai": "6.0.168", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
+1 -2
View File
@@ -1,11 +1,10 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.15.6", "version": "1.15.5",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./vite": "./vite.js", "./vite": "./vite.js",
"./index.css": "./src/index.css" "./index.css": "./src/index.css"
}, },
+148 -372
View File
@@ -1,23 +1,18 @@
import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js" import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history" import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useGlobalSync } from "@/context/global-sync"
import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@@ -44,8 +39,6 @@ const titlebarHeight = 40
const minTitlebarZoom = 0.25 const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
export function Titlebar() { export function Titlebar() {
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
@@ -59,7 +52,6 @@ export function Titlebar() {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web") const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1 const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
@@ -183,378 +175,162 @@ export function Titlebar() {
return ( return (
<header <header
class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row" class="h-10 shrink-0 bg-background-base relative overflow-hidden"
style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }} style={{ "min-height": minHeight() }}
data-tauri-drag-region data-tauri-drag-region
onMouseDown={drag} onMouseDown={drag}
onDblClick={maximize} onDblClick={maximize}
> >
<Switch> <div
<Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}> class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
{(_) => { style={{ zoom: counterZoom() }}
const globalSync = useGlobalSync() >
const navigate = useNavigate() <div
classList={{
type Tab = { dir: string; sessionId: string; params: any; href: string } "flex items-center min-w-0": true,
"pl-2": !mac(),
const [tabsStore, tabsStoreActions] = iife(() => {
const [store, setStore] = createStore<Tab[]>(
iife(() => {
if (!params.dir || !params.id) return []
return [
{
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id),
},
]
}),
)
const actions = {
addTab: (tab: Tab) => {
setStore(
produce((tabs) => {
if (tabs.some((t) => t.href === tab.href)) return
tabs.push(tab)
}),
)
},
removeTab: (href: string) => {
startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((t) => t.href === href)
if (index === -1) return
tabs.splice(index, 1)
const nextTab = tabs[index] ?? tabs[tabs.length - 1]
if (nextTab) navigate(nextTab.href)
else navigate("/")
}),
)
})
},
}
return [store, actions]
})
createEffect(() => {
const params = useParams()
if (!(params.dir && params.id)) return
tabsStoreActions.addTab({
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id),
})
})
const tabsEnriched = iife(() => {
const base = mapArray(
() => tabsStore,
(tab) => {
const sync = globalSync.createDirSyncContext(tab.dir)
const session = sync.session.get(tab.sessionId)
return session ? { ...tab, info: session } : null
},
)
return () => base().flatMap((s) => (s ? [s] : []))
})
return (
<div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
<ChannelIndicator />
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<IconButtonV2
as="a"
href="/"
variant="ghost-muted"
size="large"
class="!w-8"
state={!!useMatch(() => "/")() ? "pressed" : undefined}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
stroke="currentColor"
stroke-miterlimit="10"
stroke-linecap="square"
/>
</svg>
</IconButtonV2>
<div class="flex flex-row items-center gap-2">
<For each={tabsEnriched()}>
{(tab, i) => (
<>
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
<TabNavItem
href={tab.href}
title={tab.info.title}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
</For>
</div>
<button>
<div class="p-1.5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path
d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
stroke="#808080"
stroke-linejoin="round"
/>
</svg>
</div>
</button>
<div class="flex-1" />
{/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path
d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
stroke="#3A3A3A"
/>
</svg>
</button>*/}
</div>
)
}} }}
</Match> >
<Match when> <Show when={mac()}>
<div <div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center" <div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
style={{ zoom: counterZoom() }} <IconButton
> icon="menu"
<div variant="ghost"
classList={{ class="titlebar-icon rounded-md"
"flex items-center min-w-0": true, onClick={layout.mobileSidebar.toggle}
"pl-2": !mac(), aria-label={language.t("sidebar.menu.toggle")}
}} aria-expanded={layout.mobileSidebar.opened()}
>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<ChannelIndicator />
</div>
</div>
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div
id="opencode-titlebar-center"
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
/> />
</div> </div>
</Show>
<div <Show when={!mac()}>
classList={{ <div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
"flex items-center min-w-0 justify-end": true, <IconButton
"pr-2": !windows(), icon="menu"
}} variant="ghost"
data-tauri-drag-region class="titlebar-icon rounded-md"
onMouseDown={drag} onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
> >
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" /> <Button
<Show when={windows()}> variant="ghost"
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />} class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
<div data-tauri-decorum-tb class="flex flex-row" /> onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</div>
</Show> </Show>
<div
class="flex items-center shrink-0"
classList={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</div>
</div> </div>
</div> </div>
</Match> </div>
</Switch>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
</div>
</header> </header>
) )
} }
function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
return (
<div
class="group flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
data-active={isActive()}
>
<a
href={props.href}
class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
>
{props.title}
</a>
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
}}
/>
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
onClick={props.onClose}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
</svg>
}
/>
</div>
</div>
)
}
function ChannelIndicator() {
return (
<>
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</>
)
}
@@ -1,111 +0,0 @@
import { Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
import { usePlatform } from "@/context/platform"
export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
platform: ReturnType<typeof usePlatform>
}) {
let lastFocused: HTMLElement | undefined
const rememberFocus = () => {
const active = document.activeElement
lastFocused = active instanceof HTMLElement ? active : undefined
}
const commandDisabled = (id: string) => {
const option = props.command.options.find((option) => option.id === id)
if (!option) return true
return option.disabled ?? false
}
const runCommand = (id: string) => {
if (commandDisabled(id)) return
props.command.trigger(id)
}
const runAction = (action: DesktopMenuAction) => {
if (action.startsWith("edit.") && lastFocused?.isConnected) lastFocused.focus({ preventScroll: true })
void props.platform.runDesktopMenuAction?.(action)
}
const runEntry = (entry: DesktopMenuEntry) => {
if (entry.type === "separator") return
if (entry.command) {
runCommand(entry.command)
return
}
if (entry.action) {
runAction(entry.action)
return
}
if (entry.href) props.platform.openLink(entry.href)
}
return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label="OpenCode menu"
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
<DesktopMenuSubmenu label={menu.label}>
{menu.items
?.filter((entry) => desktopMenuVisible(entry, "windows"))
.map((entry) =>
entry.type === "separator" ? (
<DropdownMenu.Separator />
) : (
<DesktopMenuItem
label={entry.label ?? ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)}
/>
),
)}
</DesktopMenuSubmenu>
))}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
}
function DesktopMenuSubmenu(props: { label: string; children: JSX.Element }) {
return (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
<span data-slot="dropdown-menu-item-label">{props.label}</span>
<span data-slot="desktop-app-menu-chevron">
<Icon name="chevron-right" size="small" />
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent class="desktop-app-menu">{props.children}</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
)
}
function DesktopMenuItem(props: { label: string; keybind?: string; disabled?: boolean; onSelect: () => void }) {
return (
<DropdownMenu.Item disabled={props.disabled} onSelect={props.onSelect}>
<DropdownMenu.ItemLabel>{props.label}</DropdownMenu.ItemLabel>
<Show when={props.keybind}>
<span data-slot="desktop-app-menu-keybind">{props.keybind}</span>
</Show>
</DropdownMenu.Item>
)
}
-596
View File
@@ -1,596 +0,0 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync"
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[input.messageID]
}
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return [input.message]
const result = Binary.search(messages, input.message.id, (m) => m.id)
const next = [...messages]
next.splice(result.index, 0, input.message)
return next
})
setStore("part", input.message.id, sortParts(input.parts))
}
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return messages
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (!result.found) return messages
const next = [...messages]
next.splice(result.index, 1)
return next
})
setStore("part", (part: Record<string, Part[] | undefined>) => {
if (!(input.messageID in part)) return part
const next = { ...part }
delete next[input.messageID]
return next
})
}
export const createDirSyncContext = (client: OpencodeClient, directory: string) => {
const globalSync = useGlobalSync()
type Child = ReturnType<(typeof globalSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => globalSync.child(directory))
const target = (directory?: string) => {
if (!directory || directory === directory) return current()
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: { client: typeof client; sessionID: string; limit: number; before?: string }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
return {
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
setOptimistic(_directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
clearOptimistic(_directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
}
const [, setStore] = target()
setOptimistic(directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, _directory = directory) {
const [, setStore] = globalSync.child(_directory)
seenFor(_directory).delete(sessionID)
evict(_directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session.splice(match.index, 1)
}),
)
},
},
absolute,
get directory() {
return current()[0].path.directory
},
}
}
-24
View File
@@ -36,7 +36,6 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from
import { createRefreshQueue } from "./global-sync/queue" import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils" import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key" import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -432,9 +431,6 @@ function createGlobalSync() {
}, },
})) }))
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSyncContext>>()
const dirSyncContextRefCounts = new Map<string, number>()
return { return {
data: globalStore, data: globalStore,
set, set,
@@ -453,26 +449,6 @@ function createGlobalSync() {
todo: { todo: {
set: setSessionTodo, set: setSessionTodo,
}, },
createDirSyncContext: (directory: string) => {
onCleanup(() => {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) - 1)
if (dirSyncContextRefCounts.get(directory) === 0) {
dirSyncContexts.delete(directory)
dirSyncContextRefCounts.delete(directory)
}
})
const cached = dirSyncContexts.get(directory)
if (cached) {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) + 1)
return cached
}
const ctx = createDirSyncContext(globalSDK.createClient({ directory, throwOnError: true }), directory)
dirSyncContexts.set(directory, ctx)
dirSyncContextRefCounts.set(directory, 1)
return ctx
},
} }
} }
-4
View File
@@ -1,7 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage" import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server" import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null type PickerPaths = string | string[] | null
@@ -83,9 +82,6 @@ export type Platform = {
/** Webview zoom level (desktop only) */ /** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number> webviewZoom?: Accessor<number>
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
/** Check if an editor app exists (desktop only) */ /** Check if an editor app exists (desktop only) */
checkAppExists?(appName: string): Promise<boolean> checkAppExists?(appName: string): Promise<boolean>
+454 -1
View File
@@ -1,8 +1,19 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -161,6 +172,448 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const sdk = useSDK() const sdk = useSDK()
return globalSync.createDirSyncContext(sdk.directory) type Child = ReturnType<(typeof globalSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => globalSync.child(sdk.directory))
const target = (directory?: string) => {
if (!directory || directory === sdk.directory) return current()
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: {
client: typeof sdk.client
sessionID: string
limit: number
before?: string
}) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof sdk.client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
return {
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
clearOptimistic(directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
}
const [, setStore] = target()
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(sdk.directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(sdk.directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, directory = sdk.directory) {
const [, setStore] = globalSync.child(directory)
seenFor(directory).delete(sessionID)
evict(directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session.splice(match.index, 1)
}),
)
},
},
absolute,
get directory() {
return current()[0].path.directory
},
}
}, },
}) })
-221
View File
@@ -1,221 +0,0 @@
export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction =
| "app.checkForUpdates"
| "app.relaunch"
| "edit.undo"
| "edit.redo"
| "edit.cut"
| "edit.copy"
| "edit.paste"
| "edit.delete"
| "edit.selectAll"
| "view.reload"
| "view.toggleDevTools"
| "view.resetZoom"
| "view.zoomIn"
| "view.zoomOut"
| "view.toggleFullscreen"
| "window.new"
| "window.close"
| "window.minimize"
| "window.toggleMaximize"
export type DesktopMenuRole =
| "about"
| "close"
| "copy"
| "cut"
| "hide"
| "hideOthers"
| "paste"
| "quit"
| "redo"
| "reload"
| "resetZoom"
| "selectAll"
| "toggleDevTools"
| "togglefullscreen"
| "undo"
| "unhide"
| "windowMenu"
| "zoomIn"
| "zoomOut"
export type DesktopMenuItem = {
type: "item"
label?: string
command?: string
action?: DesktopMenuAction
role?: DesktopMenuRole
href?: string
accelerator?: Partial<Record<DesktopMenuPlatform, string>>
enabled?: "updater"
platforms?: DesktopMenuPlatform[]
}
export type DesktopMenuSeparator = {
type: "separator"
platforms?: DesktopMenuPlatform[]
}
export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
export type DesktopMenu = {
id: string
label: string
role?: DesktopMenuRole
items?: DesktopMenuEntry[]
platforms?: DesktopMenuPlatform[]
}
export const DESKTOP_MENU: DesktopMenu[] = [
{
id: "app",
label: "OpenCode",
platforms: ["macos"],
items: [
{ type: "item", role: "about" },
{ type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" },
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", label: "Reload Webview", action: "view.reload" },
{ type: "item", label: "Restart", action: "app.relaunch" },
{ type: "separator" },
{ type: "item", role: "hide" },
{ type: "item", role: "hideOthers" },
{ type: "item", role: "unhide" },
{ type: "separator" },
{ type: "item", role: "quit" },
],
},
{
id: "file",
label: "File",
items: [
{
type: "item",
label: "New Session",
command: "session.new",
accelerator: { macos: "Shift+Cmd+S" },
},
{ type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
{
type: "item",
label: "Settings",
command: "settings.open",
accelerator: { windows: "Ctrl+," },
platforms: ["windows"],
},
{
type: "item",
label: "New Window",
action: "window.new",
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
},
{ type: "separator" },
{ type: "item", label: "Close Window", action: "window.close", role: "close" },
],
},
{
id: "edit",
label: "Edit",
items: [
{ type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
{ type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
{ type: "separator" },
{ type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
{ type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
{ type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
{ type: "item", label: "Delete", action: "edit.delete" },
{
type: "item",
label: "Select All",
action: "edit.selectAll",
role: "selectAll",
accelerator: { windows: "Ctrl+A" },
},
],
},
{
id: "view",
label: "View",
items: [
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } },
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
{ type: "separator" },
{ type: "item", label: "Reload", action: "view.reload", role: "reload" },
{ type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" },
{ type: "separator" },
{
type: "item",
label: "Actual Size",
action: "view.resetZoom",
role: "resetZoom",
accelerator: { windows: "Ctrl+0" },
},
{ type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } },
{ type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } },
{ type: "separator" },
{ type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" },
],
},
{
id: "go",
label: "Go",
items: [
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
{ type: "separator" },
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
{ type: "separator" },
{
type: "item",
label: "Previous Project",
command: "project.previous",
accelerator: { macos: "Cmd+Option+Up" },
},
{
type: "item",
label: "Next Project",
command: "project.next",
accelerator: { macos: "Cmd+Option+Down" },
},
],
},
{
id: "window",
label: "Window",
role: "windowMenu",
items: [
{ type: "item", label: "Minimize", action: "window.minimize" },
{ type: "item", label: "Maximize", action: "window.toggleMaximize" },
{ type: "separator" },
{ type: "item", label: "Close Window", action: "window.close" },
],
},
{
id: "help",
label: "Help",
items: [
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
{ type: "separator" },
{
type: "item",
label: "Share Feedback",
href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml",
},
{
type: "item",
label: "Report a Bug",
href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml",
},
],
},
]
export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
return !item.platforms || item.platforms.includes(platform)
}
-58
View File
@@ -1,5 +1,4 @@
@import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/ui/v2/styles/tailwind.css";
@font-face { @font-face {
font-family: "JetBrainsMono Nerd Font Mono"; font-family: "JetBrainsMono Nerd Font Mono";
@@ -54,63 +53,6 @@
container-name: getting-started; container-name: getting-started;
} }
[data-component="dropdown-menu-content"].desktop-app-menu,
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
min-width: 160px;
padding: 2px;
}
[data-component="dropdown-menu-content"].desktop-app-menu {
width: 160px;
}
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
width: max-content;
min-width: 240px;
max-width: min(320px, calc(100vw - 24px));
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-group-label"] {
display: flex;
align-items: center;
height: 28px;
padding: 0 12px;
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: 1;
color: var(--text-weak);
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"] {
min-height: 28px;
padding: 0 12px;
gap: 8px;
font-weight: var(--font-weight-regular);
line-height: 1;
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"] {
white-space: nowrap;
}
[data-slot="desktop-app-menu-keybind"] {
margin-left: auto;
color: var(--text-weak);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-regular);
white-space: nowrap;
}
[data-slot="desktop-app-menu-chevron"] {
display: flex;
margin-left: auto;
color: var(--icon-base);
}
[data-component="getting-started-actions"] { [data-component="getting-started-actions"] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+1 -11
View File
@@ -8,7 +8,6 @@ import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const location = useLocation() const location = useLocation()
@@ -41,15 +40,6 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
) )
} }
export const ProjectDirString = Schema.String.pipe(Schema.brand("ProjectDirString"))
export type ProjectDirString = Schema.Schema.Type<typeof ProjectDirString>
export function decodeDirectory(dir: string): ProjectDirString | undefined {
const decoded = decode64(dir)
if (!decoded) return
return ProjectDirString.make(decoded)
}
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const language = useLanguage() const language = useLanguage()
@@ -58,7 +48,7 @@ export default function Layout(props: ParentProps) {
const resolved = createMemo(() => { const resolved = createMemo(() => {
if (!params.dir) return "" if (!params.dir) return ""
return decodeDirectory(params.dir) ?? "" return decode64(params.dir) ?? ""
}) })
createEffect(() => { createEffect(() => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.15.6", "version": "1.15.5",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -1,289 +0,0 @@
[data-component="go-credit-confirm"] {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: min(34rem, calc(100vw - var(--space-8)));
p {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: 1.6;
}
[data-slot="usage-preview"] {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
}
[data-slot="usage-preview-item"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="usage-preview-header"] {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
[data-slot="usage-preview-label"] {
color: var(--color-text);
font-size: var(--font-size-sm);
font-weight: 500;
}
[data-slot="usage-preview-value"] {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--color-text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
white-space: nowrap;
}
[data-slot="usage-preview-after-value"] {
color: var(--color-accent);
font-weight: 600;
}
[data-slot="usage-preview-progress"] {
position: relative;
height: 8px;
overflow: hidden;
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
}
[data-slot="usage-preview-before"],
[data-slot="usage-preview-after"] {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: var(--border-radius-sm);
}
[data-slot="usage-preview-before"] {
background-color: var(--color-border);
}
[data-slot="usage-preview-after"] {
background-color: var(--color-accent);
transition: width 0.35s ease;
}
[data-slot="usage-preview-reset"] {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
[data-slot="modal-actions"] {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
}
[data-slot="invite-link-box"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
> div {
display: flex;
align-items: center;
gap: var(--space-3);
border-radius: var(--border-radius-sm);
@media (max-width: 40rem) {
align-items: stretch;
flex-direction: column;
}
}
code {
flex: 1;
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@media (max-width: 40rem) {
padding: var(--space-2-5);
font-size: var(--font-size-xs);
}
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-width: 130px;
white-space: nowrap;
@media (max-width: 40rem) {
min-width: 96px;
padding: var(--space-2-5) var(--space-3);
font-size: var(--font-size-xs);
}
}
}
[data-slot="instructions"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
ol {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: 0;
padding-left: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-md);
list-style-position: inside;
line-height: 1.5;
}
}
[data-component="go-referral-section"] {
[data-component="go-referral-overview"] {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding: var(--space-6);
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
@media (max-width: 30rem) {
gap: var(--space-8);
padding: var(--space-4);
}
}
[data-component="go-referral-overview"] + [data-slot="section-title"] {
margin-top: var(--space-4);
}
[data-slot="referrals-table"] {
overflow-x: auto;
}
[data-component="empty-state"] {
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
[data-slot="referrals-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
&:nth-child(1) {
width: 120px;
}
&:nth-child(3) {
width: 180px;
}
&:nth-child(4) {
width: 140px;
}
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="referral-amount"] {
color: var(--color-text);
font-weight: 500;
}
&[data-slot="referral-source"] {
color: var(--color-text-secondary);
font-family: var(--font-sans);
white-space: nowrap;
}
&[data-slot="referral-action"] {
text-align: right;
font-family: var(--font-sans);
white-space: nowrap;
button {
min-width: 96px;
}
}
}
tbody tr {
&[data-status="applied"] {
td:not([data-slot="referral-action"]) {
opacity: 0.68;
}
}
&[data-status="pending"] {
td[data-slot="referral-amount"],
td[data-slot="referral-date"] {
color: var(--color-text-muted);
}
td[data-slot="referral-source"] {
color: var(--color-text);
}
}
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
}
}
}
@@ -1,300 +0,0 @@
import { action, json, query, useAction, useSubmission } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { withActor } from "~/context/auth.withActor"
import { Modal } from "~/component/modal"
import { IconCheck, IconCopy } from "~/component/icon"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
import "./go-referral.css"
type GoReferralSummary = Awaited<ReturnType<typeof Referral.summary>>
type GoReferralReward = GoReferralSummary["rewards"][number]
type GoLiteSubscription = Awaited<ReturnType<typeof queryLiteSubscription>>
type GoReferralUsagePreview = NonNullable<Awaited<ReturnType<typeof Referral.usagePreview>>>
type GoReferralUsagePreviewItem = GoReferralUsagePreview["rollingUsage"]
const emptyUsagePreview = {
rollingUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
weeklyUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
monthlyUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
} satisfies GoReferralUsagePreview
export const queryGoReferral = query(async (workspaceID: string) => {
"use server"
return withActor(() => Referral.summary(), workspaceID)
}, "go.referral.get")
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
"use server"
if (!referralID) return null
return withActor(() => Referral.usagePreview({ referralID }), workspaceID)
}, "go.referral.usagePreview")
export const applyGoReferralReward = action(async (workspaceID: string, referralID: string) => {
"use server"
return json(await withActor(() => Referral.applyReward({ referralID }), workspaceID), {
revalidate: [queryGoReferral.key, queryGoReferralUsagePreview.key, queryLiteSubscription.key],
})
}, "go.referral.reward.apply")
function currentUsagePreview(usage: { resetInSec: number; usagePercent: number }) {
return {
beforePercent: usage.usagePercent,
afterPercent: usage.usagePercent,
resetInSec: usage.resetInSec,
}
}
function formatCurrency(amount: number) {
if (amount % 100 === 0) return `$${amount / 100}`
return `$${(amount / 100).toFixed(2)}`
}
function formatDate(value: string | Date, locale: string) {
return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" }).format(new Date(value))
}
function rewardDescriptionKey(source: GoReferralReward["source"]) {
if (source === "invitee") return "workspace.referral.reward.description.invitee" as const
return "workspace.referral.reward.description.inviter" as const
}
function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) {
if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const
if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const
return "workspace.referral.reward.action.view" as const
}
function CopyInviteLink(props: { summary: GoReferralSummary }) {
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
const event = getRequestEvent()
const origin = event
? new URL(event.request.url).origin
: typeof window === "object"
? window.location.origin
: undefined
const inviteUrl = createMemo(() => {
const path = `/go?ref=${props.summary.referralCode}`
if (!origin) return path
return new URL(path, origin).toString()
})
async function copy() {
if (typeof navigator !== "object") return
await navigator.clipboard.writeText(inviteUrl())
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
}
return (
<div data-slot="invite-link-box">
<div>
<code title={inviteUrl()}>{inviteUrl()}</code>
<button type="button" onClick={copy}>
<Show
when={copied()}
fallback={
<>
<IconCopy style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.referral.copyLink")}
</>
}
>
<IconCheck style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.referral.copied")}
</Show>
</button>
</div>
</div>
)
}
export function GoReferralSection(props: {
workspaceID: string
summary: GoReferralSummary
lite: GoLiteSubscription | undefined
}) {
const i18n = useI18n()
const language = useLanguage()
const apply = useAction(applyGoReferralReward)
const submission = useSubmission(applyGoReferralReward)
const [selected, setSelected] = createSignal<GoReferralReward>()
const [preview, setPreview] = createSignal<GoReferralUsagePreview | null>()
const displayPreview = createMemo(() => {
const loaded = preview()
if (loaded) return loaded
const current = props.lite
if (!current) return emptyUsagePreview
return {
rollingUsage: currentUsagePreview(current.rollingUsage),
weeklyUsage: currentUsagePreview(current.weeklyUsage),
monthlyUsage: currentUsagePreview(current.monthlyUsage),
} satisfies GoReferralUsagePreview
})
createEffect(() => {
const reward = selected()
if (!reward) {
setPreview(undefined)
return
}
const request = { cancelled: false }
setPreview(undefined)
queryGoReferralUsagePreview(props.workspaceID, reward.id).then((result) => {
if (request.cancelled) return
setPreview(result)
})
onCleanup(() => {
request.cancelled = true
})
})
async function onApply() {
const reward = selected()
if (!reward) return
await apply(props.workspaceID, reward.id)
setSelected(undefined)
}
return (
<>
<Show when={props.lite || props.summary.hasReferral}>
<section data-component="go-referral-section">
<Show when={props.lite}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.referral.overview.title")}</h2>
<p>{i18n.t("workspace.referral.overview.subtitle")}</p>
</div>
<div data-component="go-referral-overview">
<CopyInviteLink summary={props.summary} />
<div data-slot="instructions">
<ol>
<li>{i18n.t("workspace.referral.instructions.share")}</li>
<li>{i18n.t("workspace.referral.instructions.subscribe")}</li>
<li>{i18n.t("workspace.referral.instructions.claim")}</li>
</ol>
</div>
</div>
</Show>
<Show when={props.summary.hasReferral}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.referral.rewards.title")}</h2>
<p>{i18n.t("workspace.referral.rewards.description")}</p>
</div>
<div data-slot="referrals-table">
<table data-slot="referrals-table-element">
<thead>
<tr>
<th>{i18n.t("workspace.referral.table.reward")}</th>
<th>{i18n.t("workspace.referral.table.referral")}</th>
<th>{i18n.t("workspace.referral.table.date")}</th>
<th></th>
</tr>
</thead>
<tbody>
<For each={props.summary.rewards}>
{(reward) => {
const earnedAt = () => formatDate(reward.timeCreated, language.tag(language.locale()))
return (
<tr data-status={reward.status} data-source={reward.source}>
<td data-slot="referral-amount">{formatCurrency(reward.amount)}</td>
<td data-slot="referral-source">
{i18n.t(rewardDescriptionKey(reward.source), { email: reward.email ?? "" })}
</td>
<td data-slot="referral-date" title={earnedAt()}>
{earnedAt()}
</td>
<td data-slot="referral-action">
<button
type="button"
disabled={reward.status !== "available" || !props.lite || submission.pending}
onClick={() => setSelected(reward)}
>
{i18n.t(rewardActionKey(reward, !!props.lite))}
</button>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
</Show>
</section>
</Show>
<Modal
open={!!selected()}
onClose={() => setSelected(undefined)}
title={i18n.t("workspace.referral.apply.confirmTitle")}
>
<div data-component="go-credit-confirm">
<p>
{i18n.t("workspace.referral.apply.confirmBody", {
amount: formatCurrency(selected()?.amount ?? 0),
})}
</p>
<GoReferralUsagePreview preview={displayPreview()} />
<div data-slot="modal-actions">
<button type="button" onClick={() => setSelected(undefined)}>
{i18n.t("common.cancel")}
</button>
<button type="button" data-color="primary" disabled={submission.pending} onClick={onApply}>
{submission.pending ? i18n.t("workspace.lite.loading") : i18n.t("workspace.referral.apply.confirmAction")}
</button>
</div>
</div>
</Modal>
</>
)
}
function GoReferralUsagePreview(props: { preview: GoReferralUsagePreview }) {
const i18n = useI18n()
return (
<div data-slot="usage-preview">
<GoReferralUsagePreviewRow
label={i18n.t("workspace.lite.subscription.rollingUsage")}
usage={props.preview.rollingUsage}
/>
<GoReferralUsagePreviewRow
label={i18n.t("workspace.lite.subscription.weeklyUsage")}
usage={props.preview.weeklyUsage}
/>
<GoReferralUsagePreviewRow
label={i18n.t("workspace.lite.subscription.monthlyUsage")}
usage={props.preview.monthlyUsage}
/>
</div>
)
}
function GoReferralUsagePreviewRow(props: { label: string; usage: GoReferralUsagePreviewItem }) {
const i18n = useI18n()
return (
<div data-slot="usage-preview-item">
<div data-slot="usage-preview-header">
<span data-slot="usage-preview-label">{props.label}</span>
<span data-slot="usage-preview-value">
<span>{props.usage.beforePercent}%</span>
<span aria-hidden="true">-&gt;</span>
<span data-slot="usage-preview-after-value">{props.usage.afterPercent}%</span>
</span>
</div>
<div data-slot="usage-preview-progress">
<div data-slot="usage-preview-before" style={{ width: `${props.usage.beforePercent}%` }} />
<div data-slot="usage-preview-after" style={{ width: `${props.usage.afterPercent}%` }} />
</div>
<span data-slot="usage-preview-reset">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(props.usage.resetInSec, i18n, liteResetTimeKeys)}
</span>
</div>
)
}
@@ -55,61 +55,6 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
} }
button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-sans);
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--color-surface-hover);
border-color: var(--color-accent);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
&[data-color="primary"] {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-text);
&:hover:not(:disabled) {
background-color: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
}
&[data-color="ghost"] {
background-color: transparent;
border-color: transparent;
color: var(--color-text-muted);
&:hover:not(:disabled) {
background-color: var(--color-surface-hover);
border-color: var(--color-border);
color: var(--color-text);
}
}
}
} }
[data-slot="title"] { [data-slot="title"] {
@@ -119,16 +64,4 @@
color: var(--color-text); color: var(--color-text);
text-align: center; text-align: center;
} }
[data-slot="content"][data-variant="black"] {
background-color: #000;
border-color: rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.92);
font-family: var(--font-mono);
[data-slot="title"] {
color: rgba(255, 255, 255, 0.92);
font-family: var(--font-mono);
}
}
} }
+8 -30
View File
@@ -1,4 +1,3 @@
import { Dialog as Kobalte } from "@kobalte/core/dialog"
import { JSX, Show } from "solid-js" import { JSX, Show } from "solid-js"
import "./modal.css" import "./modal.css"
@@ -6,41 +5,20 @@ interface ModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
title?: string title?: string
variant?: "black"
children: JSX.Element children: JSX.Element
} }
export function Modal(props: ModalProps) { export function Modal(props: ModalProps) {
return ( return (
<Show when={props.open}> <Show when={props.open}>
<Kobalte <div data-component="modal" data-slot="overlay" onClick={props.onClose}>
modal <div data-slot="content" onClick={(e) => e.stopPropagation()}>
open={props.open} <Show when={props.title}>
preventScroll={false} <h2 data-slot="title">{props.title}</h2>
onOpenChange={(open) => { </Show>
if (!open) props.onClose() {props.children}
}} </div>
> </div>
<Kobalte.Portal>
<Kobalte.Overlay data-component="modal" data-slot="overlay" onClick={props.onClose}>
<Kobalte.Content
data-slot="content"
data-variant={props.variant}
onClick={(e) => e.stopPropagation()}
onOpenAutoFocus={(e) => {
e.preventDefault()
const target = e.currentTarget as HTMLElement | null
target?.focus({ preventScroll: true })
}}
>
<Show when={props.title}>
<Kobalte.Title data-slot="title">{props.title}</Kobalte.Title>
</Show>
{props.children}
</Kobalte.Content>
</Kobalte.Overlay>
</Kobalte.Portal>
</Kobalte>
</Show> </Show>
) )
} }
-33
View File
@@ -660,39 +660,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "طرق دفع أخرى", "workspace.lite.promo.otherMethods": "طرق دفع أخرى",
"workspace.lite.promo.selectMethod": "اختر طريقة الدفع", "workspace.lite.promo.selectMethod": "اختر طريقة الدفع",
"workspace.referral.copyLink": "نسخ الرابط",
"workspace.referral.copied": "تم النسخ",
"workspace.referral.overview.title": "ادعُ أصدقاءك",
"workspace.referral.overview.subtitle": "احصل على $5 عند اشتراك صديق. وسيحصل هو أيضًا على $5.",
"workspace.referral.instructions.share": "شارك رابط الإحالة الخاص بك",
"workspace.referral.instructions.subscribe": "ينضم صديقك ويشترك في Go",
"workspace.referral.instructions.claim": "تحصلان كلاكما على رصيد استخدام بقيمة $5 لتطبيقه على حدود استخدام Go",
"workspace.referral.rewards.title": "مكافآت الإحالة",
"workspace.referral.rewards.description": "طبّق أرصدة الإحالة المتاحة على استخدامك لـ Go.",
"workspace.referral.rewards.subtitle": "تم تطبيق {{applied}} / {{total}} من المكافآت.",
"workspace.referral.rewards.empty": "لا توجد مكافآت إحالة بعد.",
"workspace.referral.table.reward": "المكافأة",
"workspace.referral.table.referral": "الوصف",
"workspace.referral.table.date": "التاريخ",
"workspace.referral.reward.description.inviter": "تمت دعوة {{email}}",
"workspace.referral.reward.description.invitee": "تمت دعوتك بواسطة {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "اشترك لإلغاء القفل",
"workspace.referral.reward.action.view": "عرض المكافأة",
"workspace.referral.reward.action.applied": "تم تطبيق المكافأة",
"workspace.referral.reward.source.pendingInviter": "بانتظار اشتراكه",
"workspace.referral.reward.source.pendingInvitee": "اشترك لإلغاء قفل المكافأة",
"workspace.referral.reward.source.available": "المكافأة جاهزة للتطبيق",
"workspace.referral.reward.source.applied": "تم تطبيق المكافأة",
"workspace.referral.reward.status.applied": "تم تطبيق المكافأة",
"workspace.referral.reward.status.pendingInviter": "اشترك لإلغاء القفل",
"workspace.referral.reward.status.pendingInvitee": "اشترك لإلغاء القفل",
"workspace.referral.apply.noGo": "اشترك لإلغاء القفل",
"workspace.referral.apply.preview": "عرض المكافأة",
"workspace.referral.apply.action": "تطبيق",
"workspace.referral.apply.confirmTitle": "تطبيق المكافأة",
"workspace.referral.apply.confirmBody": "طبِّق {{amount}} لتقليل الاستخدام الحالي في مساحة العمل هذه.",
"workspace.referral.apply.confirmAction": "تطبيق",
"download.title": "OpenCode | تنزيل", "download.title": "OpenCode | تنزيل",
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux", "download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
"download.hero.title": "تنزيل OpenCode", "download.hero.title": "تنزيل OpenCode",
-34
View File
@@ -670,40 +670,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Outros métodos de pagamento", "workspace.lite.promo.otherMethods": "Outros métodos de pagamento",
"workspace.lite.promo.selectMethod": "Selecionar método de pagamento", "workspace.lite.promo.selectMethod": "Selecionar método de pagamento",
"workspace.referral.copyLink": "Copiar link",
"workspace.referral.copied": "Copiado",
"workspace.referral.overview.title": "Convide amigos",
"workspace.referral.overview.subtitle": "Ganhe $5 quando um amigo assinar. Ele também ganha $5.",
"workspace.referral.instructions.share": "Compartilhe seu link de indicação",
"workspace.referral.instructions.subscribe": "Seu amigo entra e assina o Go",
"workspace.referral.instructions.claim":
"Vocês dois ganham um crédito de uso de $5 para aplicar aos seus limites de uso do Go",
"workspace.referral.rewards.title": "Recompensas de indicação",
"workspace.referral.rewards.description": "Aplique os créditos de indicação disponíveis no seu uso do Go.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} recompensas aplicadas.",
"workspace.referral.rewards.empty": "Ainda não há recompensas de indicação.",
"workspace.referral.table.reward": "Recompensa",
"workspace.referral.table.referral": "Descrição",
"workspace.referral.table.date": "Data",
"workspace.referral.reward.description.inviter": "Convidou {{email}}",
"workspace.referral.reward.description.invitee": "Convidado por {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Assine para desbloquear",
"workspace.referral.reward.action.view": "Ver recompensa",
"workspace.referral.reward.action.applied": "Recompensa aplicada",
"workspace.referral.reward.source.pendingInviter": "Aguardando ele assinar",
"workspace.referral.reward.source.pendingInvitee": "Assine para desbloquear a recompensa",
"workspace.referral.reward.source.available": "Recompensa pronta para usar",
"workspace.referral.reward.source.applied": "Recompensa aplicada",
"workspace.referral.reward.status.applied": "Recompensa aplicada",
"workspace.referral.reward.status.pendingInviter": "Assine para desbloquear",
"workspace.referral.reward.status.pendingInvitee": "Assine para desbloquear",
"workspace.referral.apply.noGo": "Assine para desbloquear",
"workspace.referral.apply.preview": "Ver recompensa",
"workspace.referral.apply.action": "Aplicar",
"workspace.referral.apply.confirmTitle": "Aplicar recompensa",
"workspace.referral.apply.confirmBody": "Aplique {{amount}} para reduzir o uso atual deste workspace.",
"workspace.referral.apply.confirmAction": "Aplicar",
"download.title": "OpenCode | Baixar", "download.title": "OpenCode | Baixar",
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux", "download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
"download.hero.title": "Baixar OpenCode", "download.hero.title": "Baixar OpenCode",
-33
View File
@@ -666,39 +666,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder", "workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
"workspace.lite.promo.selectMethod": "Vælg betalingsmetode", "workspace.lite.promo.selectMethod": "Vælg betalingsmetode",
"workspace.referral.copyLink": "Kopiér link",
"workspace.referral.copied": "Kopieret",
"workspace.referral.overview.title": "Inviter venner",
"workspace.referral.overview.subtitle": "Få $5, når en ven abonnerer. De får også $5.",
"workspace.referral.instructions.share": "Del dit henvisningslink",
"workspace.referral.instructions.subscribe": "Din ven tilmelder sig og abonnerer på Go",
"workspace.referral.instructions.claim": "I får begge $5 i forbrugskredit til at bruge på jeres Go-forbrugsgrænser",
"workspace.referral.rewards.title": "Henvisningsbelønninger",
"workspace.referral.rewards.description": "Brug tilgængelige henvisningskreditter på dit Go-forbrug.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} belønninger brugt.",
"workspace.referral.rewards.empty": "Ingen henvisningsbelønninger endnu.",
"workspace.referral.table.reward": "Belønning",
"workspace.referral.table.referral": "Beskrivelse",
"workspace.referral.table.date": "Dato",
"workspace.referral.reward.description.inviter": "Inviterede {{email}}",
"workspace.referral.reward.description.invitee": "Inviteret af {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Abonner for at låse op",
"workspace.referral.reward.action.view": "Vis belønning",
"workspace.referral.reward.action.applied": "Belønning brugt",
"workspace.referral.reward.source.pendingInviter": "Venter på, at de abonnerer",
"workspace.referral.reward.source.pendingInvitee": "Abonner for at låse belønningen op",
"workspace.referral.reward.source.available": "Belønning klar til brug",
"workspace.referral.reward.source.applied": "Belønning brugt",
"workspace.referral.reward.status.applied": "Belønning brugt",
"workspace.referral.reward.status.pendingInviter": "Abonner for at låse op",
"workspace.referral.reward.status.pendingInvitee": "Abonner for at låse op",
"workspace.referral.apply.noGo": "Abonner for at låse op",
"workspace.referral.apply.preview": "Vis belønning",
"workspace.referral.apply.action": "Brug",
"workspace.referral.apply.confirmTitle": "Brug belønning",
"workspace.referral.apply.confirmBody": "Brug {{amount}} til at reducere dette workspaces nuværende forbrug.",
"workspace.referral.apply.confirmAction": "Brug",
"download.title": "OpenCode | Download", "download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode til macOS, Windows og Linux", "download.meta.description": "Download OpenCode til macOS, Windows og Linux",
"download.hero.title": "Download OpenCode", "download.hero.title": "Download OpenCode",
-35
View File
@@ -669,41 +669,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden", "workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden",
"workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen", "workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen",
"workspace.referral.copyLink": "Link kopieren",
"workspace.referral.copied": "Kopiert",
"workspace.referral.overview.title": "Freunde einladen",
"workspace.referral.overview.subtitle": "Erhalte $5, wenn ein Freund abonniert. Er bekommt ebenfalls $5.",
"workspace.referral.instructions.share": "Teile deinen Empfehlungslink",
"workspace.referral.instructions.subscribe": "Dein Freund tritt bei und abonniert Go",
"workspace.referral.instructions.claim":
"Ihr erhaltet beide ein Nutzungsguthaben von $5, das ihr auf eure Go-Nutzungslimits anrechnen könnt",
"workspace.referral.rewards.title": "Empfehlungsbelohnungen",
"workspace.referral.rewards.description": "Verfügbare Empfehlungsguthaben auf deine Go-Nutzung anwenden.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} Belohnungen eingelöst.",
"workspace.referral.rewards.empty": "Noch keine Empfehlungsbelohnungen.",
"workspace.referral.table.reward": "Belohnung",
"workspace.referral.table.referral": "Beschreibung",
"workspace.referral.table.date": "Datum",
"workspace.referral.reward.description.inviter": "{{email}} eingeladen",
"workspace.referral.reward.description.invitee": "Eingeladen von {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Abonnieren zum Freischalten",
"workspace.referral.reward.action.view": "Belohnung ansehen",
"workspace.referral.reward.action.applied": "Belohnung eingelöst",
"workspace.referral.reward.source.pendingInviter": "Warten auf das Abo des Freundes",
"workspace.referral.reward.source.pendingInvitee": "Abonnieren, um Belohnung freizuschalten",
"workspace.referral.reward.source.available": "Belohnung kann eingelöst werden",
"workspace.referral.reward.source.applied": "Belohnung eingelöst",
"workspace.referral.reward.status.applied": "Belohnung eingelöst",
"workspace.referral.reward.status.pendingInviter": "Abonnieren zum Freischalten",
"workspace.referral.reward.status.pendingInvitee": "Abonnieren zum Freischalten",
"workspace.referral.apply.noGo": "Abonnieren zum Freischalten",
"workspace.referral.apply.preview": "Belohnung ansehen",
"workspace.referral.apply.action": "Einlösen",
"workspace.referral.apply.confirmTitle": "Belohnung einlösen",
"workspace.referral.apply.confirmBody":
"Löse {{amount}} ein, um die aktuelle Nutzung dieses Workspace zu reduzieren.",
"workspace.referral.apply.confirmAction": "Einlösen",
"download.title": "OpenCode | Download", "download.title": "OpenCode | Download",
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter", "download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
"download.hero.title": "OpenCode herunterladen", "download.hero.title": "OpenCode herunterladen",
-33
View File
@@ -662,39 +662,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Other payment methods", "workspace.lite.promo.otherMethods": "Other payment methods",
"workspace.lite.promo.selectMethod": "Select payment method", "workspace.lite.promo.selectMethod": "Select payment method",
"workspace.referral.copyLink": "Copy Link",
"workspace.referral.copied": "Copied",
"workspace.referral.overview.title": "Invite friends",
"workspace.referral.overview.subtitle": "Earn $5 when a friend subscribes. Theyll get $5 too.",
"workspace.referral.instructions.share": "Share your referral link",
"workspace.referral.instructions.subscribe": "Your friend joins and subscribes to Go",
"workspace.referral.instructions.claim": "You both get a $5 usage credit to apply toward your Go usage limits",
"workspace.referral.rewards.title": "Referral rewards",
"workspace.referral.rewards.description": "Apply available referral credits toward your Go usage.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} rewards applied.",
"workspace.referral.rewards.empty": "No referral rewards yet.",
"workspace.referral.table.reward": "Reward",
"workspace.referral.table.referral": "Description",
"workspace.referral.table.date": "Date",
"workspace.referral.reward.description.inviter": "Invited {{email}}",
"workspace.referral.reward.description.invitee": "Invited by {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Subscribe to unlock",
"workspace.referral.reward.action.view": "View Reward",
"workspace.referral.reward.action.applied": "Reward Applied",
"workspace.referral.reward.source.pendingInviter": "Waiting for them to subscribe",
"workspace.referral.reward.source.pendingInvitee": "Subscribe to unlock reward",
"workspace.referral.reward.source.available": "Reward ready to apply",
"workspace.referral.reward.source.applied": "Reward applied",
"workspace.referral.reward.status.applied": "Reward Applied",
"workspace.referral.reward.status.pendingInviter": "Subscribe to unlock",
"workspace.referral.reward.status.pendingInvitee": "Subscribe to unlock",
"workspace.referral.apply.noGo": "Subscribe to unlock",
"workspace.referral.apply.preview": "View Reward",
"workspace.referral.apply.action": "Apply",
"workspace.referral.apply.confirmTitle": "Apply reward",
"workspace.referral.apply.confirmBody": "Apply {{amount}} to reduce this workspace's current usage.",
"workspace.referral.apply.confirmAction": "Apply",
"download.title": "OpenCode | Download", "download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux", "download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
"download.hero.title": "Download OpenCode", "download.hero.title": "Download OpenCode",
-34
View File
@@ -670,40 +670,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Otros métodos de pago", "workspace.lite.promo.otherMethods": "Otros métodos de pago",
"workspace.lite.promo.selectMethod": "Seleccionar método de pago", "workspace.lite.promo.selectMethod": "Seleccionar método de pago",
"workspace.referral.copyLink": "Copiar enlace",
"workspace.referral.copied": "Copiado",
"workspace.referral.overview.title": "Invita amigos",
"workspace.referral.overview.subtitle": "Gana $5 cuando un amigo se suscriba. Él también recibirá $5.",
"workspace.referral.instructions.share": "Comparte tu enlace de referido",
"workspace.referral.instructions.subscribe": "Tu amigo se une y se suscribe a Go",
"workspace.referral.instructions.claim":
"Ambos reciben un crédito de uso de $5 para aplicar a sus límites de uso de Go",
"workspace.referral.rewards.title": "Recompensas por referidos",
"workspace.referral.rewards.description": "Aplica los créditos por referidos disponibles a tu uso de Go.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} recompensas aplicadas.",
"workspace.referral.rewards.empty": "Aún no hay recompensas por referidos.",
"workspace.referral.table.reward": "Recompensa",
"workspace.referral.table.referral": "Descripción",
"workspace.referral.table.date": "Fecha",
"workspace.referral.reward.description.inviter": "Invitaste a {{email}}",
"workspace.referral.reward.description.invitee": "Invitado por {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Suscríbete para desbloquear",
"workspace.referral.reward.action.view": "Ver recompensa",
"workspace.referral.reward.action.applied": "Recompensa aplicada",
"workspace.referral.reward.source.pendingInviter": "Esperando a que se suscriba",
"workspace.referral.reward.source.pendingInvitee": "Suscríbete para desbloquear la recompensa",
"workspace.referral.reward.source.available": "Recompensa lista para aplicar",
"workspace.referral.reward.source.applied": "Recompensa aplicada",
"workspace.referral.reward.status.applied": "Recompensa aplicada",
"workspace.referral.reward.status.pendingInviter": "Suscríbete para desbloquear",
"workspace.referral.reward.status.pendingInvitee": "Suscríbete para desbloquear",
"workspace.referral.apply.noGo": "Suscríbete para desbloquear",
"workspace.referral.apply.preview": "Ver recompensa",
"workspace.referral.apply.action": "Aplicar",
"workspace.referral.apply.confirmTitle": "Aplicar recompensa",
"workspace.referral.apply.confirmBody": "Aplica {{amount}} para reducir el uso actual de este workspace.",
"workspace.referral.apply.confirmAction": "Aplicar",
"download.title": "OpenCode | Descargar", "download.title": "OpenCode | Descargar",
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux", "download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
"download.hero.title": "Descargar OpenCode", "download.hero.title": "Descargar OpenCode",
-35
View File
@@ -676,41 +676,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Autres méthodes de paiement", "workspace.lite.promo.otherMethods": "Autres méthodes de paiement",
"workspace.lite.promo.selectMethod": "Sélectionner la méthode de paiement", "workspace.lite.promo.selectMethod": "Sélectionner la méthode de paiement",
"workspace.referral.copyLink": "Copier le lien",
"workspace.referral.copied": "Copié",
"workspace.referral.overview.title": "Inviter des amis",
"workspace.referral.overview.subtitle": "Gagnez $5 lorsqu'un ami s'abonne. Il recevra également $5.",
"workspace.referral.instructions.share": "Partagez votre lien de parrainage",
"workspace.referral.instructions.subscribe": "Votre ami rejoint et s'abonne à Go",
"workspace.referral.instructions.claim":
"Vous recevez tous les deux un crédit d'utilisation de $5 à appliquer à vos limites d'utilisation Go",
"workspace.referral.rewards.title": "Récompenses de parrainage",
"workspace.referral.rewards.description":
"Utilisez les crédits de parrainage disponibles pour votre utilisation de Go.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} récompenses utilisées.",
"workspace.referral.rewards.empty": "Aucune récompense de parrainage pour l'instant.",
"workspace.referral.table.reward": "Récompense",
"workspace.referral.table.referral": "Description",
"workspace.referral.table.date": "Date",
"workspace.referral.reward.description.inviter": "Vous avez invité {{email}}",
"workspace.referral.reward.description.invitee": "Invité par {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Abonnez-vous pour débloquer",
"workspace.referral.reward.action.view": "Voir la récompense",
"workspace.referral.reward.action.applied": "Récompense utilisée",
"workspace.referral.reward.source.pendingInviter": "En attente de son abonnement",
"workspace.referral.reward.source.pendingInvitee": "Abonnez-vous pour débloquer la récompense",
"workspace.referral.reward.source.available": "Récompense prête à utiliser",
"workspace.referral.reward.source.applied": "Récompense utilisée",
"workspace.referral.reward.status.applied": "Récompense utilisée",
"workspace.referral.reward.status.pendingInviter": "Abonnez-vous pour débloquer",
"workspace.referral.reward.status.pendingInvitee": "Abonnez-vous pour débloquer",
"workspace.referral.apply.noGo": "Abonnez-vous pour débloquer",
"workspace.referral.apply.preview": "Voir la récompense",
"workspace.referral.apply.action": "Utiliser",
"workspace.referral.apply.confirmTitle": "Utiliser la récompense",
"workspace.referral.apply.confirmBody": "Utilisez {{amount}} pour réduire l'utilisation actuelle de ce workspace.",
"workspace.referral.apply.confirmAction": "Utiliser",
"download.title": "OpenCode | Téléchargement", "download.title": "OpenCode | Téléchargement",
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux", "download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
"download.hero.title": "Télécharger OpenCode", "download.hero.title": "Télécharger OpenCode",
-34
View File
@@ -668,40 +668,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Altri metodi di pagamento", "workspace.lite.promo.otherMethods": "Altri metodi di pagamento",
"workspace.lite.promo.selectMethod": "Seleziona metodo di pagamento", "workspace.lite.promo.selectMethod": "Seleziona metodo di pagamento",
"workspace.referral.copyLink": "Copia link",
"workspace.referral.copied": "Copiato",
"workspace.referral.overview.title": "Invita amici",
"workspace.referral.overview.subtitle": "Guadagna $5 quando un amico si abbona. Anche lui riceverà $5.",
"workspace.referral.instructions.share": "Condividi il tuo link di referral",
"workspace.referral.instructions.subscribe": "Il tuo amico si iscrive e si abbona a Go",
"workspace.referral.instructions.claim":
"Entrambi ricevete un credito di utilizzo di $5 da applicare ai vostri limiti di utilizzo Go",
"workspace.referral.rewards.title": "Premi referral",
"workspace.referral.rewards.description": "Applica i crediti referral disponibili al tuo utilizzo di Go.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} premi utilizzati.",
"workspace.referral.rewards.empty": "Nessun premio referral ancora.",
"workspace.referral.table.reward": "Premio",
"workspace.referral.table.referral": "Descrizione",
"workspace.referral.table.date": "Data",
"workspace.referral.reward.description.inviter": "Hai invitato {{email}}",
"workspace.referral.reward.description.invitee": "Invitato da {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Abbonati per sbloccare",
"workspace.referral.reward.action.view": "Vedi premio",
"workspace.referral.reward.action.applied": "Premio utilizzato",
"workspace.referral.reward.source.pendingInviter": "In attesa che si abboni",
"workspace.referral.reward.source.pendingInvitee": "Abbonati per sbloccare il premio",
"workspace.referral.reward.source.available": "Premio pronto da utilizzare",
"workspace.referral.reward.source.applied": "Premio utilizzato",
"workspace.referral.reward.status.applied": "Premio utilizzato",
"workspace.referral.reward.status.pendingInviter": "Abbonati per sbloccare",
"workspace.referral.reward.status.pendingInvitee": "Abbonati per sbloccare",
"workspace.referral.apply.noGo": "Abbonati per sbloccare",
"workspace.referral.apply.preview": "Vedi premio",
"workspace.referral.apply.action": "Utilizza",
"workspace.referral.apply.confirmTitle": "Utilizza premio",
"workspace.referral.apply.confirmBody": "Utilizza {{amount}} per ridurre l'utilizzo attuale di questo workspace.",
"workspace.referral.apply.confirmAction": "Utilizza",
"download.title": "OpenCode | Download", "download.title": "OpenCode | Download",
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux", "download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
"download.hero.title": "Scarica OpenCode", "download.hero.title": "Scarica OpenCode",
-33
View File
@@ -668,39 +668,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "その他の支払い方法", "workspace.lite.promo.otherMethods": "その他の支払い方法",
"workspace.lite.promo.selectMethod": "支払い方法を選択", "workspace.lite.promo.selectMethod": "支払い方法を選択",
"workspace.referral.copyLink": "リンクをコピー",
"workspace.referral.copied": "コピーしました",
"workspace.referral.overview.title": "友達を招待",
"workspace.referral.overview.subtitle": "友達がサブスクライブすると $5 を獲得。友達にも $5 が付与されます。",
"workspace.referral.instructions.share": "リファラルリンクをシェア",
"workspace.referral.instructions.subscribe": "友達が参加して Go にサブスクライブ",
"workspace.referral.instructions.claim": "二人とも $5 の利用クレジットを獲得し、Go の利用上限に充当できます",
"workspace.referral.rewards.title": "リファラル特典",
"workspace.referral.rewards.description": "利用可能なリファラルクレジットを Go の利用に適用します。",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} 件の特典を適用済み。",
"workspace.referral.rewards.empty": "リファラル特典はまだありません。",
"workspace.referral.table.reward": "特典",
"workspace.referral.table.referral": "説明",
"workspace.referral.table.date": "日付",
"workspace.referral.reward.description.inviter": "{{email}} を招待しました",
"workspace.referral.reward.description.invitee": "{{email}} に招待されました",
"workspace.referral.reward.action.subscribeUnlock": "サブスクライブしてアンロック",
"workspace.referral.reward.action.view": "特典を表示",
"workspace.referral.reward.action.applied": "特典を適用済み",
"workspace.referral.reward.source.pendingInviter": "友達のサブスクライブ待ち",
"workspace.referral.reward.source.pendingInvitee": "サブスクライブして特典をアンロック",
"workspace.referral.reward.source.available": "特典は適用可能です",
"workspace.referral.reward.source.applied": "特典を適用済み",
"workspace.referral.reward.status.applied": "特典を適用済み",
"workspace.referral.reward.status.pendingInviter": "サブスクライブしてアンロック",
"workspace.referral.reward.status.pendingInvitee": "サブスクライブしてアンロック",
"workspace.referral.apply.noGo": "サブスクライブしてアンロック",
"workspace.referral.apply.preview": "特典を表示",
"workspace.referral.apply.action": "適用",
"workspace.referral.apply.confirmTitle": "特典を適用",
"workspace.referral.apply.confirmBody": "{{amount}} を適用して、このワークスペースの現在の使用量を減らします。",
"workspace.referral.apply.confirmAction": "適用",
"download.title": "OpenCode | ダウンロード", "download.title": "OpenCode | ダウンロード",
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード", "download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
"download.hero.title": "OpenCode をダウンロード", "download.hero.title": "OpenCode をダウンロード",
-33
View File
@@ -660,39 +660,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "기타 결제 수단", "workspace.lite.promo.otherMethods": "기타 결제 수단",
"workspace.lite.promo.selectMethod": "결제 수단 선택", "workspace.lite.promo.selectMethod": "결제 수단 선택",
"workspace.referral.copyLink": "링크 복사",
"workspace.referral.copied": "복사됨",
"workspace.referral.overview.title": "친구 초대",
"workspace.referral.overview.subtitle": "친구가 구독하면 $5를 받으세요. 친구도 $5를 받습니다.",
"workspace.referral.instructions.share": "추천 링크 공유",
"workspace.referral.instructions.subscribe": "친구가 가입하고 Go를 구독",
"workspace.referral.instructions.claim": "두 분 모두 $5 사용 크레딧을 받아 Go 사용 한도에 적용할 수 있습니다",
"workspace.referral.rewards.title": "추천 보상",
"workspace.referral.rewards.description": "사용 가능한 추천 크레딧을 Go 사용량에 적용합니다.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}}개 보상 사용됨.",
"workspace.referral.rewards.empty": "아직 추천 보상이 없습니다.",
"workspace.referral.table.reward": "보상",
"workspace.referral.table.referral": "설명",
"workspace.referral.table.date": "날짜",
"workspace.referral.reward.description.inviter": "{{email}} 초대됨",
"workspace.referral.reward.description.invitee": "{{email}}님이 초대",
"workspace.referral.reward.action.subscribeUnlock": "구독하여 잠금 해제",
"workspace.referral.reward.action.view": "보상 보기",
"workspace.referral.reward.action.applied": "보상 사용됨",
"workspace.referral.reward.source.pendingInviter": "친구의 구독을 기다리는 중",
"workspace.referral.reward.source.pendingInvitee": "구독하여 보상 잠금 해제",
"workspace.referral.reward.source.available": "보상 사용 가능",
"workspace.referral.reward.source.applied": "보상 사용됨",
"workspace.referral.reward.status.applied": "보상 사용됨",
"workspace.referral.reward.status.pendingInviter": "구독하여 잠금 해제",
"workspace.referral.reward.status.pendingInvitee": "구독하여 잠금 해제",
"workspace.referral.apply.noGo": "구독하여 잠금 해제",
"workspace.referral.apply.preview": "보상 보기",
"workspace.referral.apply.action": "사용",
"workspace.referral.apply.confirmTitle": "보상 사용",
"workspace.referral.apply.confirmBody": "{{amount}}를 사용하여 이 워크스페이스의 현재 사용량을 줄입니다.",
"workspace.referral.apply.confirmAction": "사용",
"download.title": "OpenCode | 다운로드", "download.title": "OpenCode | 다운로드",
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드", "download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
"download.hero.title": "OpenCode 다운로드", "download.hero.title": "OpenCode 다운로드",
-33
View File
@@ -667,39 +667,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder", "workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
"workspace.lite.promo.selectMethod": "Velg betalingsmetode", "workspace.lite.promo.selectMethod": "Velg betalingsmetode",
"workspace.referral.copyLink": "Kopier lenke",
"workspace.referral.copied": "Kopiert",
"workspace.referral.overview.title": "Inviter venner",
"workspace.referral.overview.subtitle": "Få $5 når en venn abonnerer. De får også $5.",
"workspace.referral.instructions.share": "Del henvisningslenken din",
"workspace.referral.instructions.subscribe": "Vennen din blir med og abonnerer på Go",
"workspace.referral.instructions.claim": "Dere får begge $5 i brukskreditt å bruke på Go-bruksgrensene deres",
"workspace.referral.rewards.title": "Henvisningsbelønninger",
"workspace.referral.rewards.description": "Bruk tilgjengelige henvisningskreditter på Go-bruken din.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} belønninger brukt.",
"workspace.referral.rewards.empty": "Ingen henvisningsbelønninger ennå.",
"workspace.referral.table.reward": "Belønning",
"workspace.referral.table.referral": "Beskrivelse",
"workspace.referral.table.date": "Dato",
"workspace.referral.reward.description.inviter": "Inviterte {{email}}",
"workspace.referral.reward.description.invitee": "Invitert av {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Abonner for å låse opp",
"workspace.referral.reward.action.view": "Vis belønning",
"workspace.referral.reward.action.applied": "Belønning brukt",
"workspace.referral.reward.source.pendingInviter": "Venter på at de abonnerer",
"workspace.referral.reward.source.pendingInvitee": "Abonner for å låse opp belønningen",
"workspace.referral.reward.source.available": "Belønning klar til bruk",
"workspace.referral.reward.source.applied": "Belønning brukt",
"workspace.referral.reward.status.applied": "Belønning brukt",
"workspace.referral.reward.status.pendingInviter": "Abonner for å låse opp",
"workspace.referral.reward.status.pendingInvitee": "Abonner for å låse opp",
"workspace.referral.apply.noGo": "Abonner for å låse opp",
"workspace.referral.apply.preview": "Vis belønning",
"workspace.referral.apply.action": "Bruk",
"workspace.referral.apply.confirmTitle": "Bruk belønning",
"workspace.referral.apply.confirmBody": "Bruk {{amount}} for å redusere dette workspacets nåværende forbruk.",
"workspace.referral.apply.confirmAction": "Bruk",
"download.title": "OpenCode | Last ned", "download.title": "OpenCode | Last ned",
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux", "download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
"download.hero.title": "Last ned OpenCode", "download.hero.title": "Last ned OpenCode",
-33
View File
@@ -668,39 +668,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Inne metody płatności", "workspace.lite.promo.otherMethods": "Inne metody płatności",
"workspace.lite.promo.selectMethod": "Wybierz metodę płatności", "workspace.lite.promo.selectMethod": "Wybierz metodę płatności",
"workspace.referral.copyLink": "Kopiuj link",
"workspace.referral.copied": "Skopiowano",
"workspace.referral.overview.title": "Zaproś znajomych",
"workspace.referral.overview.subtitle": "Zdobądź $5, gdy znajomy się zasubskrybuje. On też dostanie $5.",
"workspace.referral.instructions.share": "Udostępnij swój link polecający",
"workspace.referral.instructions.subscribe": "Twój znajomy dołącza i subskrybuje Go",
"workspace.referral.instructions.claim": "Oboje otrzymujecie kredyt $5 do wykorzystania na limity użycia Go",
"workspace.referral.rewards.title": "Nagrody za polecenia",
"workspace.referral.rewards.description": "Wykorzystaj dostępne środki za polecenia na swoje użycie Go.",
"workspace.referral.rewards.subtitle": "Wykorzystano {{applied}} / {{total}} nagród.",
"workspace.referral.rewards.empty": "Brak nagród za polecenia.",
"workspace.referral.table.reward": "Nagroda",
"workspace.referral.table.referral": "Opis",
"workspace.referral.table.date": "Data",
"workspace.referral.reward.description.inviter": "Zaproszono {{email}}",
"workspace.referral.reward.description.invitee": "Zaproszony przez {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Subskrybuj, aby odblokować",
"workspace.referral.reward.action.view": "Zobacz nagrodę",
"workspace.referral.reward.action.applied": "Nagroda wykorzystana",
"workspace.referral.reward.source.pendingInviter": "Oczekiwanie na jego subskrypcję",
"workspace.referral.reward.source.pendingInvitee": "Subskrybuj, aby odblokować nagrodę",
"workspace.referral.reward.source.available": "Nagroda gotowa do wykorzystania",
"workspace.referral.reward.source.applied": "Nagroda wykorzystana",
"workspace.referral.reward.status.applied": "Nagroda wykorzystana",
"workspace.referral.reward.status.pendingInviter": "Subskrybuj, aby odblokować",
"workspace.referral.reward.status.pendingInvitee": "Subskrybuj, aby odblokować",
"workspace.referral.apply.noGo": "Subskrybuj, aby odblokować",
"workspace.referral.apply.preview": "Zobacz nagrodę",
"workspace.referral.apply.action": "Wykorzystaj",
"workspace.referral.apply.confirmTitle": "Wykorzystaj nagrodę",
"workspace.referral.apply.confirmBody": "Wykorzystaj {{amount}}, aby zmniejszyć aktualne użycie w tym workspace.",
"workspace.referral.apply.confirmAction": "Wykorzystaj",
"download.title": "OpenCode | Pobierz", "download.title": "OpenCode | Pobierz",
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux", "download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
"download.hero.title": "Pobierz OpenCode", "download.hero.title": "Pobierz OpenCode",
-35
View File
@@ -674,41 +674,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Другие способы оплаты", "workspace.lite.promo.otherMethods": "Другие способы оплаты",
"workspace.lite.promo.selectMethod": "Выберите способ оплаты", "workspace.lite.promo.selectMethod": "Выберите способ оплаты",
"workspace.referral.copyLink": "Копировать ссылку",
"workspace.referral.copied": "Скопировано",
"workspace.referral.overview.title": "Пригласите друзей",
"workspace.referral.overview.subtitle": "Получите $5, когда друг оформит подписку. Он тоже получит $5.",
"workspace.referral.instructions.share": "Поделитесь своей реферальной ссылкой",
"workspace.referral.instructions.subscribe": "Ваш друг присоединяется и оформляет подписку на Go",
"workspace.referral.instructions.claim":
"Вы оба получаете кредит на использование $5, который можно применить к лимитам использования Go",
"workspace.referral.rewards.title": "Реферальные награды",
"workspace.referral.rewards.description": "Используйте доступные реферальные кредиты для оплаты использования Go.",
"workspace.referral.rewards.subtitle": "Использовано {{applied}} / {{total}} наград.",
"workspace.referral.rewards.empty": "Реферальных наград пока нет.",
"workspace.referral.table.reward": "Награда",
"workspace.referral.table.referral": "Описание",
"workspace.referral.table.date": "Дата",
"workspace.referral.reward.description.inviter": "Приглашён {{email}}",
"workspace.referral.reward.description.invitee": "Приглашены пользователем {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Оформите подписку для разблокировки",
"workspace.referral.reward.action.view": "Посмотреть награду",
"workspace.referral.reward.action.applied": "Награда использована",
"workspace.referral.reward.source.pendingInviter": "Ожидание его подписки",
"workspace.referral.reward.source.pendingInvitee": "Подпишитесь, чтобы разблокировать награду",
"workspace.referral.reward.source.available": "Награда готова к применению",
"workspace.referral.reward.source.applied": "Награда использована",
"workspace.referral.reward.status.applied": "Награда использована",
"workspace.referral.reward.status.pendingInviter": "Оформите подписку для разблокировки",
"workspace.referral.reward.status.pendingInvitee": "Оформите подписку для разблокировки",
"workspace.referral.apply.noGo": "Оформите подписку для разблокировки",
"workspace.referral.apply.preview": "Посмотреть награду",
"workspace.referral.apply.action": "Применить",
"workspace.referral.apply.confirmTitle": "Применить награду",
"workspace.referral.apply.confirmBody":
"Используйте {{amount}}, чтобы уменьшить текущее использование этого workspace.",
"workspace.referral.apply.confirmAction": "Применить",
"download.title": "OpenCode | Скачать", "download.title": "OpenCode | Скачать",
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux", "download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
"download.hero.title": "Скачать OpenCode", "download.hero.title": "Скачать OpenCode",
-33
View File
@@ -663,39 +663,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ", "workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ",
"workspace.lite.promo.selectMethod": "เลือกวิธีการชำระเงิน", "workspace.lite.promo.selectMethod": "เลือกวิธีการชำระเงิน",
"workspace.referral.copyLink": "คัดลอกลิงก์",
"workspace.referral.copied": "คัดลอกแล้ว",
"workspace.referral.overview.title": "ชวนเพื่อน",
"workspace.referral.overview.subtitle": "รับ $5 เมื่อเพื่อนสมัครสมาชิก เพื่อนก็จะได้รับ $5 เช่นกัน",
"workspace.referral.instructions.share": "แชร์ลิงก์แนะนำของคุณ",
"workspace.referral.instructions.subscribe": "เพื่อนของคุณเข้าร่วมและสมัครสมาชิก Go",
"workspace.referral.instructions.claim": "คุณทั้งคู่จะได้รับเครดิตการใช้งาน $5 เพื่อใช้กับขีดจำกัดการใช้งาน Go",
"workspace.referral.rewards.title": "รางวัลการแนะนำ",
"workspace.referral.rewards.description": "ใช้เครดิตการแนะนำที่มีอยู่กับการใช้งาน Go ของคุณ",
"workspace.referral.rewards.subtitle": "ใช้แล้ว {{applied}} / {{total}} รางวัล",
"workspace.referral.rewards.empty": "ยังไม่มีรางวัลการแนะนำ",
"workspace.referral.table.reward": "รางวัล",
"workspace.referral.table.referral": "คำอธิบาย",
"workspace.referral.table.date": "วันที่",
"workspace.referral.reward.description.inviter": "เชิญ {{email}}",
"workspace.referral.reward.description.invitee": "ได้รับเชิญจาก {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "สมัครสมาชิกเพื่อปลดล็อก",
"workspace.referral.reward.action.view": "ดูรางวัล",
"workspace.referral.reward.action.applied": "ใช้รางวัลแล้ว",
"workspace.referral.reward.source.pendingInviter": "รอเพื่อนสมัครสมาชิก",
"workspace.referral.reward.source.pendingInvitee": "สมัครสมาชิกเพื่อปลดล็อกรางวัล",
"workspace.referral.reward.source.available": "รางวัลพร้อมใช้งาน",
"workspace.referral.reward.source.applied": "ใช้รางวัลแล้ว",
"workspace.referral.reward.status.applied": "ใช้รางวัลแล้ว",
"workspace.referral.reward.status.pendingInviter": "สมัครสมาชิกเพื่อปลดล็อก",
"workspace.referral.reward.status.pendingInvitee": "สมัครสมาชิกเพื่อปลดล็อก",
"workspace.referral.apply.noGo": "สมัครสมาชิกเพื่อปลดล็อก",
"workspace.referral.apply.preview": "ดูรางวัล",
"workspace.referral.apply.action": "ใช้",
"workspace.referral.apply.confirmTitle": "ใช้รางวัล",
"workspace.referral.apply.confirmBody": "ใช้ {{amount}} เพื่อลดการใช้งานปัจจุบันของ workspace นี้",
"workspace.referral.apply.confirmAction": "ใช้",
"download.title": "OpenCode | ดาวน์โหลด", "download.title": "OpenCode | ดาวน์โหลด",
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux", "download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
"download.hero.title": "ดาวน์โหลด OpenCode", "download.hero.title": "ดาวน์โหลด OpenCode",
-34
View File
@@ -670,40 +670,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri", "workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri",
"workspace.lite.promo.selectMethod": "Ödeme yöntemini seçin", "workspace.lite.promo.selectMethod": "Ödeme yöntemini seçin",
"workspace.referral.copyLink": "Bağlantıyı Kopyala",
"workspace.referral.copied": "Kopyalandı",
"workspace.referral.overview.title": "Arkadaşlarını davet et",
"workspace.referral.overview.subtitle": "Bir arkadaşın abone olduğunda $5 kazan. O da $5 alacak.",
"workspace.referral.instructions.share": "Referans bağlantını paylaş",
"workspace.referral.instructions.subscribe": "Arkadaşın katılır ve Go'ya abone olur",
"workspace.referral.instructions.claim":
"İkiniz de Go kullanım limitlerinize uygulamak için $5 kullanım kredisi alırsınız",
"workspace.referral.rewards.title": "Davet ödülleri",
"workspace.referral.rewards.description": "Mevcut davet kredilerini Go kullanımınıza uygulayın.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} ödül kullanıldı.",
"workspace.referral.rewards.empty": "Henüz davet ödülü yok.",
"workspace.referral.table.reward": "Ödül",
"workspace.referral.table.referral": "Açıklama",
"workspace.referral.table.date": "Tarih",
"workspace.referral.reward.description.inviter": "{{email}} davet edildi",
"workspace.referral.reward.description.invitee": "{{email}} tarafından davet edildi",
"workspace.referral.reward.action.subscribeUnlock": "Kilidi açmak için abone ol",
"workspace.referral.reward.action.view": "Ödülü Görüntüle",
"workspace.referral.reward.action.applied": "Ödül Kullanıldı",
"workspace.referral.reward.source.pendingInviter": "Abone olması bekleniyor",
"workspace.referral.reward.source.pendingInvitee": "Ödülün kilidini açmak için abone ol",
"workspace.referral.reward.source.available": "Ödül kullanıma hazır",
"workspace.referral.reward.source.applied": "Ödül kullanıldı",
"workspace.referral.reward.status.applied": "Ödül Kullanıldı",
"workspace.referral.reward.status.pendingInviter": "Kilidi açmak için abone ol",
"workspace.referral.reward.status.pendingInvitee": "Kilidi açmak için abone ol",
"workspace.referral.apply.noGo": "Kilidi açmak için abone ol",
"workspace.referral.apply.preview": "Ödülü Görüntüle",
"workspace.referral.apply.action": "Kullan",
"workspace.referral.apply.confirmTitle": "Ödülü kullan",
"workspace.referral.apply.confirmBody": "Bu workspace'in mevcut kullanımını azaltmak için {{amount}} kullan.",
"workspace.referral.apply.confirmAction": "Kullan",
"download.title": "OpenCode | İndir", "download.title": "OpenCode | İndir",
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin", "download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
"download.hero.title": "OpenCode'u İndir", "download.hero.title": "OpenCode'u İndir",
-33
View File
@@ -643,39 +643,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "其他付款方式", "workspace.lite.promo.otherMethods": "其他付款方式",
"workspace.lite.promo.selectMethod": "选择付款方式", "workspace.lite.promo.selectMethod": "选择付款方式",
"workspace.referral.copyLink": "复制链接",
"workspace.referral.copied": "已复制",
"workspace.referral.overview.title": "邀请好友",
"workspace.referral.overview.subtitle": "好友订阅后,您可获得 $5,对方也可获得 $5。",
"workspace.referral.instructions.share": "分享您的推荐链接。",
"workspace.referral.instructions.subscribe": "好友加入并订阅 Go。",
"workspace.referral.instructions.claim": "你们都将获得 $5 使用额度,可用于您的 Go 使用限额。",
"workspace.referral.rewards.title": "邀请奖励",
"workspace.referral.rewards.description": "将可用的邀请积分应用到您的 Go 用量。",
"workspace.referral.rewards.subtitle": "已使用 {{applied}} / {{total}} 个奖励。",
"workspace.referral.rewards.empty": "暂无邀请奖励。",
"workspace.referral.table.reward": "奖励",
"workspace.referral.table.referral": "描述",
"workspace.referral.table.date": "日期",
"workspace.referral.reward.description.inviter": "已邀请 {{email}}",
"workspace.referral.reward.description.invitee": "由 {{email}} 邀请",
"workspace.referral.reward.action.subscribeUnlock": "订阅以解锁",
"workspace.referral.reward.action.view": "查看奖励",
"workspace.referral.reward.action.applied": "奖励已使用",
"workspace.referral.reward.source.pendingInviter": "等待对方订阅",
"workspace.referral.reward.source.pendingInvitee": "订阅即可解锁奖励",
"workspace.referral.reward.source.available": "奖励可使用",
"workspace.referral.reward.source.applied": "奖励已使用",
"workspace.referral.reward.status.applied": "奖励已使用",
"workspace.referral.reward.status.pendingInviter": "订阅以解锁",
"workspace.referral.reward.status.pendingInvitee": "订阅以解锁",
"workspace.referral.apply.noGo": "订阅以解锁",
"workspace.referral.apply.preview": "查看奖励",
"workspace.referral.apply.action": "使用",
"workspace.referral.apply.confirmTitle": "使用奖励",
"workspace.referral.apply.confirmBody": "使用 {{amount}} 抵扣当前工作区的用量。",
"workspace.referral.apply.confirmAction": "使用",
"download.title": "OpenCode | 下载", "download.title": "OpenCode | 下载",
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode", "download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
"download.hero.title": "下载 OpenCode", "download.hero.title": "下载 OpenCode",
-33
View File
@@ -643,39 +643,6 @@ export const dict = {
"workspace.lite.promo.otherMethods": "其他付款方式", "workspace.lite.promo.otherMethods": "其他付款方式",
"workspace.lite.promo.selectMethod": "選擇付款方式", "workspace.lite.promo.selectMethod": "選擇付款方式",
"workspace.referral.copyLink": "複製連結",
"workspace.referral.copied": "已複製",
"workspace.referral.overview.title": "邀請朋友",
"workspace.referral.overview.subtitle": "朋友訂閱後,您可獲得 $5,對方也可獲得 $5。",
"workspace.referral.instructions.share": "分享您的推薦連結。",
"workspace.referral.instructions.subscribe": "朋友加入並訂閱 Go。",
"workspace.referral.instructions.claim": "你們都將獲得 $5 使用額度,可用於您的 Go 使用限額。",
"workspace.referral.rewards.title": "邀請獎勵",
"workspace.referral.rewards.description": "將可用的邀請點數套用至您的 Go 使用量。",
"workspace.referral.rewards.subtitle": "已使用 {{applied}} / {{total}} 個獎勵。",
"workspace.referral.rewards.empty": "暫無邀請獎勵。",
"workspace.referral.table.reward": "獎勵",
"workspace.referral.table.referral": "描述",
"workspace.referral.table.date": "日期",
"workspace.referral.reward.description.inviter": "已邀請 {{email}}",
"workspace.referral.reward.description.invitee": "由 {{email}} 邀請",
"workspace.referral.reward.action.subscribeUnlock": "訂閱以解鎖",
"workspace.referral.reward.action.view": "查看獎勵",
"workspace.referral.reward.action.applied": "獎勵已使用",
"workspace.referral.reward.source.pendingInviter": "等待對方訂閱",
"workspace.referral.reward.source.pendingInvitee": "訂閱即可解鎖獎勵",
"workspace.referral.reward.source.available": "獎勵可使用",
"workspace.referral.reward.source.applied": "獎勵已使用",
"workspace.referral.reward.status.applied": "獎勵已使用",
"workspace.referral.reward.status.pendingInviter": "訂閱以解鎖",
"workspace.referral.reward.status.pendingInvitee": "訂閱以解鎖",
"workspace.referral.apply.noGo": "訂閱以解鎖",
"workspace.referral.apply.preview": "查看獎勵",
"workspace.referral.apply.action": "使用",
"workspace.referral.apply.confirmTitle": "使用獎勵",
"workspace.referral.apply.confirmBody": "使用 {{amount}} 抵扣目前工作區的用量。",
"workspace.referral.apply.confirmAction": "使用",
"download.title": "OpenCode | 下載", "download.title": "OpenCode | 下載",
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode", "download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
"download.hero.title": "下載 OpenCode", "download.hero.title": "下載 OpenCode",
@@ -1,47 +0,0 @@
import type { Key } from "~/i18n"
import type { useI18n } from "~/context/i18n"
type ResetTimeKeys = {
day: Key
days: Key
hour: Key
hours: Key
minute: Key
minutes: Key
fewSeconds: Key
}
export const liteResetTimeKeys = {
day: "workspace.lite.time.day",
days: "workspace.lite.time.days",
hour: "workspace.lite.time.hour",
hours: "workspace.lite.time.hours",
minute: "workspace.lite.time.minute",
minutes: "workspace.lite.time.minutes",
fewSeconds: "workspace.lite.time.fewSeconds",
} satisfies ResetTimeKeys
export const blackResetTimeKeys = {
day: "workspace.black.time.day",
days: "workspace.black.time.days",
hour: "workspace.black.time.hour",
hours: "workspace.black.time.hours",
minute: "workspace.black.time.minute",
minutes: "workspace.black.time.minutes",
fewSeconds: "workspace.black.time.fewSeconds",
} satisfies ResetTimeKeys
export function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>, keys: ResetTimeKeys) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? i18n.t(keys.day) : i18n.t(keys.days)} ${hours} ${hours === 1 ? i18n.t(keys.hour) : i18n.t(keys.hours)}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t(keys.hour) : i18n.t(keys.hours)} ${minutes} ${minutes === 1 ? i18n.t(keys.minute) : i18n.t(keys.minutes)}`
if (minutes === 0) return i18n.t(keys.fewSeconds)
return `${minutes} ${minutes === 1 ? i18n.t(keys.minute) : i18n.t(keys.minutes)}`
}
@@ -1,28 +0,0 @@
import { Referral } from "@opencode-ai/console-core/referral.js"
const REFERRAL_COOKIE = "oc_referral"
const REFERRAL_MAX_AGE = 60 * 60 * 24 * 30
export function normalizeReferralCode(code?: string | null) {
return Referral.normalizeCode(code)
}
export function referralCookie(code: string) {
return `${REFERRAL_COOKIE}=${encodeURIComponent(code)}; Path=/; Max-Age=${REFERRAL_MAX_AGE}; SameSite=Lax; HttpOnly`
}
export function clearReferralCookie() {
return `${REFERRAL_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly`
}
export function referralCodeFromCookieHeader(header: string | null) {
if (!header) return undefined
return normalizeReferralCode(
header
.split(";")
.map((x) => x.trim())
.find((x) => x.startsWith(`${REFERRAL_COOKIE}=`))
?.slice(`${REFERRAL_COOKIE}=`.length),
)
}
+6 -10
View File
@@ -1,20 +1,16 @@
import { createMiddleware } from "@solidjs/start/middleware" import { createMiddleware } from "@solidjs/start/middleware"
import { LOCALE_HEADER, cookie, fromPathname, strip } from "~/lib/language" import { LOCALE_HEADER, cookie, fromPathname, strip } from "~/lib/language"
import { normalizeReferralCode, referralCookie } from "~/lib/referral-invite"
export default createMiddleware({ export default createMiddleware({
onRequest(event) { onRequest(event) {
const url = new URL(event.request.url) const url = new URL(event.request.url)
const locale = fromPathname(url.pathname) const locale = fromPathname(url.pathname)
if (locale) { if (!locale) return
url.pathname = strip(url.pathname)
const request = new Request(url, event.request)
request.headers.set(LOCALE_HEADER, locale)
event.request = request
event.response.headers.append("set-cookie", cookie(locale))
}
const referralCode = normalizeReferralCode(url.searchParams.get("ref")) url.pathname = strip(url.pathname)
if (referralCode) event.response.headers.append("set-cookie", referralCookie(referralCode)) const request = new Request(url, event.request)
request.headers.set(LOCALE_HEADER, locale)
event.request = request
event.response.headers.append("set-cookie", cookie(locale))
}, },
}) })
@@ -1,11 +1,9 @@
import { redirect } from "@solidjs/router" import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server" import type { APIEvent } from "@solidjs/start/server"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { AuthClient } from "~/context/auth" import { AuthClient } from "~/context/auth"
import { useAuthSession } from "~/context/auth" import { useAuthSession } from "~/context/auth"
import { i18n } from "~/i18n" import { i18n } from "~/i18n"
import { localeFromRequest, route } from "~/lib/language" import { localeFromRequest, route } from "~/lib/language"
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
export async function GET(input: APIEvent) { export async function GET(input: APIEvent) {
const url = new URL(input.request.url) const url = new URL(input.request.url)
@@ -19,7 +17,6 @@ export async function GET(input: APIEvent) {
if (result.err) throw new Error(result.err.message) if (result.err) throw new Error(result.err.message)
const decoded = AuthClient.decode(result.tokens.access, {} as any) const decoded = AuthClient.decode(result.tokens.access, {} as any)
if (decoded.err) throw new Error(decoded.err.message) if (decoded.err) throw new Error(decoded.err.message)
const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie"))
const session = await useAuthSession() const session = await useAuthSession()
const id = decoded.subject.properties.accountID const id = decoded.subject.properties.accountID
await session.update((value) => { await session.update((value) => {
@@ -35,15 +32,8 @@ export async function GET(input: APIEvent) {
current: id, current: id,
} }
}) })
if (decoded.subject.properties.newAccount && referralCode) {
await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => {
console.error("Referral create failed", error)
})
}
const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "") const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")
const response = redirect(route(locale, next)) return redirect(route(locale, next))
if (referralCode) response.headers.append("set-cookie", clearReferralCookie())
return response
} catch (e: any) { } catch (e: any) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
+59 -61
View File
@@ -700,6 +700,65 @@
text-decoration: underline; text-decoration: underline;
} }
} }
[data-slot="workspace-picker"] {
[data-slot="workspace-list"] {
width: 100%;
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
outline: none;
overflow-y: auto;
max-height: 240px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
[data-slot="workspace-item"] {
width: 100%;
display: flex;
padding: 8px 12px;
align-items: center;
gap: 8px;
align-self: stretch;
cursor: pointer;
[data-slot="selected-icon"] {
visibility: hidden;
color: rgba(255, 255, 255, 0.39);
font-family: "IBM Plex Mono", monospace;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
span:last-child {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
&:hover,
&[data-active="true"] {
background: #161616;
[data-slot="selected-icon"] {
visibility: visible;
}
}
}
}
}
} }
} }
@@ -780,64 +839,3 @@
} }
} }
} }
[data-component="black-workspace-picker-modal"] {
font-family: var(--font-mono);
[data-slot="workspace-list"] {
width: 100%;
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
outline: none;
overflow-y: auto;
max-height: 240px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-slot="workspace-item"] {
width: 100%;
display: flex;
padding: 8px 12px;
align-items: center;
gap: 8px;
align-self: stretch;
cursor: pointer;
[data-slot="selected-icon"] {
visibility: hidden;
color: rgba(255, 255, 255, 0.39);
font-family: "IBM Plex Mono", monospace;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
span:last-child {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
&:hover,
&[data-active="true"] {
background: #161616;
[data-slot="selected-icon"] {
visibility: visible;
}
}
}
}
@@ -444,13 +444,8 @@ export default function BlackSubscribe() {
</div> </div>
{/* Workspace picker modal */} {/* Workspace picker modal */}
<Modal <Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title={i18n.t("black.workspace.selectPlan")}>
open={showWorkspacePicker() ?? false} <div data-slot="workspace-picker">
onClose={() => {}}
title={i18n.t("black.workspace.selectPlan")}
variant="black"
>
<div data-component="black-workspace-picker-modal" data-slot="workspace-picker">
<ul <ul
ref={listRef} ref={listRef}
data-slot="workspace-list" data-slot="workspace-list"
@@ -9,7 +9,7 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource" import { Resource } from "@opencode-ai/console-resource"
import { LiteData } from "@opencode-ai/console-core/lite.js" import { LiteData } from "@opencode-ai/console-core/lite.js"
import { BlackData } from "@opencode-ai/console-core/black.js" import { BlackData } from "@opencode-ai/console-core/black.js"
import { Referral } from "@opencode-ai/console-core/referral.js" import { User } from "@opencode-ai/console-core/user.js"
export async function POST(input: APIEvent) { export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync( const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -174,13 +174,6 @@ export async function POST(input: APIEvent) {
} }
} }
}) })
await Referral.completeFromLiteSubscription({
workspaceID,
userID,
}).catch((error) => {
console.error("Referral sync failed", error)
})
}) })
} }
} }
@@ -34,10 +34,6 @@
background-color: var(--color-bg-surface); background-color: var(--color-bg-surface);
} }
} }
}
[data-component="workspace-create-modal"] {
width: 100%;
[data-slot="create-form"] { [data-slot="create-form"] {
width: 100%; width: 100%;
@@ -1,5 +1,6 @@
import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router" import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
import { For, createEffect, createSignal } from "solid-js" import { For, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
@@ -50,7 +51,9 @@ export function WorkspacePicker() {
const i18n = useI18n() const i18n = useI18n()
const workspaces = createAsync(() => getWorkspaces()) const workspaces = createAsync(() => getWorkspaces())
const submission = useSubmission(createWorkspace) const submission = useSubmission(createWorkspace)
const [showForm, setShowForm] = createSignal(false) const [store, setStore] = createStore({
showForm: false,
})
let inputRef: HTMLInputElement | undefined let inputRef: HTMLInputElement | undefined
const currentWorkspace = () => { const currentWorkspace = () => {
@@ -58,8 +61,12 @@ export function WorkspacePicker() {
return ws ? ws.name : i18n.t("workspace.select") return ws ? ws.name : i18n.t("workspace.select")
} }
const handleWorkspaceNew = () => {
setStore("showForm", true)
}
createEffect(() => { createEffect(() => {
if (showForm() && inputRef) { if (store.showForm && inputRef) {
setTimeout(() => inputRef?.focus(), 0) setTimeout(() => inputRef?.focus(), 0)
} }
}) })
@@ -72,7 +79,7 @@ export function WorkspacePicker() {
// Reset signals when workspace ID changes // Reset signals when workspace ID changes
createEffect(() => { createEffect(() => {
params.id params.id
setShowForm(false) setStore("showForm", false)
}) })
return ( return (
@@ -85,34 +92,32 @@ export function WorkspacePicker() {
</DropdownItem> </DropdownItem>
)} )}
</For> </For>
<button data-slot="create-item" type="button" onClick={() => setShowForm(true)}> <button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
{i18n.t("workspace.createNew")} {i18n.t("workspace.createNew")}
</button> </button>
</Dropdown> </Dropdown>
<Modal open={showForm()} onClose={() => setShowForm(false)} title={i18n.t("workspace.modal.title")}> <Modal open={store.showForm} onClose={() => setStore("showForm", false)} title={i18n.t("workspace.modal.title")}>
<div data-component="workspace-create-modal"> <form data-slot="create-form" action={createWorkspace} method="post">
<form data-slot="create-form" action={createWorkspace} method="post"> <div data-slot="create-input-group">
<div data-slot="create-input-group"> <input
<input ref={inputRef}
ref={inputRef} data-slot="create-input"
data-slot="create-input" type="text"
type="text" name="workspaceName"
name="workspaceName" placeholder={i18n.t("workspace.modal.placeholder")}
placeholder={i18n.t("workspace.modal.placeholder")} required
required />
/> <div data-slot="button-group">
<div data-slot="button-group"> <button type="button" data-color="ghost" onClick={() => setStore("showForm", false)}>
<button type="button" data-color="ghost" onClick={() => setShowForm(false)}> {i18n.t("common.cancel")}
{i18n.t("common.cancel")} </button>
</button> <button type="submit" data-color="primary" disabled={submission.pending}>
<button type="submit" data-color="primary" disabled={submission.pending}> {submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")} </button>
</button>
</div>
</div> </div>
</form> </div>
</div> </form>
</Modal> </Modal>
</div> </div>
) )
@@ -25,7 +25,6 @@
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed;
transform: none; transform: none;
} }
@@ -13,7 +13,6 @@ import styles from "./black-section.module.css"
import waitlistStyles from "./black-waitlist-section.module.css" import waitlistStyles from "./black-waitlist-section.module.css"
import { useI18n } from "~/context/i18n" import { useI18n } from "~/context/i18n"
import { formError } from "~/lib/form-error" import { formError } from "~/lib/form-error"
import { blackResetTimeKeys, formatResetTime } from "~/lib/format-reset-time"
const querySubscription = query(async (workspaceID: string) => { const querySubscription = query(async (workspaceID: string) => {
"use server" "use server"
@@ -53,6 +52,20 @@ const querySubscription = query(async (workspaceID: string) => {
}, workspaceID) }, workspaceID)
}, "subscription.get") }, "subscription.get")
function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? i18n.t("workspace.black.time.day") : i18n.t("workspace.black.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
if (minutes === 0) return i18n.t("workspace.black.time.fewSeconds")
return `${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
}
const cancelWaitlist = action(async (workspaceID: string) => { const cancelWaitlist = action(async (workspaceID: string) => {
"use server" "use server"
return json( return json(
@@ -196,7 +209,7 @@ export function BlackSection() {
</div> </div>
<span data-slot="reset-time"> <span data-slot="reset-time">
{i18n.t("workspace.black.subscription.resetsIn")}{" "} {i18n.t("workspace.black.subscription.resetsIn")}{" "}
{formatResetTime(sub().rollingUsage.resetInSec, i18n, blackResetTimeKeys)} {formatResetTime(sub().rollingUsage.resetInSec, i18n)}
</span> </span>
</div> </div>
<div data-slot="usage-item"> <div data-slot="usage-item">
@@ -209,7 +222,7 @@ export function BlackSection() {
</div> </div>
<span data-slot="reset-time"> <span data-slot="reset-time">
{i18n.t("workspace.black.subscription.resetsIn")}{" "} {i18n.t("workspace.black.subscription.resetsIn")}{" "}
{formatResetTime(sub().weeklyUsage.resetInSec, i18n, blackResetTimeKeys)} {formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
</span> </span>
</div> </div>
</div> </div>
@@ -1,17 +1,11 @@
import { createAsync, useParams } from "@solidjs/router"
import { Show } from "solid-js"
import { IconGo } from "~/component/icon" import { IconGo } from "~/component/icon"
import { GoReferralSection, queryGoReferral } from "~/component/go-referral"
import { useI18n } from "~/context/i18n" import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language" import { useLanguage } from "~/context/language"
import { LiteSection, queryLiteSubscription } from "./lite-section" import { LiteSection } from "./lite-section"
export default function () { export default function () {
const params = useParams()
const i18n = useI18n() const i18n = useI18n()
const language = useLanguage() const language = useLanguage()
const referral = createAsync(() => queryGoReferral(params.id!))
const lite = createAsync(() => queryLiteSubscription(params.id!))
return ( return (
<div data-page="workspace-[id]"> <div data-page="workspace-[id]">
@@ -29,10 +23,7 @@ export default function () {
</section> </section>
<div data-slot="sections"> <div data-slot="sections">
<LiteSection lite={lite()} /> <LiteSection />
<Show when={referral()} fallback={<section>{i18n.t("workspace.lite.loading")}</section>}>
{(summary) => <GoReferralSection workspaceID={params.id!} summary={summary()} lite={lite()} />}
</Show>
</div> </div>
</div> </div>
) )
@@ -211,9 +211,7 @@
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
}
.paymentMethodModal {
[data-slot="modal-actions"] { [data-slot="modal-actions"] {
display: flex; display: flex;
gap: var(--space-3); gap: var(--space-3);
@@ -14,11 +14,10 @@ import styles from "./lite-section.module.css"
import { useI18n } from "~/context/i18n" import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language" import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error" import { formError } from "~/lib/form-error"
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { IconAlipay, IconUpi } from "~/component/icon" import { IconAlipay, IconUpi } from "~/component/icon"
export const queryLiteSubscription = query(async (workspaceID: string) => { const queryLiteSubscription = query(async (workspaceID: string) => {
"use server" "use server"
return withActor(async () => { return withActor(async () => {
const row = await Database.use((tx) => const row = await Database.use((tx) =>
@@ -68,7 +67,19 @@ export const queryLiteSubscription = query(async (workspaceID: string) => {
}, workspaceID) }, workspaceID)
}, "lite.subscription.get") }, "lite.subscription.get")
type LiteSubscription = Awaited<ReturnType<typeof queryLiteSubscription>> function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? i18n.t("workspace.lite.time.day") : i18n.t("workspace.lite.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
if (minutes === 0) return i18n.t("workspace.lite.time.fewSeconds")
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
}
const createLiteCheckoutUrl = action( const createLiteCheckoutUrl = action(
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => { async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
@@ -129,32 +140,13 @@ const setLiteUseBalance = action(async (form: FormData) => {
) )
}, "setLiteUseBalance") }, "setLiteUseBalance")
function LiteUsageItem(props: { label: string; usage: { usagePercent: number; resetInSec: number } }) { export function LiteSection() {
const i18n = useI18n()
return (
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{props.label}</span>
<span data-slot="usage-value">{props.usage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${props.usage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(props.usage.resetInSec, i18n, liteResetTimeKeys)}
</span>
</div>
)
}
export function LiteSection(props: { lite: LiteSubscription | undefined }) {
const params = useParams() const params = useParams()
const i18n = useI18n() const i18n = useI18n()
const language = useLanguage() const language = useLanguage()
const billingInfo = createAsync(() => queryBillingInfo(params.id!)) const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked) const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked)
const lite = createAsync(() => queryLiteSubscription(params.id!))
const sessionAction = useAction(createSessionUrl) const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl) const sessionSubmission = useSubmission(createSessionUrl)
const checkoutAction = useAction(createLiteCheckoutUrl) const checkoutAction = useAction(createLiteCheckoutUrl)
@@ -194,7 +186,7 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
<p data-slot="other-message">{i18n.t("workspace.lite.black.message")}</p> <p data-slot="other-message">{i18n.t("workspace.lite.black.message")}</p>
</section> </section>
</Show> </Show>
<Show when={!isBlack() && props.lite && props.lite.mine && props.lite}> <Show when={!isBlack() && lite() && lite()!.mine && lite()!}>
{(sub) => ( {(sub) => (
<section class={styles.root}> <section class={styles.root}>
<div data-slot="section-title"> <div data-slot="section-title">
@@ -215,9 +207,44 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
. .
</div> </div>
<div data-slot="usage"> <div data-slot="usage">
<LiteUsageItem label={i18n.t("workspace.lite.subscription.rollingUsage")} usage={sub().rollingUsage} /> <div data-slot="usage-item">
<LiteUsageItem label={i18n.t("workspace.lite.subscription.weeklyUsage")} usage={sub().weeklyUsage} /> <div data-slot="usage-header">
<LiteUsageItem label={i18n.t("workspace.lite.subscription.monthlyUsage")} usage={sub().monthlyUsage} /> <span data-slot="usage-label">{i18n.t("workspace.lite.subscription.rollingUsage")}</span>
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.weeklyUsage")}</span>
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.monthlyUsage")}</span>
<span data-slot="usage-value">{sub().monthlyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().monthlyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(sub().monthlyUsage.resetInSec, i18n)}
</span>
</div>
</div> </div>
<form action={setLiteUseBalance} method="post" data-slot="setting-row"> <form action={setLiteUseBalance} method="post" data-slot="setting-row">
<p>{i18n.t("workspace.lite.subscription.useBalance")}</p> <p>{i18n.t("workspace.lite.subscription.useBalance")}</p>
@@ -236,12 +263,12 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
</section> </section>
)} )}
</Show> </Show>
<Show when={!isBlack() && props.lite && !props.lite.mine}> <Show when={!isBlack() && lite() && !lite()!.mine}>
<section class={styles.root}> <section class={styles.root}>
<p data-slot="other-message">{i18n.t("workspace.lite.other.message")}</p> <p data-slot="other-message">{i18n.t("workspace.lite.other.message")}</p>
</section> </section>
</Show> </Show>
<Show when={!isBlack() && props.lite === null}> <Show when={!isBlack() && lite() === null}>
<section class={styles.root}> <section class={styles.root}>
<p data-slot="promo-description"> <p data-slot="promo-description">
<For <For
@@ -303,33 +330,31 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
onClose={() => setStore("showModal", false)} onClose={() => setStore("showModal", false)}
title={i18n.t("workspace.lite.promo.selectMethod")} title={i18n.t("workspace.lite.promo.selectMethod")}
> >
<div class={styles.paymentMethodModal}> <div data-slot="modal-actions">
<div data-slot="modal-actions"> <button
<button type="button"
type="button" data-slot="method-button"
data-slot="method-button" data-color="ghost"
data-color="ghost" disabled={checkoutSubmission.pending || busy()}
disabled={checkoutSubmission.pending || busy()} onClick={() => onClickSubscribe("alipay")}
onClick={() => onClickSubscribe("alipay")} >
> <Show when={store.loading !== "alipay"}>
<Show when={store.loading !== "alipay"}> <IconAlipay style={{ width: "24px", height: "24px" }} />
<IconAlipay style={{ width: "24px", height: "24px" }} /> </Show>
</Show> {store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"} </button>
</button> <button
<button type="button"
type="button" data-slot="method-button"
data-slot="method-button" data-color="ghost"
data-color="ghost" disabled={checkoutSubmission.pending || busy()}
disabled={checkoutSubmission.pending || busy()} onClick={() => onClickSubscribe("upi")}
onClick={() => onClickSubscribe("upi")} >
> <Show when={store.loading !== "upi"}>
<Show when={store.loading !== "upi"}> <IconUpi style={{ width: "auto", height: "16px" }} />
<IconUpi style={{ width: "auto", height: "16px" }} /> </Show>
</Show> {store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"} </button>
</button>
</div>
</div> </div>
</Modal> </Modal>
</section> </section>
@@ -10,7 +10,6 @@ import {
IconAnthropic, IconAnthropic,
IconArcee, IconArcee,
IconGemini, IconGemini,
IconDeepSeek,
IconMiniMax, IconMiniMax,
IconMoonshotAI, IconMoonshotAI,
IconNvidia, IconNvidia,
@@ -28,7 +27,6 @@ const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic" if (modelId.startsWith("claude")) return "Anthropic"
if (modelId.startsWith("gpt")) return "OpenAI" if (modelId.startsWith("gpt")) return "OpenAI"
if (modelId.startsWith("gemini")) return "Google" if (modelId.startsWith("gemini")) return "Google"
if (modelId.startsWith("deepseek")) return "DeepSeek"
if (modelId.startsWith("kimi")) return "Moonshot AI" if (modelId.startsWith("kimi")) return "Moonshot AI"
if (modelId.startsWith("glm")) return "Z.ai" if (modelId.startsWith("glm")) return "Z.ai"
if (modelId.startsWith("qwen")) return "Alibaba" if (modelId.startsWith("qwen")) return "Alibaba"
@@ -49,19 +47,7 @@ const getModelsInfo = query(async (workspaceID: string) => {
.filter(([id, _model]) => !id.startsWith("alpha-")) .filter(([id, _model]) => !id.startsWith("alpha-"))
.filter(([id, _model]) => !id.endsWith(":global")) .filter(([id, _model]) => !id.endsWith(":global"))
.sort(([idA, modelA], [idB, modelB]) => { .sort(([idA, modelA], [idB, modelB]) => {
const priority = [ const priority = ["big-pickle", "minimax", "grok", "claude", "gpt", "gemini"]
"big-pickle",
"claude",
"gpt",
"gemini",
"deepseek",
"glm",
"kimi",
"qwen",
"grok",
"minimax",
"mimo",
]
const getPriority = (id: string) => { const getPriority = (id: string) => {
const index = priority.findIndex((p) => id.startsWith(p)) const index = priority.findIndex((p) => id.startsWith(p))
return index === -1 ? Infinity : index return index === -1 ? Infinity : index
@@ -150,8 +136,6 @@ export function ModelSection() {
return <IconAnthropic width={16} height={16} /> return <IconAnthropic width={16} height={16} />
case "Google": case "Google":
return <IconGemini width={16} height={16} /> return <IconGemini width={16} height={16} />
case "DeepSeek":
return <IconDeepSeek width={16} height={16} />
case "Moonshot AI": case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} /> return <IconMoonshotAI width={16} height={16} />
case "Z.ai": case "Z.ai":
@@ -170,10 +170,6 @@ export async function handler(
if (v === "$ip") return [[k, ip]] if (v === "$ip") return [[k, ip]]
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
if (v === "$session") return sessionId ? [[k, sessionId]] : [] if (v === "$session") return sessionId ? [[k, sessionId]] : []
if (v === "$user") {
const user = sessionId ?? authInfo?.workspaceID ?? ip
return user ? [[k, user]] : []
}
if (v.startsWith("$header.")) { if (v.startsWith("$header.")) {
const headerValue = input.request.headers.get(v.slice(8)) const headerValue = input.request.headers.get(v.slice(8))
return headerValue ? [[k, headerValue]] : [] return headerValue ? [[k, headerValue]] : []
@@ -13,7 +13,7 @@ export function createRateLimiter(
if (!zenApiKey) return if (!zenApiKey) return
const dict = i18n(localeFromRequest(request)) const dict = i18n(localeFromRequest(request))
const LIMIT = rateLimit ?? 1000 const LIMIT = rateLimit ?? 500
const yyyyMMddHHmm = new Date(Date.now()) const yyyyMMddHHmm = new Date(Date.now())
.toISOString() .toISOString()
.replace(/[^0-9]/g, "") .replace(/[^0-9]/g, "")
@@ -1,47 +0,0 @@
CREATE TABLE `referral_code` (
`id` varchar(30) NOT NULL,
`workspace_id` varchar(30) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`code` varchar(10) NOT NULL,
CONSTRAINT PRIMARY KEY(`workspace_id`,`id`),
CONSTRAINT `referral_code_workspace_id` UNIQUE INDEX(`workspace_id`),
CONSTRAINT `referral_code_code` UNIQUE INDEX(`code`)
);
--> statement-breakpoint
CREATE TABLE `referral_reward` (
`id` varchar(30) NOT NULL,
`workspace_id` varchar(30) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`referral_id` varchar(30) NOT NULL,
`source` enum('inviter','invitee') NOT NULL,
`amount` bigint NOT NULL,
`applied_by_user_id` varchar(30),
`time_applied` timestamp(3),
CONSTRAINT PRIMARY KEY(`workspace_id`,`id`),
CONSTRAINT `referral_reward_referral_source` UNIQUE INDEX(`referral_id`,`source`)
);
--> statement-breakpoint
CREATE TABLE `referral` (
`id` varchar(30) NOT NULL,
`workspace_id` varchar(30) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`inviter_workspace_id` varchar(30) NOT NULL,
`invitee_account_id` varchar(30) NOT NULL,
`invitee_user_id` varchar(30) NOT NULL,
`referral_code_id` varchar(30) NOT NULL,
`stripe_customer_id` varchar(255) NOT NULL,
`stripe_subscription_id` varchar(255) NOT NULL,
CONSTRAINT PRIMARY KEY(`workspace_id`,`id`),
CONSTRAINT `referral_invitee_account_id` UNIQUE INDEX(`invitee_account_id`),
CONSTRAINT `referral_stripe_subscription_id` UNIQUE INDEX(`stripe_subscription_id`)
);
--> statement-breakpoint
CREATE INDEX `referral_reward_workspace_time` ON `referral_reward` (`workspace_id`,`time_created`);--> statement-breakpoint
CREATE INDEX `referral_inviter_workspace_id` ON `referral` (`inviter_workspace_id`);--> statement-breakpoint
CREATE INDEX `referral_code_id` ON `referral` (`referral_code_id`);
File diff suppressed because it is too large Load Diff
@@ -1,20 +0,0 @@
DROP TABLE `referral_code`;--> statement-breakpoint
DROP INDEX `referral_reward_referral_source` ON `referral_reward`;--> statement-breakpoint
DROP INDEX `referral_stripe_subscription_id` ON `referral`;--> statement-breakpoint
DROP INDEX `referral_inviter_workspace_id` ON `referral`;--> statement-breakpoint
DROP INDEX `referral_code_id` ON `referral`;--> statement-breakpoint
ALTER TABLE `referral_reward` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `referral` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `referral_reward` MODIFY COLUMN `workspace_id` varchar(30);--> statement-breakpoint
ALTER TABLE `workspace` ADD `referral_code` varchar(16);--> statement-breakpoint
ALTER TABLE `referral_reward` ADD PRIMARY KEY (`id`);--> statement-breakpoint
ALTER TABLE `referral` ADD PRIMARY KEY (`id`);--> statement-breakpoint
CREATE INDEX `referral_workspace_id` ON `referral` (`workspace_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `workspace_referral_code` ON `workspace` (`referral_code`);--> statement-breakpoint
ALTER TABLE `referral_reward` DROP COLUMN `source`;--> statement-breakpoint
ALTER TABLE `referral_reward` DROP COLUMN `applied_by_user_id`;--> statement-breakpoint
ALTER TABLE `referral` DROP COLUMN `inviter_workspace_id`;--> statement-breakpoint
ALTER TABLE `referral` DROP COLUMN `invitee_user_id`;--> statement-breakpoint
ALTER TABLE `referral` DROP COLUMN `referral_code_id`;--> statement-breakpoint
ALTER TABLE `referral` DROP COLUMN `stripe_customer_id`;--> statement-breakpoint
ALTER TABLE `referral` DROP COLUMN `stripe_subscription_id`;
File diff suppressed because it is too large Load Diff
@@ -1,4 +0,0 @@
DROP INDEX `referral_reward_workspace_time` ON `referral_reward`;--> statement-breakpoint
ALTER TABLE `referral_reward` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `referral_reward` ADD PRIMARY KEY (`workspace_id`,`referral_id`);--> statement-breakpoint
ALTER TABLE `referral_reward` DROP COLUMN `id`;
File diff suppressed because it is too large Load Diff
@@ -1,3 +0,0 @@
DROP INDEX `referral_workspace_id` ON `referral`;--> statement-breakpoint
ALTER TABLE `referral` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `referral` ADD PRIMARY KEY (`workspace_id`,`id`);
@@ -1,2 +0,0 @@
UPDATE `workspace` SET `referral_code` = NULL WHERE CHAR_LENGTH(`referral_code`) > 10;--> statement-breakpoint
ALTER TABLE `workspace` MODIFY COLUMN `referral_code` varchar(10);
@@ -1,2 +0,0 @@
DROP INDEX `workspace_referral_code` ON `workspace`;--> statement-breakpoint
CREATE UNIQUE INDEX `referral_code` ON `workspace` (`referral_code`);
File diff suppressed because it is too large Load Diff
@@ -1,13 +0,0 @@
CREATE TABLE `referral_code` (
`workspace_id` varchar(30) PRIMARY KEY,
`code` varchar(10) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
CONSTRAINT `code` UNIQUE INDEX(`code`)
);
--> statement-breakpoint
DROP INDEX `referral_invitee_account_id` ON `referral`;--> statement-breakpoint
DROP INDEX `referral_code` ON `workspace`;--> statement-breakpoint
CREATE UNIQUE INDEX `invitee_account_id` ON `referral` (`invitee_account_id`);--> statement-breakpoint
ALTER TABLE `workspace` DROP COLUMN `referral_code`;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.15.6", "version": "1.15.5",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
-20
View File
@@ -155,26 +155,6 @@ export namespace Billing {
return amountInMicroCents return amountInMicroCents
} }
export const subtractLiteUsage = async (workspaceID: string, amountInMicroCents: number) => {
await Database.transaction(async (tx) => {
const lite = await tx
.select({ id: LiteTable.id })
.from(LiteTable)
.where(and(eq(LiteTable.workspaceID, workspaceID), isNull(LiteTable.timeDeleted)))
.then((rows) => rows[0])
if (!lite) throw new Error("Subscribe to Go before applying referral rewards")
await tx
.update(LiteTable)
.set({
monthlyUsage: sql`GREATEST(0, COALESCE(${LiteTable.monthlyUsage}, 0) - ${amountInMicroCents})`,
weeklyUsage: sql`GREATEST(0, COALESCE(${LiteTable.weeklyUsage}, 0) - ${amountInMicroCents})`,
rollingUsage: sql`GREATEST(0, COALESCE(${LiteTable.rollingUsage}, 0) - ${amountInMicroCents})`,
})
.where(and(eq(LiteTable.workspaceID, workspaceID), isNull(LiteTable.timeDeleted)))
})
}
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => { export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
// validate coupon type // validate coupon type
await (async () => { await (async () => {
-1
View File
@@ -12,7 +12,6 @@ export namespace Identifier {
model: "mod", model: "mod",
payment: "pay", payment: "pay",
provider: "prv", provider: "prv",
referral: "ref",
subscription: "sub", subscription: "sub",
usage: "usg", usage: "usg",
user: "usr", user: "usr",
-390
View File
@@ -1,390 +0,0 @@
import { z } from "zod"
import { and, asc, eq, isNull, sql, Database } from "./drizzle"
import { Actor } from "./actor"
import { Identifier } from "./identifier"
import { LiteTable } from "./schema/billing.sql"
import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql"
import { AuthTable } from "./schema/auth.sql"
import { UserTable } from "./schema/user.sql"
import { WorkspaceTable } from "./schema/workspace.sql"
import { centsToMicroCents, microCentsToCents } from "./util/price"
import { fn } from "./util/fn"
import { Billing } from "./billing"
import { LiteData } from "./lite"
import { Subscription } from "./subscription"
import { ulid } from "ulid"
export namespace Referral {
export const REWARD_AMOUNT = centsToMicroCents(500)
export const CODE_LENGTH = 10
export function normalizeCode(code?: string | null) {
return code
?.toUpperCase()
.replace(/[^A-Z0-9]/g, "")
.slice(0, CODE_LENGTH)
}
function generateCode() {
return ulid().slice(-CODE_LENGTH).toUpperCase()
}
async function ensureCode(workspaceID = Actor.workspace()) {
return Database.use(async (db) => {
const existing = await db
.select({ code: ReferralCodeTable.code })
.from(ReferralCodeTable)
.where(eq(ReferralCodeTable.workspaceID, workspaceID))
.then((rows) => rows[0])
if (existing) return { code: existing.code }
await db.insert(ReferralCodeTable).ignore().values({
workspaceID,
code: generateCode(),
})
const created = await db
.select({ code: ReferralCodeTable.code })
.from(ReferralCodeTable)
.where(eq(ReferralCodeTable.workspaceID, workspaceID))
.then((rows) => rows[0])
if (created) return { code: created.code }
throw new Error("Failed to generate referral code")
})
}
export const summary = fn(z.void(), async () => {
const workspaceID = Actor.workspace()
const accountID = Actor.account()
const code = await ensureCode(workspaceID)
const rows = await Database.use(async (tx) => {
const [rewards, invites, inviteeReferral, inviteeRewards] = await Promise.all([
tx
.select({
referralID: ReferralRewardTable.referralID,
workspaceID: ReferralRewardTable.workspaceID,
referralWorkspaceID: ReferralTable.workspaceID,
inviteeEmail: AuthTable.subject,
amount: ReferralRewardTable.amount,
timeCreated: ReferralRewardTable.timeCreated,
timeApplied: ReferralRewardTable.timeApplied,
})
.from(ReferralRewardTable)
.innerJoin(ReferralTable, eq(ReferralTable.id, ReferralRewardTable.referralID))
.innerJoin(
AuthTable,
and(eq(AuthTable.accountID, ReferralTable.inviteeAccountID), eq(AuthTable.provider, "email")),
)
.where(
and(
eq(ReferralRewardTable.workspaceID, workspaceID),
isNull(ReferralRewardTable.timeDeleted),
isNull(ReferralTable.timeDeleted),
),
),
tx
.select({ id: ReferralTable.id, inviteeEmail: AuthTable.subject, timeCreated: ReferralTable.timeCreated })
.from(ReferralTable)
.innerJoin(
AuthTable,
and(eq(AuthTable.accountID, ReferralTable.inviteeAccountID), eq(AuthTable.provider, "email")),
)
.where(and(eq(ReferralTable.workspaceID, workspaceID), isNull(ReferralTable.timeDeleted))),
tx
.select({ id: ReferralTable.id, inviterEmail: AuthTable.subject, timeCreated: ReferralTable.timeCreated })
.from(ReferralTable)
.leftJoin(
UserTable,
and(
eq(UserTable.workspaceID, ReferralTable.workspaceID),
eq(UserTable.role, "admin"),
isNull(UserTable.timeDeleted),
),
)
.leftJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(ReferralTable.inviteeAccountID, accountID), isNull(ReferralTable.timeDeleted)))
.orderBy(asc(UserTable.timeCreated))
.then((rows) => rows.find((row) => row.inviterEmail) ?? rows[0]),
tx
.select({ referralID: ReferralRewardTable.referralID })
.from(ReferralRewardTable)
.innerJoin(ReferralTable, eq(ReferralTable.id, ReferralRewardTable.referralID))
.where(
and(
eq(ReferralTable.inviteeAccountID, accountID),
isNull(ReferralRewardTable.timeDeleted),
isNull(ReferralTable.timeDeleted),
),
),
])
return { inviteeReferral, inviteeRewards, invites, rewards }
})
const rewardReferralIDs = new Set(rows.rewards.map((reward) => reward.referralID))
const inviteeRewardReferralIDs = new Set(rows.inviteeRewards.map((reward) => reward.referralID))
const rewards = rows.rewards.map((reward) => {
const source = reward.workspaceID === reward.referralWorkspaceID ? ("inviter" as const) : ("invitee" as const)
return {
id: reward.referralID,
source,
status: reward.timeApplied ? ("applied" as const) : ("available" as const),
email: source === "invitee" ? (rows.inviteeReferral?.inviterEmail ?? null) : reward.inviteeEmail,
amount: microCentsToCents(reward.amount),
timeCreated: reward.timeCreated,
timeApplied: reward.timeApplied,
}
})
const pending = [
...rows.invites
.filter((referral) => !rewardReferralIDs.has(referral.id))
.map((referral) => ({
id: `${referral.id}:inviter`,
source: "inviter" as const,
status: "pending" as const,
email: referral.inviteeEmail,
amount: microCentsToCents(REWARD_AMOUNT),
timeCreated: referral.timeCreated,
timeApplied: null,
})),
...(rows.inviteeReferral && !inviteeRewardReferralIDs.has(rows.inviteeReferral.id)
? [
{
id: `${rows.inviteeReferral.id}:invitee`,
source: "invitee" as const,
status: "pending" as const,
email: rows.inviteeReferral.inviterEmail,
amount: microCentsToCents(REWARD_AMOUNT),
timeCreated: rows.inviteeReferral.timeCreated,
timeApplied: null,
},
]
: []),
]
const allRewards = [...pending, ...rewards].sort(
(a, b) => new Date(b.timeCreated).getTime() - new Date(a.timeCreated).getTime(),
)
return {
referralCode: code.code,
hasReferral: allRewards.length > 0,
rewardAmount: microCentsToCents(REWARD_AMOUNT),
rewards: allRewards,
}
})
export const applyReward = fn(z.object({ referralID: z.string() }), async (input) => {
const workspaceID = Actor.workspace()
return Database.transaction(async (tx) => {
const reward = await tx
.select({ amount: ReferralRewardTable.amount, timeApplied: ReferralRewardTable.timeApplied })
.from(ReferralRewardTable)
.where(
and(
eq(ReferralRewardTable.workspaceID, workspaceID),
eq(ReferralRewardTable.referralID, input.referralID),
isNull(ReferralRewardTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (!reward) throw new Error("Referral reward not found")
if (reward.timeApplied) throw new Error("Referral reward already applied")
const update = await tx
.update(ReferralRewardTable)
.set({
timeApplied: sql`now()`,
})
.where(
and(
eq(ReferralRewardTable.workspaceID, workspaceID),
eq(ReferralRewardTable.referralID, input.referralID),
isNull(ReferralRewardTable.timeApplied),
isNull(ReferralRewardTable.timeDeleted),
),
)
if (update.rowsAffected === 0) throw new Error("Referral reward already applied")
await Billing.subtractLiteUsage(workspaceID, reward.amount)
return { amount: microCentsToCents(reward.amount) }
})
})
export const usagePreview = fn(z.object({ referralID: z.string() }), async (input) => {
const row = await Database.use((tx) =>
tx
.select({
rewardAmount: ReferralRewardTable.amount,
rollingUsage: LiteTable.rollingUsage,
weeklyUsage: LiteTable.weeklyUsage,
monthlyUsage: LiteTable.monthlyUsage,
timeRollingUpdated: LiteTable.timeRollingUpdated,
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
timeCreated: LiteTable.timeCreated,
})
.from(ReferralRewardTable)
.innerJoin(LiteTable, eq(LiteTable.workspaceID, ReferralRewardTable.workspaceID))
.where(
and(
eq(ReferralRewardTable.workspaceID, Actor.workspace()),
eq(ReferralRewardTable.referralID, input.referralID),
isNull(ReferralRewardTable.timeApplied),
isNull(ReferralRewardTable.timeDeleted),
isNull(LiteTable.timeDeleted),
),
)
.then((rows) => rows[0]),
)
if (!row) return null
const limits = LiteData.getLimits()
return {
rollingUsage: usagePreviewItem(
Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: Math.max(0, (row.rollingUsage ?? 0) - row.rewardAmount),
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
),
weeklyUsage: usagePreviewItem(
Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: row.weeklyUsage ?? 0,
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: Math.max(0, (row.weeklyUsage ?? 0) - row.rewardAmount),
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
),
monthlyUsage: usagePreviewItem(
Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: row.monthlyUsage ?? 0,
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeCreated,
}),
Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: Math.max(0, (row.monthlyUsage ?? 0) - row.rewardAmount),
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeCreated,
}),
),
}
})
export async function createFromAccount(input: { accountID: string; referralCode?: string }) {
const referralCode = normalizeCode(input.referralCode)
if (!referralCode) return
return Database.transaction(async (tx) => {
const code = await tx
.select({ workspaceID: ReferralCodeTable.workspaceID })
.from(ReferralCodeTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, ReferralCodeTable.workspaceID))
.where(and(eq(ReferralCodeTable.code, referralCode), isNull(WorkspaceTable.timeDeleted)))
.then((rows) => rows[0])
if (!code) throw new Error("Referral code invalid")
const existingReferral = await tx
.select({ id: ReferralTable.id })
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, input.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (existingReferral) throw new Error("Referral already redeemed")
const selfReferral = await tx
.select({ id: UserTable.id })
.from(UserTable)
.where(
and(
eq(UserTable.workspaceID, code.workspaceID),
eq(UserTable.accountID, input.accountID),
isNull(UserTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (selfReferral) throw new Error("Self-referral is not allowed")
const referralID = Identifier.create("referral")
await tx.insert(ReferralTable).ignore().values({
workspaceID: code.workspaceID,
id: referralID,
inviteeAccountID: input.accountID,
})
const referral = await tx
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, input.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (!referral) throw new Error("Referral not created")
if (referral.id !== referralID) throw new Error("Referral already redeemed")
})
}
export async function completeFromLiteSubscription(input: { workspaceID: string; userID: string }) {
return Database.transaction(async (tx) => {
const invitee = await tx
.select({ accountID: UserTable.accountID })
.from(UserTable)
.where(
and(
eq(UserTable.workspaceID, input.workspaceID),
eq(UserTable.id, input.userID),
isNull(UserTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (!invitee?.accountID) throw new Error("Referral invitee account missing")
const referral = await tx
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (!referral) throw new Error("Referral not found")
const result = await tx
.insert(ReferralRewardTable)
.ignore()
.values([
{
workspaceID: referral.workspaceID,
referralID: referral.id,
amount: REWARD_AMOUNT,
},
{
workspaceID: input.workspaceID,
referralID: referral.id,
amount: REWARD_AMOUNT,
},
])
if (result.rowsAffected === 0) throw new Error("Referral already completed")
})
}
function usagePreviewItem(
before: { usagePercent: number; resetInSec: number },
after: { usagePercent: number; resetInSec: number },
) {
return {
beforePercent: before.usagePercent,
afterPercent: after.usagePercent,
resetInSec: after.resetInSec,
}
}
}
@@ -1,35 +0,0 @@
import { bigint, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
export const ReferralCodeTable = mysqlTable(
"referral_code",
{
workspaceID: ulid("workspace_id").notNull(),
code: varchar("code", { length: 10 }).notNull(),
...timestamps,
},
(table) => [primaryKey({ columns: [table.workspaceID] }), uniqueIndex("code").on(table.code)],
)
export const ReferralTable = mysqlTable(
"referral",
{
...workspaceColumns,
...timestamps,
inviteeAccountID: ulid("invitee_account_id").notNull(),
},
(table) => [...workspaceIndexes(table), uniqueIndex("invitee_account_id").on(table.inviteeAccountID)],
)
export const ReferralRewardTable = mysqlTable(
"referral_reward",
{
workspaceID: ulid("workspace_id").notNull(),
referralID: ulid("referral_id").notNull(),
...timestamps,
amount: bigint("amount", { mode: "number" }).notNull(),
timeApplied: utc("time_applied"),
},
(table) => [primaryKey({ columns: [table.workspaceID, table.referralID] })],
)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.15.6", "version": "1.15.5",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -4
View File
@@ -26,7 +26,6 @@ export const subjects = createSubjects({
account: z.object({ account: z.object({
accountID: z.string(), accountID: z.string(),
email: z.string(), email: z.string(),
newAccount: z.boolean().optional(),
}), }),
user: z.object({ user: z.object({
userID: z.string(), userID: z.string(),
@@ -143,7 +142,6 @@ export default {
} }
// Get account // Get account
let newAccount = false
const accountID = await (async () => { const accountID = await (async () => {
const matches = await Database.use(async (tx) => const matches = await Database.use(async (tx) =>
tx tx
@@ -168,7 +166,6 @@ export default {
if (!accountID) { if (!accountID) {
console.log("creating account for", email) console.log("creating account for", email)
accountID = await Account.create({}) accountID = await Account.create({})
newAccount = true
} }
await Database.use(async (tx) => await Database.use(async (tx) =>
@@ -218,7 +215,7 @@ export default {
await Workspace.create({ name: "Default" }) await Workspace.create({ name: "Default" })
} }
}) })
return ctx.subject("account", accountID, { accountID, email, newAccount }) return ctx.subject("account", accountID, { accountID, email })
}, },
}).fetch(request, env, ctx) }).fetch(request, env, ctx)
return result return result
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.15.6", "version": "1.15.5",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.6", "version": "1.15.5",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -3
View File
@@ -21,6 +21,7 @@ export const Flag = {
OPENCODE_DISABLE_PRUNE: truthy("OPENCODE_DISABLE_PRUNE"), OPENCODE_DISABLE_PRUNE: truthy("OPENCODE_DISABLE_PRUNE"),
OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"), OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"), OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"],
OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"), OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"), OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"), OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
@@ -58,9 +59,6 @@ export const Flag = {
get OPENCODE_PURE() { get OPENCODE_PURE() {
return truthy("OPENCODE_PURE") return truthy("OPENCODE_PURE")
}, },
get OPENCODE_PERMISSION() {
return process.env["OPENCODE_PERMISSION"]
},
get OPENCODE_PLUGIN_META_FILE() { get OPENCODE_PLUGIN_META_FILE() {
return process.env["OPENCODE_PLUGIN_META_FILE"] return process.env["OPENCODE_PLUGIN_META_FILE"]
}, },
@@ -111,7 +111,7 @@ describe("cross-spawn spawner", () => {
ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }), ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
), ),
) )
expect(yield* Effect.promise(() => fs.realpath(out))).toBe(yield* Effect.promise(() => fs.realpath(tmp.path))) expect(out).toBe(tmp.path)
}), }),
) )
@@ -119,9 +119,7 @@ describe("cross-spawn spawner", () => {
"fails for invalid cwd", "fails for invalid cwd",
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* Effect.exit( const exit = yield* Effect.exit(
ChildProcessSpawner.ChildProcessSpawner.use((svc) => ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(),
svc.spawn(ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" })),
),
) )
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
}), }),
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.15.6", "version": "1.15.5",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
@@ -1,84 +0,0 @@
import { BrowserWindow } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import { createMainWindow, updateTitlebar } from "./windows"
export type DesktopMenuActionHandlers = Partial<{
checkForUpdates: () => void
relaunch: () => void
}>
export function runDesktopMenuAction(
win: BrowserWindow | null,
action: DesktopMenuAction,
handlers: DesktopMenuActionHandlers = {},
) {
switch (action) {
case "app.checkForUpdates":
handlers.checkForUpdates?.()
return
case "app.relaunch":
handlers.relaunch?.()
return
case "window.new":
createMainWindow()
return
case "window.close":
win?.close()
return
case "window.minimize":
win?.minimize()
return
case "window.toggleMaximize":
if (win?.isMaximized()) {
win.unmaximize()
return
}
win?.maximize()
return
case "view.reload":
win?.reload()
return
case "view.toggleDevTools":
win?.webContents.toggleDevTools()
return
case "view.resetZoom":
setZoom(win, 1)
return
case "view.zoomIn":
setZoom(win, (win?.webContents.getZoomFactor() ?? 1) + 0.2)
return
case "view.zoomOut":
setZoom(win, (win?.webContents.getZoomFactor() ?? 1) - 0.2)
return
case "view.toggleFullscreen":
win?.setFullScreen(!win.isFullScreen())
return
case "edit.undo":
win?.webContents.undo()
return
case "edit.redo":
win?.webContents.redo()
return
case "edit.cut":
win?.webContents.cut()
return
case "edit.copy":
win?.webContents.copy()
return
case "edit.paste":
win?.webContents.paste()
return
case "edit.delete":
win?.webContents.delete()
return
case "edit.selectAll":
win?.webContents.selectAll()
return
}
}
function setZoom(win: BrowserWindow | null, value: number) {
if (!win) return
win.webContents.setZoomFactor(Math.min(Math.max(value, 0.2), 10))
updateTitlebar(win)
}
+2 -4
View File
@@ -345,13 +345,11 @@ const main = Effect.gen(function* () {
mainWindow = createMainWindow() mainWindow = createMainWindow()
if (mainWindow) { if (mainWindow) {
createMenu({ createMenu({
trigger: (id) => { trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => { checkForUpdates: () => {
void checkForUpdates(true, killSidecar) void checkForUpdates(true, killSidecar)
}, },
reload: () => mainWindow?.reload(),
relaunch: () => { relaunch: () => {
void killSidecar().finally(() => { void killSidecar().finally(() => {
app.relaunch() app.relaunch()
-5
View File
@@ -1,7 +1,6 @@
import { execFile } from "node:child_process" import { execFile } from "node:child_process"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron" import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { import type {
InitStep, InitStep,
@@ -11,7 +10,6 @@ import type {
WindowConfig, WindowConfig,
WslConfig, WslConfig,
} from "../preload/types" } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { getStore } from "./store" import { getStore } from "./store"
import { setTitlebar, updateTitlebar } from "./windows" import { setTitlebar, updateTitlebar } from "./windows"
@@ -200,9 +198,6 @@ export function registerIpcHandlers(deps: Deps) {
if (!win) return if (!win) return
setTitlebar(win, theme) setTitlebar(win, theme)
}) })
ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action)
})
} }
export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) { export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
+126 -52
View File
@@ -1,67 +1,141 @@
import { BrowserWindow, Menu, shell } from "electron" import { Menu, shell } from "electron"
import type { MenuItemConstructorOptions } from "electron"
import {
DESKTOP_MENU,
desktopMenuVisible,
type DesktopMenuEntry,
type DesktopMenuRole,
} from "@opencode-ai/app/desktop-menu"
import { UPDATER_ENABLED } from "./constants" import { UPDATER_ENABLED } from "./constants"
import { runDesktopMenuAction } from "./desktop-menu-actions" import { createMainWindow } from "./windows"
type Deps = { type Deps = {
trigger: (id: string) => void trigger: (id: string) => void
checkForUpdates: () => void checkForUpdates: () => void
reload: () => void
relaunch: () => void relaunch: () => void
} }
export function createMenu(deps: Deps) { export function createMenu(deps: Deps) {
if (process.platform !== "darwin") return if (process.platform !== "darwin") return
const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => { const template: Electron.MenuItemConstructorOptions[] = [
if (menu.role) return { role: nativeRole(menu.role) } {
return { label: "OpenCode",
label: menu.label, submenu: [
submenu: menu.items { role: "about" },
?.filter((entry) => desktopMenuVisible(entry, "macos")) {
.map((entry) => nativeItem(entry, deps)), label: "Check for Updates...",
} enabled: UPDATER_ENABLED,
}) click: () => deps.checkForUpdates(),
},
{
label: "Settings",
accelerator: "Cmd+,",
click: () => deps.trigger("settings.open"),
},
{
label: "Reload Webview",
click: () => deps.reload(),
},
{
label: "Restart",
click: () => deps.relaunch(),
},
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
},
{
label: "File",
submenu: [
{ label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
{ label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
{
label: "New Window",
accelerator: "Cmd+Shift+N",
click: () => createMainWindow(),
},
{ type: "separator" },
{ role: "close" },
],
},
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: "View",
submenu: [
{ label: "Toggle Sidebar", accelerator: "Cmd+B", click: () => deps.trigger("sidebar.toggle") },
{ label: "Toggle Terminal", accelerator: "Ctrl+`", click: () => deps.trigger("terminal.toggle") },
{ label: "Toggle File Tree", click: () => deps.trigger("fileTree.toggle") },
{ type: "separator" },
{ role: "reload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Go",
submenu: [
{ label: "Back", accelerator: "Cmd+[", click: () => deps.trigger("common.goBack") },
{ label: "Forward", accelerator: "Cmd+]", click: () => deps.trigger("common.goForward") },
{ type: "separator" },
{
label: "Previous Session",
accelerator: "Option+Up",
click: () => deps.trigger("session.previous"),
},
{
label: "Next Session",
accelerator: "Option+Down",
click: () => deps.trigger("session.next"),
},
{ type: "separator" },
{
label: "Previous Project",
accelerator: "Cmd+Option+Up",
click: () => deps.trigger("project.previous"),
},
{
label: "Next Project",
accelerator: "Cmd+Option+Down",
click: () => deps.trigger("project.next"),
},
],
},
{ role: "windowMenu" },
{
label: "Help",
submenu: [
{ label: "OpenCode Documentation", click: () => shell.openExternal("https://opencode.ai/docs") },
{ label: "Support Forum", click: () => shell.openExternal("https://discord.com/invite/opencode") },
{ type: "separator" },
{ type: "separator" },
{
label: "Share Feedback",
click: () =>
shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml"),
},
{
label: "Report a Bug",
click: () => shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml"),
},
],
},
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template)) Menu.setApplicationMenu(Menu.buildFromTemplate(template))
} }
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
if (entry.type === "separator") return { type: "separator" }
if (entry.role) return { role: nativeRole(entry.role) }
const item: MenuItemConstructorOptions = {
label: entry.label,
accelerator: entry.accelerator?.macos,
enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined,
}
if (entry.command) {
const command = entry.command
item.click = () => deps.trigger(command)
}
if (entry.action) {
const action = entry.action
item.click = () =>
runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
checkForUpdates: deps.checkForUpdates,
relaunch: deps.relaunch,
})
}
if (entry.href) {
const href = entry.href
item.click = () => shell.openExternal(href)
}
return item
}
function nativeRole(role: DesktopMenuRole) {
return role as NonNullable<MenuItemConstructorOptions["role"]>
}
-1
View File
@@ -61,7 +61,6 @@ const api: ElectronAPI = {
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"), getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor), setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme), setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"), loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail), runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"), checkUpdate: () => ipcRenderer.invoke("check-update"),
+1 -3
View File
@@ -1,5 +1,3 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" } export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
export type ServerReadyData = { export type ServerReadyData = {
@@ -16,6 +14,7 @@ export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = { export type TitlebarTheme = {
mode: "light" | "dark" mode: "light" | "dark"
} }
export type WindowConfig = { export type WindowConfig = {
updaterEnabled: boolean updaterEnabled: boolean
} }
@@ -72,7 +71,6 @@ export type ElectronAPI = {
getZoomFactor: () => Promise<number> getZoomFactor: () => Promise<number>
setZoomFactor: (factor: number) => Promise<void> setZoomFactor: (factor: number) => Promise<void>
setTitlebar: (theme: TitlebarTheme) => Promise<void> setTitlebar: (theme: TitlebarTheme) => Promise<void>
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
loadingWindowComplete: () => void loadingWindowComplete: () => void
runUpdater: (alertOnFail: boolean) => Promise<void> runUpdater: (alertOnFail: boolean) => Promise<void>
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
+1 -19
View File
@@ -21,7 +21,7 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js
import { render } from "solid-js/web" import { render } from "solid-js/web"
import pkg from "../../package.json" import pkg from "../../package.json"
import { initI18n, t } from "./i18n" import { initI18n, t } from "./i18n"
import { resetZoom, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" import { webviewZoom } from "./webview-zoom"
import "./styles.css" import "./styles.css"
import { useTheme } from "@opencode-ai/ui/theme" import { useTheme } from "@opencode-ai/ui/theme"
@@ -100,22 +100,6 @@ const createPlatform = (): Platform => {
return window.api.wslPath(result, "linux").catch(() => result) as any return window.api.wslPath(result, "linux").catch(() => result) as any
} }
const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
switch (action) {
case "view.resetZoom":
resetZoom()
return
case "view.zoomIn":
zoomIn()
return
case "view.zoomOut":
zoomOut()
return
}
return window.api.runDesktopMenuAction(action)
}
const storage = (() => { const storage = (() => {
const cache = new Map<string, AsyncStorage>() const cache = new Map<string, AsyncStorage>()
@@ -270,8 +254,6 @@ const createPlatform = (): Platform => {
webviewZoom, webviewZoom,
runDesktopMenuAction,
checkAppExists: async (appName: string) => { checkAppExists: async (appName: string) => {
return window.api.checkAppExists(appName) return window.api.checkAppExists(appName)
}, },
@@ -33,27 +33,23 @@ const applyZoom = (next: number) => {
}) })
} }
const resetZoom = () => applyZoom(1)
const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2))
const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2))
window.addEventListener("keydown", (event) => { window.addEventListener("keydown", (event) => {
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
if (event.key === "-") { if (event.key === "-") {
event.preventDefault() event.preventDefault()
zoomOut() applyZoom(clamp(requestedZoom - 0.2))
return return
} }
if (event.key === "=" || event.key === "+") { if (event.key === "=" || event.key === "+") {
event.preventDefault() event.preventDefault()
zoomIn() applyZoom(clamp(requestedZoom + 0.2))
return return
} }
if (event.key === "0") { if (event.key === "0") {
event.preventDefault() event.preventDefault()
resetZoom() applyZoom(1)
} }
}) })
export { webviewZoom, resetZoom, zoomIn, zoomOut } export { webviewZoom }
-19
View File
@@ -1,19 +0,0 @@
# Effect Drizzle SQLite
This package vendors a Drizzle Effect SQLite adapter for this repo.
- Keep this package generic: Drizzle + Effect + SQLite only.
- Do not add opencode-specific tables, paths, migrations, post-commit hooks, or domain storage APIs here.
- Runtime code should depend on generic `effect/unstable/sql/SqlClient`, not a specific SQLite driver.
- Concrete SQLite clients such as `@effect/sql-sqlite-bun` belong in tests or examples unless this package intentionally adds a driver-specific helper.
- Preserve Drizzle adapter naming and behavior where possible so this can be replaced by upstream `drizzle-orm/effect-sqlite` later.
- If touching copied Drizzle internals, compare with current `drizzle-orm@1.0.0-rc.2` declarations and runtime JS.
- If touching Effect APIs, verify against `/Users/kit/code/open-source/effect-smol`.
Useful entry points:
- `src/effect-sqlite/driver.ts`: creates the Effect-backed Drizzle database with `make` and `makeWithDefaults`.
- `src/effect-sqlite/session.ts`: adapts generic Effect `SqlClient` execution and transactions to Drizzle SQLite sessions.
- `src/sqlite-core/effect/*`: Effect-yieldable SQLite query builders.
- `src/internal/drizzle-utils.ts`: local typed shims for Drizzle runtime internals that RC2 does not expose in declarations.
- `examples/basic.ts`: minimal usage example with Bun SQLite.
@@ -1,92 +0,0 @@
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { eq } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Schema from "effect/Schema"
import { EffectDrizzleSqlite } from "../src"
const users = sqliteTable("users", {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
type User = typeof users.$inferSelect
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
const sqliteLayer = SqliteClient.layer({ filename: ":memory:", disableWAL: true })
class Database extends Context.Service<Database, DatabaseShape>()("@opencode/example/Database") {
static layer = Layer.effect(Database, makeDatabase).pipe(Layer.provide(sqliteLayer))
}
class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()("UserStoreError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const mapStoreError = (message: string) => (cause: unknown) => new UserStoreError({ message, cause })
interface UserStoreShape {
migrate(): Effect.Effect<void, UserStoreError>
create(name: string): Effect.Effect<void, UserStoreError>
rename(from: string, to: string): Effect.Effect<void, UserStoreError>
list(): Effect.Effect<User[], UserStoreError>
}
class UserStore extends Context.Service<UserStore, UserStoreShape>()("@opencode/example/UserStore") {
static layer = Layer.effect(
UserStore,
Effect.gen(function* () {
const db = yield* Database
return UserStore.of({
migrate: Effect.fn("UserStore.migrate")(function* () {
yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder: `${import.meta.dirname}/migrations` }).pipe(
Effect.mapError((cause) => new UserStoreError({ message: "Failed to migrate users", cause })),
)
}),
create: Effect.fn("UserStore.create")(function* (name: string) {
yield* db
.insert(users)
.values({ name })
.pipe(Effect.asVoid, Effect.mapError(mapStoreError("Failed to create user")))
}),
rename: Effect.fn("UserStore.rename")(function* (from: string, to: string) {
yield* db
.transaction(
Effect.fnUntraced(function* (tx) {
yield* tx.insert(users).values({ name: from })
yield* tx.update(users).set({ name: to }).where(eq(users.name, from))
}),
{ behavior: "immediate" },
)
.pipe(Effect.asVoid, Effect.mapError(mapStoreError("Failed to rename user")))
}),
list: Effect.fn("UserStore.list")(function* () {
return yield* db
.select()
.from(users)
.pipe(Effect.mapError(mapStoreError("Failed to list users")))
}),
})
}),
).pipe(Layer.provide(Database.layer))
}
const program = Effect.gen(function* () {
const userStore = yield* UserStore
yield* userStore.migrate()
yield* userStore.create("Ada")
yield* userStore.rename("Grace", "Grace Hopper")
return yield* userStore.list()
})
const rows = await Effect.runPromise(program.pipe(Effect.provide(UserStore.layer)))
console.log(rows)
@@ -1,4 +0,0 @@
CREATE TABLE users (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
name text NOT NULL
);
@@ -1,29 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.5",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts",
"./effect-sqlite": "./src/effect-sqlite/index.ts",
"./effect-sqlite/migrator": "./src/effect-sqlite/migrator.ts",
"./sqlite-core/effect": "./src/sqlite-core/effect/index.ts"
},
"devDependencies": {
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:"
}
}
@@ -1,77 +0,0 @@
/* oxlint-disable */
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import { SqlClient } from "effect/unstable/sql/SqlClient"
import { EffectCache } from "drizzle-orm/cache/core/cache-effect"
import { EffectLogger } from "drizzle-orm/effect-core"
import { entityKind } from "drizzle-orm/entity"
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
import { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteEffectDatabase } from "../sqlite-core/effect/db"
import type { DrizzleConfig } from "drizzle-orm/utils"
import { jitCompatCheck } from "../internal/drizzle-utils"
import { type EffectSQLiteQueryEffectHKT, type EffectSQLiteRunResult, EffectSQLiteSession } from "./session"
export class EffectSQLiteDatabase<TRelations extends AnyRelations = EmptyRelations> extends SQLiteEffectDatabase<
EffectSQLiteQueryEffectHKT,
EffectSQLiteRunResult,
TRelations
> {
static override readonly [entityKind]: string = "EffectSQLiteDatabase"
}
export type EffectDrizzleSQLiteConfig<TRelations extends AnyRelations = EmptyRelations> = Omit<
DrizzleConfig<Record<string, never>, TRelations>,
"cache" | "logger" | "schema"
>
export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Default)
/**
* Creates an EffectSQLiteDatabase instance.
*
* Requires a generic Effect `SqlClient`, `EffectLogger`, and `EffectCache` services to be provided.
* Drizzle only depends on the generic `SqlClient`; install and provide a compatible SQLite provider such as
* `@effect/sql-sqlite-node`, `@effect/sql-sqlite-bun`, or another package that exposes `SqlClient`.
*
* @example
* ```ts
* import { SqliteClient } from '@effect/sql-sqlite-node';
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
* import * as Effect from 'effect/Effect';
*
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
* Effect.provide(SQLiteDrizzle.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
* );
* ```
*/
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
config: EffectDrizzleSQLiteConfig<TRelations> = {},
) {
const client = yield* SqlClient
const cache = yield* EffectCache
const logger = yield* EffectLogger
const dialect = new SQLiteAsyncDialect()
const relations = config.relations ?? ({} as TRelations)
const session = new EffectSQLiteSession(client, dialect, relations, {
logger,
cache,
useJitMappers: jitCompatCheck(config.jit),
})
const db = new EffectSQLiteDatabase(dialect, session, relations) as EffectSQLiteDatabase<TRelations> & {
$client: SqlClient
}
db.$client = client
db.$cache.invalidate = cache.onMutate
return db
})
/**
* Convenience function that creates an EffectSQLiteDatabase with `DefaultServices` already provided.
*/
export const makeWithDefaults = <TRelations extends AnyRelations = EmptyRelations>(
config: EffectDrizzleSQLiteConfig<TRelations> = {},
) => make(config).pipe(Effect.provide(DefaultServices))
@@ -1,4 +0,0 @@
/* oxlint-disable */
export { EffectLogger } from "drizzle-orm/effect-core"
export * from "./driver"
export * from "./session"
@@ -1,14 +0,0 @@
/* oxlint-disable */
import type { MigrationConfig } from "drizzle-orm/migrator"
import { readMigrationFiles } from "drizzle-orm/migrator"
import type { AnyRelations } from "drizzle-orm/relations"
import { migrate as coreMigrate } from "../sqlite-core/effect/session"
import type { EffectSQLiteDatabase } from "./driver"
export function migrate<TRelations extends AnyRelations>(
db: EffectSQLiteDatabase<TRelations>,
config: MigrationConfig,
) {
const migrations = readMigrationFiles(config)
return coreMigrate(migrations, db.session, config)
}
@@ -1,214 +0,0 @@
/* oxlint-disable */
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as Scope from "effect/Scope"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import type { SqlError } from "effect/unstable/sql/SqlError"
import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect"
import type { WithCacheConfig } from "drizzle-orm/cache/core/types"
import type { EffectDrizzleQueryError } from "drizzle-orm/effect-core/errors"
import type { EffectLoggerShape } from "drizzle-orm/effect-core/logger"
import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import type { AnyRelations } from "drizzle-orm/relations"
import type { RelationalQueryMapperConfig } from "drizzle-orm/relations"
import type { Query } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteEffectPreparedQuery, SQLiteEffectSession, SQLiteEffectTransaction } from "../sqlite-core/effect/session"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
export interface EffectSQLiteQueryEffectHKT extends QueryEffectHKTBase {
readonly error: EffectDrizzleQueryError
readonly context: never
}
export type EffectSQLiteRunResult = readonly never[]
export interface EffectSQLiteSessionOptions {
logger: EffectLoggerShape
cache: EffectCacheShape
useJitMappers?: boolean
}
export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLiteEffectSession<
EffectSQLiteQueryEffectHKT,
EffectSQLiteRunResult,
TRelations
> {
static override readonly [entityKind]: string = "EffectSQLiteSession"
constructor(
private client: SqlClient,
dialect: SQLiteAsyncDialect,
protected relations: TRelations,
private options: EffectSQLiteSessionOptions,
) {
super(dialect)
}
override prepareQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown,
queryMetadata?: {
type: "select" | "update" | "delete" | "insert"
tables: string[]
},
cacheConfig?: WithCacheConfig,
): SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT> {
return new SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT>(
(params, method) => this.execute(query, params, method),
query,
this.options.logger,
this.options.cache,
queryMetadata,
cacheConfig,
fields,
executeMethod,
this.options.useJitMappers,
customResultMapper,
undefined,
undefined,
this.isInTransaction(),
)
}
override prepareRelationalQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper: (rows: Record<string, unknown>[], mapColumnValue?: (value: unknown) => unknown) => unknown,
config: RelationalQueryMapperConfig,
): SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT, true> {
return new SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT, true>(
(params, method) => this.execute(query, params, method),
query,
this.options.logger,
this.options.cache,
undefined,
undefined,
fields,
executeMethod,
this.options.useJitMappers,
customResultMapper,
true,
config,
this.isInTransaction(),
)
}
private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") {
const statement = this.client.unsafe(query.sql, params)
if (method === "values") return statement.values
if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0]))
return statement.withoutTransform
}
private isInTransaction() {
return Effect.serviceOption(this.client.transactionService).pipe(Effect.map((option) => option._tag === "Some"))
}
private executeTransactionStatement(connection: Effect.Success<SqlClient["reserve"]>, query: string) {
return connection.executeUnprepared(query, [], undefined).pipe(Effect.asVoid)
}
private withTransaction<A, E, R>(effect: Effect.Effect<A, E, R>, config: SQLiteTransactionConfig | undefined) {
return Effect.uninterruptibleMask((restore) =>
Effect.withFiber<A, E | SqlError, R>((fiber) => {
const services = fiber.context
const connectionOption = Context.getOption(services, this.client.transactionService)
const connection: Effect.Effect<
readonly [Scope.Closeable | undefined, Effect.Success<SqlClient["reserve"]>],
SqlError
> =
connectionOption._tag === "Some"
? Effect.succeed([undefined, connectionOption.value[0]] as const)
: Scope.make().pipe(
Effect.flatMap((scope) =>
Scope.provide(this.client.reserve, scope).pipe(
Effect.map((connection) => [scope, connection] as const),
Effect.catch((error) =>
Scope.close(scope, Exit.fail(error)).pipe(Effect.andThen(Effect.fail(error))),
),
),
),
)
const id = connectionOption._tag === "Some" ? connectionOption.value[1] + 1 : 0
return connection.pipe(
Effect.flatMap(([scope, connection]) =>
this.executeTransactionStatement(
connection,
id === 0 ? `begin ${config?.behavior ?? "deferred"}` : `savepoint effect_sql_${id}`,
).pipe(
Effect.flatMap(() =>
Effect.provideContext(
restore(effect),
Context.add(services, this.client.transactionService, [connection, id]),
),
),
Effect.exit,
Effect.flatMap((exit) => {
const finalize = Exit.isSuccess(exit)
? id === 0
? this.executeTransactionStatement(connection, "commit").pipe(
// SQLite keeps the transaction open after deferred constraint commit failures.
Effect.catch((error) =>
this.executeTransactionStatement(connection, "rollback").pipe(
Effect.catch(() => Effect.void),
Effect.andThen(Effect.fail(error)),
),
),
)
: this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`)
: id === 0
? this.executeTransactionStatement(connection, "rollback")
: this.executeTransactionStatement(connection, `rollback to savepoint effect_sql_${id}`).pipe(
Effect.andThen(
this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`),
),
)
const scoped = scope === undefined ? finalize : Effect.ensuring(finalize, Scope.close(scope, exit))
return scoped.pipe(Effect.flatMap(() => exit))
}),
),
),
)
}),
)
}
override transaction<A, E, R>(
transaction: (tx: EffectSQLiteTransaction<TRelations>) => Effect.Effect<A, E, R>,
config?: SQLiteTransactionConfig,
): Effect.Effect<A, E | SqlError, R> {
const { dialect, relations } = this
return this.withTransaction(
Effect.gen({ self: this }, function* () {
const tx = new EffectSQLiteTransaction<TRelations>(dialect, this, relations)
return yield* transaction(tx)
}),
config,
)
}
}
export class EffectSQLiteTransaction<TRelations extends AnyRelations> extends SQLiteEffectTransaction<
EffectSQLiteQueryEffectHKT,
EffectSQLiteRunResult,
TRelations
> {
static override readonly [entityKind]: string = "EffectSQLiteTransaction"
override transaction: <A, E, R>(
transaction: (
tx: SQLiteEffectTransaction<EffectSQLiteQueryEffectHKT, EffectSQLiteRunResult, TRelations>,
) => Effect.Effect<A, E, R>,
) => Effect.Effect<A, SqlError | E, R> = (tx) => this.session.transaction(tx)
}
@@ -1,6 +0,0 @@
export { EffectLogger } from "drizzle-orm/effect-core"
export * from "./effect-sqlite/driver"
export * from "./effect-sqlite/session"
export { migrate } from "./effect-sqlite/migrator"
export * as EffectDrizzleSqlite from "."
@@ -1,127 +0,0 @@
/* oxlint-disable */
import { Column, getColumnTable } from "drizzle-orm/column"
import { is } from "drizzle-orm/entity"
import type { JoinNullability } from "drizzle-orm/query-builders/select.types"
import { Param, SQL } from "drizzle-orm/sql/sql"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core/query-builders/update"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base"
import { Subquery } from "drizzle-orm/subquery"
import { Table, getTableName } from "drizzle-orm/table"
import type { UpdateSet } from "drizzle-orm/utils"
import { ViewBaseConfig } from "drizzle-orm/view-common"
const TableSymbol = (
Table as unknown as {
Symbol: { Columns: symbol; IsAlias: symbol; Name: symbol; BaseName: symbol }
}
).Symbol
export function getTableColumnsRuntime(table: SQLiteTable) {
return (table as unknown as Record<symbol, Record<string, Column>>)[TableSymbol.Columns]
}
export function getViewSelectedFieldsRuntime(view: SQLiteViewBase) {
return (view as unknown as Record<symbol, { selectedFields: Record<string, unknown>; name: string }>)[ViewBaseConfig]
}
export function jitCompatCheck(isEnabled: boolean | undefined) {
if (!isEnabled) return false
try {
return new Function("input", '"use strict"; return input;')(true) === true
} catch {
return false
}
}
export function orderSelectedFields<TColumn extends Column>(
fields: Record<string, unknown>,
pathPrefix?: string[],
): SelectedFieldsOrdered {
return Object.entries(fields).flatMap(([name, field]) => {
const path = pathPrefix ? [...pathPrefix, name] : [name]
if (is(field, Column) || is(field, SQL) || is(field, SQL.Aliased) || is(field, Subquery)) {
return [{ path, field }] as SelectedFieldsOrdered
}
if (is(field, Table)) return orderSelectedFields(getTableColumnsRuntime(field as SQLiteTable), path)
return orderSelectedFields(field as Record<string, unknown>, path)
}) as SelectedFieldsOrdered
}
export function mapUpdateSet<TTable extends SQLiteTable>(table: TTable, values: SQLiteUpdateSetSource<TTable>) {
const entries = Object.entries(values).filter(([, value]) => value !== undefined)
if (entries.length === 0) throw new Error("No values to set")
return Object.fromEntries(
entries.map(([key, value]) => [
key,
is(value, SQL) || is(value, Column) ? value : new Param(value, getTableColumnsRuntime(table)[key]),
]),
) as UpdateSet
}
export function mapResultRow(
columns: SelectedFieldsOrdered,
row: unknown[],
joinsNotNullableMap: Record<string, boolean> | undefined,
) {
const nullifyMap: Record<string, string | false> = {}
const result: Record<string, unknown> = {}
columns.forEach((column, columnIndex) => {
const decoder = (
is(column.field, Column)
? column.field
: is(column.field, SQL)
? (column.field as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder
: is(column.field, Subquery)
? (column.field._.sql as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder
: (column.field.sql as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder
) as {
mapFromDriverValue(value: unknown): unknown
}
const rawValue = row[columnIndex]
const value = rawValue === null ? null : decoder.mapFromDriverValue(rawValue)
const objectName = column.path[0]
let node = result
column.path.forEach((pathChunk, pathChunkIndex) => {
if (pathChunkIndex === column.path.length - 1) {
node[pathChunk] = value
return
}
node[pathChunk] = (node[pathChunk] ?? {}) as Record<string, unknown>
node = node[pathChunk] as Record<string, unknown>
})
if (joinsNotNullableMap && is(column.field, Column) && column.path.length === 2 && objectName) {
const tableName = getTableName(getColumnTable(column.field))
nullifyMap[objectName] =
!(objectName in nullifyMap) && value === null
? tableName
: typeof nullifyMap[objectName] === "string" && nullifyMap[objectName] !== tableName
? false
: nullifyMap[objectName]
}
})
Object.entries(nullifyMap).forEach(([objectName, tableName]) => {
if (typeof tableName === "string" && !joinsNotNullableMap?.[tableName]) result[objectName] = null
})
return result
}
export function getTableLikeName(table: SQLiteTable | Subquery | SQLiteViewBase | SQL) {
if (is(table, Subquery)) return table._.alias
if (is(table, SQLiteViewBase)) return getViewSelectedFieldsRuntime(table).name
if (is(table, SQL)) return undefined
return (table as unknown as Record<symbol, string | boolean>)[
(table as unknown as Record<symbol, string | boolean>)[TableSymbol.IsAlias]
? TableSymbol.Name
: TableSymbol.BaseName
] as string
}
export type { JoinNullability }
@@ -1,58 +0,0 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import { SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import type { SQLiteView } from "drizzle-orm/sqlite-core/view"
import type { SQLiteEffectSession } from "./session"
function buildSQLiteEmbeddedCount(source: SQLiteTable | SQLiteView | SQL | SQLWrapper, filters?: SQL<unknown>) {
return sql<number>`(select count(*) from ${source}${sql.raw(" where ").if(filters)}${filters})`
}
function buildSQLiteCount(source: SQLiteTable | SQLiteView | SQL | SQLWrapper, filters?: SQL<unknown>) {
return sql<number>`select count(*) from ${source}${sql.raw(" where ").if(filters)}${filters}`
}
export interface SQLiteEffectCountBuilder<TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
extends SQL<number>,
SQLWrapper<number>,
Effect.Effect<number, TEffectHKT["error"], TEffectHKT["context"]> {}
export class SQLiteEffectCountBuilder<TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase> extends SQL<number> {
static override readonly [entityKind]: string = "SQLiteEffectCountBuilder"
private sql: SQL<number>
private session: SQLiteEffectSession<TEffectHKT, any, any>
constructor(params: {
source: SQLiteTable | SQLiteView | SQL | SQLWrapper
filters?: SQL<unknown>
session: SQLiteEffectSession<TEffectHKT, any, any>
}) {
super(buildSQLiteEmbeddedCount(params.source, params.filters).queryChunks)
this.session = params.session
this.sql = buildSQLiteCount(params.source, params.filters)
}
execute(placeholderValues?: Record<string, unknown>) {
return this.session
.prepareQuery<{
type: "async"
execute: number
run: unknown
all: unknown
get: unknown
values: unknown
}>(this.session.dialect.sqlToQuery(this.sql), undefined, "all", (rows) => {
const v = rows[0]?.[0]
if (typeof v === "number") return v
return v ? Number(v) : 0
})
.execute(placeholderValues)
}
}
applyEffectWrapper(SQLiteEffectCountBuilder)

Some files were not shown because too many files have changed in this diff Show More