mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5b22986b4 |
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"@opencode-ai/core": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
"@opencode-ai/core": minor
|
|
||||||
"@opencode-ai/schema": minor
|
|
||||||
"@opencode-ai/protocol": minor
|
|
||||||
"@opencode-ai/client": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Replace Core shell permission parsing with portable, fail-closed Bash and PowerShell scanners.
|
|
||||||
@@ -10,7 +10,6 @@ kitlangton
|
|||||||
kommander
|
kommander
|
||||||
ludvigrask
|
ludvigrask
|
||||||
MrMushrooooom
|
MrMushrooooom
|
||||||
neriousy
|
|
||||||
nexxeln
|
nexxeln
|
||||||
R44VC0RP
|
R44VC0RP
|
||||||
rekram1-node
|
rekram1-node
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
name: deploy-lab-catalog
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [v2]
|
|
||||||
paths:
|
|
||||||
- ".github/workflows/deploy-lab-catalog.yml"
|
|
||||||
- "bun.lock"
|
|
||||||
- "package.json"
|
|
||||||
- "packages/drive/**"
|
|
||||||
- "packages/protocol/src/simulation.ts"
|
|
||||||
- "packages/simulation/**"
|
|
||||||
- "packages/lab/catalog/**"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-lab-catalog-${{ github.ref_name }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
|
||||||
|
|
||||||
- name: Install ffmpeg
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install --yes ffmpeg
|
|
||||||
|
|
||||||
- name: Validate
|
|
||||||
run: |
|
|
||||||
bun --cwd packages/protocol typecheck
|
|
||||||
bun --cwd packages/simulation typecheck
|
|
||||||
bun --cwd packages/drive run check
|
|
||||||
bun --cwd packages/drive run test
|
|
||||||
bun --cwd packages/lab/catalog run check
|
|
||||||
|
|
||||||
- name: Deploy
|
|
||||||
working-directory: packages/lab/catalog
|
|
||||||
run: bun run deploy
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
@@ -6,6 +6,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- ci
|
- ci
|
||||||
- dev
|
- dev
|
||||||
|
- v2
|
||||||
- beta
|
- beta
|
||||||
- fix/npm-native-binary-install
|
- fix/npm-native-binary-install
|
||||||
- snapshot-*
|
- snapshot-*
|
||||||
|
|||||||
@@ -55,12 +55,6 @@ jobs:
|
|||||||
git config --global user.email "bot@opencode.ai"
|
git config --global user.email "bot@opencode.ai"
|
||||||
git config --global user.name "opencode"
|
git config --global user.name "opencode"
|
||||||
|
|
||||||
- name: Install ffmpeg
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install --yes ffmpeg
|
|
||||||
|
|
||||||
- name: Cache Turbo
|
- name: Cache Turbo
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
with:
|
with:
|
||||||
@@ -72,20 +66,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
|
run: GITHUB_ACTIONS=false bun turbo test
|
||||||
env:
|
env:
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
- name: Verify PowerShell 7 scanner conformance
|
|
||||||
if: always() && runner.os == 'Windows'
|
|
||||||
working-directory: packages/shell-scan
|
|
||||||
run: PWSH=pwsh bun run research:powershell
|
|
||||||
|
|
||||||
- name: Verify Windows PowerShell scanner conformance
|
|
||||||
if: always() && runner.os == 'Windows'
|
|
||||||
working-directory: packages/shell-scan
|
|
||||||
run: PWSH=powershell.exe bun run research:powershell
|
|
||||||
|
|
||||||
- name: Verify compiled service lifecycle
|
- name: Verify compiled service lifecycle
|
||||||
if: always()
|
if: always()
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# he Glossary
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
- Hebrew Academy approved IT terminology: https://terms.hebrew-academy.org.il/Millonim/ShowMillon?KodMillon=192
|
|
||||||
- Firefox Hebrew localization corpus: https://github.com/mozilla-l10n/firefox-l10n/tree/main/he
|
|
||||||
- KDE Hebrew localization team and corpus: https://l10n.kde.org/team-infos.php?teamcode=he
|
|
||||||
- Community-maintained VS Code Hebrew language pack: https://github.com/AMAARETS/vscode-language-pack-he
|
|
||||||
- Microsoft Hebrew developer documentation for Git terminology: https://learn.microsoft.com/he-il/power-platform/alm/tutorials/github-actions-deploy
|
|
||||||
- W3C guidance for bidirectional text: https://www.w3.org/International/articles/strings-and-bidi/
|
|
||||||
|
|
||||||
## Do Not Translate (Locale Additions)
|
|
||||||
|
|
||||||
- `OpenCode` (preserve casing in prose and UI copy)
|
|
||||||
- `API`, `MCP`, `LSP`, `OAuth`, `Git`, model names, and provider names
|
|
||||||
- Commands, flags, keyboard shortcuts, file paths, URLs, identifiers, hashes, and code literals
|
|
||||||
- Keep `commit` and `diff` when they name the exact Git artifact or operation
|
|
||||||
|
|
||||||
## Preferred Terms
|
|
||||||
|
|
||||||
| English / Context | Preferred | Notes |
|
|
||||||
| ----------------- | ------------- | ------------------------------------------------------------------------- |
|
|
||||||
| session | `הפעלה` | Use `שיחה` only when the source specifically means a chat or conversation |
|
|
||||||
| workspace | `סביבת עבודה` | |
|
|
||||||
| terminal | `מסוף` | Prefer the established Hebrew term over transliteration |
|
|
||||||
| command | `פקודה` | |
|
|
||||||
| provider | `ספק` | Use `ספק מודלים` where the bare noun is ambiguous |
|
|
||||||
| model | `מודל` | |
|
|
||||||
| API key | `מפתח API` | Keep the acronym in Latin letters |
|
|
||||||
| plugin | `תוסף` | |
|
|
||||||
| repository | `מאגר` | Use `מאגר Git` where context is ambiguous |
|
|
||||||
| branch | `ענף` | |
|
|
||||||
| context | `הקשר` | Use `חלון הקשר` for context window |
|
|
||||||
| tokens | `אסימונים` | |
|
|
||||||
|
|
||||||
## Guidance
|
|
||||||
|
|
||||||
- Prefer natural modern Israeli Hebrew over word-for-word translation or obscure coined terms.
|
|
||||||
- Use short action verbs for controls and translate complete phrases in context.
|
|
||||||
- Keep recognized developer acronyms and exact Git vocabulary in Latin script instead of phonetic transliteration.
|
|
||||||
- Treat embedded code, paths, commands, shortcuts, hashes, model IDs, and other Latin technical artifacts as LTR content inside the RTL interface.
|
|
||||||
- Keep recurring concepts consistent and do not collapse session, chat, run, and launch into one Hebrew term.
|
|
||||||
|
|
||||||
## Avoid
|
|
||||||
|
|
||||||
- Avoid transliterations such as `טרמינל`, `פלאגין`, and `קומנד` when `מסוף`, `תוסף`, and `פקודה` are clear.
|
|
||||||
- Avoid translating `commit` as `התחייבות`.
|
|
||||||
- Avoid inventing Hebrew expansions for `API`, `MCP`, or `LSP`.
|
|
||||||
@@ -51,7 +51,7 @@ files. If the script is unsuccessful, automatically fix the script and run it ag
|
|||||||
Scripts use one typed definition object. `setup` runs before OpenCode starts,
|
Scripts use one typed definition object. `setup` runs before OpenCode starts,
|
||||||
and `fs.writeFile` always writes inside the simulated project.
|
and `fs.writeFile` always writes inside the simulated project.
|
||||||
|
|
||||||
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts
|
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { defineScript } from "opencode-drive"
|
import { defineScript } from "opencode-drive"
|
||||||
@@ -83,7 +83,7 @@ itself (this is extremely rare, do not use this unless explicitly asked). In thi
|
|||||||
mode `ui` is typed as `null`; call `server.launch()` exactly
|
mode `ui` is typed as `null`; call `server.launch()` exactly
|
||||||
once before launching clients. Each `clients.launch(name)` result provides the
|
once before launching clients. Each `clients.launch(name)` result provides the
|
||||||
same UI methods as the automatic client. You can see an example of this API
|
same UI methods as the automatic client. You can see an example of this API
|
||||||
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts
|
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
|
||||||
|
|
||||||
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
||||||
|
|
||||||
@@ -114,8 +114,8 @@ completion are automatic.
|
|||||||
|
|
||||||
You can see some example scripts here:
|
You can see some example scripts here:
|
||||||
|
|
||||||
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts
|
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
|
||||||
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts
|
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
|
||||||
|
|
||||||
## Prune
|
## Prune
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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", "neriousy", "nexxeln", "kitlangton"],
|
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
||||||
inference: ["fwang", "MrMushrooooom", "starptech"],
|
inference: ["fwang", "MrMushrooooom", "starptech"],
|
||||||
windows: ["Hona"],
|
windows: ["Hona"],
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||||
- The default branch in this repo is `v2`.
|
- The default branch in this repo is `dev`.
|
||||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
|
||||||
|
|
||||||
## Live V2 TUI Testing
|
## Live V2 TUI Testing
|
||||||
|
|
||||||
@@ -171,4 +170,4 @@ const table = sqliteTable("session", {
|
|||||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||||
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta.
|
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
|
||||||
|
|||||||
+11
-10
@@ -33,7 +33,6 @@
|
|||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/console/*",
|
"packages/console/*",
|
||||||
"packages/lab/*",
|
|
||||||
"packages/stats/*",
|
"packages/stats/*",
|
||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
@@ -47,9 +46,9 @@
|
|||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.5.2",
|
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@opentui/keymap": "0.5.2",
|
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@opentui/solid": "0.5.2",
|
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@tanstack/solid-virtual": "3.13.32",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"@standard-schema/spec": "1.1.0",
|
"@standard-schema/spec": "1.1.0",
|
||||||
@@ -121,8 +120,7 @@
|
|||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
"sst": "catalog:",
|
"sst": "catalog:",
|
||||||
"turbo": "2.10.2",
|
"turbo": "2.10.2"
|
||||||
"vitest": "4.1.10"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.933.0",
|
"@aws-sdk/client-s3": "3.933.0",
|
||||||
@@ -145,12 +143,16 @@
|
|||||||
"esbuild",
|
"esbuild",
|
||||||
"node-pty",
|
"node-pty",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
|
"tree-sitter",
|
||||||
|
"tree-sitter-bash",
|
||||||
|
"tree-sitter-powershell",
|
||||||
|
"web-tree-sitter",
|
||||||
"electron"
|
"electron"
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"effect": "catalog:"
|
"effect": "catalog:"
|
||||||
@@ -170,7 +172,6 @@
|
|||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||||
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
|
|
||||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,6 @@
|
|||||||
## Localization
|
## Localization
|
||||||
|
|
||||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
||||||
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
|
|
||||||
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
|
|
||||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||||
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
|
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
|||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||||
import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
||||||
import { expect, type Page } from "@playwright/test"
|
import { expect, type Page } from "@playwright/test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
@@ -90,8 +90,6 @@ export async function setupTimeline(
|
|||||||
page: Page,
|
page: Page,
|
||||||
input: {
|
input: {
|
||||||
messages?: TimelineMessage[]
|
messages?: TimelineMessage[]
|
||||||
currentMessages?: SessionMessageInfo[]
|
|
||||||
sessionStatus?: Record<string, SessionStatus>
|
|
||||||
settings?: Record<string, boolean>
|
settings?: Record<string, boolean>
|
||||||
sessions?: Session[]
|
sessions?: Session[]
|
||||||
cpuRate?: number
|
cpuRate?: number
|
||||||
@@ -104,22 +102,13 @@ export async function setupTimeline(
|
|||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const sessions = input.sessions ?? [session()]
|
const sessions = input.sessions ?? [session()]
|
||||||
const messages =
|
const messages = validateTimelineMessages([
|
||||||
input.currentMessages ??
|
...(input.seedHistory ? historyMessages(18) : []),
|
||||||
validateTimelineMessages([
|
...(input.messages ?? [userMessage(), assistantMessage()]),
|
||||||
...(input.seedHistory ? historyMessages(18) : []),
|
])
|
||||||
...(input.messages ?? [userMessage(), assistantMessage()]),
|
const active = messages.findLast((message) => message.info.role === "assistant")
|
||||||
])
|
|
||||||
const active = messages.findLast((message) =>
|
|
||||||
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
|
|
||||||
)
|
|
||||||
const initialStatus = decodeStatus(
|
const initialStatus = decodeStatus(
|
||||||
active &&
|
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
|
||||||
("info" in active
|
|
||||||
? active.info.role === "assistant" && active.info.time.completed === undefined
|
|
||||||
: active.type === "assistant" && active.time.completed === undefined)
|
|
||||||
? { type: "busy" }
|
|
||||||
: { type: "idle" },
|
|
||||||
decodeOptions,
|
decodeOptions,
|
||||||
)
|
)
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
@@ -133,7 +122,7 @@ export async function setupTimeline(
|
|||||||
project: project(),
|
project: project(),
|
||||||
provider: provider(),
|
provider: provider(),
|
||||||
sessions,
|
sessions,
|
||||||
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
|
sessionStatus: { [sessionID]: initialStatus },
|
||||||
pageMessages: () => ({
|
pageMessages: () => ({
|
||||||
items: messages,
|
items: messages,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ test("applies message latency after a list response gate is released", async ()
|
|||||||
const gate = Promise.withResolvers<void>()
|
const gate = Promise.withResolvers<void>()
|
||||||
let handler: ((route: Route) => Promise<void>) | undefined
|
let handler: ((route: Route) => Promise<void>) | undefined
|
||||||
const page = {
|
const page = {
|
||||||
addInitScript: () => Promise.resolve(),
|
|
||||||
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
||||||
handler = callback
|
handler = callback
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { currentSession } from "../utils/mock-server"
|
import { currentSession } from "../utils/mock-server"
|
||||||
import { installSseTransport } from "../utils/sse-transport"
|
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
@@ -79,8 +78,6 @@ function session(id: string, directory: string, title: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mockServers(page: Page, requests: string[]) {
|
async function mockServers(page: Page, requests: string[]) {
|
||||||
await installSseTransport(page, { server: serverA })
|
|
||||||
await installSseTransport(page, { server: serverB })
|
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||||
@@ -88,6 +85,7 @@ async function mockServers(page: Page, requests: string[]) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
@@ -144,3 +142,7 @@ function json(route: Route, body: unknown, status = 200) {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sse(route: Route) {
|
||||||
|
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
|
|||||||
|
|
||||||
test("session settings use the remote server context", async ({ page }) => {
|
test("session settings use the remote server context", async ({ page }) => {
|
||||||
const permissionRequests: string[] = []
|
const permissionRequests: string[] = []
|
||||||
await installSseTransport(page, { server: serverA })
|
|
||||||
await installSseTransport(page, { server: serverB })
|
|
||||||
await mockServers(page, permissionRequests)
|
await mockServers(page, permissionRequests)
|
||||||
await configureServers(page)
|
await configureServers(page)
|
||||||
|
|
||||||
@@ -48,7 +46,6 @@ test("session settings use the remote server context", async ({ page }) => {
|
|||||||
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
||||||
const permissionRequests: string[] = []
|
const permissionRequests: string[] = []
|
||||||
const permissionResponses: PermissionResponse[] = []
|
const permissionResponses: PermissionResponse[] = []
|
||||||
await installSseTransport(page, { server: serverB })
|
|
||||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||||
server: serverA,
|
server: serverA,
|
||||||
retry: 20,
|
retry: 20,
|
||||||
@@ -184,6 +181,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
|||||||
return json(route, true)
|
return json(route, true)
|
||||||
}
|
}
|
||||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
if (url.pathname === "/api/provider")
|
if (url.pathname === "/api/provider")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
location: { directory },
|
location: { directory },
|
||||||
@@ -327,3 +325,7 @@ function json(route: Route, body: unknown, status = 200) {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sse(route: Route) {
|
||||||
|
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,22 +95,22 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||||
await contextButton.click()
|
await contextButton.click()
|
||||||
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebarToggle).toBeDisabled()
|
await expect(sidebarToggle).toBeDisabled()
|
||||||
await expect(sidebar).toBeVisible()
|
await expect(sidebar).toBeVisible()
|
||||||
await contextButton.click()
|
await contextButton.click()
|
||||||
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebar).toBeHidden()
|
await expect(sidebar).toBeHidden()
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||||
await expect(filter).toBeFocused()
|
await expect(filter).toBeFocused()
|
||||||
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
|
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
await panel.getByRole("button", { name: "README.md" }).click()
|
await panel.getByRole("button", { name: "README.md" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
|
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||||
await expect(sidebar).toHaveCount(0)
|
await expect(sidebar).toHaveCount(0)
|
||||||
@@ -125,21 +125,21 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
expect(resultID).toBeTruthy()
|
expect(resultID).toBeTruthy()
|
||||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||||
await filter.press("Enter")
|
await filter.press("Enter")
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||||
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
|
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
|
||||||
|
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||||
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebarToggle).toBeDisabled()
|
await expect(sidebarToggle).toBeDisabled()
|
||||||
await panel.locator("#session-side-panel-review-tab").click()
|
await panel.locator("#session-side-panel-review-tab").click()
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||||
await page.keyboard.press("Control+w")
|
await page.keyboard.press("Control+w")
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||||
})
|
})
|
||||||
|
|
||||||
function fileNode(path: string) {
|
function fileNode(path: string) {
|
||||||
|
|||||||
@@ -11,23 +11,18 @@ const title = "Request dock regression"
|
|||||||
|
|
||||||
test("shows a pending question dock", async ({ page }) => {
|
test("shows a pending question dock", async ({ page }) => {
|
||||||
await mockServer(page, {
|
await mockServer(page, {
|
||||||
forms: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "frm_question_request",
|
id: "question-request",
|
||||||
sessionID,
|
sessionID,
|
||||||
title: "Questions",
|
questions: [
|
||||||
metadata: { kind: "question" },
|
|
||||||
fields: [
|
|
||||||
{
|
{
|
||||||
key: "q0",
|
header: "Implementation",
|
||||||
type: "string",
|
question: "Which implementation should be used?",
|
||||||
title: "Implementation",
|
|
||||||
description: "Which implementation should be used?",
|
|
||||||
options: [
|
options: [
|
||||||
{ value: "minimal", label: "Minimal", description: "Use the smallest correct change" },
|
{ label: "Minimal", description: "Use the smallest correct change" },
|
||||||
{ value: "extended", label: "Extended", description: "Include additional behavior" },
|
{ label: "Extended", description: "Include additional behavior" },
|
||||||
],
|
],
|
||||||
custom: true,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -47,7 +42,7 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
const rejectRequests: string[] = []
|
const rejectRequests: string[] = []
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.method() !== "POST") return
|
if (request.method() !== "POST") return
|
||||||
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
|
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
|
||||||
rejectRequests.push(request.url())
|
rejectRequests.push(request.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,10 +67,10 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
const reply = page.waitForRequest(
|
const reply = page.waitForRequest(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.method() === "POST" &&
|
request.method() === "POST" &&
|
||||||
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`,
|
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
|
||||||
)
|
)
|
||||||
await question.getByRole("button", { name: "Submit" }).click()
|
await question.getByRole("button", { name: "Submit" }).click()
|
||||||
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } })
|
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shows a pending permission dock", async ({ page }) => {
|
test("shows a pending permission dock", async ({ page }) => {
|
||||||
@@ -87,7 +82,7 @@ test("shows a pending permission dock", async ({ page }) => {
|
|||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["git status", "git diff"],
|
patterns: ["git status", "git diff"],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
always: ["git *"],
|
always: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -114,7 +109,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||||
retry: 20,
|
retry: 20,
|
||||||
})
|
})
|
||||||
await mockServer(page, { forms: [] })
|
await mockServer(page, { questions: [] })
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await transport.waitForConnection()
|
await transport.waitForConnection()
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
@@ -140,24 +135,18 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
await transport.send({
|
await transport.send({
|
||||||
directory,
|
directory,
|
||||||
payload: {
|
payload: {
|
||||||
type: "form.created",
|
type: "question.asked",
|
||||||
properties: {
|
properties: {
|
||||||
form: {
|
id: "question-caret",
|
||||||
id: "frm_question_caret",
|
sessionID,
|
||||||
sessionID,
|
questions: [
|
||||||
title: "Questions",
|
{
|
||||||
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
|
header: "Continue",
|
||||||
fields: [
|
question: "Continue?",
|
||||||
{
|
options: [{ label: "Yes", description: "Continue the session" }],
|
||||||
key: "q0",
|
},
|
||||||
type: "string",
|
],
|
||||||
title: "Continue",
|
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||||
description: "Continue?",
|
|
||||||
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
|
|
||||||
custom: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -167,10 +156,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
|
|
||||||
await transport.send({
|
await transport.send({
|
||||||
directory,
|
directory,
|
||||||
payload: {
|
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||||
type: "form.cancelled",
|
|
||||||
properties: { sessionID, id: "frm_question_caret" },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await expect(question).toHaveCount(0)
|
await expect(question).toHaveCount(0)
|
||||||
await expect(editor).toBeVisible()
|
await expect(editor).toBeVisible()
|
||||||
@@ -184,8 +170,6 @@ async function mockServer(
|
|||||||
requests: {
|
requests: {
|
||||||
permissions?: unknown[] | (() => unknown[])
|
permissions?: unknown[] | (() => unknown[])
|
||||||
questions?: unknown[] | (() => unknown[])
|
questions?: unknown[] | (() => unknown[])
|
||||||
forms?: unknown[] | (() => unknown[])
|
|
||||||
sessionStatus?: Record<string, unknown>
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
@@ -230,8 +214,6 @@ async function mockServer(
|
|||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
permissions: requests.permissions,
|
permissions: requests.permissions,
|
||||||
questions: requests.questions,
|
questions: requests.questions,
|
||||||
forms: requests.forms,
|
|
||||||
sessionStatus: requests.sessionStatus,
|
|
||||||
})
|
})
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
|
||||||
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
|
|
||||||
|
|
||||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
|
||||||
|
|
||||||
const assistant = (completed: boolean, tool = false, childID?: string) =>
|
|
||||||
({
|
|
||||||
id: "msg_assistant",
|
|
||||||
type: "assistant",
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
content: tool
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call_subagent",
|
|
||||||
name: "subagent",
|
|
||||||
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
|
|
||||||
time: { created: 2 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [{ type: "text", text: "Working" }],
|
|
||||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
|
||||||
}) satisfies SessionMessageInfo
|
|
||||||
|
|
||||||
test("renders current protocol notices in CLI order", async ({ page }) => {
|
|
||||||
const ownerWarnings: string[] = []
|
|
||||||
page.on("console", (message) => {
|
|
||||||
if (message.text().includes("computations created outside a `createRoot` or `render`"))
|
|
||||||
ownerWarnings.push(message.text())
|
|
||||||
})
|
|
||||||
await setupTimeline(page, {
|
|
||||||
currentMessages: [
|
|
||||||
user,
|
|
||||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
|
||||||
assistant(true),
|
|
||||||
{
|
|
||||||
id: "msg_subagent",
|
|
||||||
type: "synthetic",
|
|
||||||
text: "done",
|
|
||||||
description: "Search code",
|
|
||||||
metadata: { source: "subagent", agent: "explore", state: "completed" },
|
|
||||||
time: { created: 4 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "msg_restart",
|
|
||||||
type: "synthetic",
|
|
||||||
text: "continue",
|
|
||||||
description: "Continuing after restart",
|
|
||||||
time: { created: 5 },
|
|
||||||
},
|
|
||||||
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const notices = page.locator('[data-slot="session-timeline-notice"]')
|
|
||||||
await expect(notices).toHaveCount(4)
|
|
||||||
await expect(notices.nth(0)).toContainText("Agent · explore")
|
|
||||||
await expect(notices.nth(1)).toContainText("explore finished · Search code")
|
|
||||||
await expect(notices.nth(2)).toContainText("Continuing after restart")
|
|
||||||
await expect(notices.nth(3)).toContainText("Skill · Review")
|
|
||||||
expect(ownerWarnings).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
|
||||||
await setupTimeline(page, { currentMessages: [user, assistant(false, true)] })
|
|
||||||
await expect(page.locator('[data-component="task-tool-card"]')).toBeVisible()
|
|
||||||
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
|
|
||||||
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
|
|
||||||
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
|
|
||||||
|
|
||||||
const request = page.waitForRequest(
|
|
||||||
(request) =>
|
|
||||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
|
|
||||||
)
|
|
||||||
await page.keyboard.press("Control+b")
|
|
||||||
await request
|
|
||||||
})
|
|
||||||
|
|
||||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
|
||||||
const childID = "ses_running_child"
|
|
||||||
await setupTimeline(page, {
|
|
||||||
currentMessages: [user, assistant(false, true, childID)],
|
|
||||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
|
|
||||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
|
|
||||||
await page.locator('[data-component="task-tool-card"]').click()
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
|
|
||||||
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shows a badge for active background work", async ({ page }) => {
|
|
||||||
const childID = "ses_background_child"
|
|
||||||
await setupTimeline(page, {
|
|
||||||
currentMessages: [user, assistant(true)],
|
|
||||||
sessions: [session(), session({ id: childID, parentID: sessionID })],
|
|
||||||
sessionStatus: { [childID]: { type: "busy" } },
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
|
|
||||||
const backgroundID = "ses_background_existing"
|
|
||||||
const blockingID = "ses_background_blocking"
|
|
||||||
await setupTimeline(page, {
|
|
||||||
currentMessages: [
|
|
||||||
user,
|
|
||||||
{
|
|
||||||
id: "msg_backgrounded",
|
|
||||||
type: "assistant",
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call_backgrounded",
|
|
||||||
name: "subagent",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: { description: "Background task" },
|
|
||||||
content: [{ type: "text", text: "working" }],
|
|
||||||
metadata: { sessionID: backgroundID, status: "running" },
|
|
||||||
},
|
|
||||||
time: { created: 2, completed: 3 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call_shell_backgrounded",
|
|
||||||
name: "shell",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: { command: "sleep 120" },
|
|
||||||
content: [{ type: "text", text: "working" }],
|
|
||||||
metadata: { shellID: "shell_backgrounded", status: "running" },
|
|
||||||
},
|
|
||||||
time: { created: 2, completed: 3 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
time: { created: 2, completed: 3 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "msg_blocking",
|
|
||||||
type: "assistant",
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call_blocking",
|
|
||||||
name: "subagent",
|
|
||||||
state: {
|
|
||||||
status: "running",
|
|
||||||
input: { description: "Foreground task" },
|
|
||||||
metadata: { sessionID: blockingID },
|
|
||||||
},
|
|
||||||
time: { created: 4 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
time: { created: 4 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sessions: [
|
|
||||||
session(),
|
|
||||||
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
|
|
||||||
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
|
|
||||||
],
|
|
||||||
sessionStatus: {
|
|
||||||
[sessionID]: { type: "busy" },
|
|
||||||
[backgroundID]: { type: "busy" },
|
|
||||||
[blockingID]: { type: "busy" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator('[data-component="session-background-dock"]')
|
|
||||||
await expect(dock).toContainText("Move 1 subagent to background")
|
|
||||||
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
|
|
||||||
).toHaveAttribute("data-active", "true")
|
|
||||||
})
|
|
||||||
@@ -27,30 +27,6 @@ test("navigates to a subagent child session missing from the session list", asyn
|
|||||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps the parent visible while child lineage resolves", async ({ page }) => {
|
|
||||||
await setup(page)
|
|
||||||
const requested = Promise.withResolvers<void>()
|
|
||||||
const release = Promise.withResolvers<void>()
|
|
||||||
await page.route(
|
|
||||||
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
|
||||||
async (route) => {
|
|
||||||
requested.resolve()
|
|
||||||
await release.promise
|
|
||||||
await route.fallback()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await page.goto(sessionHref(parentID))
|
|
||||||
await expectSessionTitle(page, parentTitle)
|
|
||||||
|
|
||||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
|
||||||
await requested.promise
|
|
||||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
|
|
||||||
() => release.resolve(),
|
|
||||||
)
|
|
||||||
|
|
||||||
await expectSessionTitle(page, taskDescription)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
||||||
const events: EventPayload[] = []
|
const events: EventPayload[] = []
|
||||||
await setup(page, () => events.splice(0, 1))
|
await setup(page, () => events.splice(0, 1))
|
||||||
|
|||||||
@@ -189,12 +189,7 @@ test.describe("smoke: session timeline", () => {
|
|||||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||||
?.getBoundingClientRect()
|
?.getBoundingClientRect()
|
||||||
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
||||||
if (
|
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
|
||||||
!firstPaint &&
|
|
||||||
visible.includes(last) &&
|
|
||||||
Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1 &&
|
|
||||||
!root.querySelector('[data-markdown-key="initial"]')
|
|
||||||
) {
|
|
||||||
firstPaint = true
|
firstPaint = true
|
||||||
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
|
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
|
||||||
const rect = row.getBoundingClientRect()
|
const rect = row.getBoundingClientRect()
|
||||||
@@ -209,16 +204,10 @@ test.describe("smoke: session timeline", () => {
|
|||||||
}
|
}
|
||||||
;(
|
;(
|
||||||
window as Window & {
|
window as Window & {
|
||||||
__sessionTabPaint?: {
|
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
|
||||||
samples: typeof samples
|
|
||||||
painted: () => boolean
|
|
||||||
removed: () => number
|
|
||||||
stop: () => void
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
).__sessionTabPaint = {
|
).__sessionTabPaint = {
|
||||||
samples,
|
samples,
|
||||||
painted: () => firstPaint,
|
|
||||||
removed: () => removedFirstPaintNodes,
|
removed: () => removedFirstPaintNodes,
|
||||||
stop: () => {
|
stop: () => {
|
||||||
running = false
|
running = false
|
||||||
@@ -230,19 +219,17 @@ test.describe("smoke: session timeline", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() =>
|
||||||
const probe = (
|
(
|
||||||
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }>; painted: () => boolean } }
|
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
|
||||||
).__sessionTabPaint
|
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
|
||||||
return probe?.painted() && probe.samples.some((sample) => sample.ids.length > 0)
|
)
|
||||||
})
|
|
||||||
await page.waitForTimeout(200)
|
await page.waitForTimeout(200)
|
||||||
const first = await page.evaluate(() => {
|
const first = await page.evaluate(() => {
|
||||||
const probe = (
|
const probe = (
|
||||||
window as Window & {
|
window as Window & {
|
||||||
__sessionTabPaint?: {
|
__sessionTabPaint?: {
|
||||||
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
|
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
|
||||||
painted: () => boolean
|
|
||||||
removed: () => number
|
removed: () => number
|
||||||
stop: () => void
|
stop: () => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,110 +32,15 @@ export interface MockServerConfig {
|
|||||||
todos?: (sessionID: string) => unknown[]
|
todos?: (sessionID: string) => unknown[]
|
||||||
permissions?: unknown[] | (() => unknown[])
|
permissions?: unknown[] | (() => unknown[])
|
||||||
questions?: unknown[] | (() => unknown[])
|
questions?: unknown[] | (() => unknown[])
|
||||||
forms?: unknown[] | (() => unknown[])
|
|
||||||
fileList?: (path: string) => unknown | Promise<unknown>
|
fileList?: (path: string) => unknown | Promise<unknown>
|
||||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||||
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockStreamWindow = Window & {
|
|
||||||
__testSseTransport?: unknown
|
|
||||||
__mockServerStream?: { push: (payloads: unknown[]) => void }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
const cursors = new Map<string, string>()
|
const cursors = new Map<string, string>()
|
||||||
let nextCursor = 0
|
let nextCursor = 0
|
||||||
|
|
||||||
await page.addInitScript(
|
|
||||||
({ port, retry }) => {
|
|
||||||
const host = window as MockStreamWindow
|
|
||||||
if (host.__testSseTransport || host.__mockServerStream) return
|
|
||||||
const originalFetch = window.fetch.bind(window)
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const state: {
|
|
||||||
controller?: ReadableStreamDefaultController<Uint8Array>
|
|
||||||
buffer: string[]
|
|
||||||
connections: number
|
|
||||||
} = { buffer: [], connections: 0 }
|
|
||||||
const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\n\n`
|
|
||||||
host.__mockServerStream = {
|
|
||||||
push(payloads: unknown[]) {
|
|
||||||
const frames = payloads.map(frame)
|
|
||||||
const controller = state.controller
|
|
||||||
if (!controller) {
|
|
||||||
state.buffer.push(...frames)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
frames.forEach((item) => controller.enqueue(encoder.encode(item)))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const request = new Request(input, init)
|
|
||||||
const url = new URL(request.url)
|
|
||||||
if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request)
|
|
||||||
state.connections += 1
|
|
||||||
const id = state.connections
|
|
||||||
let ended = false
|
|
||||||
let own: ReadableStreamDefaultController<Uint8Array> | undefined
|
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
|
||||||
start(controller) {
|
|
||||||
own = controller
|
|
||||||
state.controller = controller
|
|
||||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
|
||||||
controller.enqueue(
|
|
||||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
|
||||||
)
|
|
||||||
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
|
|
||||||
request.signal.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
if (ended) return
|
|
||||||
ended = true
|
|
||||||
if (state.controller === controller) state.controller = undefined
|
|
||||||
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
if (ended) return
|
|
||||||
ended = true
|
|
||||||
if (state.controller === own) state.controller = undefined
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(stream, {
|
|
||||||
status: 200,
|
|
||||||
headers: { "cache-control": "no-cache", "content-type": "text/event-stream" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
|
|
||||||
},
|
|
||||||
{ port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (config.events) {
|
|
||||||
const pump = { busy: false }
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
if (pump.busy) return
|
|
||||||
const batch = config.events?.() ?? []
|
|
||||||
if (batch.length === 0) return
|
|
||||||
pump.busy = true
|
|
||||||
void page
|
|
||||||
.evaluate(
|
|
||||||
(payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads),
|
|
||||||
batch.map(currentEvent),
|
|
||||||
)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
pump.busy = false
|
|
||||||
})
|
|
||||||
}, 50)
|
|
||||||
page.on("close", () => clearInterval(timer))
|
|
||||||
}
|
|
||||||
const staticRoutes: Record<string, unknown> = {
|
const staticRoutes: Record<string, unknown> = {
|
||||||
"/path": {
|
"/path": {
|
||||||
state: config.directory,
|
state: config.directory,
|
||||||
@@ -267,34 +172,16 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
}
|
}
|
||||||
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
|
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
if (path === "/api/project") {
|
if (path === "/api/project") return json(route, [config.project])
|
||||||
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
|
|
||||||
return json(route, [
|
|
||||||
{
|
|
||||||
...project,
|
|
||||||
canonical: project.canonical ?? project.worktree ?? config.directory,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
if (path === "/api/project/current")
|
if (path === "/api/project/current")
|
||||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||||
const worktree = path.match(/^\/api\/experimental\/project\/([^/]+)\/worktree$/)?.[1]
|
|
||||||
if (worktree && route.request().method() === "GET")
|
|
||||||
return json(route, [
|
|
||||||
{ directory: config.directory },
|
|
||||||
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
|
|
||||||
directory,
|
|
||||||
strategy: "git",
|
|
||||||
})),
|
|
||||||
])
|
|
||||||
if (path === "/api/location") return json(route, location(config))
|
if (path === "/api/location") return json(route, location(config))
|
||||||
if (worktree && route.request().method() === "POST") {
|
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
|
||||||
|
if (projectCopy && route.request().method() === "POST") {
|
||||||
const input = route.request().postDataJSON() as { directory: string; name?: string }
|
const input = route.request().postDataJSON() as { directory: string; name?: string }
|
||||||
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
|
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
|
||||||
}
|
}
|
||||||
if (worktree && route.request().method() === "DELETE")
|
if (projectCopy && route.request().method() === "DELETE")
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
if (/^\/api\/experimental\/project\/[^/]+\/worktree\/refresh$/.test(path))
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
if (path === "/api/permission/request")
|
if (path === "/api/permission/request")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
@@ -308,11 +195,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
location: location(config),
|
location: location(config),
|
||||||
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
||||||
})
|
})
|
||||||
if (path === "/api/form/request")
|
|
||||||
return json(route, {
|
|
||||||
location: location(config),
|
|
||||||
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
|
||||||
})
|
|
||||||
if (path === "/api/vcs")
|
if (path === "/api/vcs")
|
||||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
||||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||||
@@ -361,10 +243,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||||
const sessions = config.sessions
|
const sessions = config.sessions
|
||||||
.filter((session) => {
|
.filter((session) => !directory || session.directory === directory)
|
||||||
const location = session.location as { directory?: string } | undefined
|
|
||||||
return !directory || location?.directory === directory || session.directory === directory
|
|
||||||
})
|
|
||||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||||
.filter((session) => {
|
.filter((session) => {
|
||||||
const search = url.searchParams.get("search")?.toLowerCase()
|
const search = url.searchParams.get("search")?.toLowerCase()
|
||||||
@@ -401,18 +280,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
|
|
||||||
if (sessionForm && route.request().method() === "GET") {
|
|
||||||
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
|
|
||||||
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
|
|
||||||
return json(route, { data: [] })
|
|
||||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
@@ -599,7 +466,6 @@ function currentPermission(value: unknown) {
|
|||||||
|
|
||||||
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
||||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||||
const location = session.location && typeof session.location === "object" ? session.location : {}
|
|
||||||
return {
|
return {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
parentID: session.parentID,
|
parentID: session.parentID,
|
||||||
@@ -617,19 +483,10 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
|||||||
},
|
},
|
||||||
title: session.title ?? session.id,
|
title: session.title ?? session.id,
|
||||||
location: {
|
location: {
|
||||||
directory:
|
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||||
"directory" in location && typeof location.directory === "string"
|
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||||
? location.directory
|
|
||||||
: typeof session.directory === "string"
|
|
||||||
? session.directory
|
|
||||||
: fallbackDirectory,
|
|
||||||
...(typeof session.workspaceID === "string"
|
|
||||||
? { workspaceID: session.workspaceID }
|
|
||||||
: "workspaceID" in location && typeof location.workspaceID === "string"
|
|
||||||
? { workspaceID: location.workspaceID }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
subpath: session.subpath ?? session.path,
|
subpath: session.path,
|
||||||
revert: session.revert,
|
revert: session.revert,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,13 +63,19 @@
|
|||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@pierre/trees": "1.0.0-beta.4",
|
"@pierre/trees": "1.0.0-beta.4",
|
||||||
"@sentry/solid": "catalog:",
|
"@sentry/solid": "catalog:",
|
||||||
|
"@shikijs/transformers": "3.9.2",
|
||||||
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
|
"@solid-primitives/audio": "1.4.2",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/event-listener": "2.4.5",
|
"@solid-primitives/event-listener": "2.4.5",
|
||||||
"@solid-primitives/i18n": "2.2.1",
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.5",
|
"@solid-primitives/resize-observer": "2.1.5",
|
||||||
"@solid-primitives/scheduled": "1.5.3",
|
"@solid-primitives/scheduled": "1.5.3",
|
||||||
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
|
"@solid-primitives/timer": "1.4.4",
|
||||||
|
"@solid-primitives/websocket": "1.3.1",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
"@tanstack/solid-query": "5.91.4",
|
||||||
|
|||||||
+257
-73
@@ -1,38 +1,48 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import * as Sentry from "@sentry/solid"
|
import * as Sentry from "@sentry/solid"
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
|
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { File } from "@opencode-ai/session-ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
|
import { Effect } from "effect"
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createRenderEffect,
|
createRenderEffect,
|
||||||
|
createResource,
|
||||||
|
createSignal,
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
|
For,
|
||||||
type JSX,
|
type JSX,
|
||||||
lazy,
|
lazy,
|
||||||
|
onCleanup,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
|
import { NotificationProvider } from "@/context/notification"
|
||||||
|
import { PermissionProvider } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServersProvider, useServers } from "@/context/servers"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
@@ -40,26 +50,26 @@ import { WslServersProvider } from "@/wsl/context"
|
|||||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
import { TargetSessionRouteContent } from "@/pages/session"
|
import { TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { Home } from "@/pages/home"
|
import { Home } from "@/pages/home"
|
||||||
import { ServerProvider } from "./context/server"
|
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
const DirectoryDraftRedirect = () => {
|
const DirectoryDraftRedirect = () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
const server = useServers()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (search.draftId || !tabs.ready()) return
|
if (search.draftId || !tabs.ready()) return
|
||||||
const directory = decode64(params.dir)
|
const directory = decode64(params.dir)
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt)
|
tabs.newDraft({ server: server.key, directory }, search.prompt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -76,13 +86,31 @@ function TargetServerRoute(props: ParentProps) {
|
|||||||
return (
|
return (
|
||||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
<Show when={conn()} keyed>
|
<ServerSDKProvider server={conn}>
|
||||||
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
|
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||||
</Show>
|
</ServerSDKProvider>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TargetSessionRoute = () => (
|
||||||
|
<TargetServerRoute>
|
||||||
|
<TargetSessionRouteContent />
|
||||||
|
</TargetServerRoute>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
|
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||||
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<ServerKey>
|
||||||
|
<ServerSDKProvider>
|
||||||
|
<ServerSyncProvider>{props.children}</ServerSyncProvider>
|
||||||
|
</ServerSDKProvider>
|
||||||
|
</ServerKey>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DraftRoute() {
|
function DraftRoute() {
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
@@ -102,24 +130,24 @@ function DraftRoute() {
|
|||||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
||||||
|
const directory = () => props.draft.directory
|
||||||
|
const serverKey = () => props.draft.server
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||||
<Show when={conn()} keyed>
|
<ServerSDKProvider server={conn}>
|
||||||
{(conn) => (
|
<ServerSyncProvider server={conn}>
|
||||||
<ServerProvider conn={conn}>
|
<ModelsProvider directory={directory}>
|
||||||
<ModelsProvider directory={props.draft.directory}>
|
<SDKProvider directory={directory}>
|
||||||
<SDKProvider directory={props.draft.directory}>
|
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||||
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
|
<DraftProviders>
|
||||||
<DraftProviders>
|
<NewSession />
|
||||||
<NewSession draftId={props.draft.draftID} />
|
</DraftProviders>
|
||||||
</DraftProviders>
|
</DirectoryDataProvider>
|
||||||
</DirectoryDataProvider>
|
</SDKProvider>
|
||||||
</SDKProvider>
|
</ModelsProvider>
|
||||||
</ModelsProvider>
|
</ServerSyncProvider>
|
||||||
</ServerProvider>
|
</ServerSDKProvider>
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -128,13 +156,7 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return (
|
return (
|
||||||
<I18nProvider
|
<I18nProvider
|
||||||
value={{
|
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
|
||||||
locale: language.intl,
|
|
||||||
layoutLocale: language.layoutLocale,
|
|
||||||
t: language.t as UiI18n["t"],
|
|
||||||
plural: language.plural,
|
|
||||||
pluralForm: language.pluralForm,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
@@ -158,8 +180,8 @@ function QueryProvider(props: ParentProps) {
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -179,6 +201,18 @@ function BodyDesignClass() {
|
|||||||
|
|
||||||
// Server-agnostic providers shared across every route. These live in the shared
|
// Server-agnostic providers shared across every route. These live in the shared
|
||||||
// shell (router root) so they stay mounted regardless of the active server/route.
|
// shell (router root) so they stay mounted regardless of the active server/route.
|
||||||
|
function SharedProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BodyDesignClass />
|
||||||
|
<CommandProvider>
|
||||||
|
<DesktopCommands />
|
||||||
|
<HighlightsProvider>{props.children}</HighlightsProvider>
|
||||||
|
</CommandProvider>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DesktopCommands() {
|
function DesktopCommands() {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -202,14 +236,30 @@ function DesktopCommands() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppLayout(props: ParentProps) {
|
type ServerScopedShellProps = ParentProps<{
|
||||||
|
directory?: () => string | undefined
|
||||||
|
serverScoped?: JSX.Element
|
||||||
|
}>
|
||||||
|
|
||||||
|
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
<Layout>{props.children}</Layout>
|
{props.serverScoped}
|
||||||
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||||
|
return (
|
||||||
|
<SelectedServerProviders>
|
||||||
|
<ServerScopedProviders serverScoped={props.serverScoped}>
|
||||||
|
<Layout>{props.children}</Layout>
|
||||||
|
</ServerScopedProviders>
|
||||||
|
</SelectedServerProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||||
function DraftProviders(props: ParentProps) {
|
function DraftProviders(props: ParentProps) {
|
||||||
@@ -259,6 +309,133 @@ export function AppBaseProviders(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
|
||||||
|
const server = useServer()
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
|
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
|
||||||
|
|
||||||
|
// performs repeated health check with a grace period for
|
||||||
|
// non-http connections, otherwise fails instantly
|
||||||
|
const [startupHealthCheck, healthCheckActions] = createResource(() =>
|
||||||
|
props.disableHealthCheck
|
||||||
|
? true
|
||||||
|
: Effect.gen(function* () {
|
||||||
|
if (!server.current) return true
|
||||||
|
const { http, type } = server.current
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const res = yield* Effect.promise(() => checkServerHealth(http))
|
||||||
|
if (res.healthy) return true
|
||||||
|
if (checkMode() === "background" || type === "http") return false
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
||||||
|
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||||
|
Effect.runPromise,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const checking = createMemo(
|
||||||
|
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
|
||||||
|
)
|
||||||
|
const [startup] = createResource(async () => {
|
||||||
|
if (!props.startup) return true
|
||||||
|
await props.startup.catch((error) => {
|
||||||
|
console.error("[startup] startup gate failed", error)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const startupChecking = createMemo(
|
||||||
|
() => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
|
||||||
|
)
|
||||||
|
const loading = createMemo(() => checking() || startupChecking())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Show when={!checking()}>
|
||||||
|
<Show
|
||||||
|
when={startupHealthCheck.latest}
|
||||||
|
fallback={
|
||||||
|
<ConnectionError
|
||||||
|
onRetry={() => {
|
||||||
|
if (checkMode() === "background") void healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
onServerSelected={(key) => {
|
||||||
|
setCheckMode("blocking")
|
||||||
|
server.setActive(key)
|
||||||
|
void healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
<Show when={loading()}>
|
||||||
|
<div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
|
||||||
|
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
|
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
|
||||||
|
const name = createMemo(() => server.name || server.key)
|
||||||
|
const serverToken = "\u0000server\u0000"
|
||||||
|
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
|
||||||
|
|
||||||
|
const timer = setInterval(() => props.onRetry?.(), 1000)
|
||||||
|
onCleanup(() => clearInterval(timer))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
|
||||||
|
<div class="flex flex-col items-center max-w-md text-center">
|
||||||
|
<Splash class="w-12 h-15 mb-4" />
|
||||||
|
<p class="text-14-regular text-text-base">
|
||||||
|
{unreachable()[0]}
|
||||||
|
<span class="text-text-strong font-medium">{name()}</span>
|
||||||
|
{unreachable()[1]}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
|
||||||
|
</div>
|
||||||
|
<Show when={others().length > 0}>
|
||||||
|
<div class="flex flex-col gap-2 w-full max-w-sm">
|
||||||
|
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
|
||||||
|
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
|
||||||
|
<For each={others()}>
|
||||||
|
{(conn) => {
|
||||||
|
const key = ServerConnection.key(conn)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||||
|
onClick={() => props.onServerSelected?.(key)}
|
||||||
|
>
|
||||||
|
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerKey(props: ParentProps) {
|
||||||
|
const server = useServer()
|
||||||
|
return (
|
||||||
|
<Show when={server.key} keyed>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppInterface(props: {
|
export function AppInterface(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
defaultServer: ServerConnection.Key
|
defaultServer: ServerConnection.Key
|
||||||
@@ -267,60 +444,67 @@ export function AppInterface(props: {
|
|||||||
router?: Component<BaseRouterProps>
|
router?: Component<BaseRouterProps>
|
||||||
disableHealthCheck?: boolean
|
disableHealthCheck?: boolean
|
||||||
startup?: Promise<void>
|
startup?: Promise<void>
|
||||||
|
serverScoped?: JSX.Element
|
||||||
}) {
|
}) {
|
||||||
// The visual layout lives in the router root so it remains mounted across
|
// The visual layout lives in the router root so it remains mounted across
|
||||||
// route changes. Draft and session routes override only their server-bound data
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
// providers beneath it.
|
// providers beneath it.
|
||||||
const Root = (rootProps: ParentProps) => (
|
const ServerShell = (shellProps: ParentProps) => (
|
||||||
<TabsProvider>
|
<QueryProvider>
|
||||||
<BodyDesignClass />
|
<SharedProviders>
|
||||||
<CommandProvider>
|
{props.children}
|
||||||
<DesktopCommands />
|
{shellProps.children}
|
||||||
<HighlightsProvider>
|
</SharedProviders>
|
||||||
{props.children}
|
</QueryProvider>
|
||||||
{rootProps.children}
|
|
||||||
</HighlightsProvider>
|
|
||||||
</CommandProvider>
|
|
||||||
</TabsProvider>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServersProvider
|
<ServerProvider
|
||||||
defaultServer={props.defaultServer}
|
defaultServer={props.defaultServer}
|
||||||
canonicalLocalServer={props.canonicalLocalServer}
|
canonicalLocalServer={props.canonicalLocalServer}
|
||||||
servers={props.servers}
|
servers={props.servers}
|
||||||
>
|
>
|
||||||
<SettingsProvider>
|
<GlobalProvider>
|
||||||
<GlobalProvider>
|
<SettingsProvider>
|
||||||
<Dynamic component={props.router ?? Router} root={Root}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||||
{/* Proper Routes */}
|
<Dynamic
|
||||||
<Route component={AppLayout}>
|
component={props.router ?? Router}
|
||||||
<Route path="/" component={Home} />
|
root={(routerProps) => (
|
||||||
<Route
|
<TabsProvider>
|
||||||
path="/server/:serverKey/session/:id"
|
<PermissionProvider>
|
||||||
component={() => (
|
<NotificationProvider>
|
||||||
<TargetServerRoute>
|
<ServerShell>
|
||||||
<TargetSessionRouteContent />
|
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
|
||||||
</TargetServerRoute>
|
</ServerShell>
|
||||||
)}
|
</NotificationProvider>
|
||||||
/>
|
</PermissionProvider>
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
</TabsProvider>
|
||||||
</Route>
|
)}
|
||||||
{/* Legacy Routes */}
|
>
|
||||||
<Route>
|
<Routes />
|
||||||
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
</Dynamic>
|
||||||
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
</ConnectionGate>
|
||||||
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
</SettingsProvider>
|
||||||
</Route>
|
</GlobalProvider>
|
||||||
</Dynamic>
|
</ServerProvider>
|
||||||
</GlobalProvider>
|
)
|
||||||
</SettingsProvider>
|
}
|
||||||
</ServersProvider>
|
|
||||||
|
function Routes() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Route path="/" component={Home} />
|
||||||
|
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
||||||
|
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
||||||
|
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
||||||
|
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||||
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacySessionRedirect() {
|
function LegacySessionRedirect() {
|
||||||
const server = useServers()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
|
|
||||||
@@ -331,7 +515,7 @@ function LegacySessionRedirect() {
|
|||||||
legacySessionServer(
|
legacySessionServer(
|
||||||
tabs.store.filter((item) => item.type === "session"),
|
tabs.store.filter((item) => item.type === "session"),
|
||||||
params.id,
|
params.id,
|
||||||
ServerConnection.key(server.list[0]!),
|
server.key,
|
||||||
),
|
),
|
||||||
params.id,
|
params.id,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ import { useFile } from "@/context/file"
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout, type LocalProject } from "@/context/layout"
|
import { useLayout, type LocalProject } from "@/context/layout"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
|
|
||||||
export type CommandPaletteEntry = {
|
export type CommandPaletteEntry = {
|
||||||
id: string
|
id: string
|
||||||
@@ -84,7 +83,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()()
|
||||||
const serverCtx = global.ensureServerCtx(serverSDK.server)
|
const serverCtx = global.ensureServerCtx(serverSDK.server)
|
||||||
const appTabs = useTabs()
|
const appTabs = useTabs()
|
||||||
const { tabs: sessionTabs } = useSessionLayout()
|
const { tabs: sessionTabs } = useSessionLayout()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||||
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
|
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
@@ -571,7 +571,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
value={language.t(`debugBar.direction.${language.direction()}`)}
|
value={language.t(`debugBar.direction.${language.direction()}`)}
|
||||||
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
||||||
/>
|
/>
|
||||||
<Show when={platform.setForceFocus}>
|
{platform.setForceFocus && (
|
||||||
<ToggleCell
|
<ToggleCell
|
||||||
active={state.focus}
|
active={state.focus}
|
||||||
inline={props.inline}
|
inline={props.inline}
|
||||||
@@ -580,7 +580,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
||||||
onClick={() => void toggleFocus()}
|
onClick={() => void toggleFocus()}
|
||||||
/>
|
/>
|
||||||
</Show>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onC
|
|||||||
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
|
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|||||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
@@ -20,7 +20,6 @@ import { useServerSync } from "@/context/server-sync"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { useIntegrations } from "@/hooks/use-integrations"
|
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
|
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
|
||||||
@@ -41,7 +40,7 @@ export function useProviderConnectController(options: { onBack?: () => void } =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DialogConnectProvider: Component<{
|
export const DialogConnectProvider: Component<{
|
||||||
directory?: string
|
directory?: Accessor<string | undefined>
|
||||||
controller?: ReturnType<typeof useProviderConnectController>
|
controller?: ReturnType<typeof useProviderConnectController>
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const fallback = useProviderConnectController()
|
const fallback = useProviderConnectController()
|
||||||
@@ -137,11 +136,15 @@ export const DialogConnectProvider: Component<{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
function ProviderPicker(props: {
|
||||||
|
directory?: Accessor<string | undefined>
|
||||||
|
onSelect: (provider: string) => void
|
||||||
|
onPrepare?: () => void
|
||||||
|
}) {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
if (settings.general.newLayoutDesigns())
|
if (settings.general.newLayoutDesigns())
|
||||||
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
||||||
const integrations = useIntegrations(() => props.directory)
|
const providers = useProviders(() => props.directory?.())
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||||
@@ -163,7 +166,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
|||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={() => {
|
items={() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...integrations.list()]
|
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
||||||
}}
|
}}
|
||||||
filterKeys={["id", "name"]}
|
filterKeys={["id", "name"]}
|
||||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||||
@@ -208,8 +211,12 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
function ProviderPickerV2(props: {
|
||||||
const integrations = useIntegrations(() => props.directory)
|
directory?: Accessor<string | undefined>
|
||||||
|
onSelect: (provider: string) => void
|
||||||
|
onPrepare?: () => void
|
||||||
|
}) {
|
||||||
|
const providers = useProviders(() => props.directory?.())
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
filter: "",
|
filter: "",
|
||||||
@@ -221,7 +228,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
|
|||||||
const all = createMemo(() => {
|
const all = createMemo(() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
const query = store.filter.trim().toLowerCase()
|
const query = store.filter.trim().toLowerCase()
|
||||||
const values = [custom(), ...integrations.list()]
|
const values = [custom(), ...providers.all().values()]
|
||||||
if (!query) return values
|
if (!query) return values
|
||||||
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
|
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
|
||||||
})
|
})
|
||||||
@@ -357,7 +364,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
|
|||||||
|
|
||||||
function ProviderConnection(props: {
|
function ProviderConnection(props: {
|
||||||
provider: string
|
provider: string
|
||||||
directory?: string
|
directory?: Accessor<string | undefined>
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
setBack: (handler: () => void) => void
|
setBack: (handler: () => void) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -367,9 +374,12 @@ function ProviderConnection(props: {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const newLayout = settings.general.newLayoutDesigns
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
const providers = useProviders(() => props.directory)
|
const providers = useProviders(() => props.directory?.())
|
||||||
const directory = () => props.directory ?? decode64(params.dir)
|
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||||
|
|
||||||
|
const provider = createMemo(
|
||||||
|
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||||
|
)
|
||||||
const controller = createProviderConnectionController({
|
const controller = createProviderConnectionController({
|
||||||
provider: () => props.provider,
|
provider: () => props.provider,
|
||||||
directory,
|
directory,
|
||||||
@@ -383,14 +393,6 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const provider = createMemo(() => ({
|
|
||||||
id: props.provider,
|
|
||||||
name:
|
|
||||||
providers.all().get(props.provider)?.name ??
|
|
||||||
serverSync.data.provider.all.get(props.provider)?.name ??
|
|
||||||
controller.integration()?.name ??
|
|
||||||
props.provider,
|
|
||||||
}))
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
const output = validateCustomProvider({
|
const output = validateCustomProvider({
|
||||||
form,
|
form,
|
||||||
t: language.t,
|
t: language.t,
|
||||||
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
disabledProviders: serverSync().data.config.disabled_providers ?? [],
|
||||||
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setForm("err", output.err)
|
setForm("err", output.err)
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
.project-settings-v2-dialog [data-slot="dialog-container"] {
|
|
||||||
background: var(--v2-background-bg-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-dialog [data-slot="dialog-body"] {
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2 {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden !important;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-scroll {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 40px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-page-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-page-header h2 {
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 640;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-page-header > span {
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 440;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extensions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs {
|
|
||||||
flex: 1;
|
|
||||||
height: auto;
|
|
||||||
min-height: 0;
|
|
||||||
margin-top: 24px;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
|
||||||
width: auto;
|
|
||||||
padding-inline: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
|
|
||||||
> [data-slot="tabs-v2-list"]
|
|
||||||
[data-slot="tabs-v2-trigger-wrapper"] {
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
|
|
||||||
> [data-slot="tabs-v2-list"]
|
|
||||||
[data-slot="tabs-v2-trigger"] {
|
|
||||||
padding-inline: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-section-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 530;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-section-header > :last-child {
|
|
||||||
color: var(--v2-text-text-faint);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 440;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-link {
|
|
||||||
color: var(--v2-text-text-accent) !important;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-card {
|
|
||||||
padding-inline: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--v2-background-bg-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 48px;
|
|
||||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row-main {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--v2-icon-icon-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row-name {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 530;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row-status-dot {
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--v2-state-fg-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-shared {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-shared-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
align-self: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 530;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-shared-chevron {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
color: var(--v2-icon-icon-muted);
|
|
||||||
transition: transform 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-shared-chevron.open {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-shared-count {
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--v2-background-bg-layer-02);
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
@@ -1,223 +1,156 @@
|
|||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
|
||||||
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { For, Show, createSignal, startTransition } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
|
||||||
import { createEditProjectModel } from "./edit-project"
|
import { createEditProjectModel } from "./edit-project"
|
||||||
import { ProjectSettingsExtensions } from "./project-settings-extensions"
|
|
||||||
import { SettingsServerDataScope } from "./settings-server-picker"
|
|
||||||
import "./settings-v2/settings-v2.css"
|
|
||||||
import "./dialog-edit-project-v2.css"
|
|
||||||
|
|
||||||
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
return (
|
|
||||||
<SettingsServerDataScope server={props.server}>
|
|
||||||
<SDKProvider directory={props.project.worktree}>
|
|
||||||
<ProjectSettingsDialog project={props.project} server={props.server} />
|
|
||||||
</SDKProvider>
|
|
||||||
</SettingsServerDataScope>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) {
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const model = createEditProjectModel(props)
|
const model = createEditProjectModel(props)
|
||||||
const projectName = () => displayName(props.project)
|
|
||||||
const [tab, setTab] = createSignal("general")
|
|
||||||
|
|
||||||
const Footer = () => (
|
|
||||||
<DialogFooter>
|
|
||||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
|
||||||
{language.t("common.cancel")}
|
|
||||||
</ButtonV2>
|
|
||||||
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
|
||||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
|
||||||
</ButtonV2>
|
|
||||||
</DialogFooter>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog">
|
<Dialog fit>
|
||||||
<TabsV2
|
<form onSubmit={model.submit} class="contents">
|
||||||
orientation="vertical"
|
<DialogHeader>
|
||||||
variant="settings"
|
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
||||||
value={tab()}
|
</DialogHeader>
|
||||||
onChange={(value) => void startTransition(() => setTab(value))}
|
<DividerV2 />
|
||||||
class="project-settings-v2"
|
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
||||||
>
|
<Field>
|
||||||
<TabsV2.List>
|
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||||
<div class="project-settings-v2-nav">
|
<TextInputV2
|
||||||
<TabsV2.Trigger value="general">
|
autofocus
|
||||||
<ProjectAvatar
|
appearance="large"
|
||||||
fallback={projectName()}
|
class="!w-full"
|
||||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
value={model.store.name}
|
||||||
class="!size-4 shrink-0"
|
placeholder={model.folderName()}
|
||||||
/>
|
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||||
<span class="truncate">{projectName()}</span>
|
/>
|
||||||
</TabsV2.Trigger>
|
</Field>
|
||||||
<TabsV2.Trigger value="scripts">
|
|
||||||
<Icon name="code" size="small" />
|
|
||||||
{language.t("project.settings.scripts")}
|
|
||||||
</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="extensions">
|
|
||||||
<Icon name="extensions" size="small" />
|
|
||||||
{language.t("settings.tab.extensions")}
|
|
||||||
</TabsV2.Trigger>
|
|
||||||
</div>
|
|
||||||
</TabsV2.List>
|
|
||||||
|
|
||||||
<TabsV2.Content value="general" class="project-settings-v2-panel">
|
<div class="flex w-full flex-col gap-2">
|
||||||
<form onSubmit={model.submit} class="project-settings-v2-form">
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
<div class="project-settings-v2-scroll">
|
{language.t("dialog.project.edit.icon")}
|
||||||
<div class="project-settings-page-header">
|
</div>
|
||||||
<h2>{language.t("dialog.project.edit.title")}</h2>
|
<div class="flex items-center gap-3">
|
||||||
<span>{language.t("project.settings.general.description")}</span>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||||
<Field>
|
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
classList={{
|
||||||
<TextInputV2
|
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||||
autofocus
|
}}
|
||||||
appearance="large"
|
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||||
class="!w-full"
|
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||||
value={model.store.name}
|
onDrop={model.drop}
|
||||||
placeholder={model.folderName()}
|
onDragOver={model.dragOver}
|
||||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
onDragLeave={model.dragLeave}
|
||||||
|
onClick={model.iconClick}
|
||||||
|
>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
src={getProjectAvatarSource(props.project.id, {
|
||||||
|
color: model.store.color,
|
||||||
|
url: props.project.icon?.url,
|
||||||
|
override: model.store.iconOverride,
|
||||||
|
})}
|
||||||
|
variant={getProjectAvatarVariant(model.store.color)}
|
||||||
|
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||||
/>
|
/>
|
||||||
</Field>
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||||
|
classList={{
|
||||||
|
"opacity-100": model.store.iconHover,
|
||||||
|
"opacity-0": !model.store.iconHover,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={(element) => {
|
||||||
|
model.setIconInput(element)
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onChange={model.inputChange}
|
||||||
|
/>
|
||||||
|
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||||
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
|
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
<Show when={!model.store.iconOverride}>
|
||||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
<div class="flex w-full flex-col gap-2">
|
||||||
{language.t("dialog.project.edit.icon")}
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
</div>
|
{language.t("dialog.project.edit.color")}
|
||||||
<div class="flex items-center gap-3">
|
</div>
|
||||||
<button
|
<div class="-ml-1 flex gap-1.5">
|
||||||
type="button"
|
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
{(color) => (
|
||||||
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
<button
|
||||||
classList={{
|
type="button"
|
||||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||||
}}
|
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
|
||||||
onDrop={model.drop}
|
|
||||||
onDragOver={model.dragOver}
|
|
||||||
onDragLeave={model.dragLeave}
|
|
||||||
onClick={model.iconClick}
|
|
||||||
>
|
|
||||||
<ProjectAvatar
|
|
||||||
fallback={model.store.name || model.defaultName()}
|
|
||||||
src={getProjectAvatarSource(props.project.id, {
|
|
||||||
color: model.store.color,
|
|
||||||
url: props.project.icon?.url,
|
|
||||||
override: model.store.iconOverride,
|
|
||||||
})}
|
|
||||||
variant={getProjectAvatarVariant(model.store.color)}
|
|
||||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100": model.store.iconHover,
|
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||||
"opacity-0": !model.store.iconHover,
|
getProjectAvatarVariant(model.store.color) === color,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||||
|
model.setStore(
|
||||||
|
"color",
|
||||||
|
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconV2 name={model.store.iconOverride ? "close" : "share"} />
|
<ProjectAvatar
|
||||||
</span>
|
fallback={model.store.name || model.defaultName()}
|
||||||
</button>
|
variant={getProjectAvatarVariant(color)}
|
||||||
<input
|
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||||
ref={(element) => model.setIconInput(element)}
|
/>
|
||||||
type="file"
|
</button>
|
||||||
accept="image/*"
|
)}
|
||||||
class="hidden"
|
</For>
|
||||||
onChange={model.inputChange}
|
|
||||||
/>
|
|
||||||
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
|
||||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!model.store.iconOverride}>
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
|
||||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
|
||||||
{language.t("dialog.project.edit.color")}
|
|
||||||
</div>
|
|
||||||
<div class="-ml-1 flex gap-1.5">
|
|
||||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
|
||||||
{(color) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
|
||||||
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
|
||||||
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
|
||||||
classList={{
|
|
||||||
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
|
||||||
getProjectAvatarVariant(model.store.color) === color,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
|
||||||
model.setStore(
|
|
||||||
"color",
|
|
||||||
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProjectAvatar
|
|
||||||
fallback={model.store.name || model.defaultName()}
|
|
||||||
variant={getProjectAvatarVariant(color)}
|
|
||||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
</Show>
|
||||||
</form>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="scripts" class="project-settings-v2-panel">
|
<Field>
|
||||||
<form onSubmit={model.submit} class="project-settings-v2-form">
|
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||||
<div class="project-settings-v2-scroll">
|
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||||
<div class="project-settings-page-header">
|
<TextareaV2
|
||||||
<h2>{language.t("project.settings.scripts")}</h2>
|
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||||
<span>{language.t("project.settings.scripts.description")}</span>
|
rows={3}
|
||||||
</div>
|
value={model.store.startup}
|
||||||
<Field>
|
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
spellcheck={false}
|
||||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||||
<TextareaV2
|
/>
|
||||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
</Field>
|
||||||
rows={5}
|
</DialogBody>
|
||||||
value={model.store.startup}
|
<DialogFooter>
|
||||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||||
spellcheck={false}
|
{language.t("common.cancel")}
|
||||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
</ButtonV2>
|
||||||
/>
|
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||||
</Field>
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</div>
|
</ButtonV2>
|
||||||
<Footer />
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="extensions" class="project-settings-v2-panel">
|
|
||||||
<ProjectSettingsExtensions />
|
|
||||||
</TabsV2.Content>
|
|
||||||
</TabsV2>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
|
|||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
|
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||||
}
|
}
|
||||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
|
|||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
|
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||||
}
|
}
|
||||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
const providerVisible = (providerID: string) =>
|
const providerVisible = (providerID: string) =>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, Index, Show } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -83,71 +83,61 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
{/* Bottom section - buttons and indicators (fixed position) */}
|
{/* Bottom section - buttons and indicators (fixed position) */}
|
||||||
<div class="flex flex-col gap-12">
|
<div class="flex flex-col gap-12">
|
||||||
<div class="flex flex-col items-start gap-3">
|
<div class="flex flex-col items-start gap-3">
|
||||||
<Show
|
{isLast() ? (
|
||||||
when={isLast()}
|
|
||||||
fallback={
|
|
||||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
|
||||||
{language.t("dialog.releaseNotes.action.next")}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button variant="primary" size="large" onClick={handleClose}>
|
<Button variant="primary" size="large" onClick={handleClose}>
|
||||||
{language.t("dialog.releaseNotes.action.getStarted")}
|
{language.t("dialog.releaseNotes.action.getStarted")}
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
) : (
|
||||||
|
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||||
|
{language.t("dialog.releaseNotes.action.next")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button variant="ghost" size="small" onClick={handleDisable}>
|
<Button variant="ghost" size="small" onClick={handleDisable}>
|
||||||
{language.t("dialog.releaseNotes.action.hideFuture")}
|
{language.t("dialog.releaseNotes.action.hideFuture")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={paged()}>
|
{paged() && (
|
||||||
<div class="flex items-center gap-1.5 -my-2.5">
|
<div class="flex items-center gap-1.5 -my-2.5">
|
||||||
<Index each={props.highlights}>
|
{props.highlights.map((_, i) => (
|
||||||
{(_, i) => (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
||||||
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
classList={{
|
||||||
|
"w-8": i === index(),
|
||||||
|
"w-3": i !== index(),
|
||||||
|
}}
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
||||||
classList={{
|
classList={{
|
||||||
"w-8": i === index(),
|
"bg-icon-strong-base": i === index(),
|
||||||
"w-3": i !== index(),
|
"bg-icon-weak-base": i !== index(),
|
||||||
}}
|
}}
|
||||||
onClick={() => setIndex(i)}
|
/>
|
||||||
>
|
</button>
|
||||||
<div
|
))}
|
||||||
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
|
||||||
classList={{
|
|
||||||
"bg-icon-strong-base": i === index(),
|
|
||||||
"bg-icon-weak-base": i !== index(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</Index>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Media content (edge to edge) */}
|
{/* Right side - Media content (edge to edge) */}
|
||||||
<Show when={feature()?.media}>
|
{feature()?.media && (
|
||||||
{(media) => (
|
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||||
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
{feature()!.media!.type === "image" ? (
|
||||||
<Show
|
<img
|
||||||
when={media().type === "image"}
|
src={feature()!.media!.src}
|
||||||
fallback={
|
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
||||||
<video src={media().src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
class="w-full h-full object-cover"
|
||||||
}
|
/>
|
||||||
>
|
) : (
|
||||||
<img
|
<video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||||
src={media().src}
|
)}
|
||||||
alt={media().alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
</div>
|
||||||
class="w-full h-full object-cover"
|
)}
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
import type { Path } from "@/types"
|
import type { Path } from "@/types"
|
||||||
import {
|
import {
|
||||||
absoluteTreePath,
|
absoluteTreePath,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { ListRef } from "@opencode-ai/ui/list"
|
|||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||||
import type { Path } from "@/types"
|
import type { Path } from "@/types"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { formatKeybind } from "@/context/command"
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
@@ -28,13 +29,14 @@ type DialogSelectFileMode = "all" | "files"
|
|||||||
|
|
||||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const settings = useSettings()
|
||||||
const filesOnly = () => props.mode === "files"
|
const filesOnly = () => props.mode === "files"
|
||||||
|
|
||||||
if (!filesOnly()) {
|
if (!filesOnly() && settings.general.newLayoutDesigns()) {
|
||||||
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
|
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filesOnly() && platform.platform === "desktop") {
|
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
||||||
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
|
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogSelectFileV2
|
<DialogSelectFileV2
|
||||||
server={serverSDK.server}
|
server={serverSDK().server}
|
||||||
mode="file"
|
mode="file"
|
||||||
start={projectDirectory()}
|
start={projectDirectory()}
|
||||||
title={language.t("session.header.searchFiles")}
|
title={language.t("session.header.searchFiles")}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { useTheme } from "@opencode-ai/ui/theme"
|
import { useTheme } from "@opencode-ai/ui/theme"
|
||||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useIntegrations } from "@/hooks/use-integrations"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
@@ -22,7 +22,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
const integrations = useIntegrations(directory)
|
const providers = useProviders(directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
|
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
|
||||||
const currentKey = createMemo(() => {
|
const currentKey = createMemo(() => {
|
||||||
@@ -37,7 +37,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
const controller = x.useProviderConnectController()
|
const controller = x.useProviderConnectController()
|
||||||
controller.select(provider)
|
controller.select(provider)
|
||||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
|
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,8 +127,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
||||||
<For
|
<For
|
||||||
each={integrations
|
each={[...providers.popular()]
|
||||||
.list()
|
|
||||||
.filter((provider) => featuredProviders.includes(provider.id))
|
.filter((provider) => featuredProviders.includes(provider.id))
|
||||||
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
|||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
const controller = x.useProviderConnectController()
|
const controller = x.useProviderConnectController()
|
||||||
controller.select(provider)
|
controller.select(provider)
|
||||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
|
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
close("provider")
|
close("provider")
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
|
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -240,7 +240,7 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
trigger={props.trigger}
|
trigger={props.trigger}
|
||||||
models={controller.models}
|
models={controller.models}
|
||||||
groups={controller.groups}
|
groups={controller.groups}
|
||||||
current={controller.current()}
|
current={controller.current}
|
||||||
select={controller.select}
|
select={controller.select}
|
||||||
onManage={() => {
|
onManage={() => {
|
||||||
void import("./dialog-manage-models").then((module) => {
|
void import("./dialog-manage-models").then((module) => {
|
||||||
@@ -295,7 +295,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
trigger: ModelSelectorTrigger
|
trigger: ModelSelectorTrigger
|
||||||
models: (search: string) => ModelItem[]
|
models: (search: string) => ModelItem[]
|
||||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||||
current: string | undefined
|
current: () => string | undefined
|
||||||
select: (item: ModelItem) => void
|
select: (item: ModelItem) => void
|
||||||
onManage: () => void
|
onManage: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -310,7 +310,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
const groups = createMemo(() => props.groups(models()))
|
const groups = createMemo(() => props.groups(models()))
|
||||||
const keys = () => [...models().map(modelKey), manageKey]
|
const keys = () => [...models().map(modelKey), manageKey]
|
||||||
const initialActive = () => {
|
const initialActive = () => {
|
||||||
const selected = props.current
|
const selected = props.current()
|
||||||
const options = keys()
|
const options = keys()
|
||||||
if (selected && options.includes(selected)) return selected
|
if (selected && options.includes(selected)) return selected
|
||||||
return options[0] ?? ""
|
return options[0] ?? ""
|
||||||
@@ -453,7 +453,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||||
</MenuV2.GroupLabel>
|
</MenuV2.GroupLabel>
|
||||||
<MenuV2.RadioGroup value={props.current}>
|
<MenuV2.RadioGroup value={props.current()}>
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
@@ -473,7 +473,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
<MenuV2.RadioItem
|
<MenuV2.RadioItem
|
||||||
value={modelKey(item)}
|
value={modelKey(item)}
|
||||||
data-option-key={modelKey(item)}
|
data-option-key={modelKey(item)}
|
||||||
data-selected-model={props.current === modelKey(item) ? true : undefined}
|
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
||||||
class="scroll-my-6 w-full"
|
class="scroll-my-6 w-full"
|
||||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
@@ -529,7 +529,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
|||||||
|
|
||||||
const provider = () => {
|
const provider = () => {
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
|
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { ServerCollectionController } from "@/components/server/server-management-controller"
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { type ServerDomainController } from "@/components/server/server-management-controller"
|
||||||
|
|
||||||
type ServerConnectionFormController = {
|
type ServerConnectionFormController = {
|
||||||
state: {
|
state: {
|
||||||
@@ -113,11 +117,12 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ServerConnectionList(props: {
|
export function ServerConnectionList(props: {
|
||||||
domain: ServerCollectionController
|
domain: ServerDomainController
|
||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
onEdit: (server: ServerConnection.Http) => void
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||||
@@ -131,6 +136,9 @@ export function ServerConnectionList(props: {
|
|||||||
emptyMessage={language.t("dialog.server.empty")}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
items={props.domain.collection.items}
|
items={props.domain.collection.items}
|
||||||
key={(x) => x.http.url}
|
key={(x) => x.http.url}
|
||||||
|
onSelect={(x) => {
|
||||||
|
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
|
||||||
|
}}
|
||||||
divider={true}
|
divider={true}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
@@ -155,6 +163,15 @@ export function ServerConnectionList(props: {
|
|||||||
showCredentials
|
showCredentials
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-center gap-4 pl-4">
|
<div class="flex items-center justify-center gap-4 pl-4">
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
props.domain.collection.current() &&
|
||||||
|
ServerConnection.key(props.domain.collection.current()!) === key
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="check" class="h-6" />
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={i.type === "http"}>
|
<Show when={i.type === "http"}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
import type { Platform } from "@/context/platform"
|
import type { Platform } from "@/context/platform"
|
||||||
|
|
||||||
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
|
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { lazy } from "solid-js"
|
import { lazy } from "solid-js"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createMemo } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { type LocalProject } from "@/context/layout"
|
import { type LocalProject } from "@/context/layout"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
const supported = !props.project.id || props.project.id === "global"
|
const supported = !props.project.id || props.project.id === "global"
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
|
||||||
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { ExternalLink } from "./external-link"
|
|
||||||
|
|
||||||
type SkillItem = {
|
|
||||||
name: string
|
|
||||||
location: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
|
|
||||||
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
|
||||||
|
|
||||||
const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
|
||||||
<div class="project-settings-extension-card">{props.children as any}</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const ExtensionRow: Component<{
|
|
||||||
icon: "mcp" | "cube" | "post-skill" | "code"
|
|
||||||
name: string
|
|
||||||
status?: string
|
|
||||||
children?: unknown
|
|
||||||
}> = (props) => (
|
|
||||||
<div class="project-settings-extension-row">
|
|
||||||
<div class="project-settings-extension-row-main">
|
|
||||||
<Icon name={props.icon} class="project-settings-extension-row-icon" />
|
|
||||||
<span class="project-settings-extension-row-name">{props.name}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={props.status}>
|
|
||||||
{(status) => (
|
|
||||||
<span class="project-settings-extension-row-status">
|
|
||||||
<span class="project-settings-extension-row-status-dot" />
|
|
||||||
{status()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
{props.children as any}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SharedSection: Component<{
|
|
||||||
count: number
|
|
||||||
children: unknown
|
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const [open, setOpen] = createSignal(false)
|
|
||||||
return (
|
|
||||||
<Show when={props.count > 0}>
|
|
||||||
<div class="project-settings-shared">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="project-settings-shared-trigger"
|
|
||||||
aria-expanded={open()}
|
|
||||||
onClick={() => setOpen((value) => !value)}
|
|
||||||
>
|
|
||||||
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
|
|
||||||
<span>{language.t("project.settings.extensions.shared")}</span>
|
|
||||||
<span class="project-settings-shared-count">{props.count}</span>
|
|
||||||
</button>
|
|
||||||
<Show when={open()}>
|
|
||||||
<ExtensionCard>{props.children}</ExtensionCard>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectSettingsExtensions: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const directorySDK = useSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const sync = useSync()
|
|
||||||
const toggleMcp = useMcpToggle()
|
|
||||||
|
|
||||||
const [serverMcp] = createResource(
|
|
||||||
serverSDK,
|
|
||||||
(sdk) =>
|
|
||||||
sdk.api.mcp
|
|
||||||
.list()
|
|
||||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
|
||||||
.catch(() => ({})),
|
|
||||||
{ initialValue: {} },
|
|
||||||
)
|
|
||||||
const globalMcpNames = createMemo(() =>
|
|
||||||
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
|
||||||
)
|
|
||||||
const projectMcpNames = createMemo(() => {
|
|
||||||
const shared = new Set(globalMcpNames())
|
|
||||||
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
|
||||||
if (configured.length > 0) return configured.sort()
|
|
||||||
return Object.keys(sync().data.mcp ?? {})
|
|
||||||
.filter((name) => !shared.has(name))
|
|
||||||
.sort()
|
|
||||||
})
|
|
||||||
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
|
||||||
|
|
||||||
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
|
|
||||||
const projectPlugins = createMemo(() => {
|
|
||||||
const shared = new Set(globalPlugins())
|
|
||||||
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
|
||||||
})
|
|
||||||
|
|
||||||
const [serverSkills] = createResource(
|
|
||||||
serverSDK,
|
|
||||||
(sdk): Promise<SkillItem[]> =>
|
|
||||||
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
|
||||||
{ initialValue: [] },
|
|
||||||
)
|
|
||||||
const [directorySkills] = createResource(
|
|
||||||
directorySDK,
|
|
||||||
(sdk): Promise<SkillItem[]> =>
|
|
||||||
sdk.api.skill
|
|
||||||
.list({ location: { directory: sdk.directory } })
|
|
||||||
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
|
||||||
{ initialValue: [] },
|
|
||||||
)
|
|
||||||
const projectSkills = createMemo(() => {
|
|
||||||
const shared = new Set(serverSkills.latest.map(skillKey))
|
|
||||||
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
|
|
||||||
})
|
|
||||||
|
|
||||||
const mcpRows = (items: string[]) => (
|
|
||||||
<For each={items}>
|
|
||||||
{(name) => (
|
|
||||||
<ExtensionRow icon="mcp" name={name}>
|
|
||||||
<Switch
|
|
||||||
checked={mcpEnabled(name)}
|
|
||||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
|
||||||
hideLabel
|
|
||||||
onChange={() => {
|
|
||||||
if (toggleMcp.isPending) return
|
|
||||||
toggleMcp.mutate(name)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</Switch>
|
|
||||||
</ExtensionRow>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
)
|
|
||||||
|
|
||||||
const pluginRows = (items: string[]) => <For each={items}>{(name) => <ExtensionRow icon="cube" name={name} />}</For>
|
|
||||||
|
|
||||||
const skillRows = (items: SkillItem[]) => (
|
|
||||||
<For each={items}>{(item) => <ExtensionRow icon="post-skill" name={item.name} />}</For>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="project-settings-extensions">
|
|
||||||
<div class="project-settings-page-header">
|
|
||||||
<h2>{language.t("settings.tab.extensions")}</h2>
|
|
||||||
<span>{language.t("project.settings.extensions.description")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsV2 variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
|
|
||||||
<TabsV2.List>
|
|
||||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
|
|
||||||
</TabsV2.List>
|
|
||||||
|
|
||||||
<TabsV2.Content value="mcps">
|
|
||||||
<div class="project-settings-extension-section">
|
|
||||||
<div class="project-settings-extension-section-header">
|
|
||||||
<span>{language.t("project.settings.extensions.added")}</span>
|
|
||||||
<span>{language.t("settings.extensions.manageConfig")}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={projectMcpNames().length > 0}>
|
|
||||||
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
|
|
||||||
</Show>
|
|
||||||
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="plugins">
|
|
||||||
<div class="project-settings-extension-section">
|
|
||||||
<div class="project-settings-extension-section-header">
|
|
||||||
<span>{language.t("project.settings.extensions.added")}</span>
|
|
||||||
<span>{language.t("settings.extensions.manageConfig")}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={projectPlugins().length > 0}>
|
|
||||||
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
|
|
||||||
</Show>
|
|
||||||
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="skills">
|
|
||||||
<div class="project-settings-extension-section">
|
|
||||||
<div class="project-settings-extension-section-header">
|
|
||||||
<span>{language.t("project.settings.extensions.added")}</span>
|
|
||||||
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
|
|
||||||
{language.t("settings.extensions.addSkills")}
|
|
||||||
</ExternalLink>
|
|
||||||
</div>
|
|
||||||
<Show when={projectSkills().length > 0}>
|
|
||||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
|
||||||
</Show>
|
|
||||||
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="lsps">
|
|
||||||
<div class="project-settings-extension-section">
|
|
||||||
<div class="project-settings-extension-section-header">
|
|
||||||
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
|
|
||||||
<span>{language.t("project.settings.extensions.lsp.description")}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={sync().data.lsp.length > 0}>
|
|
||||||
<ExtensionCard>
|
|
||||||
<For each={sync().data.lsp}>
|
|
||||||
{(item) => (
|
|
||||||
<ExtensionRow
|
|
||||||
icon="code"
|
|
||||||
name={item.name || item.id}
|
|
||||||
status={
|
|
||||||
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</ExtensionCard>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
</TabsV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,6 @@ export type PromptInputV2ComposerProps = {
|
|||||||
class?: string
|
class?: string
|
||||||
controller: PromptInputV2ComposerController
|
controller: PromptInputV2ComposerController
|
||||||
borderUnderlay?: boolean
|
borderUnderlay?: boolean
|
||||||
accentSubmit?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||||
@@ -54,7 +53,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<PromptInputV2
|
<PromptInputV2
|
||||||
controller={props.controller}
|
controller={props.controller}
|
||||||
accentSubmit={props.accentSubmit}
|
|
||||||
borderUnderlay={props.borderUnderlay}
|
borderUnderlay={props.borderUnderlay}
|
||||||
class={props.class}
|
class={props.class}
|
||||||
variantControlVisible={!props.controller.model.loading}
|
variantControlVisible={!props.controller.model.loading}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { Select } from "@opencode-ai/ui/select"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||||
|
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -1517,7 +1518,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label={placeholder()}
|
aria-label={placeholder()}
|
||||||
dir={store.mode === "normal" ? "auto" : "ltr"}
|
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||||
@@ -1539,11 +1539,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||||
"font-mono!": store.mode === "shell",
|
"font-mono!": store.mode === "shell",
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{ "padding-bottom": space }}
|
||||||
"padding-bottom": space,
|
|
||||||
"unicode-bidi": store.mode === "normal" ? "plaintext" : undefined,
|
|
||||||
"text-align": "start",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import type { ModelSelection } from "@/context/local"
|
||||||
|
|
||||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
|
const createdClients: string[] = []
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
type SessionCreateInput = {
|
const sessionCreateInputs: Array<{
|
||||||
agent?: string
|
agent?: string
|
||||||
model?: { id: string; providerID: string; variant?: string }
|
model?: { id: string; providerID: string; variant?: string }
|
||||||
location?: { directory: string }
|
location?: { directory: string }
|
||||||
}
|
}> = []
|
||||||
|
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -20,9 +22,11 @@ const optimistic: Array<{
|
|||||||
variant?: string
|
variant?: string
|
||||||
}
|
}
|
||||||
}> = []
|
}> = []
|
||||||
|
const optimisticSeeded: boolean[] = []
|
||||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||||
|
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||||
const sentShellDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||||
const sentPrompts: string[] = []
|
const sentPrompts: string[] = []
|
||||||
const promptInputs: unknown[] = []
|
const promptInputs: unknown[] = []
|
||||||
@@ -33,29 +37,15 @@ const switchedModels: Array<{
|
|||||||
model: { id: string; providerID: string; variant?: string }
|
model: { id: string; providerID: string; variant?: string }
|
||||||
}> = []
|
}> = []
|
||||||
const sessionRequestOrder: string[] = []
|
const sessionRequestOrder: string[] = []
|
||||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
const commands: Array<{ name: string }> = []
|
||||||
const syncedServers: string[] = []
|
|
||||||
const optimisticServers: string[] = []
|
|
||||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
|
||||||
let serverSessionSyncs = 0
|
let serverSessionSyncs = 0
|
||||||
|
|
||||||
let params: { id?: string } = {}
|
let params: { id?: string } = {}
|
||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
let variant: string | undefined
|
let variant: string | undefined
|
||||||
|
let permissionServer = "server-a"
|
||||||
let createSessionGate: Promise<void> | undefined
|
let createSessionGate: Promise<void> | undefined
|
||||||
let createWorktreeGate: Promise<void> | undefined
|
|
||||||
let worktreeFailure: Error | undefined
|
|
||||||
let locationFailure: Error | undefined
|
|
||||||
let worktreeCreates = 0
|
|
||||||
let activeSDK = "server-a"
|
|
||||||
let activeServerSync = "server-a"
|
|
||||||
let activeDirectorySync = "server-a"
|
|
||||||
let commands: Array<{ name: string }> = []
|
|
||||||
let worktreeDirectory = "/repo/new-0"
|
|
||||||
let worktreeID = 0
|
|
||||||
const draftServers: Record<string, string> = {}
|
|
||||||
const sessionDirectories: Record<string, string> = {}
|
|
||||||
|
|
||||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
@@ -83,25 +73,21 @@ const prompt = {
|
|||||||
replaceComments: () => undefined,
|
replaceComments: () => undefined,
|
||||||
items: () => [],
|
items: () => [],
|
||||||
},
|
},
|
||||||
capture: (scope?: unknown, target?: unknown) => {
|
capture: () => prompt,
|
||||||
promptCaptures.push({ scope, target })
|
|
||||||
return prompt
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
||||||
|
|
||||||
const clientFor = (directory: string) => {
|
const clientFor = (directory: string) => {
|
||||||
|
createdClients.push(directory)
|
||||||
return {
|
return {
|
||||||
api: {
|
api: {
|
||||||
session: {
|
session: {
|
||||||
create: async (input: SessionCreateInput) => {
|
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||||
await createSessionGate
|
await createSessionGate
|
||||||
const location = input.location?.directory ?? directory
|
const location = input.location?.directory ?? directory
|
||||||
createdSessions.push(location)
|
createdSessions.push(location)
|
||||||
const id = `session-${createdSessions.length}`
|
sessionCreateInputs.push(input)
|
||||||
sessionDirectories[id] = location
|
|
||||||
return {
|
return {
|
||||||
id,
|
id: `session-${createdSessions.length}`,
|
||||||
projectID: "project",
|
projectID: "project",
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
@@ -114,7 +100,7 @@ const clientFor = (directory: string) => {
|
|||||||
},
|
},
|
||||||
prompt: async (input: unknown) => {
|
prompt: async (input: unknown) => {
|
||||||
sessionRequestOrder.push("prompt")
|
sessionRequestOrder.push("prompt")
|
||||||
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
sentPrompts.push(directory)
|
||||||
promptInputs.push(input)
|
promptInputs.push(input)
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
@@ -134,21 +120,6 @@ const clientFor = (directory: string) => {
|
|||||||
},
|
},
|
||||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||||
sentShell.push(input)
|
sentShell.push(input)
|
||||||
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
worktree: {
|
|
||||||
create: async (_input: unknown) => {
|
|
||||||
worktreeCreates++
|
|
||||||
await createWorktreeGate
|
|
||||||
if (worktreeFailure) throw worktreeFailure
|
|
||||||
return { directory: worktreeDirectory }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
location: {
|
|
||||||
get: async () => {
|
|
||||||
if (locationFailure) throw locationFailure
|
|
||||||
return { directory: worktreeDirectory }
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -156,6 +127,9 @@ const clientFor = (directory: string) => {
|
|||||||
command: async () => ({ data: undefined }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
|
worktree: {
|
||||||
|
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +145,6 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@opencode-ai/ui/toast", () => ({
|
mock.module("@opencode-ai/ui/toast", () => ({
|
||||||
Toast: { Region: () => null },
|
Toast: { Region: () => null },
|
||||||
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
|
|
||||||
showToast: () => 0,
|
showToast: () => 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -189,13 +162,20 @@ beforeAll(async () => {
|
|||||||
current: () => ({ name: "agent" }),
|
current: () => ({ name: "agent" }),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
promote: () => undefined,
|
promote(directory: string, sessionID: string) {
|
||||||
|
promoted.push({ directory, sessionID })
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/permission", () => {
|
mock.module("@/context/permission", () => {
|
||||||
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
|
const state = (server: string) => ({
|
||||||
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
|
enabledAutoAccept.push({ server, sessionID, directory })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.module("@/context/server", () => ({
|
mock.module("@/context/server", () => ({
|
||||||
@@ -204,10 +184,7 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@/context/tabs", () => ({
|
mock.module("@/context/tabs", () => ({
|
||||||
useTabs: () => ({
|
useTabs: () => ({
|
||||||
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
|
draft: () => ({ server: "project-server" }),
|
||||||
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
|
||||||
updatedDrafts.push({ draftID, ...draft })
|
|
||||||
},
|
|
||||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||||
promotedDrafts.push({ draftID, ...session })
|
promotedDrafts.push({ draftID, ...session })
|
||||||
},
|
},
|
||||||
@@ -218,72 +195,78 @@ beforeAll(async () => {
|
|||||||
usePrompt: () => prompt,
|
usePrompt: () => prompt,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/layout", () => ({
|
||||||
|
useLayout: () => ({
|
||||||
|
handoff: {
|
||||||
|
setTabs: () => undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sdk", () => ({
|
mock.module("@/context/sdk", () => ({
|
||||||
useSDK: () => {
|
useSDK: () => {
|
||||||
return () => ({
|
const sdk = {
|
||||||
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
scope: "local",
|
||||||
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
directory: "/repo/main",
|
||||||
api: rootClient.api,
|
api: rootClient.api,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
})
|
}
|
||||||
|
return () => sdk
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => {
|
useSync: () => () => ({
|
||||||
const server = activeDirectorySync
|
data: { command: commands },
|
||||||
return {
|
session: {
|
||||||
data: { command: commands, project: "project" },
|
optimistic: {
|
||||||
session: {
|
add: (value: {
|
||||||
optimistic: {
|
directory?: string
|
||||||
add: (value: {
|
sessionID?: string
|
||||||
directory?: string
|
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||||
sessionID?: string
|
}) => {
|
||||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
optimistic.push(value)
|
||||||
}) => {
|
optimisticSeeded.push(
|
||||||
optimisticServers.push(server)
|
!!value.directory &&
|
||||||
optimistic.push(value)
|
!!value.sessionID &&
|
||||||
},
|
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
||||||
remove: () => undefined,
|
)
|
||||||
},
|
},
|
||||||
|
remove: () => undefined,
|
||||||
},
|
},
|
||||||
set: () => undefined,
|
},
|
||||||
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
set: () => undefined,
|
||||||
}
|
}),
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/server-sync", () => ({
|
mock.module("@/context/server-sync", () => ({
|
||||||
useServerSync: () => {
|
useServerSync: () => () => ({
|
||||||
const server = activeServerSync
|
session: {
|
||||||
return {
|
remember: () => undefined,
|
||||||
session: {
|
set: () => undefined,
|
||||||
remember: () => undefined,
|
sync: async () => {
|
||||||
set: () => undefined,
|
serverSessionSyncs++
|
||||||
sync: async () => {
|
},
|
||||||
serverSessionSyncs++
|
},
|
||||||
|
child: (directory: string) => {
|
||||||
|
syncedDirectories.push(directory)
|
||||||
|
storedSessions[directory] ??= []
|
||||||
|
return [
|
||||||
|
{ session: storedSessions[directory] },
|
||||||
|
(...args: unknown[]) => {
|
||||||
|
if (args[0] !== "session") return
|
||||||
|
const next = args[1]
|
||||||
|
if (typeof next === "function") {
|
||||||
|
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Array.isArray(next)) {
|
||||||
|
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
]
|
||||||
child: (directory: string) => {
|
},
|
||||||
syncedServers.push(server)
|
}),
|
||||||
storedSessions[directory] ??= []
|
|
||||||
return [
|
|
||||||
{ session: storedSessions[directory] },
|
|
||||||
(...args: unknown[]) => {
|
|
||||||
if (args[0] !== "session") return
|
|
||||||
const next = args[1]
|
|
||||||
if (typeof next === "function") {
|
|
||||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (Array.isArray(next)) {
|
|
||||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/platform", () => ({
|
mock.module("@/context/platform", () => ({
|
||||||
@@ -303,141 +286,205 @@ beforeAll(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
createdClients.length = 0
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
|
sessionCreateInputs.length = 0
|
||||||
|
enabledAutoAccept.length = 0
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
|
optimisticSeeded.length = 0
|
||||||
|
promoted.length = 0
|
||||||
promotedDrafts.length = 0
|
promotedDrafts.length = 0
|
||||||
updatedDrafts.length = 0
|
|
||||||
sentCommands.length = 0
|
|
||||||
sentPrompts.length = 0
|
sentPrompts.length = 0
|
||||||
promptInputs.length = 0
|
promptInputs.length = 0
|
||||||
|
sentCommands.length = 0
|
||||||
switchedAgents.length = 0
|
switchedAgents.length = 0
|
||||||
switchedModels.length = 0
|
switchedModels.length = 0
|
||||||
sessionRequestOrder.length = 0
|
sessionRequestOrder.length = 0
|
||||||
syncedServers.length = 0
|
commands.length = 0
|
||||||
optimisticServers.length = 0
|
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
promptCaptures.length = 0
|
|
||||||
params = {}
|
params = {}
|
||||||
search = {}
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
sentShellDirectories.length = 0
|
syncedDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
variant = undefined
|
variant = undefined
|
||||||
activeSDK = "server-a"
|
permissionServer = "server-a"
|
||||||
activeServerSync = "server-a"
|
|
||||||
activeDirectorySync = "server-a"
|
|
||||||
commands = []
|
|
||||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
|
||||||
worktreeDirectory = `/repo/new-${++worktreeID}`
|
|
||||||
createSessionGate = undefined
|
createSessionGate = undefined
|
||||||
serverSessionSyncs = 0
|
serverSessionSyncs = 0
|
||||||
createWorktreeGate = undefined
|
|
||||||
worktreeFailure = undefined
|
|
||||||
locationFailure = undefined
|
|
||||||
worktreeCreates = 0
|
|
||||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
|
||||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
|
||||||
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
|
|
||||||
createPromptSubmit({
|
|
||||||
prompt,
|
|
||||||
info: () => undefined,
|
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
...overrides,
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
test("admits only one concurrent new-workspace submission", async () => {
|
test("reads the latest worktree accessor value per submit", async () => {
|
||||||
selected = "create"
|
const submit = createPromptSubmit({
|
||||||
let release = () => {}
|
prompt,
|
||||||
createWorktreeGate = new Promise<void>((resolve) => {
|
info: () => undefined,
|
||||||
release = resolve
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "shell",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
})
|
})
|
||||||
const submit = makeSubmit()
|
|
||||||
|
|
||||||
const first = submit.handleSubmit(event)
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
const duplicate = submit.handleSubmit(event)
|
|
||||||
expect(worktreeCreates).toBe(1)
|
|
||||||
|
|
||||||
release()
|
await submit.handleSubmit(event)
|
||||||
await Promise.all([first, duplicate])
|
selected = "/repo/worktree-b"
|
||||||
expect(createdSessions).toEqual([worktreeDirectory])
|
await submit.handleSubmit(event)
|
||||||
await settle()
|
|
||||||
|
|
||||||
expect(worktreeCreates).toBe(1)
|
expect(createdClients).toEqual([])
|
||||||
expect(createdSessions).toHaveLength(1)
|
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
expect(sessionCreateInputs).toEqual([
|
||||||
|
{
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: undefined },
|
||||||
|
location: { directory: "/repo/worktree-a" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: undefined },
|
||||||
|
location: { directory: "/repo/worktree-b" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(sentShell).toEqual([
|
||||||
|
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||||
|
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||||
|
])
|
||||||
|
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||||
|
expect(serverSessionSyncs).toBe(0)
|
||||||
|
expect(promoted).toEqual([
|
||||||
|
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||||
|
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||||
|
])
|
||||||
|
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("stops when the created workspace cannot initialize", async () => {
|
test("applies auto-accept to newly created sessions", async () => {
|
||||||
selected = "create"
|
const submit = createPromptSubmit({
|
||||||
locationFailure = new Error("initialization failed")
|
prompt,
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => true,
|
||||||
|
mode: () => "shell",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
await makeSubmit().handleSubmit(event)
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
expect(worktreeCreates).toBe(1)
|
await submit.handleSubmit(event)
|
||||||
expect(createdSessions).toEqual([])
|
|
||||||
expect(sentPrompts).toEqual([])
|
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps async submission effects bound to the initiating context", async () => {
|
test("keeps auto-accept bound to the submission server", async () => {
|
||||||
search = { draftId: "draft-1" }
|
|
||||||
draftServers["draft-1"] = "project-server-a"
|
|
||||||
draftServers["draft-2"] = "project-server-b"
|
|
||||||
let release = () => {}
|
let release = () => {}
|
||||||
createSessionGate = new Promise<void>((resolve) => {
|
createSessionGate = new Promise<void>((resolve) => {
|
||||||
release = resolve
|
release = resolve
|
||||||
})
|
})
|
||||||
let submitted = 0
|
const submit = createPromptSubmit({
|
||||||
const submit = makeSubmit({
|
prompt,
|
||||||
onSubmit: () => submitted++,
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => true,
|
||||||
|
mode: () => "shell",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = submit.handleSubmit(event)
|
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
activeSDK = "server-b"
|
permissionServer = "server-b"
|
||||||
activeServerSync = "server-b"
|
|
||||||
activeDirectorySync = "server-b"
|
|
||||||
search.draftId = "draft-2"
|
|
||||||
release()
|
release()
|
||||||
await result
|
await result
|
||||||
await settle()
|
|
||||||
|
|
||||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
})
|
||||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
|
||||||
expect(optimisticServers).toEqual(["server-a"])
|
test("promotes drafts using the selected project's server", async () => {
|
||||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
search = { draftId: "draft-1" }
|
||||||
expect(submitted).toBe(0)
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
|
|
||||||
|
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switches the selected agent and model before prompting", async () => {
|
test("switches the selected agent and model before prompting", async () => {
|
||||||
params = { id: "session-1" }
|
params = { id: "session-1" }
|
||||||
variant = "high"
|
variant = "high"
|
||||||
|
|
||||||
const submit = makeSubmit({
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
info: () => ({
|
info: () => ({
|
||||||
id: "session-1",
|
id: "session-1",
|
||||||
agent: "old-agent",
|
agent: "old-agent",
|
||||||
model: { id: "old-model", providerID: "old-provider" },
|
model: { id: "old-model", providerID: "old-provider" },
|
||||||
}),
|
}),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
|
|
||||||
@@ -472,12 +519,24 @@ describe("prompt submit worktree selection", () => {
|
|||||||
commands.push({ name: "review" })
|
commands.push({ name: "review" })
|
||||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||||
|
|
||||||
const submit = makeSubmit({
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
info: () => ({ id: "session-1" }),
|
info: () => ({ id: "session-1" }),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
await settle()
|
|
||||||
|
|
||||||
expect(sentCommands).toEqual([
|
expect(sentCommands).toEqual([
|
||||||
{
|
{
|
||||||
@@ -493,19 +552,66 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(serverSessionSyncs).toBe(0)
|
expect(serverSessionSyncs).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("sends an initial shell after synchronous workspace creation", async () => {
|
test("uses an injected model selection", async () => {
|
||||||
selected = "create"
|
params = { id: "session-1" }
|
||||||
const submit = makeSubmit({
|
const model = {
|
||||||
mode: () => "shell",
|
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
||||||
|
variant: { current: () => "draft-variant" },
|
||||||
|
} as unknown as ModelSelection
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => ({ id: "session-1" }),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
model,
|
||||||
})
|
})
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
await settle()
|
|
||||||
|
|
||||||
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
expect(optimistic[0]).toMatchObject({
|
||||||
expect(sentShell[0]).toMatchObject({
|
message: {
|
||||||
sessionID: "session-1",
|
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
||||||
command: "ls",
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
|
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
||||||
|
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||||
|
expect(optimisticSeeded).toEqual([true])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { batch, startTransition, type Accessor } from "solid-js"
|
|||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal, type ModelSelection } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||||
import { useSync, type DirectorySync } from "@/context/sync"
|
import { useSync, type DirectorySync } from "@/context/sync"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
@@ -23,7 +25,12 @@ import { createPromptSubmissionState } from "./submission-state"
|
|||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { blobDataUrl } from "@/utils/draft-store"
|
import { blobDataUrl } from "@/utils/draft-store"
|
||||||
|
|
||||||
const submitting = new Set<string>()
|
type PendingPrompt = {
|
||||||
|
abort: AbortController
|
||||||
|
cleanup: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = new Map<string, PendingPrompt>()
|
||||||
|
|
||||||
export type FollowupDraft = {
|
export type FollowupDraft = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -43,6 +50,7 @@ type FollowupSendInput = {
|
|||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
messageID?: string
|
messageID?: string
|
||||||
optimisticBusy?: boolean
|
optimisticBusy?: boolean
|
||||||
|
before?: () => Promise<boolean> | boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||||
@@ -62,11 +70,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wait = async () => {
|
||||||
|
const ok = await input.before?.()
|
||||||
|
if (ok === false) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const [head, ...tail] = text.split(" ")
|
const [head, ...tail] = text.split(" ")
|
||||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||||
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
||||||
setBusy()
|
setBusy()
|
||||||
try {
|
try {
|
||||||
|
if (!(await wait())) {
|
||||||
|
setIdle()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
await input.api.command({
|
await input.api.command({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
@@ -140,6 +159,14 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!(await wait())) {
|
||||||
|
batch(() => {
|
||||||
|
setIdle()
|
||||||
|
remove()
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const session = input.session()
|
const session = input.session()
|
||||||
if (session?.agent !== input.draft.agent) {
|
if (session?.agent !== input.draft.agent) {
|
||||||
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
|
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
|
||||||
@@ -231,10 +258,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const prompt = input.prompt
|
const prompt = input.prompt
|
||||||
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||||
if (err && typeof err === "object" && "data" in err) {
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
@@ -248,10 +278,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const abort = async () => {
|
const abort = async () => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
serverSync.session.set("todo", sessionID, [])
|
|
||||||
|
serverSync().session.set("todo", sessionID, [])
|
||||||
|
|
||||||
input.onAbort?.()
|
input.onAbort?.()
|
||||||
|
|
||||||
|
const key = pendingKey(sessionID)
|
||||||
|
const queued = pending.get(key)
|
||||||
|
if (queued) {
|
||||||
|
queued.abort.abort()
|
||||||
|
queued.cleanup()
|
||||||
|
pending.delete(key)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
return sdk()
|
return sdk()
|
||||||
.api.session.interrupt({ sessionID })
|
.api.session.interrupt({ sessionID })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -280,9 +319,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
const seed = (dir: string, info: SessionInfo) => {
|
||||||
target.session.remember(info)
|
serverSync().session.remember(info)
|
||||||
const [, setStore] = target.child(dir)
|
const [, setStore] = serverSync().child(dir)
|
||||||
setStore("session", (list: SessionInfo[]) => {
|
setStore("session", (list: SessionInfo[]) => {
|
||||||
const result = Binary.search(list, info.id, (item) => item.id)
|
const result = Binary.search(list, info.id, (item) => item.id)
|
||||||
const next = [...list]
|
const next = [...list]
|
||||||
@@ -314,6 +353,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (input.working()) void abort()
|
if (input.working()) void abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelSelection = input.model ?? local.model
|
const modelSelection = input.model ?? local.model
|
||||||
const currentModel = modelSelection.current()
|
const currentModel = modelSelection.current()
|
||||||
const currentAgent = local.agent.current()
|
const currentAgent = local.agent.current()
|
||||||
@@ -326,251 +366,284 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const submissionSDK = sdk()
|
input.addToHistory(currentPrompt, mode)
|
||||||
const submissionSync = sync()
|
input.resetHistoryNavigation()
|
||||||
const submissionServerSync = serverSync
|
|
||||||
const submissionScope = submissionSDK.scope
|
const projectDirectory = sdk().directory
|
||||||
const projectDirectory = submissionSDK.directory
|
const permissionState = permission.currentServerState()
|
||||||
const sessionID = params.id
|
const isNewSession = !params.id
|
||||||
const isNewSession = !sessionID
|
|
||||||
const currentSession = input.info()
|
|
||||||
const draftID = search.draftId
|
|
||||||
const draftServer = draftID ? tabs.draft(draftID).server : undefined
|
|
||||||
const capturePrompt = prompt.capture
|
|
||||||
const localSession = local.session
|
|
||||||
const resetWorktree = input.onNewSessionWorktreeReset
|
|
||||||
const onSubmit = input.onSubmit
|
|
||||||
const permissionState = permission
|
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
const submissionKey = ScopedKey.from(
|
|
||||||
submissionScope,
|
|
||||||
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
|
|
||||||
)
|
|
||||||
if (submitting.has(submissionKey)) return
|
|
||||||
submitting.add(submissionKey)
|
|
||||||
|
|
||||||
try {
|
let sessionDirectory = projectDirectory
|
||||||
input.addToHistory(currentPrompt, mode)
|
if (isNewSession) {
|
||||||
input.resetHistoryNavigation()
|
if (worktreeSelection === "create") {
|
||||||
|
const createdWorktree = await sdk()
|
||||||
let sessionDirectory = projectDirectory
|
.api.projectCopy.create({
|
||||||
if (isNewSession) {
|
projectID: sync().data.project,
|
||||||
if (worktreeSelection === "create") {
|
strategy: "git_worktree",
|
||||||
const createdWorktree = await submissionSDK.api.worktree
|
directory: getDirectory(projectDirectory),
|
||||||
.create({
|
location: { directory: projectDirectory },
|
||||||
projectID: submissionSync.data.project,
|
|
||||||
strategy: "git",
|
|
||||||
directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory),
|
|
||||||
})
|
|
||||||
.then(async (created) => {
|
|
||||||
await submissionSDK.api.location.get({ location: { directory: created.directory } })
|
|
||||||
return created
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!createdWorktree) return
|
|
||||||
sessionDirectory = createdWorktree.directory
|
|
||||||
}
|
|
||||||
|
|
||||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
|
||||||
sessionDirectory = worktreeSelection
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionDirectory !== projectDirectory) {
|
|
||||||
submissionServerSync.child(sessionDirectory)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let session = currentSession
|
|
||||||
if (!session && isNewSession) {
|
|
||||||
const created = await submissionSDK.api.session
|
|
||||||
.create({
|
|
||||||
agent: currentAgent.name,
|
|
||||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
|
||||||
location: { directory: sessionDirectory },
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
description: errorMessage(err),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
if (created) {
|
if (!createdWorktree) return
|
||||||
seed(submissionServerSync, sessionDirectory, created)
|
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||||
session = created
|
sessionDirectory = createdWorktree.directory
|
||||||
await startTransition(() => {
|
}
|
||||||
if (!session) return
|
|
||||||
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||||
if (!draftID) resetWorktree?.()
|
sessionDirectory = worktreeSelection
|
||||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
}
|
||||||
localSession.promote(sessionDirectory, session.id, {
|
|
||||||
agent: currentAgent.name,
|
if (sessionDirectory !== projectDirectory) {
|
||||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
serverSync().child(sessionDirectory)
|
||||||
variant: variant ?? null,
|
}
|
||||||
})
|
|
||||||
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
input.onNewSessionWorktreeReset?.()
|
||||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
}
|
||||||
submission.retarget(
|
|
||||||
capturePrompt(
|
let session = input.info()
|
||||||
{ dir: base64Encode(sessionDirectory), id: session.id },
|
if (!session && isNewSession) {
|
||||||
{ server: draftServer, scope: submissionScope },
|
const created = await sdk()
|
||||||
),
|
.api.session.create({
|
||||||
)
|
agent: currentAgent.name,
|
||||||
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||||
|
location: { directory: sessionDirectory },
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
}
|
return undefined
|
||||||
}
|
|
||||||
if (!session) {
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
|
||||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
|
||||||
})
|
})
|
||||||
return
|
if (created) {
|
||||||
}
|
seed(sessionDirectory, created)
|
||||||
|
session = created
|
||||||
const model = {
|
await startTransition(() => {
|
||||||
modelID: currentModel.id,
|
if (!session) return
|
||||||
providerID: currentModel.provider.id,
|
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||||
}
|
local.session.promote(sessionDirectory, session.id, {
|
||||||
const agent = currentAgent.name
|
agent: currentAgent.name,
|
||||||
const draft: FollowupDraft = {
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||||
sessionID: session.id,
|
variant: variant ?? null,
|
||||||
sessionDirectory,
|
})
|
||||||
prompt: currentPrompt,
|
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
context,
|
const draftID = search.draftId
|
||||||
agent,
|
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||||
model,
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
variant,
|
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||||
}
|
|
||||||
|
|
||||||
const clearInput = () => {
|
|
||||||
submission.clear()
|
|
||||||
input.setMode("normal")
|
|
||||||
input.setPopover(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoreInput = () => {
|
|
||||||
const restored = submission.restore()
|
|
||||||
if (!restored) return false
|
|
||||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
|
||||||
if (!submission.current(prompt.capture())) return true
|
|
||||||
input.setMode(mode)
|
|
||||||
input.setPopover(null)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const editor = input.editor()
|
|
||||||
if (!editor) return
|
|
||||||
editor.focus()
|
|
||||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
|
||||||
input.queueScroll()
|
|
||||||
})
|
})
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!session) {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
|
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
const model = {
|
||||||
input.onQueue?.(draft)
|
modelID: currentModel.id,
|
||||||
clearContext(submission.target())
|
providerID: currentModel.provider.id,
|
||||||
|
}
|
||||||
|
const agent = currentAgent.name
|
||||||
|
const draft: FollowupDraft = {
|
||||||
|
sessionID: session.id,
|
||||||
|
sessionDirectory,
|
||||||
|
prompt: currentPrompt,
|
||||||
|
context,
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
variant,
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearInput = () => {
|
||||||
|
submission.clear()
|
||||||
|
input.setMode("normal")
|
||||||
|
input.setPopover(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreInput = () => {
|
||||||
|
const restored = submission.restore()
|
||||||
|
if (!restored) return false
|
||||||
|
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||||
|
if (!submission.current(prompt.capture())) return true
|
||||||
|
input.setMode(mode)
|
||||||
|
input.setPopover(null)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const editor = input.editor()
|
||||||
|
if (!editor) return
|
||||||
|
editor.focus()
|
||||||
|
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||||
|
input.queueScroll()
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||||
|
input.onQueue?.(draft)
|
||||||
|
clearContext(submission.target())
|
||||||
|
clearInput()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.onSubmit?.()
|
||||||
|
|
||||||
|
if (mode === "shell") {
|
||||||
|
clearInput()
|
||||||
|
const eventID = Event.ID.create()
|
||||||
|
sdk()
|
||||||
|
.api.session.shell({
|
||||||
|
sessionID: session.id,
|
||||||
|
id: eventID,
|
||||||
|
command: text,
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
restoreInput()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.startsWith("/")) {
|
||||||
|
const [cmdName, ...args] = text.split(" ")
|
||||||
|
const commandName = cmdName.slice(1)
|
||||||
|
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||||
|
if (customCommand) {
|
||||||
clearInput()
|
clearInput()
|
||||||
return
|
const messageID = Identifier.ascending("message")
|
||||||
}
|
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||||
|
sdk()
|
||||||
if (!draftID || search.draftId === draftID) onSubmit?.()
|
.api.session.command({
|
||||||
|
|
||||||
if (mode === "shell") {
|
|
||||||
clearInput()
|
|
||||||
const eventID = Event.ID.create()
|
|
||||||
void submissionSDK.api.session
|
|
||||||
.shell({
|
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: eventID,
|
id: messageID,
|
||||||
command: text,
|
command: commandName,
|
||||||
|
arguments: args.join(" "),
|
||||||
|
agent,
|
||||||
|
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||||
|
files: await Promise.all(
|
||||||
|
images.map(async (attachment) => ({
|
||||||
|
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
|
name: attachment.filename,
|
||||||
|
})),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
description: errorMessage(err),
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
})
|
})
|
||||||
restoreInput()
|
restoreInput()
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (text.startsWith("/")) {
|
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||||
const [cmdName, ...args] = text.split(" ")
|
const messageID = Identifier.ascending("message")
|
||||||
const commandName = cmdName.slice(1)
|
|
||||||
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
|
||||||
if (customCommand) {
|
|
||||||
clearInput()
|
|
||||||
const messageID = Identifier.ascending("message")
|
|
||||||
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
|
||||||
void submissionSDK.api.session
|
|
||||||
.command({
|
|
||||||
sessionID: session.id,
|
|
||||||
id: messageID,
|
|
||||||
command: commandName,
|
|
||||||
arguments: args.join(" "),
|
|
||||||
agent,
|
|
||||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
|
||||||
files: await Promise.all(
|
|
||||||
images.map(async (attachment) => ({
|
|
||||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
|
||||||
name: attachment.filename,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
|
||||||
})
|
|
||||||
restoreInput()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
const removeOptimisticMessage = () => {
|
||||||
const messageID = Identifier.ascending("message")
|
sync().session.optimistic.remove({
|
||||||
|
directory: sessionDirectory,
|
||||||
const removeOptimisticMessage = () => {
|
sessionID: session.id,
|
||||||
submissionSync.session.optimistic.remove({
|
|
||||||
directory: sessionDirectory,
|
|
||||||
sessionID: session.id,
|
|
||||||
messageID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
|
||||||
clearInput()
|
|
||||||
|
|
||||||
void sendFollowupDraft({
|
|
||||||
api: submissionSDK.api.session,
|
|
||||||
sync: submissionSync,
|
|
||||||
serverSync: submissionServerSync,
|
|
||||||
session: () => session,
|
|
||||||
draft,
|
|
||||||
messageID,
|
messageID,
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
})
|
||||||
}).catch((err) => {
|
}
|
||||||
|
|
||||||
|
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||||
|
clearInput()
|
||||||
|
|
||||||
|
const waitForWorktree = async () => {
|
||||||
|
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
||||||
|
if (!worktree || worktree.status !== "pending") return true
|
||||||
|
|
||||||
|
if (sessionDirectory === projectDirectory) {
|
||||||
|
sync().set("session_status", session.id, { type: "busy" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const cleanup = () => {
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
sync().set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
removeOptimisticMessage()
|
removeOptimisticMessage()
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||||
|
|
||||||
|
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
resolve({ status: "failed", message: "aborted" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
controller.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
resolve({ status: "failed", message: "aborted" })
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
} finally {
|
|
||||||
submitting.delete(submissionKey)
|
const timeoutMs = 5 * 60 * 1000
|
||||||
|
const timer = { id: undefined as number | undefined }
|
||||||
|
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
|
timer.id = window.setTimeout(() => {
|
||||||
|
resolve({
|
||||||
|
status: "failed",
|
||||||
|
message: language.t("workspace.error.stillPreparing"),
|
||||||
|
})
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
WorktreeState.wait(sdk().scope, sessionDirectory),
|
||||||
|
abortWait,
|
||||||
|
timeout,
|
||||||
|
]).finally(() => {
|
||||||
|
if (timer.id === undefined) return
|
||||||
|
clearTimeout(timer.id)
|
||||||
|
})
|
||||||
|
pending.delete(pendingKey(session.id))
|
||||||
|
if (controller.signal.aborted) return false
|
||||||
|
if (result.status === "failed") throw new Error(result.message)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void sendFollowupDraft({
|
||||||
|
api: sdk().api.session,
|
||||||
|
sync: sync(),
|
||||||
|
serverSync: serverSync(),
|
||||||
|
session: () => input.info() ?? session,
|
||||||
|
draft,
|
||||||
|
messageID,
|
||||||
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
|
before: waitForWorktree,
|
||||||
|
}).catch((err) => {
|
||||||
|
pending.delete(pendingKey(session.id))
|
||||||
|
if (sessionDirectory === projectDirectory) {
|
||||||
|
sync().set("session_status", session.id, { type: "idle" })
|
||||||
|
}
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
removeOptimisticMessage()
|
||||||
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,52 +1,35 @@
|
|||||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { sameDirectory } from "@/utils/workspace"
|
|
||||||
|
|
||||||
export function PromptWorkspaceSelector(props: {
|
export function PromptWorkspaceSelector(props: {
|
||||||
value: string
|
value: string
|
||||||
projectRoot: string
|
projectRoot: string
|
||||||
workspaces: string[]
|
workspaces: string[]
|
||||||
branch?: string
|
branch?: string
|
||||||
onboarding?: boolean
|
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
onViewAll: () => void
|
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [search, setSearch] = createSignal("")
|
let pending: string | undefined
|
||||||
let searchInput: HTMLInputElement | undefined
|
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||||
let focusSearch = false
|
|
||||||
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
|
|
||||||
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
|
|
||||||
const workspaces = createMemo(() => {
|
|
||||||
const query = search().trim().toLowerCase()
|
|
||||||
if (!query) return props.workspaces
|
|
||||||
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
|
|
||||||
})
|
|
||||||
const icon = () => {
|
const icon = () => {
|
||||||
if (selected() === "main") return "monitor"
|
if (selected() === "main") return "monitor"
|
||||||
if (selected() === "create") return "workspace-new"
|
if (selected() === "create") return "workspace-new"
|
||||||
return "workspace-isolated"
|
return "workspace"
|
||||||
}
|
}
|
||||||
const select = (value: string) => {
|
const select = (value: string) => {
|
||||||
pending = { type: "select", value }
|
pending = value
|
||||||
}
|
}
|
||||||
const onOpenChange = (open: boolean) => {
|
const onOpenChange = (open: boolean) => {
|
||||||
if (open) {
|
if (open) return
|
||||||
setSearch("")
|
const value = pending
|
||||||
return
|
|
||||||
}
|
|
||||||
const action = pending
|
|
||||||
pending = undefined
|
pending = undefined
|
||||||
if (action?.type === "select") props.onChange(action.value)
|
if (value) props.onChange(value)
|
||||||
if (action?.type === "viewAll") {
|
|
||||||
props.onViewAll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
props.onDone()
|
props.onDone()
|
||||||
}
|
}
|
||||||
const label = () => {
|
const label = () => {
|
||||||
@@ -58,214 +41,87 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
<TooltipV2
|
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||||
placement="top"
|
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||||
openDelay={800}
|
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
value={
|
<span class="min-w-0 truncate">{label()}</span>
|
||||||
props.onboarding ? (
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
<div class="flex flex-col gap-1 text-start">
|
</MenuV2.Trigger>
|
||||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
<MenuV2.Portal>
|
||||||
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
<MenuV2.Content class="w-[180px]">
|
||||||
<span>{language.t("workspace.onboarding.title")}</span>
|
<MenuV2.Group>
|
||||||
</div>
|
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||||
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
|
<MenuV2.Item onSelect={() => select("main")}>
|
||||||
</div>
|
<IconV2 name="monitor" />
|
||||||
) : (
|
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
||||||
language.t("session.new.workspace.trigger.tooltip")
|
<Show when={selected() === "main"}>
|
||||||
)
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
}
|
</Show>
|
||||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
</MenuV2.Item>
|
||||||
class="min-w-0"
|
<MenuV2.Item onSelect={() => select("create")}>
|
||||||
>
|
<IconV2 name="workspace-new" />
|
||||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||||
<MenuV2.Trigger
|
<Show when={selected() === "create"}>
|
||||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
</Show>
|
||||||
>
|
</MenuV2.Item>
|
||||||
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
</MenuV2.Group>
|
||||||
<span class="min-w-0 truncate">{label()}</span>
|
<Show when={props.workspaces.length > 0}>
|
||||||
<Show when={props.onboarding}>
|
<MenuV2.Separator />
|
||||||
<span
|
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||||
data-slot="workspace-onboarding-dot"
|
<MenuV2.SubTrigger>
|
||||||
aria-hidden="true"
|
<IconV2 name="workspace" />
|
||||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
{language.t("session.new.workspace.existing")}
|
||||||
/>
|
</MenuV2.SubTrigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.SubContent class="max-w-[200px]">
|
||||||
|
<For each={props.workspaces}>
|
||||||
|
{(workspace) => (
|
||||||
|
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||||
|
<IconV2 name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||||
|
<Show when={selected() === workspace}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</MenuV2.SubContent>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2.Sub>
|
||||||
</Show>
|
</Show>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
</MenuV2.Content>
|
||||||
</MenuV2.Trigger>
|
</MenuV2.Portal>
|
||||||
<MenuV2.Portal>
|
</MenuV2>
|
||||||
<MenuV2.Content class="w-[200px]">
|
<PromptGitStatus branch={props.branch} />
|
||||||
<MenuV2.Group>
|
|
||||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
|
||||||
<MenuV2.Item onSelect={() => select("main")}>
|
|
||||||
<Icon name="monitor" />
|
|
||||||
<TooltipV2
|
|
||||||
placement="right"
|
|
||||||
openDelay={800}
|
|
||||||
value={
|
|
||||||
<span class="flex flex-col gap-0.5">
|
|
||||||
<span>{language.t("session.new.workspace.local")}</span>
|
|
||||||
<span class="font-[440] text-v2-text-text-muted">
|
|
||||||
{language.t("session.new.workspace.local.tooltip")}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
class="min-w-0 flex-1"
|
|
||||||
>
|
|
||||||
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
|
||||||
</TooltipV2>
|
|
||||||
<Show when={selected() === "main"}>
|
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
|
||||||
</Show>
|
|
||||||
</MenuV2.Item>
|
|
||||||
<MenuV2.Item onSelect={() => select("create")}>
|
|
||||||
<Icon name="workspace-new" />
|
|
||||||
<TooltipV2
|
|
||||||
placement="right"
|
|
||||||
openDelay={800}
|
|
||||||
value={
|
|
||||||
<span class="flex flex-col gap-0.5">
|
|
||||||
<span>{language.t("workspace.new")}</span>
|
|
||||||
<span class="font-[440] text-v2-text-text-muted">
|
|
||||||
{language.t("session.new.workspace.new.tooltip")}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
class="min-w-0 flex-1"
|
|
||||||
>
|
|
||||||
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
|
||||||
</TooltipV2>
|
|
||||||
<Show when={selected() === "create"}>
|
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
|
||||||
</Show>
|
|
||||||
</MenuV2.Item>
|
|
||||||
</MenuV2.Group>
|
|
||||||
<Show
|
|
||||||
when={props.workspaces.length > 0}
|
|
||||||
fallback={
|
|
||||||
<>
|
|
||||||
<MenuV2.Separator class="h-[0.5px]" />
|
|
||||||
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
|
||||||
</MenuV2.Item>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuV2.Separator class="h-[0.5px]" />
|
|
||||||
<MenuV2.Sub
|
|
||||||
gutter={0}
|
|
||||||
overlap
|
|
||||||
overflowPadding={8}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
focusSearch = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!focusSearch || props.workspaces.length < 10) return
|
|
||||||
focusSearch = false
|
|
||||||
requestAnimationFrame(() => searchInput?.focus())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuV2.SubTrigger
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (
|
|
||||||
event.key === "ArrowRight" ||
|
|
||||||
event.key === "ArrowLeft" ||
|
|
||||||
event.key === "Enter" ||
|
|
||||||
event.key === " "
|
|
||||||
)
|
|
||||||
focusSearch = true
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="workspace-isolated" />
|
|
||||||
<span class="min-w-0 flex-1 truncate">
|
|
||||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
|
||||||
</span>
|
|
||||||
</MenuV2.SubTrigger>
|
|
||||||
<MenuV2.Portal>
|
|
||||||
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
|
||||||
<Show when={props.workspaces.length >= 10}>
|
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
|
|
||||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
|
||||||
<input
|
|
||||||
ref={(element) => {
|
|
||||||
searchInput = element
|
|
||||||
}}
|
|
||||||
value={search()}
|
|
||||||
placeholder={language.t("session.new.workspace.search.placeholder")}
|
|
||||||
aria-label={language.t("session.new.workspace.search.placeholder")}
|
|
||||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
|
||||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (
|
|
||||||
event.key === "Escape" ||
|
|
||||||
event.key === "ArrowDown" ||
|
|
||||||
event.key === "ArrowUp" ||
|
|
||||||
event.key === "Enter"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<For each={workspaces()}>
|
|
||||||
{(workspace) => (
|
|
||||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
|
||||||
<Icon name="workspace-isolated" />
|
|
||||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
|
||||||
<Show when={selected() === workspace}>
|
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
|
||||||
</Show>
|
|
||||||
</MenuV2.Item>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<MenuV2.Separator class="h-[0.5px]" />
|
|
||||||
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
|
||||||
</MenuV2.Item>
|
|
||||||
</MenuV2.SubContent>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2.Sub>
|
|
||||||
</Show>
|
|
||||||
</MenuV2.Content>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2>
|
|
||||||
</TooltipV2>
|
|
||||||
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const label = () => {
|
const label = () => {
|
||||||
if (props.noGit) return language.t("session.new.git.none")
|
if (props.noGit) return language.t("session.new.git.none")
|
||||||
if (!props.branch) return undefined
|
|
||||||
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
|
|
||||||
return props.branch
|
return props.branch
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = () => {
|
|
||||||
if (props.noGit) return "monitor"
|
|
||||||
if (props.from) return "branch-out"
|
|
||||||
return "branch"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={label()}>
|
<Show when={label()}>
|
||||||
{(value) => (
|
{(value) => (
|
||||||
<TooltipV2
|
<>
|
||||||
placement="top"
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
value={value()}
|
<TooltipV2
|
||||||
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
placement="top"
|
||||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
value={value()}
|
||||||
>
|
class="min-w-0 max-w-[220px]"
|
||||||
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
>
|
||||||
<span class="min-w-0 truncate">{value()}</span>
|
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||||
</div>
|
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
</TooltipV2>
|
<span class="min-w-0 truncate">{value()}</span>
|
||||||
|
</div>
|
||||||
|
</TooltipV2>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export function createProviderConnectionController(options: {
|
|||||||
const [integration] = createResource(
|
const [integration] = createResource(
|
||||||
() => ({ provider: options.provider(), directory: options.directory() }),
|
() => ({ provider: options.provider(), directory: options.directory() }),
|
||||||
(input) =>
|
(input) =>
|
||||||
serverSDK.api.integration
|
serverSDK()
|
||||||
.get({ integrationID: input.provider, location: location() })
|
.api.integration.get({ integrationID: input.provider, location: location() })
|
||||||
.then((result) => result.data),
|
.then((result) => result.data),
|
||||||
)
|
)
|
||||||
const methods = createMemo<ProviderConnectMethod[]>(() => {
|
const methods = createMemo<ProviderConnectMethod[]>(() => {
|
||||||
@@ -120,14 +120,14 @@ export function createProviderConnectionController(options: {
|
|||||||
cancelPolling()
|
cancelPolling()
|
||||||
const directory = options.directory()
|
const directory = options.directory()
|
||||||
await queryClient
|
await queryClient
|
||||||
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
|
.refetchQueries(serverSync().queryOptions.providers(directory ? pathKey(directory) : null))
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (polling.disposed) return
|
if (polling.disposed) return
|
||||||
options.onComplete()
|
options.onComplete()
|
||||||
}
|
}
|
||||||
const poll = async (authorization: Authorization, generation: number) => {
|
const poll = async (authorization: Authorization, generation: number) => {
|
||||||
const result = await serverSDK.api.integration.oauth
|
const result = await serverSDK()
|
||||||
.status({
|
.api.integration.oauth.status({
|
||||||
integrationID: options.provider(),
|
integrationID: options.provider(),
|
||||||
attemptID: authorization.attemptID,
|
attemptID: authorization.attemptID,
|
||||||
location: location(),
|
location: location(),
|
||||||
@@ -175,8 +175,8 @@ export function createProviderConnectionController(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const result = await serverSDK.api.integration.oauth
|
const result = await serverSDK()
|
||||||
.connect({
|
.api.integration.oauth.connect({
|
||||||
integrationID: options.provider(),
|
integrationID: options.provider(),
|
||||||
methodID: selected.id,
|
methodID: selected.id,
|
||||||
...(answer ? { answer } : {}),
|
...(answer ? { answer } : {}),
|
||||||
@@ -197,7 +197,7 @@ export function createProviderConnectionController(options: {
|
|||||||
dispatch({ type: "method.reset" })
|
dispatch({ type: "method.reset" })
|
||||||
}
|
}
|
||||||
const connectKey = async (key: string) => {
|
const connectKey = async (key: string) => {
|
||||||
await serverSDK.api.integration.connect.key({
|
await serverSDK().api.integration.connect.key({
|
||||||
integrationID: options.provider(),
|
integrationID: options.provider(),
|
||||||
location: location(),
|
location: location(),
|
||||||
key,
|
key,
|
||||||
@@ -208,8 +208,8 @@ export function createProviderConnectionController(options: {
|
|||||||
const completeCode = async (code: string) => {
|
const completeCode = async (code: string) => {
|
||||||
const authorization = store.authorization
|
const authorization = store.authorization
|
||||||
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
|
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
|
||||||
const result = await serverSDK.api.integration.oauth
|
const result = await serverSDK()
|
||||||
.complete({
|
.api.integration.oauth.complete({
|
||||||
integrationID: options.provider(),
|
integrationID: options.provider(),
|
||||||
attemptID: authorization.attemptID,
|
attemptID: authorization.attemptID,
|
||||||
location: location(),
|
location: location(),
|
||||||
@@ -238,7 +238,6 @@ export function createProviderConnectionController(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
loading: () => integration.loading,
|
loading: () => integration.loading,
|
||||||
integration: () => integration.latest,
|
|
||||||
methods,
|
methods,
|
||||||
currentMethod,
|
currentMethod,
|
||||||
methodIndex: () => store.methodIndex,
|
methodIndex: () => store.methodIndex,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createMemo, createResource } from "solid-js"
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { ServerConnection, useServers } from "@/context/servers"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { type ServerHealth } from "@/utils/server-health"
|
import { type ServerHealth } from "@/utils/server-health"
|
||||||
@@ -49,7 +49,7 @@ function useDefaultServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useServerActionsController() {
|
export function useServerActionsController() {
|
||||||
const server = useServers()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -72,16 +72,28 @@ export function useServerActionsController() {
|
|||||||
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
||||||
|
|
||||||
export function useServerCollectionController() {
|
export function useServerCollectionController() {
|
||||||
const server = useServers()
|
const server = useServer()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const actions = useServerActionsController()
|
const actions = useServerActionsController()
|
||||||
|
|
||||||
const items = createMemo(() => server.list)
|
const items = createMemo(() => {
|
||||||
|
const current = server.current
|
||||||
|
const list = server.list
|
||||||
|
if (!current) return list
|
||||||
|
if (!list.includes(current)) return [current, ...list]
|
||||||
|
return [current, ...list.filter((item) => item !== current)]
|
||||||
|
})
|
||||||
|
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||||
|
settings.general.newLayoutDesigns()
|
||||||
|
? undefined
|
||||||
|
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
|
||||||
|
)
|
||||||
const sorted = createMemo(() => {
|
const sorted = createMemo(() => {
|
||||||
const raw = items()
|
const raw = items()
|
||||||
const list = raw
|
const list = raw
|
||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
|
const active = current()
|
||||||
const order = new Map(list.map((item, index) => [item, index] as const))
|
const order = new Map(list.map((item, index) => [item, index] as const))
|
||||||
const rank = (value?: ServerHealth) => {
|
const rank = (value?: ServerHealth) => {
|
||||||
if (value?.healthy === true) return 0
|
if (value?.healthy === true) return 0
|
||||||
@@ -89,6 +101,8 @@ export function useServerCollectionController() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
return list.slice().sort((a, b) => {
|
return list.slice().sort((a, b) => {
|
||||||
|
if (a === active) return -1
|
||||||
|
if (b === active) return 1
|
||||||
const diff =
|
const diff =
|
||||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||||
if (diff !== 0) return diff
|
if (diff !== 0) return diff
|
||||||
@@ -99,6 +113,7 @@ export function useServerCollectionController() {
|
|||||||
return {
|
return {
|
||||||
collection: {
|
collection: {
|
||||||
items: sorted,
|
items: sorted,
|
||||||
|
current,
|
||||||
health: () => global.servers.health,
|
health: () => global.servers.health,
|
||||||
},
|
},
|
||||||
...actions,
|
...actions,
|
||||||
@@ -106,3 +121,21 @@ export function useServerCollectionController() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
||||||
|
|
||||||
|
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const server = useServer()
|
||||||
|
const global = useGlobal()
|
||||||
|
const collection = useServerCollectionController()
|
||||||
|
|
||||||
|
const select = async (connection: ServerConnection.Any) => {
|
||||||
|
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
|
||||||
|
options.onSelect?.()
|
||||||
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...collection, selection: { select } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerDomainController = ReturnType<typeof useServerDomainController>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||||
|
|
||||||
function deferred<T>() {
|
function deferred<T>() {
|
||||||
@@ -65,17 +65,20 @@ describe("replaceServerConnection", () => {
|
|||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
|
|
||||||
replaceServerConnection(ServerConnection.key(original), next, {
|
replaceServerConnection(ServerConnection.key(original), next, {
|
||||||
|
active: () => ServerConnection.key(original),
|
||||||
removeTabs: (key) => calls.push(`tabs:${key}`),
|
removeTabs: (key) => calls.push(`tabs:${key}`),
|
||||||
add: (server) => {
|
add: (server) => {
|
||||||
calls.push(`add:${ServerConnection.key(server)}`)
|
calls.push(`add:${ServerConnection.key(server)}`)
|
||||||
return server
|
return server
|
||||||
},
|
},
|
||||||
|
setActive: (key) => calls.push(`active:${key}`),
|
||||||
remove: (key) => calls.push(`remove:${key}`),
|
remove: (key) => calls.push(`remove:${key}`),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
"tabs:https://old.example.com",
|
"tabs:https://old.example.com",
|
||||||
"add:https://new.example.com",
|
"add:https://new.example.com",
|
||||||
|
"active:https://new.example.com",
|
||||||
"remove:https://old.example.com",
|
"remove:https://old.example.com",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -84,8 +87,10 @@ describe("replaceServerConnection", () => {
|
|||||||
const removed: ServerConnection.Key[] = []
|
const removed: ServerConnection.Key[] = []
|
||||||
|
|
||||||
replaceServerConnection(ServerConnection.key(original), next, {
|
replaceServerConnection(ServerConnection.key(original), next, {
|
||||||
|
active: () => ServerConnection.key(original),
|
||||||
removeTabs: () => {},
|
removeTabs: () => {},
|
||||||
add: () => undefined,
|
add: () => undefined,
|
||||||
|
setActive: () => {},
|
||||||
remove: (key) => removed.push(key),
|
remove: (key) => removed.push(key),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { normalizeServerUrl, ServerConnection } from "@/context/servers"
|
import { normalizeServerUrl, ServerConnection } from "@/context/server"
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
export type ServerFormValues = {
|
export type ServerFormValues = {
|
||||||
@@ -42,13 +42,18 @@ export function replaceServerConnection(
|
|||||||
originalKey: ServerConnection.Key,
|
originalKey: ServerConnection.Key,
|
||||||
next: ServerConnection.Http,
|
next: ServerConnection.Http,
|
||||||
operations: {
|
operations: {
|
||||||
|
active: () => ServerConnection.Key | undefined
|
||||||
removeTabs: (key: ServerConnection.Key) => void
|
removeTabs: (key: ServerConnection.Key) => void
|
||||||
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
||||||
|
setActive: (key: ServerConnection.Key) => void
|
||||||
remove: (key: ServerConnection.Key) => void
|
remove: (key: ServerConnection.Key) => void
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
const active = operations.active()
|
||||||
operations.removeTabs(originalKey)
|
operations.removeTabs(originalKey)
|
||||||
const added = operations.add(next)
|
const added = operations.add(next)
|
||||||
if (!added) return
|
if (!added) return
|
||||||
|
const nextActive = active === originalKey ? ServerConnection.key(added) : active
|
||||||
|
if (nextActive) operations.setActive(nextActive)
|
||||||
operations.remove(originalKey)
|
operations.remove(originalKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
|||||||
import { type Component, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
export const ServerRowMenu: Component<{
|
export const ServerRowMenu: Component<{
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { type ServerConnection, serverName } from "@/context/servers"
|
import { type ServerConnection, serverName } from "@/context/server"
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
interface ServerRowProps extends ParentProps {
|
interface ServerRowProps extends ParentProps {
|
||||||
@@ -96,16 +96,13 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
{(conn) => (
|
{(conn) => (
|
||||||
<div class="flex flex-row gap-3">
|
<div class="flex flex-row gap-3">
|
||||||
<span>
|
<span>
|
||||||
<Show
|
{conn().http.username ? (
|
||||||
when={conn().http.username}
|
|
||||||
fallback={<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>}
|
|
||||||
>
|
|
||||||
<span class="text-text-weak">{conn().http.username}</span>
|
<span class="text-text-weak">{conn().http.username}</span>
|
||||||
</Show>
|
) : (
|
||||||
|
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<Show when={conn().http.password}>
|
{conn().http.password && <span class="text-text-weak">••••••••</span>}
|
||||||
<span class="text-text-weak">••••••••</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function openSessionContext(args: {
|
|||||||
}) {
|
}) {
|
||||||
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
|
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
|
||||||
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
|
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
|
||||||
void args.tabs.open("context").then(() => args.tabs.setActive("context"))
|
void args.tabs.open("context")
|
||||||
|
args.tabs.setActive("context")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
|
||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
|
|
||||||
import type { Project } from "@/types"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/utils/workspace"
|
|
||||||
|
|
||||||
export function SessionWorkspaceMenu(props: {
|
|
||||||
eligible?: boolean
|
|
||||||
sessionID: string
|
|
||||||
project: Project
|
|
||||||
directory: string
|
|
||||||
placement?: ComponentProps<typeof MenuV2>["placement"]
|
|
||||||
gutter?: number
|
|
||||||
class?: string
|
|
||||||
contentClass?: string
|
|
||||||
children: JSX.Element
|
|
||||||
onOpenChange?: (open: boolean) => void
|
|
||||||
}) {
|
|
||||||
const language = useLanguage()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const openWorkspaces = useSettingsDialog("workspaces")
|
|
||||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
|
||||||
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
|
|
||||||
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
|
|
||||||
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
|
|
||||||
const workspaces = () =>
|
|
||||||
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
|
|
||||||
const onOpenChange = (open: boolean) => {
|
|
||||||
props.onOpenChange?.(open)
|
|
||||||
if (!open) return
|
|
||||||
const sdk = serverSDK
|
|
||||||
void sdk.api.worktree
|
|
||||||
.refresh({ projectID: props.project.id })
|
|
||||||
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
|
|
||||||
.then((items) =>
|
|
||||||
setDirectories(
|
|
||||||
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() => undefined)
|
|
||||||
}
|
|
||||||
const move = async (selection: "create" | string) => {
|
|
||||||
if (store.selected || blocked()) return
|
|
||||||
const sdk = serverSDK
|
|
||||||
const sessionID = props.sessionID
|
|
||||||
setStore("selected", selection)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection
|
|
||||||
if (!destination) return
|
|
||||||
|
|
||||||
await sdk.api.session.move({ sessionID, directory: destination })
|
|
||||||
} catch (error) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("workspace.move.failed"),
|
|
||||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setStore("selected", undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MenuV2
|
|
||||||
placement={props.placement ?? "bottom-end"}
|
|
||||||
gutter={props.gutter ?? 4}
|
|
||||||
modal={false}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
>
|
|
||||||
<MenuV2.Trigger class={props.class} disabled={blocked()}>
|
|
||||||
{props.children}
|
|
||||||
</MenuV2.Trigger>
|
|
||||||
<MenuV2.Portal>
|
|
||||||
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
|
|
||||||
<MenuV2.Group>
|
|
||||||
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
|
|
||||||
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
|
|
||||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
|
|
||||||
<Icon name="monitor" />
|
|
||||||
{language.t("session.new.workspace.local")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
</Show>
|
|
||||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
|
||||||
<Icon name="workspace-new" />
|
|
||||||
{language.t("workspace.new")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
<Show when={workspaces().length > 0}>
|
|
||||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
|
||||||
<MenuV2.SubTrigger>
|
|
||||||
<Icon name="workspace-isolated" />
|
|
||||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
|
||||||
</MenuV2.SubTrigger>
|
|
||||||
<MenuV2.Portal>
|
|
||||||
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
|
||||||
<For each={workspaces()}>
|
|
||||||
{(workspace) => (
|
|
||||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
|
||||||
<Icon name="workspace-isolated" />
|
|
||||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
|
||||||
</MenuV2.Item>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</MenuV2.SubContent>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2.Sub>
|
|
||||||
</Show>
|
|
||||||
</MenuV2.Group>
|
|
||||||
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
|
||||||
<MenuV2.Item onSelect={() => openWorkspaces()}>
|
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
|
||||||
</MenuV2.Item>
|
|
||||||
</MenuV2.Content>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createWorkspace(project: Project, serverSDK: ReturnType<typeof useServerSDK>) {
|
|
||||||
const created = await serverSDK.api.worktree.create({
|
|
||||||
projectID: project.id,
|
|
||||||
strategy: "git",
|
|
||||||
directory: getDirectory(project.worktree),
|
|
||||||
})
|
|
||||||
await serverSDK.api.location.get({ location: { directory: created.directory } })
|
|
||||||
return created.directory
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,9 @@ import { createEffect, createMemo } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
|
|
||||||
export const OPEN_APPS = [
|
export const OPEN_APPS = [
|
||||||
"vscode",
|
"vscode",
|
||||||
@@ -169,7 +169,7 @@ export function useOpenInApp(input: { directory: () => string }) {
|
|||||||
app: undefined as OpenApp | undefined,
|
app: undefined as OpenApp | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal)
|
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||||
const current = createMemo(
|
const current = createMemo(
|
||||||
() =>
|
() =>
|
||||||
options().find((o) => o.id === prefs.app) ??
|
options().find((o) => o.id === prefs.app) ??
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||||
import { createMemo, Show } from "solid-js"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||||
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { Portal } from "solid-js/web"
|
import { Portal } from "solid-js/web"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useTerminal } from "@/context/terminal"
|
||||||
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { StatusPopoverV2 } from "../status-popover"
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { fileManagerApp } from "@/utils/file-manager"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
@@ -14,15 +34,206 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|||||||
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
||||||
import { useTitlebarRightMount } from "../titlebar"
|
import { useTitlebarRightMount } from "../titlebar"
|
||||||
|
|
||||||
|
const OPEN_APPS = [
|
||||||
|
"vscode",
|
||||||
|
"cursor",
|
||||||
|
"zed",
|
||||||
|
"textmate",
|
||||||
|
"antigravity",
|
||||||
|
"finder",
|
||||||
|
"terminal",
|
||||||
|
"iterm2",
|
||||||
|
"ghostty",
|
||||||
|
"warp",
|
||||||
|
"xcode",
|
||||||
|
"android-studio",
|
||||||
|
"powershell",
|
||||||
|
"sublime-text",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type OpenApp = (typeof OPEN_APPS)[number]
|
||||||
|
type OS = "macos" | "windows" | "linux" | "unknown"
|
||||||
|
|
||||||
|
const MAC_APPS = [
|
||||||
|
{
|
||||||
|
id: "vscode",
|
||||||
|
label: "session.header.open.app.vscode",
|
||||||
|
icon: "vscode",
|
||||||
|
openWith: "Visual Studio Code",
|
||||||
|
},
|
||||||
|
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
|
||||||
|
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
|
||||||
|
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
|
||||||
|
{
|
||||||
|
id: "antigravity",
|
||||||
|
label: "session.header.open.app.antigravity",
|
||||||
|
icon: "antigravity",
|
||||||
|
openWith: "Antigravity",
|
||||||
|
},
|
||||||
|
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
|
||||||
|
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
|
||||||
|
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||||
|
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
|
||||||
|
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
|
||||||
|
{
|
||||||
|
id: "android-studio",
|
||||||
|
label: "session.header.open.app.androidStudio",
|
||||||
|
icon: "android-studio",
|
||||||
|
openWith: "Android Studio",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sublime-text",
|
||||||
|
label: "session.header.open.app.sublimeText",
|
||||||
|
icon: "sublime-text",
|
||||||
|
openWith: "Sublime Text",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const WINDOWS_APPS = [
|
||||||
|
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
||||||
|
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
||||||
|
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
||||||
|
{
|
||||||
|
id: "powershell",
|
||||||
|
label: "session.header.open.app.powershell",
|
||||||
|
icon: "powershell",
|
||||||
|
openWith: "powershell",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sublime-text",
|
||||||
|
label: "session.header.open.app.sublimeText",
|
||||||
|
icon: "sublime-text",
|
||||||
|
openWith: "Sublime Text",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const LINUX_APPS = [
|
||||||
|
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
||||||
|
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
||||||
|
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
||||||
|
{
|
||||||
|
id: "sublime-text",
|
||||||
|
label: "session.header.open.app.sublimeText",
|
||||||
|
icon: "sublime-text",
|
||||||
|
openWith: "Sublime Text",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
|
||||||
|
if (platform.platform === "desktop" && platform.os) return platform.os
|
||||||
|
if (typeof navigator !== "object") return "unknown"
|
||||||
|
const value = navigator.platform || navigator.userAgent
|
||||||
|
if (/Mac/i.test(value)) return "macos"
|
||||||
|
if (/Win/i.test(value)) return "windows"
|
||||||
|
if (/Linux/i.test(value)) return "linux"
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function SessionHeader() {
|
export function SessionHeader() {
|
||||||
|
const layout = useLayout()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
const server = useServer()
|
||||||
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const { view } = useSessionLayout()
|
const sync = useSync()
|
||||||
|
const terminal = useTerminal()
|
||||||
|
const { params, view } = useSessionLayout()
|
||||||
|
|
||||||
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
|
const project = createMemo(() => {
|
||||||
|
const directory = projectDirectory()
|
||||||
|
if (!directory) return
|
||||||
|
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
||||||
|
})
|
||||||
|
const name = createMemo(() => {
|
||||||
|
const current = project()
|
||||||
|
if (current) return current.name || getFilename(current.worktree)
|
||||||
|
return getFilename(projectDirectory())
|
||||||
|
})
|
||||||
|
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||||
|
const os = createMemo(() => detectOS(platform))
|
||||||
|
const isV2 = settings.general.newLayoutDesigns
|
||||||
|
const search = settings.visibility.search
|
||||||
const status = settings.visibility.status
|
const status = settings.visibility.status
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||||
|
finder: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const apps = createMemo(() => {
|
||||||
|
if (os() === "macos") return MAC_APPS
|
||||||
|
if (os() === "windows") return WINDOWS_APPS
|
||||||
|
return LINUX_APPS
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileManager = createMemo(() => fileManagerApp(os()))
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (platform.platform !== "desktop") return
|
||||||
|
if (!platform.checkAppExists) return
|
||||||
|
|
||||||
|
const list = apps()
|
||||||
|
|
||||||
|
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
|
||||||
|
|
||||||
|
void Promise.all(
|
||||||
|
list.map((app) =>
|
||||||
|
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||||
|
.then((value) => Boolean(value))
|
||||||
|
.catch(() => false)
|
||||||
|
.then((ok) => [app.id, ok] as const),
|
||||||
|
),
|
||||||
|
).then((entries) => {
|
||||||
|
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = createMemo(() => {
|
||||||
|
return [
|
||||||
|
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
|
||||||
|
...apps()
|
||||||
|
.filter((app) => exists[app.id])
|
||||||
|
.map((app) => ({ ...app, label: language.t(app.label) })),
|
||||||
|
] as const
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleTerminal = () => {
|
||||||
|
const next = !view().terminal.opened()
|
||||||
|
view().terminal.toggle()
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
const id = terminal.active()
|
||||||
|
if (!id) return
|
||||||
|
focusTerminalById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
||||||
|
const [menu, setMenu] = createStore({ open: false })
|
||||||
|
const [openRequest, setOpenRequest] = createStore({
|
||||||
|
app: undefined as OpenApp | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||||
|
const current = createMemo(
|
||||||
|
() =>
|
||||||
|
options().find((o) => o.id === prefs.app) ??
|
||||||
|
options()[0] ??
|
||||||
|
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||||
|
)
|
||||||
|
const opening = createMemo(() => openRequest.app !== undefined)
|
||||||
|
const tint = createMemo(() =>
|
||||||
|
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
|
||||||
|
)
|
||||||
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
||||||
statusVisible: status(),
|
statusVisible: status(),
|
||||||
statusLabel: language.t("status.popover.trigger"),
|
statusLabel: language.t("status.popover.trigger"),
|
||||||
@@ -33,16 +244,275 @@ export function SessionHeader() {
|
|||||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const selectApp = (app: OpenApp) => {
|
||||||
|
if (!options().some((item) => item.id === app)) return
|
||||||
|
setPrefs("app", app)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDir = (app: OpenApp) => {
|
||||||
|
if (opening() || !canOpen() || !platform.openPath) return
|
||||||
|
const directory = projectDirectory()
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
const item = options().find((o) => o.id === app)
|
||||||
|
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||||
|
setOpenRequest("app", app)
|
||||||
|
platform
|
||||||
|
.openPath(directory, openWith)
|
||||||
|
.catch((err: unknown) => showRequestError(language, err))
|
||||||
|
.finally(() => {
|
||||||
|
setOpenRequest("app", undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyPath = () => {
|
||||||
|
const directory = projectDirectory()
|
||||||
|
if (!directory) return
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(directory)
|
||||||
|
.then(() => {
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("session.share.copy.copied"),
|
||||||
|
description: directory,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => showRequestError(language, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
|
||||||
const rightMount = useTitlebarRightMount()
|
const rightMount = useTitlebarRightMount()
|
||||||
|
onMount(() => {
|
||||||
|
setCenterMount(document.getElementById("opencode-titlebar-center"))
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={rightMount()} keyed>
|
<>
|
||||||
{(mount) => (
|
<Show when={search() && centerMount()} keyed>
|
||||||
<Portal mount={mount}>
|
{(mount) => (
|
||||||
<SessionHeaderV2Actions state={v2ActionsState()} />
|
<Portal mount={mount}>
|
||||||
</Portal>
|
<Button
|
||||||
)}
|
type="button"
|
||||||
</Show>
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||||
|
onClick={() => command.trigger("file.open")}
|
||||||
|
aria-label={language.t("session.header.searchFiles")}
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-1 items-center overflow-visible">
|
||||||
|
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||||
|
{language.t("session.header.search.placeholder", {
|
||||||
|
project: name(),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={hotkey()} keyed>
|
||||||
|
{(keybind) => (
|
||||||
|
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||||
|
{keybind}
|
||||||
|
</Keybind>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Button>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={rightMount()} keyed>
|
||||||
|
{(mount) => (
|
||||||
|
<Portal mount={mount}>
|
||||||
|
<Show
|
||||||
|
when={isV2}
|
||||||
|
fallback={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Show when={projectDirectory()}>
|
||||||
|
<div class="hidden xl:flex items-center">
|
||||||
|
<Show
|
||||||
|
when={canOpen()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
|
||||||
|
onClick={copyPath}
|
||||||
|
aria-label={language.t("session.header.open.copyPath")}
|
||||||
|
>
|
||||||
|
<Icon name="copy" size="small" class="text-icon-base" />
|
||||||
|
<span class="text-12-regular text-text-strong">
|
||||||
|
{language.t("session.header.open.copyPath")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
|
||||||
|
classList={{
|
||||||
|
"bg-surface-raised-base-active": opening(),
|
||||||
|
}}
|
||||||
|
onClick={() => openDir(current().id)}
|
||||||
|
disabled={opening()}
|
||||||
|
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||||
|
>
|
||||||
|
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||||
|
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
|
||||||
|
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu
|
||||||
|
gutter={4}
|
||||||
|
placement="bottom-end"
|
||||||
|
open={menu.open}
|
||||||
|
onOpenChange={(open) => setMenu("open", open)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButton}
|
||||||
|
icon="chevron-down"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={opening()}
|
||||||
|
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
||||||
|
classList={{
|
||||||
|
"bg-surface-raised-base-active": opening(),
|
||||||
|
}}
|
||||||
|
aria-label={language.t("session.header.open.menu")}
|
||||||
|
/>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
|
||||||
|
<DropdownMenu.Group>
|
||||||
|
<DropdownMenu.GroupLabel class="!px-1 !py-1">
|
||||||
|
{language.t("session.header.openIn")}
|
||||||
|
</DropdownMenu.GroupLabel>
|
||||||
|
<DropdownMenu.RadioGroup
|
||||||
|
class="mt-1"
|
||||||
|
value={current().id}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||||
|
selectApp(value as OpenApp)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={options()}>
|
||||||
|
{(o) => (
|
||||||
|
<DropdownMenu.RadioItem
|
||||||
|
value={o.id}
|
||||||
|
disabled={opening()}
|
||||||
|
onSelect={() => {
|
||||||
|
setMenu("open", false)
|
||||||
|
openDir(o.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||||
|
<AppIcon id={o.icon} />
|
||||||
|
</div>
|
||||||
|
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||||
|
<DropdownMenu.ItemIndicator>
|
||||||
|
<Icon name="check-small" size="small" class="text-icon-weak" />
|
||||||
|
</DropdownMenu.ItemIndicator>
|
||||||
|
</DropdownMenu.RadioItem>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</DropdownMenu.RadioGroup>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => {
|
||||||
|
setMenu("open", false)
|
||||||
|
copyPath()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||||
|
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||||
|
</div>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("session.header.open.copyPath")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Show when={status()}>
|
||||||
|
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||||
|
<StatusPopover />
|
||||||
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
<TooltipKeybind
|
||||||
|
title={language.t("command.terminal.toggle")}
|
||||||
|
keybind={command.keybind("terminal.toggle")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||||
|
onClick={toggleTerminal}
|
||||||
|
aria-label={language.t("command.terminal.toggle")}
|
||||||
|
aria-expanded={view().terminal.opened()}
|
||||||
|
aria-controls="terminal-panel"
|
||||||
|
>
|
||||||
|
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
|
||||||
|
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||||
|
<TooltipKeybind
|
||||||
|
title={language.t("command.review.toggle")}
|
||||||
|
keybind={command.keybind("review.toggle")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
|
onClick={() => view().reviewPanel.toggle()}
|
||||||
|
aria-label={language.t("command.review.toggle")}
|
||||||
|
aria-expanded={view().reviewPanel.opened()}
|
||||||
|
aria-controls="review-panel"
|
||||||
|
>
|
||||||
|
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
|
||||||
|
<TooltipKeybind
|
||||||
|
title={language.t("command.fileTree.toggle")}
|
||||||
|
keybind={command.keybind("fileTree.toggle")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
|
onClick={() => layout.fileTree.toggle()}
|
||||||
|
aria-label={language.t("command.fileTree.toggle")}
|
||||||
|
aria-expanded={layout.fileTree.opened()}
|
||||||
|
aria-controls="file-tree-panel"
|
||||||
|
>
|
||||||
|
<div class="relative flex items-center justify-center size-4">
|
||||||
|
<Icon
|
||||||
|
size="small"
|
||||||
|
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||||
|
classList={{
|
||||||
|
"text-icon-strong": layout.fileTree.opened(),
|
||||||
|
"text-icon-weak": !layout.fileTree.opened(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SessionHeaderV2Actions state={v2ActionsState()} />
|
||||||
|
</Show>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +527,8 @@ type SessionHeaderV2ActionsState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when={props.state.statusVisible}>
|
<Show when={props.state.statusVisible}>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { FileVisual } from "./session-sortable-tab"
|
|||||||
|
|
||||||
export function SortableTabV2(props: {
|
export function SortableTabV2(props: {
|
||||||
tab: string
|
tab: string
|
||||||
index: number
|
index: () => number
|
||||||
temporary?: boolean
|
temporary?: boolean
|
||||||
onTabClose: (tab: string) => void
|
onTabClose: (tab: string) => void
|
||||||
onTabDoubleClick?: (tab: string) => void
|
onTabDoubleClick?: (tab: string) => void
|
||||||
@@ -26,7 +26,7 @@ export function SortableTabV2(props: {
|
|||||||
return props.tab
|
return props.tab
|
||||||
},
|
},
|
||||||
get index() {
|
get index() {
|
||||||
return props.index
|
return props.index()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
|
|||||||
|
|
||||||
export function SortableTerminalTabV2(props: {
|
export function SortableTerminalTabV2(props: {
|
||||||
terminal: LocalPTY
|
terminal: LocalPTY
|
||||||
index: number
|
index: () => number
|
||||||
newLayout: boolean
|
newLayout: boolean
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
@@ -25,7 +25,7 @@ export function SortableTerminalTabV2(props: {
|
|||||||
return props.terminal.id
|
return props.terminal.id
|
||||||
},
|
},
|
||||||
get index() {
|
get index() {
|
||||||
return props.index
|
return props.index()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { For, Show, createMemo, lazy, onCleanup } from "solid-js"
|
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
@@ -9,6 +13,7 @@ import fuzzysort from "fuzzysort"
|
|||||||
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { SettingsList } from "./settings-list"
|
||||||
import { SettingsListV2 } from "./settings-v2/parts/list"
|
import { SettingsListV2 } from "./settings-v2/parts/list"
|
||||||
|
|
||||||
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
|
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
|
||||||
@@ -196,6 +201,69 @@ function filteredFor(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useKeyCapture(input: {
|
||||||
|
active: () => string | null
|
||||||
|
stop: () => void
|
||||||
|
set: (id: string, keybind: string) => void
|
||||||
|
used: () => Map<string, { id: string; title: string }[]>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
onMount(() => {
|
||||||
|
const handle = (event: KeyboardEvent) => {
|
||||||
|
const id = input.active()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
input.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear =
|
||||||
|
(event.key === "Backspace" || event.key === "Delete") &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event.shiftKey
|
||||||
|
if (clear) {
|
||||||
|
input.set(id, "none")
|
||||||
|
input.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = recordKeybind(event)
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
const conflicts = new Map<string, string>()
|
||||||
|
for (const sig of signatures(next)) {
|
||||||
|
for (const item of input.used().get(sig) ?? []) {
|
||||||
|
if (item.id === id) continue
|
||||||
|
conflicts.set(item.id, item.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflicts.size > 0) {
|
||||||
|
showToast({
|
||||||
|
title: input.language.t("settings.shortcuts.conflict.title"),
|
||||||
|
description: input.language.t("settings.shortcuts.conflict.description", {
|
||||||
|
keybind: formatKeybind(next, input.language.t),
|
||||||
|
titles: [...conflicts.values()].join(", "),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.set(id, next)
|
||||||
|
input.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
makeEventListener(document, "keydown", handle, { capture: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function createKeybindSettingsController(
|
export function createKeybindSettingsController(
|
||||||
input: {
|
input: {
|
||||||
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
|
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
|
||||||
@@ -342,7 +410,7 @@ export function createKeybindSettingsController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsKeybinds() {
|
function SettingsKeybindsV2() {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const controller = createKeybindSettingsController({
|
const controller = createKeybindSettingsController({
|
||||||
@@ -356,9 +424,9 @@ export function SettingsKeybinds() {
|
|||||||
filtered={controller.catalog.filtered}
|
filtered={controller.catalog.filtered}
|
||||||
title={controller.catalog.title}
|
title={controller.catalog.title}
|
||||||
keybind={controller.catalog.keybind}
|
keybind={controller.catalog.keybind}
|
||||||
active={controller.capture.active()}
|
active={controller.capture.active}
|
||||||
onCapture={controller.capture.toggle}
|
onCapture={controller.capture.toggle}
|
||||||
hasOverrides={controller.settings.hasOverrides()}
|
hasOverrides={controller.settings.hasOverrides}
|
||||||
onReset={controller.settings.reset}
|
onReset={controller.settings.reset}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -369,9 +437,9 @@ function SettingsKeybindsV2View(props: {
|
|||||||
filtered: (query: string) => Map<KeybindGroup, string[]>
|
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||||
title: (id: string) => string
|
title: (id: string) => string
|
||||||
keybind: (id: string) => string
|
keybind: (id: string) => string
|
||||||
active: string | null
|
active: () => string | null
|
||||||
onCapture: (id: string) => void
|
onCapture: (id: string) => void
|
||||||
hasOverrides: boolean
|
hasOverrides: () => boolean
|
||||||
onReset: () => void
|
onReset: () => void
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -383,11 +451,8 @@ function SettingsKeybindsV2View(props: {
|
|||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
<div class="settings-v2-tab-header-row">
|
<div class="settings-v2-tab-header-row">
|
||||||
<div class="flex flex-col gap-1">
|
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.shortcuts.description")}</span>
|
|
||||||
</div>
|
|
||||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides}>
|
|
||||||
{language.t("settings.shortcuts.reset.button")}
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,12 +498,12 @@ function SettingsKeybindsV2View(props: {
|
|||||||
data-keybind-id={id}
|
data-keybind-id={id}
|
||||||
classList={{
|
classList={{
|
||||||
"settings-v2-keybind-button": true,
|
"settings-v2-keybind-button": true,
|
||||||
"settings-v2-keybind-button--active": props.active === id,
|
"settings-v2-keybind-button--active": props.active() === id,
|
||||||
}}
|
}}
|
||||||
onClick={() => props.onCapture(id)}
|
onClick={() => props.onCapture(id)}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.active === id}
|
when={props.active() === id}
|
||||||
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||||
>
|
>
|
||||||
{language.t("settings.shortcuts.pressKeys")}
|
{language.t("settings.shortcuts.pressKeys")}
|
||||||
@@ -463,3 +528,254 @@ function SettingsKeybindsV2View(props: {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||||
|
if (props.v2) return <SettingsKeybindsV2 />
|
||||||
|
|
||||||
|
const command = useCommand()
|
||||||
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
active: null as string | null,
|
||||||
|
filter: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (!store.active) return
|
||||||
|
setStore("active", null)
|
||||||
|
command.keybinds(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (id: string) => {
|
||||||
|
if (store.active === id) {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.active) stop()
|
||||||
|
|
||||||
|
setStore("active", id)
|
||||||
|
command.keybinds(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = createMemo(() => keybinds(settings.current.keybinds))
|
||||||
|
|
||||||
|
const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
|
||||||
|
|
||||||
|
const resetAll = () => {
|
||||||
|
stop()
|
||||||
|
settings.keybinds.resetAll()
|
||||||
|
showToast({
|
||||||
|
title: language.t("settings.shortcuts.reset.toast.title"),
|
||||||
|
description: language.t("settings.shortcuts.reset.toast.description"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = createMemo(() => {
|
||||||
|
language.locale()
|
||||||
|
return listFor(command, map(), language.t("command.palette"))
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = (id: string) => list().get(id)?.title ?? ""
|
||||||
|
|
||||||
|
const grouped = createMemo(() => groupedFor(list()))
|
||||||
|
|
||||||
|
const filtered = createMemo(() => {
|
||||||
|
return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasResults = createMemo(() => {
|
||||||
|
for (const group of GROUPS) {
|
||||||
|
const ids = filtered().get(group) ?? []
|
||||||
|
if (ids.length > 0) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const used = createMemo(() => {
|
||||||
|
const map = new Map<string, { id: string; title: string }[]>()
|
||||||
|
|
||||||
|
const add = (key: string, value: { id: string; title: string }) => {
|
||||||
|
const list = map.get(key)
|
||||||
|
if (!list) {
|
||||||
|
map.set(key, [value])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list.push(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
|
||||||
|
for (const sig of signatures(palette)) {
|
||||||
|
add(sig, { id: PALETTE_ID, title: title(PALETTE_ID) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueFor = (id: string) => {
|
||||||
|
const custom = settings.keybinds.get(id)
|
||||||
|
if (typeof custom === "string") return custom
|
||||||
|
|
||||||
|
const live = command.options.find((x) => x.id === id)
|
||||||
|
if (live?.keybind) return live.keybind
|
||||||
|
|
||||||
|
const meta = command.catalog.find((x) => x.id === id)
|
||||||
|
return meta?.keybind
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of list().keys()) {
|
||||||
|
if (id === PALETTE_ID) continue
|
||||||
|
for (const sig of signatures(valueFor(id))) {
|
||||||
|
add(sig, { id, title: title(id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
|
||||||
|
|
||||||
|
useKeyCapture({
|
||||||
|
active: () => store.active,
|
||||||
|
stop,
|
||||||
|
set: setKeybind,
|
||||||
|
used,
|
||||||
|
language,
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (store.active) command.keybinds(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const emptyResults = (
|
||||||
|
<Show when={store.filter && !hasResults()}>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex flex-col items-center justify-center py-12 text-center": !props.v2,
|
||||||
|
"settings-v2-shortcuts-status": props.v2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
classList={{
|
||||||
|
"text-14-regular text-text-weak": !props.v2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("settings.shortcuts.search.empty")}
|
||||||
|
</span>
|
||||||
|
<Show when={store.filter}>
|
||||||
|
<span
|
||||||
|
classList={{
|
||||||
|
"text-14-regular text-text-strong mt-1": !props.v2,
|
||||||
|
"settings-v2-shortcuts-status-filter": props.v2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
"{store.filter}"
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
|
const List = props.v2 ? SettingsListV2 : SettingsList
|
||||||
|
|
||||||
|
const groups = (
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"settings-v2-shortcuts flex flex-col gap-8": props.v2,
|
||||||
|
"flex flex-col gap-8 max-w-[720px]": !props.v2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={GROUPS}>
|
||||||
|
{(group) => (
|
||||||
|
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"settings-v2-section": props.v2,
|
||||||
|
"flex flex-col gap-1": !props.v2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
classList={{
|
||||||
|
"settings-v2-section-title": props.v2,
|
||||||
|
"text-14-medium text-text-strong pb-2": !props.v2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t(groupKey[group])}
|
||||||
|
</h3>
|
||||||
|
<List>
|
||||||
|
<For each={filtered().get(group) ?? []}>
|
||||||
|
{(id) => (
|
||||||
|
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||||
|
<span
|
||||||
|
classList={{
|
||||||
|
"text-14-regular text-text-strong": !props.v2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title(id)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-keybind-id={id}
|
||||||
|
classList={{
|
||||||
|
"settings-v2-keybind-button": props.v2,
|
||||||
|
"settings-v2-keybind-button--active": props.v2 && store.active === id,
|
||||||
|
"h-8 px-3 rounded-md text-12-regular": !props.v2,
|
||||||
|
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
||||||
|
!props.v2 && store.active !== id,
|
||||||
|
"border border-border-weak-base bg-surface-inset-base text-text-weak":
|
||||||
|
!props.v2 && store.active === id,
|
||||||
|
}}
|
||||||
|
onClick={() => start(id)}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={store.active === id}
|
||||||
|
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||||
|
>
|
||||||
|
{language.t("settings.shortcuts.pressKeys")}
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
{emptyResults}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||||
|
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||||
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||||
|
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||||
|
<TextField
|
||||||
|
variant="ghost"
|
||||||
|
type="text"
|
||||||
|
value={store.filter}
|
||||||
|
onChange={(v) => setStore("filter", v)}
|
||||||
|
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Show when={store.filter}>
|
||||||
|
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{groups}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { type ParentProps, Show } from "solid-js"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { ModelsProvider } from "@/context/models"
|
|
||||||
import { ServerProvider } from "@/context/server"
|
|
||||||
import { ServerConnection } from "@/context/servers"
|
|
||||||
|
|
||||||
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
|
|
||||||
const global = useGlobal()
|
|
||||||
return (
|
|
||||||
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
|
|
||||||
{(server) => (
|
|
||||||
<SettingsServerDataScope server={server} directory={props.directory}>
|
|
||||||
{props.children}
|
|
||||||
</SettingsServerDataScope>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
|
|
||||||
return (
|
|
||||||
<ServerProvider conn={props.server}>
|
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
||||||
</ServerProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { Component, createMemo } from "solid-js"
|
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { ExternalLink } from "../external-link"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
|
||||||
import { createAppearanceSettingsController, type AppearanceSettingsController } from "./general-controllers"
|
|
||||||
import "./settings-v2.css"
|
|
||||||
|
|
||||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
|
||||||
const fontSettings = {
|
|
||||||
ui: {
|
|
||||||
action: "settings-ui-font",
|
|
||||||
title: "settings.general.row.uiFont.title",
|
|
||||||
description: "settings.general.row.uiFont.description",
|
|
||||||
font: "ui",
|
|
||||||
input: "setUI",
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
action: "settings-code-font",
|
|
||||||
title: "settings.general.row.font.title",
|
|
||||||
description: "settings.general.row.font.description",
|
|
||||||
font: "code",
|
|
||||||
input: "setCode",
|
|
||||||
},
|
|
||||||
terminal: {
|
|
||||||
action: "settings-terminal-font",
|
|
||||||
title: "settings.general.row.terminalFont.title",
|
|
||||||
description: "settings.general.row.terminalFont.description",
|
|
||||||
font: "terminal",
|
|
||||||
input: "setTerminal",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const FontSetting: Component<{
|
|
||||||
kind: "ui" | "code" | "terminal"
|
|
||||||
fonts: AppearanceSettingsController["fonts"]
|
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const config = () => fontSettings[props.kind]
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextInputV2
|
|
||||||
data-action={config().action}
|
|
||||||
type="text"
|
|
||||||
appearance="base"
|
|
||||||
value={props.fonts[config().font]().value}
|
|
||||||
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
|
||||||
placeholder={props.fonts[config().font]().placeholder}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
aria-label={language.t(config().title)}
|
|
||||||
style={{ "font-family": props.fonts[config().font]().family }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsAppearanceV2: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const appearance = createAppearanceSettingsController()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.appearance")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.appearance.description")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.colorScheme.title")}
|
|
||||||
description={language.t("settings.general.row.colorScheme.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-color-scheme"
|
|
||||||
options={schemeOptions}
|
|
||||||
current={schemeOptions.find((option) => option === appearance.scheme.current())}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
label={(option) => {
|
|
||||||
if (option === "system") return language.t("theme.scheme.system")
|
|
||||||
if (option === "light") return language.t("theme.scheme.light")
|
|
||||||
return language.t("theme.scheme.dark")
|
|
||||||
}}
|
|
||||||
onSelect={(option) => option && appearance.scheme.select(option)}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.theme.title")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
|
||||||
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
|
||||||
{language.t("common.learnMore")}
|
|
||||||
</ExternalLink>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-theme"
|
|
||||||
options={appearance.theme.options()}
|
|
||||||
current={appearance.theme.current()}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
value={(option) => option.id}
|
|
||||||
label={(option) => option.name}
|
|
||||||
onSelect={appearance.theme.select}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<FontSetting kind="ui" fonts={appearance.fonts} />
|
|
||||||
<FontSetting kind="code" fonts={appearance.fonts} />
|
|
||||||
<FontSetting kind="terminal" fonts={appearance.fonts} />
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/components/server/server-management"
|
} from "@/components/server/server-management"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { normalizeServerUrl, ServerConnection, useServers } from "@/context/servers"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useCheckServerHealth } from "@/utils/server-health"
|
import { useCheckServerHealth } from "@/utils/server-health"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -146,7 +146,7 @@ export const DialogServerV2: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createFormController(options: { onSelect?: () => void } = {}) {
|
function createFormController(options: { onSelect?: () => void } = {}) {
|
||||||
const server = useServers()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -173,7 +173,8 @@ function createFormController(options: { onSelect?: () => void } = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const allServers = () => {
|
const allServers = () => {
|
||||||
return server.list
|
if (!server.current || server.list.includes(server.current)) return server.list
|
||||||
|
return [server.current, ...server.list]
|
||||||
}
|
}
|
||||||
const editing = createMemo(() =>
|
const editing = createMemo(() =>
|
||||||
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
||||||
@@ -181,8 +182,10 @@ function createFormController(options: { onSelect?: () => void } = {}) {
|
|||||||
const add = (connection: ServerConnection.Http) => server.add(connection)
|
const add = (connection: ServerConnection.Http) => server.add(connection)
|
||||||
const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
|
const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
|
||||||
replaceServerConnection(originalKey, next, {
|
replaceServerConnection(originalKey, next, {
|
||||||
|
active: () => server.key,
|
||||||
removeTabs: (key) => tabs.removeServer(key),
|
removeTabs: (key) => tabs.removeServer(key),
|
||||||
add,
|
add,
|
||||||
|
setActive: (key) => server.setActive(key),
|
||||||
remove: (key) => server.remove(key),
|
remove: (key) => server.remove(key),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
import { Component, createEffect, createMemo, createSignal, startTransition } from "solid-js"
|
import { Component, createMemo, createSignal, startTransition } from "solid-js"
|
||||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { SettingsGeneral } from "./general"
|
import { SettingsGeneralV2 } from "./general"
|
||||||
import { SettingsAppearanceV2 } from "./appearance"
|
|
||||||
import { SettingsKeybinds } from "../settings-keybinds"
|
import { SettingsKeybinds } from "../settings-keybinds"
|
||||||
import { SettingsNotificationsV2 } from "./notifications"
|
|
||||||
import { SettingsProvidersV2 } from "./providers"
|
import { SettingsProvidersV2 } from "./providers"
|
||||||
import { SettingsModelsV2 } from "./models"
|
import { SettingsModelsV2 } from "./models"
|
||||||
|
import "./settings-v2.css"
|
||||||
import { SettingsServersV2 } from "./servers"
|
import { SettingsServersV2 } from "./servers"
|
||||||
import { SettingsWorkspacesV2 } from "./workspaces"
|
|
||||||
import { SettingsProjectsV2 } from "./projects"
|
|
||||||
import { SettingsExtensionsV2 } from "./extensions"
|
|
||||||
import { SettingsServerScope } from "../settings-server-picker"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useGlobal, useServerCtx } from "@/context/global"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { ServerConnection, useServers } from "@/context/servers"
|
|
||||||
import "./settings-v2.css"
|
|
||||||
|
|
||||||
export const DialogSettings: Component<{
|
export const DialogSettings: Component<{
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -30,40 +23,17 @@ export const DialogSettings: Component<{
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const servers = useServers()
|
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const global = useGlobal()
|
const serverSync = useServerSync()
|
||||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||||
const server = createMemo(() => {
|
|
||||||
const route = layout.route()
|
|
||||||
switch (route.type) {
|
|
||||||
case "draft": {
|
|
||||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
|
||||||
return servers.list.find((item) => ServerConnection.key(item) === draft?.server)
|
|
||||||
}
|
|
||||||
case "session":
|
|
||||||
return servers.list.find((item) => ServerConnection.key(item) === route.server)
|
|
||||||
case "home":
|
|
||||||
return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const serverCtx = useServerCtx(server)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const current = server()
|
|
||||||
if (current) global.settings.server.set(ServerConnection.key(current))
|
|
||||||
})
|
|
||||||
|
|
||||||
const directory = createMemo(() => {
|
const directory = createMemo(() => {
|
||||||
const selected = global.settings.server.selected()
|
|
||||||
const current = server()
|
|
||||||
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
|
|
||||||
const route = layout.route()
|
const route = layout.route()
|
||||||
|
if (route.type === "dir-new-sesssion") return route.dir
|
||||||
if (route.type === "draft") {
|
if (route.type === "draft") {
|
||||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||||
return draft?.type === "draft" ? draft.directory : undefined
|
return draft?.type === "draft" ? draft.directory : undefined
|
||||||
}
|
}
|
||||||
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory
|
if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -82,99 +52,62 @@ export const DialogSettings: Component<{
|
|||||||
>
|
>
|
||||||
<TabsV2.List>
|
<TabsV2.List>
|
||||||
<div class="flex flex-col justify-between h-full w-full">
|
<div class="flex flex-col justify-between h-full w-full">
|
||||||
<div class="flex flex-col gap-4 w-full">
|
<div class="flex flex-col gap-3 w-full">
|
||||||
{/* Group 1: Preferences */}
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex flex-col gap-1 w-full">
|
<div class="flex flex-col gap-1.5">
|
||||||
<TabsV2.Trigger value="general">
|
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
|
||||||
<Icon name="sliders" />
|
<div class="flex flex-col gap-1.5 w-full">
|
||||||
{language.t("settings.tab.preferences")}
|
<TabsV2.Trigger value="general">
|
||||||
</TabsV2.Trigger>
|
<Icon name="sliders" />
|
||||||
<TabsV2.Trigger value="appearance">
|
{language.t("settings.tab.general")}
|
||||||
<Icon name="appearance" />
|
</TabsV2.Trigger>
|
||||||
{language.t("settings.general.section.appearance")}
|
<TabsV2.Trigger value="shortcuts">
|
||||||
</TabsV2.Trigger>
|
<Icon name="keyboard" />
|
||||||
<TabsV2.Trigger value="notifications">
|
{language.t("settings.tab.shortcuts")}
|
||||||
<Icon name="notifications" />
|
</TabsV2.Trigger>
|
||||||
{language.t("settings.tab.notifications")}
|
</div>
|
||||||
</TabsV2.Trigger>
|
</div>
|
||||||
<TabsV2.Trigger value="shortcuts">
|
|
||||||
<Icon name="keyboard" />
|
|
||||||
{language.t("settings.tab.shortcuts")}
|
|
||||||
</TabsV2.Trigger>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Group 2: Environment & Workspaces */}
|
<div class="flex flex-col gap-1.5">
|
||||||
<div class="flex flex-col gap-1 w-full">
|
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
||||||
<TabsV2.Trigger value="servers">
|
<div class="flex flex-col gap-1.5 w-full">
|
||||||
<Icon name="server" />
|
<TabsV2.Trigger value="servers">
|
||||||
{language.t("status.popover.tab.servers")}
|
<Icon name="server" />
|
||||||
</TabsV2.Trigger>
|
{language.t("status.popover.tab.servers")}
|
||||||
<TabsV2.Trigger value="projects">
|
</TabsV2.Trigger>
|
||||||
<Icon name="folder" />
|
<TabsV2.Trigger value="providers">
|
||||||
{language.t("settings.tab.projects")}
|
<Icon name="providers" />
|
||||||
</TabsV2.Trigger>
|
{language.t("settings.providers.title")}
|
||||||
<TabsV2.Trigger value="workspaces">
|
</TabsV2.Trigger>
|
||||||
<Icon name="workspace-isolated" />
|
<TabsV2.Trigger value="models">
|
||||||
{language.t("settings.tab.workspaces")}
|
<Icon name="models" />
|
||||||
</TabsV2.Trigger>
|
{language.t("settings.models.title")}
|
||||||
</div>
|
</TabsV2.Trigger>
|
||||||
|
</div>
|
||||||
{/* Group 3: Capabilities & Extensions */}
|
</div>
|
||||||
<div class="flex flex-col gap-1 w-full">
|
|
||||||
<TabsV2.Trigger value="providers">
|
|
||||||
<Icon name="providers" />
|
|
||||||
{language.t("settings.providers.title")}
|
|
||||||
</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="models">
|
|
||||||
<Icon name="models" />
|
|
||||||
{language.t("settings.models.title")}
|
|
||||||
</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="extensions">
|
|
||||||
<Icon name="extensions" />
|
|
||||||
{language.t("settings.tab.extensions")}
|
|
||||||
</TabsV2.Trigger>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-nav-footer">
|
<div class="settings-v2-nav-footer">
|
||||||
<span>{language.t("app.name.desktop")}</span>
|
<span>{language.t("app.name.desktop")}</span>
|
||||||
<span>v{platform.version}</span>
|
<span>v{platform.version}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsV2.List>
|
</TabsV2.List>
|
||||||
|
|
||||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||||
<SettingsGeneral server={server()} sessionID={props.sessionID} />
|
<SettingsGeneralV2 sessionID={props.sessionID} />
|
||||||
</TabsV2.Content>
|
|
||||||
<TabsV2.Content value="appearance" class="settings-v2-panel">
|
|
||||||
<SettingsAppearanceV2 />
|
|
||||||
</TabsV2.Content>
|
|
||||||
<TabsV2.Content value="notifications" class="settings-v2-panel">
|
|
||||||
<SettingsNotificationsV2 />
|
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||||
<SettingsKeybinds />
|
<SettingsKeybinds v2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="servers" class="settings-v2-panel">
|
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||||
<SettingsServersV2 />
|
<SettingsServersV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="projects" class="settings-v2-panel">
|
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||||
<SettingsProjectsV2 />
|
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||||
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||||
|
<SettingsModelsV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<SettingsServerScope directory={directory()}>
|
|
||||||
<TabsV2.Content value="workspaces" class="settings-v2-panel">
|
|
||||||
<SettingsWorkspacesV2 activeDirectory={directory()} />
|
|
||||||
</TabsV2.Content>
|
|
||||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
|
||||||
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
|
|
||||||
</TabsV2.Content>
|
|
||||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
|
||||||
<SettingsModelsV2 />
|
|
||||||
</TabsV2.Content>
|
|
||||||
<TabsV2.Content value="extensions" class="settings-v2-panel">
|
|
||||||
<SettingsExtensionsV2 />
|
|
||||||
</TabsV2.Content>
|
|
||||||
</SettingsServerScope>
|
|
||||||
</TabsV2>
|
</TabsV2>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { Component, For, createMemo, createResource } from "solid-js"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { ExternalLink } from "../external-link"
|
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
|
||||||
import "./settings-v2.css"
|
|
||||||
|
|
||||||
interface McpRowItem {
|
|
||||||
name: string
|
|
||||||
enabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginRowItem {
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsExtensionsV2: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const serverSdk = useServerSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const mcps = createMemo<McpRowItem[]>(() => {
|
|
||||||
const configMcp = serverSync.data.config.mcp ?? {}
|
|
||||||
return Object.entries(configMcp).map(([name, config]) => ({
|
|
||||||
name,
|
|
||||||
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
|
||||||
const before = serverSync.data.config.mcp ?? {}
|
|
||||||
const config = before[item.name]
|
|
||||||
if (typeof config !== "object" || config === null) return
|
|
||||||
const next = { ...before, [item.name]: { ...config, enabled: checked } }
|
|
||||||
serverSync.set("config", "mcp", next)
|
|
||||||
void serverSync.updateConfig({ mcp: next }).catch(() => serverSync.set("config", "mcp", before))
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugins = createMemo<PluginRowItem[]>(() => {
|
|
||||||
const raw = serverSync.data.config.plugin ?? []
|
|
||||||
return raw.map((item) => {
|
|
||||||
const name = typeof item === "string" ? item : item[0]
|
|
||||||
return { name }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
|
|
||||||
initialValue: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.extensions")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
|
|
||||||
</div>
|
|
||||||
<InlineServerSelect />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
|
||||||
<TabsV2 variant="pill" defaultValue="mcps" class="settings-v2-extensions-tabs">
|
|
||||||
<TabsV2.List>
|
|
||||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
|
||||||
</TabsV2.List>
|
|
||||||
|
|
||||||
<TabsV2.Content value="mcps">
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-13-medium text-v2-text-text-base">
|
|
||||||
{language.t("settings.extensions.availableAll")}
|
|
||||||
</span>
|
|
||||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
|
||||||
<For each={mcps()}>
|
|
||||||
{(item) => (
|
|
||||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
|
||||||
<div class="flex items-center gap-2.5 min-w-0">
|
|
||||||
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
|
|
||||||
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
|
|
||||||
{item.name}
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="plugins">
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-13-medium text-v2-text-text-base">
|
|
||||||
{language.t("settings.extensions.availableAll")}
|
|
||||||
</span>
|
|
||||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
|
||||||
<For each={plugins()}>
|
|
||||||
{(plugin) => (
|
|
||||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
|
||||||
<div class="flex items-center gap-2.5 min-w-0">
|
|
||||||
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
|
|
||||||
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="skills">
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-13-medium text-v2-text-text-base">
|
|
||||||
{language.t("settings.extensions.availableAll")}
|
|
||||||
</span>
|
|
||||||
<ExternalLink
|
|
||||||
class="text-13-regular text-v2-text-accent hover:underline"
|
|
||||||
href="https://opencode.ai/docs/skills/"
|
|
||||||
>
|
|
||||||
{language.t("settings.extensions.addSkills")}
|
|
||||||
</ExternalLink>
|
|
||||||
</div>
|
|
||||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
|
||||||
<For each={skills()}>
|
|
||||||
{(skill) => (
|
|
||||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
|
||||||
<div class="flex items-center gap-2.5 min-w-0">
|
|
||||||
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
|
|
||||||
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
</TabsV2>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -17,24 +17,17 @@ import {
|
|||||||
} from "@/context/settings"
|
} from "@/context/settings"
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
|
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
|
||||||
import { ServerConnection } from "@/context/servers"
|
|
||||||
import { useGlobal, useServerCtx } from "@/context/global"
|
|
||||||
|
|
||||||
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
||||||
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
|
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
|
||||||
|
|
||||||
export function createPermissionScopeController(
|
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
|
||||||
server: Accessor<ServerConnection.Any | undefined>,
|
const permission = usePermission()
|
||||||
sessionID: Accessor<string | undefined>,
|
const serverSync = useServerSync()
|
||||||
) {
|
|
||||||
const serverCtx = useServerCtx(server)
|
|
||||||
const permission = () => serverCtx()?.permission
|
|
||||||
|
|
||||||
const directory = createMemo(() => {
|
const directory = createMemo(() => {
|
||||||
const s = server()
|
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
if (!s || !id) return undefined
|
if (!id) return undefined
|
||||||
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory
|
return serverSync().session.lineage.peek(id)?.session.location.directory
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -42,21 +35,21 @@ export function createPermissionScopeController(
|
|||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
const dir = directory()
|
const dir = directory()
|
||||||
if (!id || !dir) return false
|
if (!id || !dir) return false
|
||||||
return permission()?.isAutoAccepting(id, dir)
|
return permission.isAutoAccepting(id, dir)
|
||||||
}),
|
}),
|
||||||
enabled: createMemo(() => !!directory()),
|
enabled: createMemo(() => !!directory()),
|
||||||
set: (checked: boolean) => {
|
set: (checked: boolean) => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
const dir = directory()
|
const dir = directory()
|
||||||
if (!id || !dir) return
|
if (!id || !dir) return
|
||||||
if (checked) return permission()?.enableAutoAccept(id, dir)
|
if (checked) return permission.enableAutoAccept(id, dir)
|
||||||
permission()?.disableAutoAccept(id, dir)
|
permission.disableAutoAccept(id, dir)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
|
export function createShellSettingsController() {
|
||||||
const serverCtx = useServerCtx(server)
|
const serverSync = useServerSync()
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
async () => {
|
async () => {
|
||||||
// TODO: Dax is considering the V2 shell discovery and config update APIs.
|
// TODO: Dax is considering the V2 shell discovery and config update APIs.
|
||||||
@@ -65,7 +58,7 @@ export function createShellSettingsController(server: Accessor<ServerConnection.
|
|||||||
},
|
},
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "")
|
const current = createMemo(() => serverSync().data.config.shell ?? "")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shells: () => shells.latest,
|
shells: () => shells.latest,
|
||||||
@@ -73,7 +66,7 @@ export function createShellSettingsController(server: Accessor<ServerConnection.
|
|||||||
select: (value: string) => {
|
select: (value: string) => {
|
||||||
if (value === current()) return
|
if (value === current()) return
|
||||||
// TODO: Dax is considering the V2 shell discovery and config update APIs.
|
// TODO: Dax is considering the V2 shell discovery and config update APIs.
|
||||||
// void serverSync.updateConfig({ shell: value })
|
// void serverSync().updateConfig({ shell: value })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { ExternalLink } from "../external-link"
|
import { ExternalLink } from "../external-link"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
type SoundSettingsController,
|
type SoundSettingsController,
|
||||||
} from "./general-controllers"
|
} from "./general-controllers"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
import { ServerConnection } from "@/context/servers"
|
|
||||||
|
|
||||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||||
const fontSettings = {
|
const fontSettings = {
|
||||||
@@ -86,34 +85,6 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const WorkspaceDestinationSetting: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const settings = useSettings()
|
|
||||||
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
|
|
||||||
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
|
|
||||||
{ value: "local", label: language.t("settings.workspaces.default.local") },
|
|
||||||
{ value: "new", label: language.t("settings.workspaces.default.new") },
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.workspaces.default.title")}
|
|
||||||
description={language.t("settings.workspaces.default.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
options={options()}
|
|
||||||
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
|
|
||||||
value={(option) => option.value}
|
|
||||||
label={(option) => option.label}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const options = createMemo(() =>
|
const options = createMemo(() =>
|
||||||
@@ -298,20 +269,18 @@ const LanguageSetting = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsGeneral: Component<{
|
export const SettingsGeneralV2: Component<{
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
server?: ServerConnection.Any
|
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
const permissionScope = createPermissionScopeController(
|
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||||
() => props.server,
|
const shell = createShellSettingsController()
|
||||||
() => props.sessionID,
|
const appearance = createAppearanceSettingsController()
|
||||||
)
|
const sounds = createSoundSettingsController()
|
||||||
const shell = createShellSettingsController(() => props.server)
|
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
@@ -329,11 +298,9 @@ export const SettingsGeneral: Component<{
|
|||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.general")}</h3>
|
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<LanguageSetting />
|
<LanguageSetting />
|
||||||
|
|
||||||
<WorkspaceDestinationSetting />
|
|
||||||
<PermissionScopeSetting controller={permissionScope} />
|
<PermissionScopeSetting controller={permissionScope} />
|
||||||
|
|
||||||
<ShellSetting controller={shell} />
|
<ShellSetting controller={shell} />
|
||||||
@@ -396,6 +363,18 @@ export const SettingsGeneral: Component<{
|
|||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||||
|
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.showFileTree.title")}
|
||||||
|
description={language.t("settings.general.row.showFileTree.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-file-tree">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showFileTree()}
|
||||||
|
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.showSearch.title")}
|
title={language.t("settings.general.row.showSearch.title")}
|
||||||
description={language.t("settings.general.row.showSearch.description")}
|
description={language.t("settings.general.row.showSearch.description")}
|
||||||
@@ -531,18 +510,18 @@ export const SettingsGeneral: Component<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header">
|
<div class="settings-v2-tab-header">
|
||||||
<div class="settings-v2-tab-header-row">
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.preferences")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">
|
|
||||||
{language.t("settings.preferences.description")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
|
<AppearanceSection controller={appearance} />
|
||||||
|
|
||||||
|
<NotificationsSection />
|
||||||
|
|
||||||
|
<SoundsSection controller={sounds} />
|
||||||
|
|
||||||
<Show when={desktop()}>
|
<Show when={desktop()}>
|
||||||
<UpdatesSection />
|
<UpdatesSection />
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useModels } from "@/context/models"
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -25,7 +24,7 @@ export const SettingsModelsV2: Component = () => {
|
|||||||
const models = useModels()
|
const models = useModels()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const [store, setStore] = persisted(
|
const [store, setStore] = persisted(
|
||||||
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
|
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
|
||||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,13 +53,7 @@ export const SettingsModelsV2: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
<div class="settings-v2-tab-header-row">
|
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
|
|
||||||
</div>
|
|
||||||
<InlineServerSelect />
|
|
||||||
</div>
|
|
||||||
<div class="settings-v2-tab-search">
|
<div class="settings-v2-tab-search">
|
||||||
<TextInputV2
|
<TextInputV2
|
||||||
type="search"
|
type="search"
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { Component } from "solid-js"
|
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
|
||||||
import { createSoundSettingsController, soundOptions, type SoundSettingsController } from "./general-controllers"
|
|
||||||
import "./settings-v2.css"
|
|
||||||
|
|
||||||
const soundSettings = {
|
|
||||||
agent: {
|
|
||||||
action: "settings-sounds-agent",
|
|
||||||
title: "settings.general.sounds.agent.title",
|
|
||||||
description: "settings.general.sounds.agent.description",
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
action: "settings-sounds-permissions",
|
|
||||||
title: "settings.general.sounds.permissions.title",
|
|
||||||
description: "settings.general.sounds.permissions.description",
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
action: "settings-sounds-errors",
|
|
||||||
title: "settings.general.sounds.errors.title",
|
|
||||||
description: "settings.general.sounds.errors.description",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const SoundSetting: Component<{
|
|
||||||
kind: "agent" | "permissions" | "errors"
|
|
||||||
channel: SoundSettingsController["agent"]
|
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const config = () => soundSettings[props.kind]
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action={config().action}
|
|
||||||
options={soundOptions}
|
|
||||||
current={props.channel.current()}
|
|
||||||
value={(option) => option.id}
|
|
||||||
label={(option) => language.t(option.label)}
|
|
||||||
onHighlight={props.channel.highlight}
|
|
||||||
onSelect={props.channel.select}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsNotificationsV2: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const settings = useSettings()
|
|
||||||
const sounds = createSoundSettingsController()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.notifications")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">
|
|
||||||
{language.t("settings.notifications.description")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.agent.title")}
|
|
||||||
description={language.t("settings.general.notifications.agent.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-agent">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.agent()}
|
|
||||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.permissions.title")}
|
|
||||||
description={language.t("settings.general.notifications.permissions.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-permissions">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.permissions()}
|
|
||||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.errors.title")}
|
|
||||||
description={language.t("settings.general.notifications.errors.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-errors">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.errors()}
|
|
||||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
|
||||||
<SettingsListV2>
|
|
||||||
<SoundSetting kind="agent" channel={sounds.agent} />
|
|
||||||
<SoundSetting kind="permissions" channel={sounds.permissions} />
|
|
||||||
<SoundSetting kind="errors" channel={sounds.errors} />
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Show, createMemo, type Component } from "solid-js"
|
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { ServerConnection, serverName } from "@/context/servers"
|
|
||||||
|
|
||||||
const allServers = { type: "all" } as const
|
|
||||||
type ServerOption = ServerConnection.Any | typeof allServers
|
|
||||||
|
|
||||||
export const InlineServerSelect: Component<{
|
|
||||||
all?: {
|
|
||||||
label: string
|
|
||||||
selected: () => boolean
|
|
||||||
onSelect: () => void
|
|
||||||
}
|
|
||||||
onServerSelect?: () => void
|
|
||||||
}> = (props) => {
|
|
||||||
const global = useGlobal()
|
|
||||||
const options = createMemo<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
|
|
||||||
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={options().length > 1}>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-server-select"
|
|
||||||
options={options()}
|
|
||||||
current={current()}
|
|
||||||
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
|
|
||||||
label={(server) =>
|
|
||||||
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
|
|
||||||
}
|
|
||||||
optionDisabled={(server) =>
|
|
||||||
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
|
|
||||||
}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
onSelect={(server) => {
|
|
||||||
if (!server) return
|
|
||||||
if (server.type === "all") {
|
|
||||||
props.all?.onSelect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
global.settings.server.set(ServerConnection.key(server))
|
|
||||||
props.onServerSelect?.()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { getProjectAvatarVariant } from "@/context/layout"
|
|
||||||
import { ServerConnection, serverName } from "@/context/servers"
|
|
||||||
import { displayName } from "@/pages/layout/helpers"
|
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
|
||||||
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
|
|
||||||
import "./settings-v2.css"
|
|
||||||
|
|
||||||
export const SettingsProjectsV2: Component = () => {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const language = useLanguage()
|
|
||||||
const global = useGlobal()
|
|
||||||
const [allServers, setAllServers] = createSignal(true)
|
|
||||||
const selected = global.settings.server.selected
|
|
||||||
const projects = createMemo(() => {
|
|
||||||
const server = selected()
|
|
||||||
if (!server) return []
|
|
||||||
return global.ensureServerCtx(server).projects.list()
|
|
||||||
})
|
|
||||||
|
|
||||||
type ProjectItem = ReturnType<typeof projects>[number]
|
|
||||||
|
|
||||||
const groups = createMemo(() =>
|
|
||||||
global.servers
|
|
||||||
.list()
|
|
||||||
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
|
|
||||||
.filter((group) => group.projects.length > 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
const openProjectSettings = (project: ProjectItem, server = selected()) => {
|
|
||||||
if (!server) return
|
|
||||||
dialog.push(() => <DialogEditProjectV2 project={project} server={server} />)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
|
|
||||||
const name = () => displayName(props.project)
|
|
||||||
const color = () => getProjectAvatarVariant(props.project.icon?.color)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
|
|
||||||
onClick={() => openProjectSettings(props.project, props.server)}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
|
||||||
<ProjectAvatar fallback={name()} variant={color()} class="shrink-0" />
|
|
||||||
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<IconButtonV2
|
|
||||||
type="button"
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="small"
|
|
||||||
icon={<IconV2 name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
|
|
||||||
onClick={(event: MouseEvent) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
openProjectSettings(props.project, props.server)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.projects.title")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
|
|
||||||
</div>
|
|
||||||
<InlineServerSelect
|
|
||||||
all={{
|
|
||||||
label: language.t("settings.projects.server.all"),
|
|
||||||
selected: allServers,
|
|
||||||
onSelect: () => setAllServers(true),
|
|
||||||
}}
|
|
||||||
onServerSelect={() => setAllServers(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
|
||||||
<Show
|
|
||||||
when={allServers()}
|
|
||||||
fallback={
|
|
||||||
<div class="flex flex-col gap-2 w-full">
|
|
||||||
<Show
|
|
||||||
when={projects().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
|
||||||
{language.t("settings.projects.empty")}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show when={selected()} keyed>
|
|
||||||
{(server) => (
|
|
||||||
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-8 w-full">
|
|
||||||
<Show
|
|
||||||
when={groups().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
|
||||||
{language.t("settings.projects.empty")}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={groups()}>
|
|
||||||
{(group) => (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">
|
|
||||||
{serverName(group.server) || ServerConnection.key(group.server)}
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-col gap-2 w-full">
|
|
||||||
<For each={group.projects}>
|
|
||||||
{(project) => <ProjectRow project={project} server={group.server} />}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,12 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||||
import { SettingsServerScope } from "../settings-server-picker"
|
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
@@ -32,35 +30,25 @@ const PROVIDER_NOTES = [
|
|||||||
const PROVIDER_ICON_SIZE = 16
|
const PROVIDER_ICON_SIZE = 16
|
||||||
|
|
||||||
export const SettingsProvidersV2: Component<{
|
export const SettingsProvidersV2: Component<{
|
||||||
directory: string | undefined
|
directory: Accessor<string | undefined>
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders(() => props.directory)
|
const providers = useProviders(props.directory)
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
|
||||||
const connect = (provider?: string) => {
|
const connect = (provider?: string) => {
|
||||||
providerConnect.select(provider)
|
providerConnect.select(provider)
|
||||||
void dialog.show(() => (
|
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||||
<SettingsServerScope directory={props.directory}>
|
|
||||||
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
|
|
||||||
</SettingsServerScope>
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const connected = createMemo(() => {
|
const connected = createMemo(() => {
|
||||||
return providers.connected().filter(
|
return providers
|
||||||
(provider) =>
|
.connected()
|
||||||
provider.id !== "opencode" ||
|
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
|
||||||
Object.values(provider.models).some((model) => {
|
|
||||||
if (typeof model !== "object" || model === null || !("cost" in model)) return false
|
|
||||||
const cost = model.cost
|
|
||||||
return typeof cost === "object" && cost !== null && "input" in cost
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const popular = createMemo(() => {
|
const popular = createMemo(() => {
|
||||||
@@ -97,22 +85,45 @@ export const SettingsProvidersV2: Component<{
|
|||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
const isConfigCustom = (providerID: string) => {
|
const isConfigCustom = (providerID: string) => {
|
||||||
const provider = serverSync.data.config.provider?.[providerID]
|
const provider = serverSync().data.config.provider?.[providerID]
|
||||||
if (!provider) return false
|
if (!provider) return false
|
||||||
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||||
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
|
return
|
||||||
|
const before = serverSync().data.config.disabled_providers ?? []
|
||||||
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
|
serverSync().set("config", "disabled_providers", next)
|
||||||
|
|
||||||
|
await serverSync()
|
||||||
|
.updateConfig({ disabled_providers: next })
|
||||||
|
.then(() => {
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||||
|
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
serverSync().set("config", "disabled_providers", before)
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
const location = props.directory ? { directory: props.directory } : undefined
|
const location = props.directory() ? { directory: props.directory() } : undefined
|
||||||
await serverSdk.api.integration
|
await serverSdk()
|
||||||
.get({ integrationID: providerID, location })
|
.api.integration.get({ integrationID: providerID, location })
|
||||||
.then(async (integration) => {
|
.then(async (integration) => {
|
||||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })),
|
credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })),
|
||||||
)
|
)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
@@ -130,13 +141,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header">
|
<div class="settings-v2-tab-header">
|
||||||
<div class="settings-v2-tab-header-row">
|
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
|
|
||||||
</div>
|
|
||||||
<InlineServerSelect />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body settings-v2-providers">
|
<div class="settings-v2-tab-body settings-v2-providers">
|
||||||
@@ -239,11 +244,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dialog.show(() => (
|
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||||
<SettingsServerScope directory={props.directory}>
|
|
||||||
<DialogCustomProvider onBack={dialog.close} />
|
|
||||||
</SettingsServerScope>
|
|
||||||
))
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{language.t("common.connect")}
|
{language.t("common.connect")}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, serverName } from "@/context/servers"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { useServerCollectionController } from "../server/server-management-controller"
|
import { useServerCollectionController } from "../server/server-management-controller"
|
||||||
import { DialogServerV2 } from "./dialog-server-v2"
|
import { DialogServerV2 } from "./dialog-server-v2"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
@@ -53,10 +53,7 @@ export const SettingsServersV2: Component = () => {
|
|||||||
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
|
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
|
||||||
>
|
>
|
||||||
<div class="settings-v2-tab-header-row">
|
<div class="settings-v2-tab-header-row">
|
||||||
<div class="flex flex-col gap-1">
|
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||||
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
|
|
||||||
</div>
|
|
||||||
<AddServerMenu onAddServer={openAdd} />
|
<AddServerMenu onAddServer={openAdd} />
|
||||||
</div>
|
</div>
|
||||||
<Show when={showSearch()}>
|
<Show when={showSearch()}>
|
||||||
|
|||||||
@@ -39,14 +39,6 @@
|
|||||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-tab-header-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-tab-title {
|
.settings-v2-tab-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 640;
|
font-weight: 640;
|
||||||
@@ -182,13 +174,13 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] > [data-slot="tabs-v2-list"] {
|
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
|
||||||
background-color: var(--v2-background-bg-layer-01);
|
background-color: var(--v2-background-bg-layer-01);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||||
> [data-slot="tabs-v2-list"] {
|
[data-slot="tabs-v2-list"] {
|
||||||
width: 144px;
|
width: 144px;
|
||||||
min-width: 144px;
|
min-width: 144px;
|
||||||
padding-inline: 8px;
|
padding-inline: 8px;
|
||||||
@@ -692,223 +684,6 @@
|
|||||||
color: var(--v2-text-text-base);
|
color: var(--v2-text-text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-tab-header.settings-v2-workspaces-header {
|
|
||||||
padding-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-header .settings-v2-tab-title {
|
|
||||||
font-weight: 610;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-tab-body.settings-v2-workspaces {
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-toolbar {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-count {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 530;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-toolbar-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-delete-all {
|
|
||||||
color: var(--v2-state-fg-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: var(--v2-background-bg-base);
|
|
||||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-row {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-row:not(:last-child) {
|
|
||||||
padding-bottom: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-row-header {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-copy {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-main {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-row-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-path {
|
|
||||||
display: block;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 530;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.04px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 0;
|
|
||||||
text-align: left;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-meta {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 440;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--v2-text-text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-active,
|
|
||||||
.settings-v2-workspaces-more {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 440;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--v2-text-text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-sessions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--v2-background-bg-base);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-session {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 440;
|
|
||||||
line-height: 16px;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-session:not(:last-child) {
|
|
||||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-session > span:first-child {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-session-time {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding-block: 48px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 440;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.settings-v2-workspaces-header {
|
|
||||||
padding: 24px 20px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-tab-body.settings-v2-workspaces {
|
|
||||||
padding: 0 20px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-toolbar,
|
|
||||||
.settings-v2-workspaces-main {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-toolbar {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-toolbar-actions {
|
|
||||||
width: 100%;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-path {
|
|
||||||
overflow: visible;
|
|
||||||
text-overflow: clip;
|
|
||||||
white-space: normal;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-workspaces-active {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
@@ -952,24 +727,3 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--v2-state-fg-danger);
|
color: var(--v2-state-fg-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
|
||||||
width: 280px;
|
|
||||||
padding-inline: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
|
||||||
> [data-slot="tabs-v2-list"]
|
|
||||||
[data-slot="tabs-v2-trigger-wrapper"] {
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
|
||||||
> [data-slot="tabs-v2-list"]
|
|
||||||
[data-slot="tabs-v2-trigger"] {
|
|
||||||
padding-inline: 8px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,502 +0,0 @@
|
|||||||
import type { Component } from "solid-js"
|
|
||||||
import { For, Show, createMemo } from "solid-js"
|
|
||||||
import { createStore, produce } from "solid-js/store"
|
|
||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
|
||||||
import { useQuery } from "@tanstack/solid-query"
|
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|
||||||
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
|
||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { getRelativeTime } from "@/utils/time"
|
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
|
||||||
import { useTabs } from "@/context/tabs"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
|
||||||
import { ServerConnection } from "@/context/servers"
|
|
||||||
import type { Project } from "@/types"
|
|
||||||
import {
|
|
||||||
containsDirectory,
|
|
||||||
filterWorkspaceInventory,
|
|
||||||
inspectWorkspaceDeletion,
|
|
||||||
managedWorkspaceDirectories,
|
|
||||||
mergeWorkspaceSessionInventory,
|
|
||||||
removeWorkspacesSequentially,
|
|
||||||
sessionsForWorkspace,
|
|
||||||
type WorkspaceDeleteInspection,
|
|
||||||
workspaceInventory,
|
|
||||||
} from "@/utils/workspace"
|
|
||||||
import { listAllSessions } from "@/utils/session"
|
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
|
||||||
import "./settings-v2.css"
|
|
||||||
|
|
||||||
type Workspace = {
|
|
||||||
directory: string
|
|
||||||
project: Project
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const language = useLanguage()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
project: "all",
|
|
||||||
transaction: undefined as "confirm" | "running" | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const workspaces = createMemo(() => workspaceInventory(serverSync.data.project))
|
|
||||||
const projects = createMemo(() =>
|
|
||||||
serverSync.data.project.filter((project) => managedWorkspaceDirectories(project).length > 0),
|
|
||||||
)
|
|
||||||
const projectName = (project: Project) => project.name || getFilename(project.worktree)
|
|
||||||
const projectOptions = createMemo(() => [
|
|
||||||
{ id: "all", label: language.t("settings.workspaces.filter.all") },
|
|
||||||
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
|
|
||||||
])
|
|
||||||
const selectedProject = createMemo(() =>
|
|
||||||
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
|
|
||||||
)
|
|
||||||
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
|
||||||
const captureDeleteContext = () => {
|
|
||||||
const sdk = serverSDK
|
|
||||||
return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
|
||||||
}
|
|
||||||
const loadSessions = async (context = captureDeleteContext()) => {
|
|
||||||
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
|
||||||
return mergeWorkspaceSessionInventory(
|
|
||||||
fetched,
|
|
||||||
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const sessionQuery = useQuery(() => ({
|
|
||||||
queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
|
|
||||||
queryFn: () => loadSessions(),
|
|
||||||
refetchOnMount: "always",
|
|
||||||
}))
|
|
||||||
const sessionsByWorkspace = createMemo(
|
|
||||||
() =>
|
|
||||||
new Map(
|
|
||||||
workspaces().map((workspace) => [
|
|
||||||
pathKey(workspace.directory),
|
|
||||||
sessionQuery.isSuccess ? sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) : [],
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
|
|
||||||
const sessionCount = (workspace: Workspace) => {
|
|
||||||
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
|
||||||
if (sessionQuery.isError) return language.t("common.requestFailed")
|
|
||||||
const count = workspaceSessions(workspace).length
|
|
||||||
return language.plural("settings.workspaces.sessions", count, {
|
|
||||||
count,
|
|
||||||
project: projectName(workspace.project),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const lastActive = (workspace: Workspace) => {
|
|
||||||
const updated = workspaceSessions(workspace)[0]?.time.updated
|
|
||||||
if (!updated) return undefined
|
|
||||||
return getRelativeTime(new Date(updated).toISOString(), language.t)
|
|
||||||
}
|
|
||||||
const sessionTime = (session: SessionInfo) => {
|
|
||||||
if (!session.time.updated) return undefined
|
|
||||||
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
|
|
||||||
const [working, branch, sessions] = await Promise.all([
|
|
||||||
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
|
|
||||||
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
|
|
||||||
loadSessions(context),
|
|
||||||
])
|
|
||||||
const result = inspectWorkspaceDeletion({
|
|
||||||
workspace: workspace.directory,
|
|
||||||
activeDirectory: context.activeDirectory,
|
|
||||||
sessions,
|
|
||||||
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
|
|
||||||
})
|
|
||||||
return { result, sessions }
|
|
||||||
}
|
|
||||||
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
|
|
||||||
const messages = [
|
|
||||||
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
|
|
||||||
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
|
|
||||||
result.dirty ? language.t("workspace.status.dirty") : undefined,
|
|
||||||
].filter((message): message is string => message !== undefined)
|
|
||||||
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
|
|
||||||
}
|
|
||||||
const blocked = (result: WorkspaceDeleteInspection) => {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("workspace.delete.failed.title"),
|
|
||||||
description: inspectionMessages(result)[0],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
|
|
||||||
const preflight = await inspect(workspace, context)
|
|
||||||
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
|
|
||||||
blocked(preflight.result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const removed = await context.sdk.api.worktree
|
|
||||||
.remove({
|
|
||||||
projectID: workspace.project.id,
|
|
||||||
directory: workspace.directory,
|
|
||||||
force,
|
|
||||||
})
|
|
||||||
.then(() => true)
|
|
||||||
.catch((error) => {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("workspace.delete.failed.title"),
|
|
||||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (!removed) return
|
|
||||||
tabs.store.forEach((tab) => {
|
|
||||||
if (tab.type !== "draft" || tab.server !== context.server) return
|
|
||||||
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
|
||||||
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
|
||||||
if (!directoryMatches && !worktreeMatches) return
|
|
||||||
tabs.updateDraft(tab.draftID, {
|
|
||||||
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
|
||||||
worktree: undefined,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
clearWorkspaceTerminals(
|
|
||||||
workspace.directory,
|
|
||||||
preflight.sessions.map((session) => session.id),
|
|
||||||
platform,
|
|
||||||
context.sdk.scope,
|
|
||||||
)
|
|
||||||
context.sync.set(
|
|
||||||
"project",
|
|
||||||
produce((draft) => {
|
|
||||||
const project = draft.find((item) => item.id === workspace.project.id)
|
|
||||||
if (!project) return
|
|
||||||
project.sandboxes = (project.sandboxes ?? []).filter(
|
|
||||||
(directory) => pathKey(directory) !== pathKey(workspace.directory),
|
|
||||||
)
|
|
||||||
project.worktrees = project.worktrees.filter(
|
|
||||||
(worktree) => pathKey(worktree.directory) !== pathKey(workspace.directory),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let inspectionID = 0
|
|
||||||
const releaseConfirmation = () => {
|
|
||||||
if (store.transaction === "confirm") setStore("transaction", undefined)
|
|
||||||
}
|
|
||||||
const transact = async (task: () => Promise<void>) => {
|
|
||||||
if (store.transaction !== "confirm") return
|
|
||||||
setStore("transaction", "running")
|
|
||||||
try {
|
|
||||||
await task()
|
|
||||||
} catch (error) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("workspace.delete.failed.title"),
|
|
||||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setStore("transaction", undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const confirmDelete = (workspace: Workspace) => {
|
|
||||||
if (store.transaction) return
|
|
||||||
const context = captureDeleteContext()
|
|
||||||
const current = ++inspectionID
|
|
||||||
setStore("transaction", "confirm")
|
|
||||||
void dialog.push(
|
|
||||||
() => (
|
|
||||||
<DialogDeleteWorkspace
|
|
||||||
workspace={workspace}
|
|
||||||
scope={context.sdk.scope}
|
|
||||||
inspectionID={current}
|
|
||||||
inspect={() => inspect(workspace, context)}
|
|
||||||
inspectionMessages={inspectionMessages}
|
|
||||||
onDelete={() => transact(() => remove(workspace, true, context))}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
releaseConfirmation,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
|
|
||||||
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
|
|
||||||
}
|
|
||||||
const confirmDeleteAll = () => {
|
|
||||||
if (store.transaction) return
|
|
||||||
const context = captureDeleteContext()
|
|
||||||
const inventory = [...filtered()]
|
|
||||||
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
|
|
||||||
setStore("transaction", "confirm")
|
|
||||||
void dialog.push(
|
|
||||||
() => (
|
|
||||||
<DialogDeleteAllWorkspaces
|
|
||||||
count={inventory.length}
|
|
||||||
project={project}
|
|
||||||
onDelete={() => transact(() => removeAll(inventory, context))}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
releaseConfirmation,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header settings-v2-workspaces-header">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
|
||||||
<InlineServerSelect />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-tab-body settings-v2-workspaces">
|
|
||||||
<div class="settings-v2-workspaces-toolbar">
|
|
||||||
<span class="settings-v2-workspaces-count">
|
|
||||||
{language.plural("settings.workspaces.count", filtered().length)}
|
|
||||||
</span>
|
|
||||||
<div class="settings-v2-workspaces-toolbar-actions">
|
|
||||||
<Show when={projects().length > 1}>
|
|
||||||
<MenuV2 placement="bottom-end" gutter={6}>
|
|
||||||
<MenuV2.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed">
|
|
||||||
<span class="min-w-0 truncate">
|
|
||||||
{projectOptions().find((option) => option.id === selectedProject())?.label}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
|
||||||
</MenuV2.Trigger>
|
|
||||||
<MenuV2.Portal>
|
|
||||||
<MenuV2.Content>
|
|
||||||
<For each={projectOptions()}>
|
|
||||||
{(option) => (
|
|
||||||
<MenuV2.Item onSelect={() => setStore("project", option.id)}>
|
|
||||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
|
||||||
<Show when={selectedProject() === option.id}>
|
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
|
||||||
</Show>
|
|
||||||
</MenuV2.Item>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</MenuV2.Content>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2>
|
|
||||||
</Show>
|
|
||||||
<Show when={filtered().length > 0}>
|
|
||||||
<MenuV2 placement="bottom-end" gutter={4}>
|
|
||||||
<MenuV2.Trigger
|
|
||||||
as={IconButtonV2}
|
|
||||||
type="button"
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="small"
|
|
||||||
aria-label={language.t("common.moreOptions")}
|
|
||||||
disabled={!!store.transaction}
|
|
||||||
icon={<Icon name="outline-dots" size="small" />}
|
|
||||||
/>
|
|
||||||
<MenuV2.Portal>
|
|
||||||
<MenuV2.Content>
|
|
||||||
<MenuV2.Item onSelect={confirmDeleteAll}>
|
|
||||||
<span class="settings-v2-workspaces-delete-all">
|
|
||||||
{language.t("settings.workspaces.deleteAll")}
|
|
||||||
</span>
|
|
||||||
</MenuV2.Item>
|
|
||||||
</MenuV2.Content>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-workspaces-inventory">
|
|
||||||
<Show
|
|
||||||
when={filtered().length > 0}
|
|
||||||
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
|
|
||||||
>
|
|
||||||
<SettingsListV2>
|
|
||||||
<For each={filtered()}>
|
|
||||||
{(workspace) => {
|
|
||||||
const linked = () => workspaceSessions(workspace)
|
|
||||||
return (
|
|
||||||
<div class="settings-v2-workspaces-row">
|
|
||||||
<div class="settings-v2-workspaces-row-header">
|
|
||||||
<div class="settings-v2-workspaces-copy">
|
|
||||||
<div class="settings-v2-workspaces-main">
|
|
||||||
<TooltipV2
|
|
||||||
value={workspace.directory}
|
|
||||||
placement="top-start"
|
|
||||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
tabIndex={0}
|
|
||||||
dir="ltr"
|
|
||||||
aria-label={workspace.directory}
|
|
||||||
class="settings-v2-workspaces-path"
|
|
||||||
>
|
|
||||||
{workspace.directory}
|
|
||||||
</span>
|
|
||||||
</TooltipV2>
|
|
||||||
</div>
|
|
||||||
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="settings-v2-workspaces-row-actions">
|
|
||||||
<Show when={lastActive(workspace)}>
|
|
||||||
{(value) => (
|
|
||||||
<TooltipV2
|
|
||||||
value={language.t("settings.workspaces.lastActiveSession")}
|
|
||||||
placement="top-end"
|
|
||||||
>
|
|
||||||
<span tabIndex={0} class="settings-v2-workspaces-active">
|
|
||||||
{value()}
|
|
||||||
</span>
|
|
||||||
</TooltipV2>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<IconButtonV2
|
|
||||||
type="button"
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="small"
|
|
||||||
aria-label={language.t("workspace.delete.confirm", {
|
|
||||||
name: getFilename(workspace.directory),
|
|
||||||
})}
|
|
||||||
disabled={!!store.transaction}
|
|
||||||
icon={<Icon name="trash" size="small" />}
|
|
||||||
onClick={() => confirmDelete(workspace)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={linked().length > 0}>
|
|
||||||
<div class="settings-v2-workspaces-sessions">
|
|
||||||
<For each={linked()}>
|
|
||||||
{(session) => (
|
|
||||||
<div class="settings-v2-workspaces-session">
|
|
||||||
<span>{session.title}</span>
|
|
||||||
<Show when={sessionTime(session)}>
|
|
||||||
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</SettingsListV2>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const language = useLanguage()
|
|
||||||
const remove = () => {
|
|
||||||
const deleting = props.onDelete()
|
|
||||||
dialog.close()
|
|
||||||
void deleting
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog fit>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitleGroup
|
|
||||||
title={language.t("settings.workspaces.deleteAll")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
|
||||||
<br />
|
|
||||||
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
|
||||||
{language.t("common.cancel")}
|
|
||||||
</ButtonV2>
|
|
||||||
<ButtonV2 type="button" variant="danger" onClick={remove}>
|
|
||||||
{language.t("settings.workspaces.deleteAll")}
|
|
||||||
</ButtonV2>
|
|
||||||
</DialogFooter>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogDeleteWorkspace(props: {
|
|
||||||
workspace: Workspace
|
|
||||||
scope: ServerScope
|
|
||||||
inspectionID: number
|
|
||||||
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: SessionInfo[] }>
|
|
||||||
inspectionMessages: (result: WorkspaceDeleteInspection) => string[]
|
|
||||||
onDelete: () => Promise<void>
|
|
||||||
}) {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const language = useLanguage()
|
|
||||||
const status = useQuery(() => ({
|
|
||||||
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
|
|
||||||
queryFn: props.inspect,
|
|
||||||
staleTime: 0,
|
|
||||||
}))
|
|
||||||
const descriptions = () => {
|
|
||||||
if (status.isPending) return [language.t("workspace.status.checking")]
|
|
||||||
if (status.isError) return [language.t("workspace.status.error")]
|
|
||||||
if (!status.data) return []
|
|
||||||
return props.inspectionMessages(status.data.result)
|
|
||||||
}
|
|
||||||
const remove = () => {
|
|
||||||
const deleting = props.onDelete()
|
|
||||||
dialog.close()
|
|
||||||
void deleting
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog fit>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitleGroup
|
|
||||||
title={language.t("workspace.delete.title")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
|
||||||
<br />
|
|
||||||
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
|
||||||
{props.workspace.directory}
|
|
||||||
</code>
|
|
||||||
<br />
|
|
||||||
{language.t("settings.workspaces.delete.warning")}
|
|
||||||
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
|
||||||
{language.t("common.cancel")}
|
|
||||||
</ButtonV2>
|
|
||||||
<ButtonV2
|
|
||||||
type="button"
|
|
||||||
variant="danger"
|
|
||||||
disabled={status.isPending || status.isError || status.data?.result.active}
|
|
||||||
onClick={remove}
|
|
||||||
>
|
|
||||||
{language.t("workspace.delete.button")}
|
|
||||||
</ButtonV2>
|
|
||||||
</DialogFooter>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import {
|
||||||
|
type Accessor,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
For,
|
||||||
|
type JSXElement,
|
||||||
|
onCleanup,
|
||||||
|
Show,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { ServerConnection, useServers } from "@/context/servers"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { type ServerHealth } from "@/utils/server-health"
|
import { type ServerHealth } from "@/utils/server-health"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
|
|
||||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
const parts = value.split(file)
|
const parts = value.split(file)
|
||||||
@@ -101,9 +109,12 @@ type ServerStatusItem = {
|
|||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const global = useGlobal()
|
||||||
|
const server = useServer()
|
||||||
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
const fail = (err: unknown) => {
|
||||||
@@ -114,18 +125,29 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.shown()) return
|
||||||
|
})
|
||||||
|
|
||||||
let dialogRun = 0
|
let dialogRun = 0
|
||||||
|
let dialogDead = false
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
dialogDead = true
|
||||||
dialogRun += 1
|
dialogRun += 1
|
||||||
})
|
})
|
||||||
|
const sortedServers = createMemo(() => {
|
||||||
|
const list = global.servers.list()
|
||||||
|
return listServersByHealth(list, server.key, global.servers.health)
|
||||||
|
})
|
||||||
const toggleMcp = useMcpToggle()
|
const toggleMcp = useMcpToggle()
|
||||||
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||||
const lspCount = createMemo(() => lspItems().length)
|
const lspCount = createMemo(() => lspItems().length)
|
||||||
const [pluginList] = createResource(
|
const [pluginList] = createResource(
|
||||||
() => (props.shown ? sdk().directory : undefined),
|
() => (props.shown() ? sdk().directory : undefined),
|
||||||
(directory) =>
|
(directory) =>
|
||||||
sdk()
|
sdk()
|
||||||
.api.plugin.list({ location: { directory } })
|
.api.plugin.list({ location: { directory } })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { Popover } from "@opencode-ai/ui/popover"
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, useServers } from "@/context/servers"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
hasServiceNeedingAttention,
|
hasServiceNeedingAttention,
|
||||||
serverStatusDotClass,
|
serverStatusDotClass,
|
||||||
} from "./status-popover-indicator"
|
} from "./status-popover-indicator"
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||||
|
|
||||||
@@ -75,7 +74,7 @@ export function StatusPopover() {
|
|||||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Body shown={shown()} />
|
<Body shown={shown} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -83,12 +82,16 @@ export function StatusPopover() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPopoverV2() {
|
export function StatusPopoverV2() {
|
||||||
|
return <DirectoryStatusPopover />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectoryStatusPopover() {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServerSDK()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
const attention = createMemo(() =>
|
const attention = createMemo(() =>
|
||||||
hasServiceNeedingAttention({
|
hasServiceNeedingAttention({
|
||||||
@@ -111,7 +114,7 @@ export function StatusPopoverV2() {
|
|||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
body: () => (
|
body: () => (
|
||||||
<StatusPopoverBody shown={shown()}>
|
<StatusPopoverBody shown={shown()}>
|
||||||
<Body shown={shown()} />
|
<Body shown={shown} />
|
||||||
</StatusPopoverBody>
|
</StatusPopoverBody>
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
|
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
const url = sdk().url
|
const url = sdk().url
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { ServerConnection } from "@/context/servers"
|
import type { ServerConnection } from "@/context/server"
|
||||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
|
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
|
||||||
|
|
||||||
const remote = "remote" as ServerConnection.Key
|
const remote = "remote" as ServerConnection.Key
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ServerConnection } from "@/context/servers"
|
import type { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
|
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
|
||||||
|
|
||||||
export type SessionTabsRemovedDetail = {
|
export type SessionTabsRemovedDetail = {
|
||||||
server: ServerConnection.Key
|
server?: ServerConnection.Key
|
||||||
directory: string
|
directory: string
|
||||||
sessionIDs: string[]
|
sessionIDs: string[]
|
||||||
}
|
}
|
||||||
@@ -19,15 +19,16 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
|
|||||||
if (!detail || typeof detail !== "object") return undefined
|
if (!detail || typeof detail !== "object") return undefined
|
||||||
if (!("directory" in detail)) return undefined
|
if (!("directory" in detail)) return undefined
|
||||||
if (!("sessionIDs" in detail)) return undefined
|
if (!("sessionIDs" in detail)) return undefined
|
||||||
if (!("server" in detail) || typeof detail.server !== "string") return undefined
|
|
||||||
if (typeof detail.directory !== "string") return undefined
|
if (typeof detail.directory !== "string") return undefined
|
||||||
if (!Array.isArray(detail.sessionIDs)) return undefined
|
if (!Array.isArray(detail.sessionIDs)) return undefined
|
||||||
|
if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined
|
||||||
|
|
||||||
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
|
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
|
||||||
if (sessionIDs.length === 0) return undefined
|
if (sessionIDs.length === 0) return undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server: ServerConnection.Key.make(detail.server),
|
server:
|
||||||
|
"server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
|
||||||
directory: detail.directory,
|
directory: detail.directory,
|
||||||
sessionIDs,
|
sessionIDs,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|||||||
import { createMutation } from "@tanstack/solid-query"
|
import { createMutation } from "@tanstack/solid-query"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { useGlobal, useServerCtx } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, serverName, useServers } from "@/context/servers"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
@@ -22,14 +22,14 @@ export function TabNavItem(props: {
|
|||||||
ref?: Ref<HTMLDivElement>
|
ref?: Ref<HTMLDivElement>
|
||||||
href: string
|
href: string
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
session: SessionInfo | undefined
|
session: () => SessionInfo | undefined
|
||||||
fallbackTitle?: string
|
fallbackTitle?: string
|
||||||
onRename: (title: string) => Promise<void>
|
onRename: (title: string) => Promise<void>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onNavigate: () => void
|
onNavigate: () => void
|
||||||
active?: boolean
|
active?: boolean
|
||||||
forceTruncate?: boolean
|
forceTruncate?: boolean
|
||||||
suppressNavigation?: boolean
|
suppressNavigation?: () => boolean
|
||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
@@ -46,38 +46,41 @@ export function TabNavItem(props: {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
const servers = useServers()
|
const global = useGlobal()
|
||||||
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
|
const serverCtx = createMemo(() => {
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||||
|
if (conn) return global.ensureServerCtx(conn)
|
||||||
|
})
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
const session = props.session
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||||
})
|
})
|
||||||
const title = createMemo(() => {
|
const title = createMemo(() => {
|
||||||
const session = props.session
|
const session = props.session()
|
||||||
return session ? sessionLabel(session) : props.fallbackTitle
|
return session ? sessionLabel(session) : props.fallbackTitle
|
||||||
})
|
})
|
||||||
|
|
||||||
const projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const session = props.session
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
return displayName(project() ?? { worktree: session.location.directory })
|
return displayName(project() ?? { worktree: session.location.directory })
|
||||||
})
|
})
|
||||||
const previewPath = createMemo(() => {
|
const previewPath = createMemo(() => {
|
||||||
const session = props.session
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
const home = serverCtx()?.sync.data.path.home
|
const home = serverCtx()?.sync.data.path.home
|
||||||
return home ? session.location.directory.replace(home, "~") : session.location.directory
|
return home ? session.location.directory.replace(home, "~") : session.location.directory
|
||||||
})
|
})
|
||||||
// Only label the server when multiple servers are connected.
|
// Only label the server when multiple servers are connected.
|
||||||
const serverLabel = createMemo(() => {
|
const serverLabel = createMemo(() => {
|
||||||
if (servers.list.length <= 1) return
|
if (global.servers.list().length <= 1) return
|
||||||
const conn = servers.list.find((item) => ServerConnection.key(item) === props.server)
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||||
return conn ? serverName(conn) : undefined
|
return conn ? serverName(conn) : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
||||||
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session
|
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
|
||||||
|
|
||||||
const measureTitleOverflow = () => {
|
const measureTitleOverflow = () => {
|
||||||
if (!titleEl || editing()) {
|
if (!titleEl || editing()) {
|
||||||
@@ -118,7 +121,7 @@ export function TabNavItem(props: {
|
|||||||
const closeRename = async (save: boolean) => {
|
const closeRename = async (save: boolean) => {
|
||||||
if (rename.isPending || !editing()) return
|
if (rename.isPending || !editing()) return
|
||||||
|
|
||||||
const original = props.session?.title ?? ""
|
const original = props.session()?.title ?? ""
|
||||||
const next = (titleEl.textContent ?? "").trim()
|
const next = (titleEl.textContent ?? "").trim()
|
||||||
|
|
||||||
titleEl.scrollLeft = 0
|
titleEl.scrollLeft = 0
|
||||||
@@ -143,7 +146,7 @@ export function TabNavItem(props: {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
||||||
const session = props.session
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
titleEl.textContent = session.title ?? ""
|
titleEl.textContent = session.title ?? ""
|
||||||
setEditing(true)
|
setEditing(true)
|
||||||
@@ -210,7 +213,7 @@ export function TabNavItem(props: {
|
|||||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
if (editing()) return
|
if (editing()) return
|
||||||
if (props.suppressNavigation) return
|
if (props.suppressNavigation?.()) return
|
||||||
props.onNavigate()
|
props.onNavigate()
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -218,14 +221,14 @@ export function TabNavItem(props: {
|
|||||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||||
if (event.detail > 0) return
|
if (event.detail > 0) return
|
||||||
if (editing()) return
|
if (editing()) return
|
||||||
if (props.suppressNavigation) return
|
if (props.suppressNavigation?.()) return
|
||||||
props.onNavigate()
|
props.onNavigate()
|
||||||
}}
|
}}
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
>
|
>
|
||||||
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
||||||
<Show
|
<Show
|
||||||
when={props.session}
|
when={props.session()}
|
||||||
keyed
|
keyed
|
||||||
fallback={
|
fallback={
|
||||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||||
@@ -248,7 +251,6 @@ export function TabNavItem(props: {
|
|||||||
}}
|
}}
|
||||||
data-slot="tab-title"
|
data-slot="tab-title"
|
||||||
data-titlebar-tab-title
|
data-titlebar-tab-title
|
||||||
dir="auto"
|
|
||||||
class="min-w-0 flex-1 outline-none leading-4"
|
class="min-w-0 flex-1 outline-none leading-4"
|
||||||
classList={{
|
classList={{
|
||||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||||
@@ -265,7 +267,7 @@ export function TabNavItem(props: {
|
|||||||
}
|
}
|
||||||
if (event.key !== "Escape") return
|
if (event.key !== "Escape") return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
titleEl.textContent = props.session?.title ?? ""
|
titleEl.textContent = props.session()?.title ?? ""
|
||||||
void closeRename(false)
|
void closeRename(false)
|
||||||
}}
|
}}
|
||||||
onBlur={() => void closeRename(true)}
|
onBlur={() => void closeRename(true)}
|
||||||
@@ -306,7 +308,7 @@ export function TabNavItem(props: {
|
|||||||
}}
|
}}
|
||||||
data={{
|
data={{
|
||||||
projectName: projectName(),
|
projectName: projectName(),
|
||||||
title: props.session?.title,
|
title: props.session()?.title,
|
||||||
path: previewPath(),
|
path: previewPath(),
|
||||||
serverName: serverLabel(),
|
serverName: serverLabel(),
|
||||||
}}
|
}}
|
||||||
@@ -321,7 +323,7 @@ export function DraftTabItem(props: {
|
|||||||
active?: boolean
|
active?: boolean
|
||||||
onNavigate: () => void
|
onNavigate: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
suppressNavigation?: boolean
|
suppressNavigation?: () => boolean
|
||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
@@ -364,14 +366,14 @@ export function DraftTabItem(props: {
|
|||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
if (props.suppressNavigation) return
|
if (props.suppressNavigation?.()) return
|
||||||
props.onNavigate()
|
props.onNavigate()
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||||
if (event.detail > 0) return
|
if (event.detail > 0) return
|
||||||
if (props.suppressNavigation) return
|
if (props.suppressNavigation?.()) return
|
||||||
props.onNavigate()
|
props.onNavigate()
|
||||||
}}
|
}}
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
|
|||||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||||
import { arrayMove } from "@dnd-kit/helpers"
|
import { arrayMove } from "@dnd-kit/helpers"
|
||||||
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
||||||
import { useGlobal, useServerCtx, type ServerCtx } from "@/context/global"
|
import { useGlobal, type ServerCtx } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
@@ -24,10 +24,10 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
|
|||||||
function SessionTabSlot(props: {
|
function SessionTabSlot(props: {
|
||||||
tab: SessionTab
|
tab: SessionTab
|
||||||
id: string
|
id: string
|
||||||
index: number
|
index: () => number
|
||||||
active: boolean
|
active: () => boolean
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
session: SessionInfo | undefined
|
session: () => SessionInfo | undefined
|
||||||
fallbackTitle?: string
|
fallbackTitle?: string
|
||||||
onRename: (title: string) => Promise<void>
|
onRename: (title: string) => Promise<void>
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
@@ -38,7 +38,7 @@ function SessionTabSlot(props: {
|
|||||||
return props.id
|
return props.id
|
||||||
},
|
},
|
||||||
get index() {
|
get index() {
|
||||||
return props.index
|
return props.index()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
@@ -48,7 +48,7 @@ function SessionTabSlot(props: {
|
|||||||
ref={sortable.ref}
|
ref={sortable.ref}
|
||||||
data-titlebar-tab-slot
|
data-titlebar-tab-slot
|
||||||
data-tab-key={props.id}
|
data-tab-key={props.id}
|
||||||
data-active={props.active}
|
data-active={props.active()}
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
>
|
>
|
||||||
<TabNavItem
|
<TabNavItem
|
||||||
@@ -62,7 +62,7 @@ function SessionTabSlot(props: {
|
|||||||
onRename={props.onRename}
|
onRename={props.onRename}
|
||||||
onNavigate={() => props.onNavigate(ref)}
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
active={props.active}
|
active={props.active()}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
dragging={sortable.isDragSource()}
|
dragging={sortable.isDragSource()}
|
||||||
/>
|
/>
|
||||||
@@ -73,34 +73,34 @@ function SessionTabSlot(props: {
|
|||||||
function SessionTabEntry(props: {
|
function SessionTabEntry(props: {
|
||||||
tab: SessionTab
|
tab: SessionTab
|
||||||
id: string
|
id: string
|
||||||
index: number
|
index: () => number
|
||||||
active: boolean
|
active: () => boolean
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
serverCtx: ServerCtx | undefined
|
serverCtx: () => ServerCtx | undefined
|
||||||
onVisibleChange: (visible: boolean) => void
|
onVisibleChange: (visible: boolean) => void
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||||
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
|
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||||
const persisted = createMemo(() => tabs.info[props.id])
|
const persisted = createMemo(() => tabs.info[props.id])
|
||||||
const [loadedSession] = createResource(
|
const [loadedSession] = createResource(
|
||||||
() => {
|
() => {
|
||||||
const ctx = props.serverCtx
|
const ctx = props.serverCtx()
|
||||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||||
},
|
},
|
||||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||||
)
|
)
|
||||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||||
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
|
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||||
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
|
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
|
||||||
let prefetched = false
|
let prefetched = false
|
||||||
|
|
||||||
const rename = async (title: string) => {
|
const rename = async (title: string) => {
|
||||||
const value = session()
|
const value = session()
|
||||||
const ctx = props.serverCtx
|
const ctx = props.serverCtx()
|
||||||
if (!value || !ctx) return
|
if (!value || !ctx) return
|
||||||
|
|
||||||
ctx.sync.session.remember({ ...value, title })
|
ctx.sync.session.remember({ ...value, title })
|
||||||
@@ -108,7 +108,7 @@ function SessionTabEntry(props: {
|
|||||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const current = session()
|
const current = session()
|
||||||
const currentCtx = props.serverCtx
|
const currentCtx = props.serverCtx()
|
||||||
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("common.requestFailed"),
|
title: language.t("common.requestFailed"),
|
||||||
@@ -120,7 +120,7 @@ function SessionTabEntry(props: {
|
|||||||
createEffect(() => props.onVisibleChange(visible()))
|
createEffect(() => props.onVisibleChange(visible()))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const ctx = props.serverCtx
|
const ctx = props.serverCtx()
|
||||||
const value = session()
|
const value = session()
|
||||||
if (!ctx || !value || prefetched) return
|
if (!ctx || !value || prefetched) return
|
||||||
prefetched = true
|
prefetched = true
|
||||||
@@ -157,7 +157,7 @@ function SessionTabEntry(props: {
|
|||||||
index={props.index}
|
index={props.index}
|
||||||
active={props.active}
|
active={props.active}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
session={session()}
|
session={session}
|
||||||
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
||||||
onRename={rename}
|
onRename={rename}
|
||||||
onNavigate={props.onNavigate}
|
onNavigate={props.onNavigate}
|
||||||
@@ -170,8 +170,8 @@ function SessionTabEntry(props: {
|
|||||||
function DraftTabSlot(props: {
|
function DraftTabSlot(props: {
|
||||||
tab: Extract<Tab, { type: "draft" }>
|
tab: Extract<Tab, { type: "draft" }>
|
||||||
id: string
|
id: string
|
||||||
index: number
|
index: () => number
|
||||||
active: boolean
|
active: () => boolean
|
||||||
title: string
|
title: string
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -181,7 +181,7 @@ function DraftTabSlot(props: {
|
|||||||
return props.id
|
return props.id
|
||||||
},
|
},
|
||||||
get index() {
|
get index() {
|
||||||
return props.index
|
return props.index()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
@@ -191,7 +191,7 @@ function DraftTabSlot(props: {
|
|||||||
ref={sortable.ref}
|
ref={sortable.ref}
|
||||||
data-titlebar-tab-slot
|
data-titlebar-tab-slot
|
||||||
data-tab-key={props.id}
|
data-tab-key={props.id}
|
||||||
data-active={props.active}
|
data-active={props.active()}
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
>
|
>
|
||||||
<DraftTabItem
|
<DraftTabItem
|
||||||
@@ -202,7 +202,7 @@ function DraftTabSlot(props: {
|
|||||||
title={props.title}
|
title={props.title}
|
||||||
onNavigate={() => props.onNavigate(ref)}
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
active={props.active}
|
active={props.active()}
|
||||||
dragging={sortable.isDragSource()}
|
dragging={sortable.isDragSource()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
|
|||||||
|
|
||||||
export function TitlebarTabStrip(props: {
|
export function TitlebarTabStrip(props: {
|
||||||
tabs: Tab[]
|
tabs: Tab[]
|
||||||
currentTab: Tab | undefined
|
currentTab: () => Tab | undefined
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||||
onClose: (tab: Tab) => void
|
onClose: (tab: Tab) => void
|
||||||
@@ -248,7 +248,7 @@ export function TitlebarTabStrip(props: {
|
|||||||
])
|
])
|
||||||
|
|
||||||
function selectAdjacentTab(offset: -1 | 1) {
|
function selectAdjacentTab(offset: -1 | 1) {
|
||||||
const current = props.currentTab
|
const current = props.currentTab()
|
||||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
||||||
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
||||||
if (next) props.onNavigate(next)
|
if (next) props.onNavigate(next)
|
||||||
@@ -339,9 +339,10 @@ export function TitlebarTabStrip(props: {
|
|||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
|
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
|
||||||
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
|
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
|
||||||
const serverCtx = useServerCtx(() => {
|
const serverCtx = createMemo(() => {
|
||||||
if (tab.type !== "session") return
|
if (tab.type !== "session") return
|
||||||
return global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||||
|
if (conn) return global.ensureServerCtx(conn)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (tab.type === "session") {
|
if (tab.type === "session") {
|
||||||
@@ -349,10 +350,10 @@ export function TitlebarTabStrip(props: {
|
|||||||
<SessionTabEntry
|
<SessionTabEntry
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
index={visibleIndex()}
|
index={visibleIndex}
|
||||||
active={props.currentTab === tab}
|
active={() => props.currentTab() === tab}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
serverCtx={serverCtx()}
|
serverCtx={serverCtx}
|
||||||
onVisibleChange={(visible) => setVisibility(id, visible)}
|
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||||
onNavigate={(element) => {
|
onNavigate={(element) => {
|
||||||
ref = element
|
ref = element
|
||||||
@@ -367,8 +368,8 @@ export function TitlebarTabStrip(props: {
|
|||||||
<DraftTabSlot
|
<DraftTabSlot
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
index={visibleIndex()}
|
index={visibleIndex}
|
||||||
active={props.currentTab === tab}
|
active={() => props.currentTab() === tab}
|
||||||
title={language.t("command.session.new")}
|
title={language.t("command.session.new")}
|
||||||
onNavigate={(element) => {
|
onNavigate={(element) => {
|
||||||
ref = element
|
ref = element
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
|
|||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||||
import { useGlobal, useServerCtx } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { ServerConnection, useServers } from "@/context/servers"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { tabKey, useTabs } from "@/context/tabs"
|
import { tabKey, useTabs } from "@/context/tabs"
|
||||||
import type { PromptSession } from "@/context/prompt"
|
import type { PromptSession } from "@/context/prompt"
|
||||||
import "./titlebar.css"
|
import "./titlebar.css"
|
||||||
@@ -46,8 +46,8 @@ const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px
|
|||||||
const macTrafficLightsBaseWidth = 84
|
const macTrafficLightsBaseWidth = 84
|
||||||
|
|
||||||
export type TitlebarUpdate = {
|
export type TitlebarUpdate = {
|
||||||
version: string | undefined
|
version: () => string | undefined
|
||||||
installing: boolean
|
installing: () => boolean
|
||||||
install: () => void
|
install: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,13 +61,15 @@ export function useTitlebarRightMount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
|
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||||
|
const layout = useLayout()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const servers = useServers()
|
const server = useServer()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const params = useParams()
|
||||||
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
|
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
|
||||||
@@ -75,9 +77,11 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
|
const web = createMemo(() => platform.platform === "web")
|
||||||
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
|
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
|
||||||
const zoom = () => platform.webviewZoom?.() ?? 1
|
const zoom = () => platform.webviewZoom?.() ?? 1
|
||||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||||
|
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||||
const minHeight = () => {
|
const minHeight = () => {
|
||||||
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
||||||
if (mac()) return `${height / zoom()}px`
|
if (mac()) return `${height / zoom()}px`
|
||||||
@@ -93,6 +97,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
})
|
})
|
||||||
|
|
||||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||||
|
const creating = createMemo(() => {
|
||||||
|
const route = layout.route()
|
||||||
|
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
|
||||||
|
if (!params.dir) return false
|
||||||
|
if (params.id) return false
|
||||||
|
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
||||||
|
return parts.at(-1) === "session"
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = path()
|
const current = path()
|
||||||
@@ -104,9 +116,13 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const canBack = createMemo(() => history.index > 0)
|
||||||
|
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||||
|
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||||
|
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
||||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||||
const installing = props.update?.installing ?? false
|
const installing = props.update?.installing() ?? false
|
||||||
const version = props.update?.version
|
const version = props.update?.version()
|
||||||
return {
|
return {
|
||||||
visible: version !== undefined || installing,
|
visible: version !== undefined || installing,
|
||||||
installing,
|
installing,
|
||||||
@@ -172,7 +188,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when>
|
<Match when={useV2Titlebar()}>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
@@ -184,7 +200,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
() => {
|
() => {
|
||||||
const route = layout.route()
|
const route = layout.route()
|
||||||
if (route.type !== "session") return undefined
|
if (route.type !== "session") return undefined
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
|
const conn = global.servers
|
||||||
|
.list()
|
||||||
|
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
|
||||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||||
},
|
},
|
||||||
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
|
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
|
||||||
@@ -227,7 +245,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
const s = session()
|
const s = session()
|
||||||
if (!s) return
|
if (!s) return
|
||||||
const sessionId = s.parentID ?? s.id
|
const sessionId = s.parentID ?? s.id
|
||||||
const next = { server: route.server, sessionId }
|
const next = { server: route.server ?? server.key, sessionId }
|
||||||
tabsStoreActions.addSessionTab(next)
|
tabsStoreActions.addSessionTab(next)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -240,43 +258,53 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
|
|
||||||
const openNewTab = () => {
|
const openNewTab = () => {
|
||||||
const route = layout.route()
|
const route = layout.route()
|
||||||
switch (route.type) {
|
const activeSession = session()
|
||||||
case "session": {
|
if (route.type === "session" && activeSession) {
|
||||||
const activeSession = session()
|
const sessionTab = {
|
||||||
if (!activeSession) return
|
type: "session" as const,
|
||||||
|
server: route.server ?? server.key,
|
||||||
const sessionTab = {
|
sessionId: activeSession.id,
|
||||||
type: "session" as const,
|
|
||||||
server: route.server,
|
|
||||||
sessionId: activeSession.id,
|
|
||||||
}
|
|
||||||
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
|
|
||||||
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
case "draft": {
|
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
|
||||||
const activeTab = currentTab()
|
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
|
||||||
if (activeTab?.type !== "draft") return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
|
const activeTab = currentTab()
|
||||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
if (activeTab?.type === "draft") {
|
||||||
|
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
|
||||||
|
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.type === "home") {
|
||||||
|
const selection = layout.home.selection()
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
|
||||||
|
const project = conn
|
||||||
|
? global
|
||||||
|
.ensureServerCtx(conn)
|
||||||
|
.projects.list()
|
||||||
|
.find((item) => item.worktree === selection.directory)
|
||||||
|
: undefined
|
||||||
|
if (conn && project) {
|
||||||
|
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "home": {
|
|
||||||
const selection = layout.home.selection()
|
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
|
|
||||||
const project = conn
|
|
||||||
? global
|
|
||||||
.ensureServerCtx(conn)
|
|
||||||
.projects.list()
|
|
||||||
.find((item) => item.worktree === selection.directory)
|
|
||||||
: undefined
|
|
||||||
if (conn && project) {
|
|
||||||
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const current = layout.projects.list()[0]
|
||||||
|
if (current) {
|
||||||
|
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = global.servers.list().flatMap((conn) => {
|
||||||
|
const project = global.ensureServerCtx(conn).projects.list()[0]
|
||||||
|
return project ? [{ server: ServerConnection.key(conn), project }] : []
|
||||||
|
})[0]
|
||||||
|
if (!fallback) return
|
||||||
|
|
||||||
|
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
|
||||||
}
|
}
|
||||||
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
|
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
|
||||||
|
|
||||||
@@ -364,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
|
|
||||||
<TitlebarTabStrip
|
<TitlebarTabStrip
|
||||||
tabs={tabsStore}
|
tabs={tabsStore}
|
||||||
currentTab={currentTab()}
|
currentTab={currentTab}
|
||||||
forceTruncate={tabsAreOverflowing()}
|
forceTruncate={tabsAreOverflowing()}
|
||||||
onOverflowChange={setTabsAreOverflowing}
|
onOverflowChange={setTabsAreOverflowing}
|
||||||
onNavigate={(tab, el) => {
|
onNavigate={(tab, el) => {
|
||||||
@@ -402,6 +430,158 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when>
|
||||||
|
<div
|
||||||
|
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
|
||||||
|
style={{ zoom: counterZoom() }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex items-center min-w-0": true,
|
||||||
|
"pl-2": !macTrafficLights(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={windows() || linux()}>
|
||||||
|
<WindowsAppMenu command={command} platform={platform} />
|
||||||
|
</Show>
|
||||||
|
<Show when={mac()}>
|
||||||
|
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||||
|
<IconButton
|
||||||
|
icon="menu"
|
||||||
|
variant="ghost"
|
||||||
|
class="titlebar-icon rounded-md"
|
||||||
|
onClick={layout.mobileSidebar.toggle}
|
||||||
|
aria-label={language.t("sidebar.menu.toggle")}
|
||||||
|
aria-expanded={layout.mobileSidebar.opened()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!mac()}>
|
||||||
|
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
|
||||||
|
<IconButton
|
||||||
|
icon="menu"
|
||||||
|
variant="ghost"
|
||||||
|
class="titlebar-icon rounded-md"
|
||||||
|
onClick={layout.mobileSidebar.toggle}
|
||||||
|
aria-label={language.t("sidebar.menu.toggle")}
|
||||||
|
aria-expanded={layout.mobileSidebar.opened()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<TooltipKeybind
|
||||||
|
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
|
||||||
|
placement="bottom"
|
||||||
|
title={language.t("command.sidebar.toggle")}
|
||||||
|
keybind={command.keybind("sidebar.toggle")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
|
onClick={layout.sidebar.toggle}
|
||||||
|
aria-label={language.t("command.sidebar.toggle")}
|
||||||
|
aria-expanded={layout.sidebar.opened()}
|
||||||
|
>
|
||||||
|
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
<div class="hidden xl:flex items-center shrink-0">
|
||||||
|
<Show when={params.dir}>
|
||||||
|
<div
|
||||||
|
class="flex items-center shrink-0 w-8 mr-1"
|
||||||
|
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="transition-opacity"
|
||||||
|
classList={{
|
||||||
|
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
|
||||||
|
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="bottom"
|
||||||
|
title={language.t("command.session.new")}
|
||||||
|
keybind={command.keybind("session.new")}
|
||||||
|
openDelay={800}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
|
disabled={layout.sidebar.opened()}
|
||||||
|
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (!params.dir) return
|
||||||
|
navigate(`/${params.dir}/session`)
|
||||||
|
}}
|
||||||
|
aria-label={language.t("command.session.new")}
|
||||||
|
aria-current={creating() ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<IconV2 name="edit" size="small" />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div
|
||||||
|
class="flex items-center shrink-0"
|
||||||
|
classList={{
|
||||||
|
"ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
||||||
|
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||||
|
"duration-180 ease-in": layout.sidebar.opened(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={hasProjects() && nav()}>
|
||||||
|
<div class="flex items-center gap-0 transition-transform">
|
||||||
|
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="chevron-left"
|
||||||
|
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||||
|
disabled={!canBack()}
|
||||||
|
onClick={back}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="chevron-right"
|
||||||
|
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||||
|
disabled={!canForward()}
|
||||||
|
onClick={forward}
|
||||||
|
aria-label={language.t("common.goForward")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChannelIndicator debugTools={props.debugTools} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div
|
||||||
|
id="opencode-titlebar-center"
|
||||||
|
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex items-center min-w-0 justify-end": true,
|
||||||
|
"pr-2": !windows(),
|
||||||
|
}}
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||||
|
<Show when={windows()}>
|
||||||
|
<div class="shrink-0" style={{ width: windowsControlsWidth() }} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
@@ -477,10 +657,12 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={["local", "beta", "dev"].includes(channel)}>
|
<>
|
||||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
{["local", "beta", "dev"].includes(channel) && (
|
||||||
{channel.toUpperCase()}
|
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||||
</div>
|
{channel.toUpperCase()}
|
||||||
</Show>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show, type JSX } from "solid-js"
|
import { Show, type JSX } from "solid-js"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -50,20 +50,7 @@ export function WindowsAppMenu(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
||||||
<Show
|
{props.variant === "v2" ? (
|
||||||
when={props.variant === "v2"}
|
|
||||||
fallback={
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="menu"
|
|
||||||
variant="ghost"
|
|
||||||
class="titlebar-icon rounded-md shrink-0"
|
|
||||||
aria-label={language.t("desktop.menu.ariaLabel")}
|
|
||||||
onPointerDown={rememberFocus}
|
|
||||||
onKeyDown={rememberFocus}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
data-component="desktop-icon-button"
|
data-component="desktop-icon-button"
|
||||||
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
||||||
@@ -78,31 +65,39 @@ export function WindowsAppMenu(props: {
|
|||||||
onKeyDown={rememberFocus}
|
onKeyDown={rememberFocus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
) : (
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButton}
|
||||||
|
icon="menu"
|
||||||
|
variant="ghost"
|
||||||
|
class="titlebar-icon rounded-md shrink-0"
|
||||||
|
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||||
|
onPointerDown={rememberFocus}
|
||||||
|
onKeyDown={rememberFocus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content class="desktop-app-menu">
|
<DropdownMenu.Content class="desktop-app-menu">
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
||||||
<For each={DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows"))}>
|
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
|
||||||
{(menu) => (
|
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
||||||
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
{menu.items
|
||||||
<For each={menu.items?.filter((entry) => desktopMenuVisible(entry, "windows"))}>
|
?.filter((entry) => desktopMenuVisible(entry, "windows"))
|
||||||
{(entry) => {
|
.map((entry) =>
|
||||||
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
|
entry.type === "separator" ? (
|
||||||
if (entry.type === "separator") return <DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
return (
|
) : (
|
||||||
<DesktopMenuItem
|
<DesktopMenuItem
|
||||||
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||||
onSelect={() => runEntry(entry)}
|
onSelect={() => runEntry(entry)}
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
}}
|
)}
|
||||||
</For>
|
</DesktopMenuSubmenu>
|
||||||
</DesktopMenuSubmenu>
|
))}
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||||||
const decoded = decodeSessionKey(key)
|
const decoded = decodeSessionKey(key)
|
||||||
return createRoot((dispose) => ({
|
return createRoot((dispose) => ({
|
||||||
value: createCommentSession(
|
value: createCommentSession(
|
||||||
serverSDK.scope,
|
serverSDK().scope,
|
||||||
decoded.dir,
|
decoded.dir,
|
||||||
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
|
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
const scope = createMemo(() => sdk().directory)
|
const scope = createMemo(() => sdk().directory)
|
||||||
const path = createPathHelpers(scope)
|
const path = createPathHelpers(scope)
|
||||||
const tabs = layout.tabs(() =>
|
const tabs = layout.tabs(() =>
|
||||||
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
|
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
@@ -123,7 +123,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewCache = createFileViewCache(serverSDK.scope)
|
const viewCache = createFileViewCache(serverSDK().scope)
|
||||||
const view = createMemo(() => viewCache.load(scope(), params.id))
|
const view = createMemo(() => viewCache.load(scope(), params.id))
|
||||||
|
|
||||||
const ensure = (file: string) => {
|
const ensure = (file: string) => {
|
||||||
@@ -211,8 +211,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||||
serverSDK.api.file
|
serverSDK()
|
||||||
.find(
|
.api.file.find(
|
||||||
{
|
{
|
||||||
location: { directory: sdk().directory },
|
location: { directory: sdk().directory },
|
||||||
query,
|
query,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
|
|||||||
const refresh: string[] = []
|
const refresh: string[] = []
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src/new.ts",
|
file: "src/new.ts",
|
||||||
event: "add",
|
event: "add",
|
||||||
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
|
|||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src/open.ts",
|
file: "src/open.ts",
|
||||||
event: "change",
|
event: "change",
|
||||||
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
|
|||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src",
|
file: "src",
|
||||||
event: "change",
|
event: "change",
|
||||||
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
|
|||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src/file.ts",
|
file: "src/file.ts",
|
||||||
event: "change",
|
event: "change",
|
||||||
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
|
|||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: ".git/index.lock",
|
file: ".git/index.lock",
|
||||||
event: "change",
|
event: "change",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type WatcherOps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||||
if (event.type !== "filesystem.changed") return
|
if (event.type !== "file.watcher.updated") return
|
||||||
const props =
|
const props =
|
||||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { ServerScope } from "@/utils/server-scope"
|
|||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type ProjectApi = ServerApi["project"]
|
type ProjectApi = ServerApi["project"]
|
||||||
type WorktreeApi = ServerApi["worktree"]
|
|
||||||
|
|
||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
test("partitions identical directories by server scope", () => {
|
test("partitions identical directories by server scope", () => {
|
||||||
@@ -107,58 +106,16 @@ describe("query keys", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("loads projects from the current endpoint", async () => {
|
test("loads projects from the current endpoint", async () => {
|
||||||
const calls: string[] = []
|
const api = {
|
||||||
const projects = {
|
|
||||||
list: async () => [
|
list: async () => [
|
||||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
],
|
],
|
||||||
} as unknown as ProjectApi
|
} as unknown as ProjectApi
|
||||||
const worktrees = {
|
|
||||||
list: async ({ projectID }: { projectID: string }) => {
|
|
||||||
calls.push(projectID)
|
|
||||||
return [
|
|
||||||
{ directory: `/${projectID}` },
|
|
||||||
{ directory: `/${projectID}/clone` },
|
|
||||||
{ directory: `/${projectID}/copy`, strategy: "git" },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
} as unknown as WorktreeApi
|
|
||||||
|
|
||||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
||||||
|
|
||||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||||
expect(result.map((project) => project.sandboxes)).toEqual([
|
|
||||||
["/a/clone", "/a/copy"],
|
|
||||||
["/b/clone", "/b/copy"],
|
|
||||||
])
|
|
||||||
expect(result.map((project) => project.worktrees)).toEqual([
|
|
||||||
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
|
|
||||||
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
|
|
||||||
])
|
|
||||||
expect(calls.toSorted()).toEqual(["a", "b"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps projects whose directory inventory cannot load", async () => {
|
|
||||||
const projects = {
|
|
||||||
list: async () => [
|
|
||||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
|
||||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
|
||||||
],
|
|
||||||
} as unknown as ProjectApi
|
|
||||||
const worktrees = {
|
|
||||||
list: async ({ projectID }: { projectID: string }) => {
|
|
||||||
if (projectID === "b") throw new Error("unavailable")
|
|
||||||
return [{ directory: "/a/copy", strategy: "git" as const }]
|
|
||||||
},
|
|
||||||
} as unknown as WorktreeApi
|
|
||||||
|
|
||||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
|
||||||
|
|
||||||
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
|
|
||||||
{ id: "a", sandboxes: ["/a/copy"] },
|
|
||||||
{ id: "b", sandboxes: [] },
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads references from the current location-scoped endpoint", async () => {
|
test("loads references from the current location-scoped endpoint", async () => {
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import type {
|
|||||||
CommandInfo,
|
CommandInfo,
|
||||||
CommandListInput,
|
CommandListInput,
|
||||||
CommandListOutput,
|
CommandListOutput,
|
||||||
IntegrationListInput,
|
|
||||||
IntegrationListOutput,
|
|
||||||
LocationGetInput,
|
LocationGetInput,
|
||||||
LocationGetOutput,
|
LocationGetOutput,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
@@ -26,16 +24,15 @@ import { getFilename } from "@opencode-ai/core/util/path"
|
|||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { State } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import type { ServerSession } from "../server-session"
|
import type { ServerSession } from "../server-session"
|
||||||
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
import { sameDirectory } from "@/utils/workspace"
|
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -107,7 +104,6 @@ type ProjectApi = {
|
|||||||
readonly list: () => Promise<ProjectListOutput>
|
readonly list: () => Promise<ProjectListOutput>
|
||||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||||
}
|
}
|
||||||
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
|
||||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
type McpApi = ServerApi["mcp"]
|
||||||
@@ -115,35 +111,15 @@ type PermissionApi = ServerApi["permission"]
|
|||||||
type QuestionApi = ServerApi["question"]
|
type QuestionApi = ServerApi["question"]
|
||||||
type VcsApi = ServerApi["vcs"]
|
type VcsApi = ServerApi["vcs"]
|
||||||
|
|
||||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "project"],
|
queryKey: [scope, "project"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
projects.list().then(async (items) => {
|
api.list().then((projects) => {
|
||||||
return (
|
return projects
|
||||||
await Promise.all(
|
.filter((p) => !!p?.id)
|
||||||
items
|
.map(normalizeProjectInfo)
|
||||||
.filter((project) => !!project?.id)
|
|
||||||
.map(async (project) => {
|
|
||||||
const directories = await worktrees
|
|
||||||
.list({ projectID: project.id })
|
|
||||||
.catch(() => [
|
|
||||||
{ directory: project.canonical },
|
|
||||||
...(project.sandboxes ?? [])
|
|
||||||
.filter((directory) => !sameDirectory(project.canonical, directory))
|
|
||||||
.map((directory) => ({ directory })),
|
|
||||||
])
|
|
||||||
return normalizeProjectInfo({
|
|
||||||
...project,
|
|
||||||
sandboxes: directories
|
|
||||||
.map((item) => item.directory)
|
|
||||||
.filter((directory) => !sameDirectory(project.canonical, directory)),
|
|
||||||
worktrees: directories,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
@@ -152,11 +128,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
|
|||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
serverAPI: CatalogApi & {
|
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
|
||||||
readonly location: LocationApi
|
|
||||||
readonly project: ProjectApi
|
|
||||||
readonly worktree: WorktreeApi
|
|
||||||
}
|
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
@@ -170,7 +142,7 @@ export async function bootstrapGlobal(input: {
|
|||||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
|
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||||
.then((data) => input.setGlobalStore("project", data)),
|
.then((data) => input.setGlobalStore("project", data)),
|
||||||
]
|
]
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
@@ -249,10 +221,6 @@ type CommandListApi = {
|
|||||||
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||||
}
|
}
|
||||||
|
|
||||||
type IntegrationListApi = {
|
|
||||||
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReferenceListApi = {
|
type ReferenceListApi = {
|
||||||
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||||
}
|
}
|
||||||
@@ -263,13 +231,6 @@ export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: Agen
|
|||||||
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: [scope, directory, "integrations"] as const,
|
|
||||||
queryFn: () =>
|
|
||||||
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
|
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
|
||||||
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
||||||
|
|
||||||
@@ -311,6 +272,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
}
|
}
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
|
vcsCache: VcsCache
|
||||||
loadSessions: (directory: string) => Promise<void> | void
|
loadSessions: (directory: string) => Promise<void> | void
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
global: {
|
global: {
|
||||||
@@ -335,109 +297,104 @@ export async function bootstrapDirectory(input: {
|
|||||||
const revKey = ScopedKey.from(input.scope, input.directory)
|
const revKey = ScopedKey.from(input.scope, input.directory)
|
||||||
const rev = (providerRev.get(revKey) ?? 0) + 1
|
const rev = (providerRev.get(revKey) ?? 0) + 1
|
||||||
providerRev.set(revKey, rev)
|
providerRev.set(revKey, rev)
|
||||||
const slow = [
|
;(async () => {
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
const slow = [
|
||||||
() =>
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
input.queryClient
|
() =>
|
||||||
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
|
|
||||||
.then((data) => input.setStore("agent", data)),
|
|
||||||
!seededProject &&
|
|
||||||
(() =>
|
|
||||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
|
||||||
input.setStore("project", project.id),
|
|
||||||
)),
|
|
||||||
!seededPath &&
|
|
||||||
(() =>
|
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
|
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
|
||||||
.then((data) => {
|
.then((data) => input.setStore("agent", data)),
|
||||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
!seededProject &&
|
||||||
if (next) input.setStore("project", next)
|
(() =>
|
||||||
})),
|
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||||
input.mcp &&
|
input.setStore("project", project.id),
|
||||||
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
)),
|
||||||
() =>
|
!seededPath &&
|
||||||
input.queryClient.fetchQuery(
|
(() =>
|
||||||
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
|
input.queryClient
|
||||||
),
|
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
|
||||||
() =>
|
.then((data) => {
|
||||||
retry(() =>
|
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||||
input.api.permission.request
|
if (next) input.setStore("project", next)
|
||||||
.list({ location: { directory: input.directory } })
|
})),
|
||||||
.then((result) => result.data)
|
input.mcp &&
|
||||||
.then((permissions) => {
|
(() =>
|
||||||
const ids = permissions.map((permission) => permission.sessionID)
|
loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
||||||
const grouped = groupBySession(
|
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)),
|
||||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
() =>
|
||||||
)
|
retry(() =>
|
||||||
const warm = input.session
|
input.api.permission.request
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
.list({ location: { directory: input.directory } })
|
||||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
.then((result) => result.data)
|
||||||
return warm.then(() =>
|
.then((permissions) => {
|
||||||
batch(() => {
|
const ids = permissions.map((permission) => permission.sessionID)
|
||||||
const current = input.session?.data.permission ?? input.store.permission
|
const grouped = groupBySession(
|
||||||
for (const sessionID of Object.keys(current)) {
|
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||||
if (grouped[sessionID]) continue
|
)
|
||||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
const warm = input.session
|
||||||
if (input.session) input.session.set("permission", sessionID, [])
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||||
if (!input.session) input.setStore("permission", sessionID, [])
|
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||||
}
|
return warm.then(() =>
|
||||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
batch(() => {
|
||||||
const value = reconcile(
|
const current = input.session?.data.permission ?? input.store.permission
|
||||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
for (const sessionID of Object.keys(current)) {
|
||||||
{ key: "id" },
|
if (grouped[sessionID]) continue
|
||||||
)
|
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||||
if (input.session) input.session.set("permission", sessionID, value)
|
if (input.session) input.session.set("permission", sessionID, [])
|
||||||
if (!input.session) input.setStore("permission", sessionID, value)
|
if (!input.session) input.setStore("permission", sessionID, [])
|
||||||
}
|
}
|
||||||
}),
|
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||||
)
|
const value = reconcile(
|
||||||
}),
|
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
),
|
{ key: "id" },
|
||||||
() =>
|
)
|
||||||
retry(() =>
|
if (input.session) input.session.set("permission", sessionID, value)
|
||||||
input.api.question.request
|
if (!input.session) input.setStore("permission", sessionID, value)
|
||||||
.list({ location: { directory: input.directory } })
|
}
|
||||||
.then((result) => result.data)
|
}),
|
||||||
.then((questions) => {
|
)
|
||||||
const ids = questions.map((question) => question.sessionID)
|
}),
|
||||||
const grouped = groupBySession(
|
),
|
||||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
() =>
|
||||||
)
|
retry(() =>
|
||||||
const warm = input.session
|
input.api.question.request
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
.list({ location: { directory: input.directory } })
|
||||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
.then((result) => result.data)
|
||||||
return warm.then(() =>
|
.then((questions) => {
|
||||||
batch(() => {
|
const ids = questions.map((question) => question.sessionID)
|
||||||
const current = input.session?.data.question ?? input.store.question
|
const grouped = groupBySession(
|
||||||
for (const sessionID of Object.keys(current)) {
|
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||||
if (grouped[sessionID]) continue
|
)
|
||||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
const warm = input.session
|
||||||
if (input.session) input.session.set("question", sessionID, [])
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||||
if (!input.session) input.setStore("question", sessionID, [])
|
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||||
}
|
return warm.then(() =>
|
||||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
batch(() => {
|
||||||
const value = reconcile(
|
const current = input.session?.data.question ?? input.store.question
|
||||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
for (const sessionID of Object.keys(current)) {
|
||||||
{ key: "id" },
|
if (grouped[sessionID]) continue
|
||||||
)
|
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||||
if (input.session) input.session.set("question", sessionID, value)
|
if (input.session) input.session.set("question", sessionID, [])
|
||||||
if (!input.session) input.setStore("question", sessionID, value)
|
if (!input.session) input.setStore("question", sessionID, [])
|
||||||
}
|
}
|
||||||
}),
|
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||||
)
|
const value = reconcile(
|
||||||
}),
|
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
),
|
{ key: "id" },
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
)
|
||||||
input.mcp &&
|
if (input.session) input.session.set("question", sessionID, value)
|
||||||
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
if (!input.session) input.setStore("question", sessionID, value)
|
||||||
input.mcp &&
|
}
|
||||||
(() =>
|
}),
|
||||||
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
)
|
||||||
() =>
|
}),
|
||||||
input.queryClient
|
),
|
||||||
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
.catch((err) => {
|
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
|
||||||
|
input.mcp &&
|
||||||
|
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
|
||||||
|
() =>
|
||||||
|
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api)).catch((err) => {
|
||||||
const project = getFilename(input.directory)
|
const project = getFilename(input.directory)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -445,19 +402,20 @@ export async function bootstrapDirectory(input: {
|
|||||||
description: formatServerError(err, input.translate),
|
description: formatServerError(err, input.translate),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
].filter(Boolean) as (() => Promise<any>)[]
|
].filter(Boolean) as (() => Promise<any>)[]
|
||||||
|
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
const slowErrs = errors(await runAll(slow))
|
const slowErrs = errors(await runAll(slow))
|
||||||
if (slowErrs.length > 0) {
|
if (slowErrs.length > 0) {
|
||||||
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||||
const project = getFilename(input.directory)
|
const project = getFilename(input.directory)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
description: formatServerError(slowErrs[0], input.translate),
|
description: formatServerError(slowErrs[0], input.translate),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
|
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { ServerScope } from "@/utils/server-scope"
|
|||||||
|
|
||||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||||
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
||||||
let providerQuerySuccess = true
|
|
||||||
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
||||||
store[0],
|
store[0],
|
||||||
store[1],
|
store[1],
|
||||||
@@ -59,17 +58,11 @@ beforeAll(async () => {
|
|||||||
get isLoading() {
|
get isLoading() {
|
||||||
return options().queryKey?.[1] === "path"
|
return options().queryKey?.[1] === "path"
|
||||||
},
|
},
|
||||||
get isSuccess() {
|
|
||||||
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
|
|
||||||
},
|
|
||||||
get isRefetchError() {
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
get data() {
|
get data() {
|
||||||
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
|
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
|
||||||
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
|
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
|
||||||
if (options().queryKey?.[1] === "lsp") return []
|
if (options().queryKey?.[1] === "lsp") return []
|
||||||
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
|
if (options().queryKey?.[1] === "providers") return provider
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -90,7 +83,6 @@ describe("createChildStoreManager", () => {
|
|||||||
|
|
||||||
const manager = createChildStoreManager({
|
const manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
persist,
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
@@ -122,7 +114,6 @@ describe("createChildStoreManager", () => {
|
|||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
persist,
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
@@ -157,7 +148,6 @@ describe("createChildStoreManager", () => {
|
|||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
persist,
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
@@ -183,37 +173,6 @@ describe("createChildStoreManager", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("writes refreshed VCS data to the child store", () => {
|
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
|
||||||
const dispose = createOwner((owner) => {
|
|
||||||
manager = createChildStoreManager({
|
|
||||||
owner,
|
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
|
||||||
persist,
|
|
||||||
isBooting: () => false,
|
|
||||||
isLoadingSessions: () => false,
|
|
||||||
onBootstrap() {},
|
|
||||||
onMcp() {},
|
|
||||||
onDispose() {},
|
|
||||||
translate: (key) => key,
|
|
||||||
queryOptions: queryOptionsApi,
|
|
||||||
global: { provider },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!manager) throw new Error("manager required")
|
|
||||||
const [store] = manager.child("/project", { bootstrap: false })
|
|
||||||
|
|
||||||
manager.vcs("/project", { branch: "feature", default_branch: "main" })
|
|
||||||
|
|
||||||
expect(store.vcs).toEqual({ branch: "feature", default_branch: "main" })
|
|
||||||
} finally {
|
|
||||||
dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("enables MCP only when requested for the directory", () => {
|
test("enables MCP only when requested for the directory", () => {
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
const offset = querySingles.length
|
const offset = querySingles.length
|
||||||
@@ -222,7 +181,6 @@ describe("createChildStoreManager", () => {
|
|||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
persist,
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
@@ -272,7 +230,6 @@ describe("createChildStoreManager", () => {
|
|||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
persist,
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
@@ -316,71 +273,4 @@ describe("createChildStoreManager", () => {
|
|||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not mark a cancelled provider query as ready", () => {
|
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
|
||||||
providerQuerySuccess = false
|
|
||||||
|
|
||||||
const dispose = createOwner((owner) => {
|
|
||||||
manager = createChildStoreManager({
|
|
||||||
owner,
|
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
|
||||||
persist,
|
|
||||||
isBooting: () => false,
|
|
||||||
isLoadingSessions: () => false,
|
|
||||||
onBootstrap() {},
|
|
||||||
onMcp() {},
|
|
||||||
onDispose() {},
|
|
||||||
translate: (key) => key,
|
|
||||||
queryOptions: queryOptionsApi,
|
|
||||||
global: { provider },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!manager) throw new Error("manager required")
|
|
||||||
const [store] = manager.child("/cancelled")
|
|
||||||
expect(store.provider_ready).toBe(false)
|
|
||||||
} finally {
|
|
||||||
providerQuerySuccess = true
|
|
||||||
dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not enable location queries before the event handshake", () => {
|
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
|
||||||
let connected = false
|
|
||||||
const offset = querySingles.length
|
|
||||||
const dispose = createOwner((owner) => {
|
|
||||||
manager = createChildStoreManager({
|
|
||||||
owner,
|
|
||||||
connected: () => connected,
|
|
||||||
scope: ServerScope.local,
|
|
||||||
persist,
|
|
||||||
isBooting: () => false,
|
|
||||||
isLoadingSessions: () => false,
|
|
||||||
onBootstrap() {},
|
|
||||||
onMcp() {},
|
|
||||||
onDispose() {},
|
|
||||||
translate: (key) => key,
|
|
||||||
queryOptions: queryOptionsApi,
|
|
||||||
global: { provider },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!manager) throw new Error("manager required")
|
|
||||||
manager.child("/handshake")
|
|
||||||
const queries = querySingles.slice(offset)
|
|
||||||
expect(queries[0]?.().enabled).toBe(false)
|
|
||||||
expect(queries[4]?.().enabled).toBe(false)
|
|
||||||
|
|
||||||
connected = true
|
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
|
||||||
expect(queries[4]?.().enabled).toBe(true)
|
|
||||||
} finally {
|
|
||||||
dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { VcsInfo } from "@/types"
|
import type { VcsInfo } from "@/types"
|
||||||
@@ -22,7 +22,6 @@ import type { ServerScope } from "@/utils/server-scope"
|
|||||||
|
|
||||||
export function createChildStoreManager(input: {
|
export function createChildStoreManager(input: {
|
||||||
owner: Owner
|
owner: Owner
|
||||||
connected: Accessor<boolean>
|
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
persist: typeof persisted
|
persist: typeof persisted
|
||||||
isBooting: (directory: string) => boolean
|
isBooting: (directory: string) => boolean
|
||||||
@@ -189,29 +188,17 @@ export function createChildStoreManager(input: {
|
|||||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||||
|
|
||||||
const pathQuery = useQuery(() => ({
|
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||||
...input.queryOptions.path(key),
|
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||||
}))
|
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||||
const mcpQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.mcp(key),
|
|
||||||
enabled: input.connected() && mcpEnabled(),
|
|
||||||
}))
|
|
||||||
const mcpResourceQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.mcpResources(key),
|
|
||||||
enabled: input.connected() && mcpEnabled(),
|
|
||||||
}))
|
|
||||||
const lspQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.lsp(key),
|
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
|
||||||
}))
|
|
||||||
const providerQuery = useQuery(() => ({
|
const providerQuery = useQuery(() => ({
|
||||||
...input.queryOptions.providers(key),
|
...input.queryOptions.providers(key),
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
enabled: instanceQueriesEnabled(),
|
||||||
}))
|
}))
|
||||||
const referenceQuery = useQuery(() => ({
|
const referenceQuery = useQuery(() => ({
|
||||||
...input.queryOptions.references(key),
|
...input.queryOptions.references(key),
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
enabled: instanceQueriesEnabled(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
@@ -219,14 +206,13 @@ export function createChildStoreManager(input: {
|
|||||||
projectMeta: initialMeta,
|
projectMeta: initialMeta,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
get provider_ready() {
|
get provider_ready() {
|
||||||
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError)
|
return instanceQueriesEnabled() && !providerQuery.isLoading
|
||||||
},
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||||
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY
|
if (providerQuery.isLoading) return EMPTY
|
||||||
const provider = providerQuery.data
|
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
||||||
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
return providerQuery.data ?? EMPTY
|
||||||
return provider
|
|
||||||
},
|
},
|
||||||
config: {},
|
config: {},
|
||||||
get path() {
|
get path() {
|
||||||
@@ -387,15 +373,6 @@ export function createChildStoreManager(input: {
|
|||||||
setStore("icon", value)
|
setStore("icon", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function vcs(directory: string, value: VcsInfo) {
|
|
||||||
const key = directoryKey(directory)
|
|
||||||
const child = ensureChild(directory)
|
|
||||||
const cached = vcsCache.get(key)
|
|
||||||
if (!cached) return
|
|
||||||
cached.setStore("value", value)
|
|
||||||
child[1]("vcs", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
children,
|
children,
|
||||||
ensureChild,
|
ensureChild,
|
||||||
@@ -403,7 +380,6 @@ export function createChildStoreManager(input: {
|
|||||||
peek,
|
peek,
|
||||||
projectMeta,
|
projectMeta,
|
||||||
projectIcon,
|
projectIcon,
|
||||||
vcs,
|
|
||||||
mark,
|
mark,
|
||||||
pin,
|
pin,
|
||||||
unpin,
|
unpin,
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe("applyGlobalEvent", () => {
|
|||||||
expect(refreshCount).toBe(1)
|
expect(refreshCount).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("leaves server.connected refresh to the connection sync", () => {
|
test("handles server.connected by triggering refresh", () => {
|
||||||
let refreshCount = 0
|
let refreshCount = 0
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
event: { type: "server.connected" },
|
event: { type: "server.connected" },
|
||||||
@@ -131,7 +131,7 @@ describe("applyGlobalEvent", () => {
|
|||||||
setGlobalProject() {},
|
setGlobalProject() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(refreshCount).toBe(0)
|
expect(refreshCount).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { Message, Part, Project, Todo } from "@/types"
|
import type { Message, Part, Project, Todo } from "@/types"
|
||||||
import type {
|
import type {
|
||||||
@@ -38,10 +37,11 @@ export function applyGlobalEvent(input: {
|
|||||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
|
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
|
||||||
refresh: () => void
|
refresh: () => void
|
||||||
}) {
|
}) {
|
||||||
if (input.event.type === "global.disposed") {
|
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
|
||||||
input.refresh()
|
input.refresh()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.event.type !== "project.updated") return
|
if (input.event.type !== "project.updated") return
|
||||||
const properties = input.event.properties as Project
|
const properties = input.event.properties as Project
|
||||||
const result = Binary.search(input.project, properties.id, (s) => s.id)
|
const result = Binary.search(input.project, properties.id, (s) => s.id)
|
||||||
@@ -187,18 +187,6 @@ export function applyDirectoryEvent(input: {
|
|||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "worktree.resolved": {
|
|
||||||
const properties = event.properties as { projectID: string; directory: string; previous: string }
|
|
||||||
input.store.session.forEach((session, index) => {
|
|
||||||
const adopted = Worktree.adopt(
|
|
||||||
{ projectID: session.projectID, directory: session.location.directory },
|
|
||||||
properties,
|
|
||||||
)
|
|
||||||
if (!adopted) return
|
|
||||||
input.setStore("session", index, (current) => ({ ...current, ...adopted }))
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "session.renamed": {
|
case "session.renamed": {
|
||||||
const properties = event.properties as { sessionID: string; title: string }
|
const properties = event.properties as { sessionID: string; title: string }
|
||||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message, Part, Todo } from "@/types"
|
import type { Message, Part, Todo } from "@/types"
|
||||||
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
|
|
||||||
@@ -34,7 +34,6 @@ describe("app session cache", () => {
|
|||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
form: Record<string, FormInfo[] | undefined>
|
|
||||||
part_text_accum_delta: Record<string, string | undefined>
|
part_text_accum_delta: Record<string, string | undefined>
|
||||||
} = {
|
} = {
|
||||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||||
@@ -45,7 +44,6 @@ describe("app session cache", () => {
|
|||||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||||
permission: { ses_1: [] as PermissionRequest[] },
|
permission: { ses_1: [] as PermissionRequest[] },
|
||||||
question: { ses_1: [] as QuestionRequest[] },
|
question: { ses_1: [] as QuestionRequest[] },
|
||||||
form: { ses_1: [] as FormInfo[] },
|
|
||||||
part_text_accum_delta: { prt_1: "streamed text" },
|
part_text_accum_delta: { prt_1: "streamed text" },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +57,6 @@ describe("app session cache", () => {
|
|||||||
expect(store.session_status.ses_1).toBeUndefined()
|
expect(store.session_status.ses_1).toBeUndefined()
|
||||||
expect(store.permission.ses_1).toBeUndefined()
|
expect(store.permission.ses_1).toBeUndefined()
|
||||||
expect(store.question.ses_1).toBeUndefined()
|
expect(store.question.ses_1).toBeUndefined()
|
||||||
expect(store.form.ses_1).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("dropSessionCaches clears message-backed parts", () => {
|
test("dropSessionCaches clears message-backed parts", () => {
|
||||||
@@ -73,7 +70,6 @@ describe("app session cache", () => {
|
|||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
form: Record<string, FormInfo[] | undefined>
|
|
||||||
part_text_accum_delta: Record<string, string | undefined>
|
part_text_accum_delta: Record<string, string | undefined>
|
||||||
} = {
|
} = {
|
||||||
session_status: {},
|
session_status: {},
|
||||||
@@ -84,7 +80,6 @@ describe("app session cache", () => {
|
|||||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
form: {},
|
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Message, Part, Todo } from "@/types"
|
import type { Message, Part, Todo } from "@/types"
|
||||||
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ type SessionCache = {
|
|||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
form?: Record<string, FormInfo[] | undefined>
|
|
||||||
part_text_accum_delta: Record<string, string | undefined>
|
part_text_accum_delta: Record<string, string | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
|||||||
delete store.session_status[sessionID]
|
delete store.session_status[sessionID]
|
||||||
delete store.permission[sessionID]
|
delete store.permission[sessionID]
|
||||||
delete store.question[sessionID]
|
delete store.question[sessionID]
|
||||||
if (store.form) delete store.form[sessionID]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,11 +124,9 @@ export function normalizeProviderList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||||
const worktree = "canonical" in project ? project.canonical : project.worktree
|
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
worktree,
|
worktree: "canonical" in project ? project.canonical : project.worktree,
|
||||||
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
|
|
||||||
vcs: project.vcs === "git" ? "git" : undefined,
|
vcs: project.vcs === "git" ? "git" : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user