mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-21 14:23:26 +00:00
merge(dev): integrate desktop workspace controls
Merge upstream dev into the OpenCode V2 foundation while preserving native V2 session, provider, proxy, PTY, and explicit database behavior. Resolve desktop workspace-open and provider visibility conflicts across Electron and Tauri, bound local API requests, refresh standalone dependency locks, and keep grouped integration preferences keyed by native provider IDs. Validated with desktop typechecks, 267 UI tests, 308 server tests, 122 Electron tests, 74 Tauri tests, packaged builds, and an exact OpenCode 0.0.0-next-17353 Tauri smoke covering prompt, reload, agent fallback, and PTY lifecycle.
This commit is contained in:
commit
c025bfcd78
88 changed files with 3875 additions and 769 deletions
3
.github/workflows/pr-build.yml
vendored
3
.github/workflows/pr-build.yml
vendored
|
|
@ -113,6 +113,8 @@ jobs:
|
|||
packages/ui/src/lib/hooks/use-foreground-refresh.test.ts
|
||||
packages/ui/src/lib/launch-errors.test.ts
|
||||
packages/ui/src/lib/message-selection-position.test.ts
|
||||
packages/ui/src/lib/model-visibility.test.ts
|
||||
packages/ui/src/lib/runtime-env.test.ts
|
||||
packages/ui/src/lib/trailing-resync.test.ts
|
||||
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
|
||||
packages/ui/src/stores/app-session-reconciliation.test.ts
|
||||
|
|
@ -128,6 +130,7 @@ jobs:
|
|||
packages/ui/src/stores/message-v2/message-hydration-authority.test.ts
|
||||
packages/ui/src/stores/message-v2/message-status.test.ts
|
||||
packages/ui/src/stores/message-v2/normalizers.test.ts
|
||||
packages/ui/src/stores/pty-store.test.ts
|
||||
packages/ui/src/stores/session-generation-recovery.test.ts
|
||||
packages/ui/src/stores/session-pagination.test.ts
|
||||
packages/ui/src/stores/session-pending-state.test.ts
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ description: |
|
|||
- There is no `packages/opencode-plugin/`. Do not restore plugin tools, plugin routes, or plugin packaging.
|
||||
- The server owns one shared OpenCode service through `OpenCodeSharedService` and its custom lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle. Production does not call `Service.ensure` or `Service.stop` directly. Workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes.
|
||||
- The UI uses generated Promise clients from `OpenCode.make()` through the CodeNomad proxy.
|
||||
- OpenCode owns session APIs, native Shell (`client.session.shell`) and session instructions (`client.session.instructions.entry`). PTY/background-process parity is not integrated.
|
||||
- OpenCode owns session APIs, native Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), and location-scoped native PTYs. Shell remains separate. The Status panel lists PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Exact `next-17353` has no PTY output/read/stream or separate stop API, so output and distinct stop are unavailable; removal is the native stop action for a running PTY.
|
||||
- CodeNomad owns workspace lifecycle, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`.
|
||||
- V2 service startup requires a user-configured `OPENCODE_DB`. There is no default path; never share a V1 database with V2. Environment changes apply at service start/restart.
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ description: |
|
|||
| Public `@opencode-ai/sdk` examples | Installed experimental `@opencode-ai/client@0.0.0-next-17353` declarations |
|
||||
| One `opencode serve` per workspace | One CodeNomad-managed shared service |
|
||||
| Per-worktree clients/processes | Root proxy client plus native location/directory inputs |
|
||||
| Reintroducing `packages/opencode-plugin` | Native OpenCode Shell/instructions; no PTY/background parity claim |
|
||||
| Reintroducing `packages/opencode-plugin` or server plugin/background-process paths | Separate native Shell/instructions and native PTY management |
|
||||
| OpenCode APIs for stage/commit/Yolo policy | CodeNomad routes and managers |
|
||||
| Hardcoded UI strings | `t()` / `tGlobal()` and every locale |
|
||||
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ The server uses `packages/server/src/workspaces/opencode-service.ts` for a custo
|
|||
|
||||
| Owner | Responsibilities | Main paths |
|
||||
|---|---|---|
|
||||
| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell and instructions | experimental `@opencode-ai/client@0.0.0-next-17353` protocol |
|
||||
| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell/instructions, location-scoped PTYs | experimental `@opencode-ai/client@0.0.0-next-17353` protocol |
|
||||
| CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing | `packages/server/src/` |
|
||||
| CodeNomad UI | Generated Promise clients, state reconciliation, interaction and rendering | `packages/ui/src/` |
|
||||
| Desktop hosts | Start CodeNomad and provide native OS integration | `packages/electron-app/`, `packages/tauri-app/` |
|
||||
|
||||
`packages/opencode-plugin/` and the server plugin/background-process integration were deleted. Native Shell is integrated; PTY/background-process parity is not. Do not use deleted paths as extension points.
|
||||
Native Shell remains separate from PTY management. The Status panel lists location-scoped PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Exact `next-17353` has no PTY output/read/stream or separate stop endpoint; output display and a distinct stop action are unavailable, and removal is the native stop action for a running PTY. `packages/opencode-plugin/` and the server plugin/background-process integration remain deleted and must not be restored or used as extension points.
|
||||
|
||||
## HTTP And Events
|
||||
|
||||
|
|
|
|||
|
|
@ -9,15 +9,18 @@
|
|||
5. CodeNomad publishes workspace events on `/api/events` and exposes `/workspaces/:id/instance` as the authorized native API proxy.
|
||||
6. Final-owner deletion queues location eviction. Proven final shared-service shutdown flushes queued evictions and sends an authenticated stop only if no live CodeNomad peer remains and the exact daemon identity still matches.
|
||||
|
||||
## Prompt, Shell And Instructions
|
||||
## Prompt, Shell, Instructions, And PTYs
|
||||
|
||||
1. UI obtains `getRootClient(instanceId)`.
|
||||
2. Conversation mode updates `client.session.instructions.entry` for the voice instruction.
|
||||
3. A normal prompt calls `client.session.prompt`; `!` shell mode calls native `client.session.shell`. There is no integrated PTY/background-process parity.
|
||||
4. The proxy checks directory/session ownership and forwards to the shared service's `/api/*` route.
|
||||
5. One upstream event subscription feeds `InstanceEventBridge`, then CodeNomad `/api/events`, then UI stores.
|
||||
3. A normal prompt calls `client.session.prompt`; `!` shell mode calls native `client.session.shell`. Native Shell remains separate from PTY management.
|
||||
4. The Status panel lists native PTYs for the active location, displays their native metadata, and refreshes on PTY events and reconnect.
|
||||
5. Title updates and removal use native PTY APIs; the proxy verifies native `cwd` ownership before ID-scoped operations. Removing a running PTY is its native stop action.
|
||||
6. Exact `next-17353` has no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable.
|
||||
7. The proxy checks directory/session ownership and forwards to the shared service's `/api/*` route.
|
||||
8. One upstream event subscription feeds `InstanceEventBridge`, then CodeNomad `/api/events`, then UI stores.
|
||||
|
||||
No CodeNomad OpenCode plugin participates in this flow.
|
||||
No CodeNomad OpenCode plugin participates in this flow. `packages/opencode-plugin` and server plugin/background-process paths remain deleted and must not be restored.
|
||||
|
||||
## Permission And Yolo
|
||||
|
||||
|
|
|
|||
|
|
@ -26,11 +26,13 @@
|
|||
|
||||
| Concern | Owner |
|
||||
|---|---|
|
||||
| Session/message/Shell/instructions | OpenCode native API; PTY/background parity not integrated |
|
||||
| Session/message/Shell/instructions | OpenCode native API; Shell remains separate from PTY management |
|
||||
| PTY list/metadata/title/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on PTY events/reconnect |
|
||||
| PTY output/distinct stop | Unavailable in exact `next-17353`; removal is the native stop action for a running PTY |
|
||||
| Service discovery/start/stop | CodeNomad hardened adapter using selected OpenCode primitives |
|
||||
| Workspace and directory authorization | CodeNomad |
|
||||
| Git status/diff and mutations | CodeNomad |
|
||||
| Yolo policy/persistence/auto-reply | CodeNomad |
|
||||
| Browser event multiplexing | CodeNomad `/api/events` |
|
||||
|
||||
Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, plugin background-process tools, or deleted plugin/runtime file paths.
|
||||
Exact `next-17353` has no PTY output/read/stream API or separate stop endpoint, so the UI cannot display PTY output or offer a distinct stop action. Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, server plugin/background-process tools, or deleted plugin/runtime file paths.
|
||||
|
|
|
|||
|
|
@ -22,11 +22,15 @@ const client = OpenCode.make({ baseUrl, fetch: createInstanceFetch(baseUrl) })
|
|||
|
||||
Use `getRootClient(instanceId)` from `packages/ui/src/stores/opencode-client.ts`. Native location/directory inputs replace the old per-worktree-client pattern. Destroy cached clients when an instance is removed.
|
||||
|
||||
## Native Shell And Instructions
|
||||
## Native Shell, Instructions, And PTYs
|
||||
|
||||
- Shell mode calls `client.session.shell({ sessionID, command })`.
|
||||
- Conversation mode adds/removes `client.session.instructions.entry` before `client.session.prompt`.
|
||||
- PTY and background-process feature parity are not integrated.
|
||||
- Shell remains separate from native PTY management.
|
||||
- PTYs are location-scoped and listed with `client.pty.list`; the Status panel refreshes on PTY lifecycle events and reconnect, displays native metadata, and supports title updates.
|
||||
- PTY ID operations are ownership-checked against the native `cwd`. Removal is the native stop action for a running PTY.
|
||||
- Exact `next-17353` has no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable.
|
||||
- Keep `packages/opencode-plugin` and server plugin/background-process paths deleted.
|
||||
|
||||
## Event Flow
|
||||
|
||||
|
|
|
|||
|
|
@ -116,7 +116,8 @@ Then open a pull request on GitHub targeting the `dev` branch.
|
|||
- V2 startup requires a user-configured, non-empty `OPENCODE_DB`; there is no CodeNomad default. Never reuse a V1 database for V2. Environment changes take effect when the shared service starts or restarts.
|
||||
- OpenCode session calls use `/workspaces/:id/instance/api/*`; CodeNomad control routes and multiplexed events use `/api/*` and `/api/events`.
|
||||
- The proxy is method/path allowlisted, so new upstream functionality is not exposed automatically.
|
||||
- Native `client.session.shell` and `client.session.instructions.entry` cover Shell and prompt instructions. PTY/background-process parity is not integrated, and there is no `packages/opencode-plugin` integration.
|
||||
- Native Shell (`client.session.shell`) and prompt instructions (`client.session.instructions.entry`) remain separate from native V2 PTY management.
|
||||
- Native PTYs are location-scoped and listed in the Status panel. The UI refreshes them on PTY events and reconnect, displays native metadata, and supports title updates and ownership-checked removal. With exact `0.0.0-next-17353`, removal is the native stop action for a running PTY; there is no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored.
|
||||
- Native events are volatile. Reconnect handlers must refetch authoritative state instead of assuming missed events will replay.
|
||||
- Git mutations and Yolo policy remain CodeNomad-owned server boundaries.
|
||||
|
||||
|
|
|
|||
|
|
@ -303,7 +303,9 @@ tasks/ Task tracking
|
|||
- Database: user-supplied `OPENCODE_DB` is required for V2 and must never be shared with V1; changes apply at service start/restart
|
||||
- Events: volatile native stream with authoritative reconnect reconciliation
|
||||
- Proxy: explicit method/path allowlist; upstream additions are not automatic
|
||||
- Shell and instructions: native session APIs; PTY/background-process parity is not integrated
|
||||
- Shell and instructions: native session APIs, separate from PTY management
|
||||
- PTYs: location-scoped native entries in Status, refreshed on PTY events/reconnect with metadata, title updates, and ownership-checked removal; exact `next-17353` has no output/read/stream or separate stop API, so output and distinct stop are unavailable and removal stops a running PTY
|
||||
- Legacy plugin/background processes: `packages/opencode-plugin` and server plugin/background-process paths remain deleted
|
||||
- Git mutations and Yolo: CodeNomad-owned
|
||||
|
||||
## Estimated Timeline
|
||||
|
|
|
|||
|
|
@ -57,13 +57,14 @@ Current native events include session lifecycle/output events (`session.created`
|
|||
| Sessions, messages, permission/question APIs | OpenCode V2 |
|
||||
| Shell mode | `client.session.shell` |
|
||||
| Conversation instructions | `client.session.instructions.entry` |
|
||||
| PTY/background-process parity | Not integrated |
|
||||
| PTY management | Location-scoped OpenCode V2 API through the ownership-checking proxy; Status panel UI |
|
||||
| PTY output and distinct stop | Unavailable in exact `next-17353`; removal is the native stop action for a running PTY |
|
||||
| Workspace lifecycle and directory authorization | CodeNomad |
|
||||
| Git status/diff/stage/unstage/commit | CodeNomad server |
|
||||
| Yolo state, persistence and auto-accept | CodeNomad server |
|
||||
| Browser SSE multiplexing | CodeNomad server |
|
||||
|
||||
Native Shell and session instructions replace the corresponding deleted plugin-backed integrations. Do not claim PTY/background-process parity or restore deleted plugin routes.
|
||||
Native Shell remains separate from PTY management. The Status panel lists location-scoped native PTYs, refreshes on PTY events/reconnect, displays native metadata, and allows title updates and ownership-checked removal. Exact `next-17353` exposes no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored.
|
||||
|
||||
## Persistence
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ await client.session.shell({ sessionID, command })
|
|||
await client.session.instructions.entry.put({ sessionID, key, value })
|
||||
```
|
||||
|
||||
Shell mode and conversation instructions are upstream features. They do not require a CodeNomad plugin. CodeNomad does not currently integrate PTY or background-process parity.
|
||||
Shell mode and conversation instructions are upstream features and remain separate from native V2 PTYs. None requires a CodeNomad plugin.
|
||||
|
||||
Native PTYs are location-scoped and listed in the Status panel. `packages/ui/src/stores/pty-store.ts` refreshes the list on native PTY events and reconnect, exposes native metadata, and supports title updates and removal. The proxy verifies PTY `cwd` ownership before ID-scoped operations. With exact `0.0.0-next-17353`, the client has no PTY output/read/stream API and no separate stop endpoint: output is not displayed, and removing a running PTY is the only native stop action.
|
||||
|
||||
## Routing And Security
|
||||
|
||||
|
|
@ -55,7 +57,7 @@ Yolo also remains CodeNomad-owned. `AutoAcceptManager` persists policy state, ob
|
|||
|
||||
`InstanceEventBridge` consumes the one shared `client.event.subscribe()` iterable. It maps location-scoped events to workspace IDs and publishes `instance.event` through the CodeNomad `EventBus`. This stream is volatile: reconnection does not replay a guaranteed history, so UI stores refetch sessions and pending requests and other consumers must re-read authoritative file/config state.
|
||||
|
||||
Use current protocol names. Session events include `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`; file and config invalidations are `filesystem.changed` and `config.updated`.
|
||||
Use current protocol names. Session events include `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`; PTY refresh events include `pty.created`, `pty.updated`, `pty.exited`, and `pty.deleted`; file and config invalidations are `filesystem.changed` and `config.updated`.
|
||||
|
||||
## Current Structure
|
||||
|
||||
|
|
@ -75,9 +77,10 @@ packages/ui/src/
|
|||
stores/opencode-client.ts root client authority
|
||||
stores/session-api.ts session queries/lifecycle
|
||||
stores/session-actions.ts prompt, Shell, instructions
|
||||
stores/pty-store.ts location-scoped native PTY state/actions
|
||||
```
|
||||
|
||||
Deleted plugin, background-process, and per-workspace runtime files are not architectural extension points.
|
||||
Deleted `packages/opencode-plugin`, server plugin/background-process, and per-workspace runtime files are not architectural extension points and must not be restored.
|
||||
|
||||
## Validation
|
||||
|
||||
|
|
|
|||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
|
|
@ -13621,7 +13621,7 @@
|
|||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yaml": "^2.4.2"
|
||||
|
|
@ -13648,7 +13648,7 @@
|
|||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
|
|
@ -13691,7 +13691,7 @@
|
|||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
|
|
@ -13699,7 +13699,7 @@
|
|||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron"
|
||||
import fs from "fs"
|
||||
import { requestMicrophoneAccess } from "./permissions"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open"
|
||||
import { setWorkspaceMenuEnabled } from "./menu"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
|
||||
|
|
@ -18,6 +20,37 @@ interface DialogOpenResult {
|
|||
paths: string[]
|
||||
}
|
||||
|
||||
async function resolveLocalWorkspaceFolder(
|
||||
mainWindow: BrowserWindow,
|
||||
cliManager: CliProcessManager,
|
||||
instanceId: string,
|
||||
worktreeSlug: string,
|
||||
): Promise<string> {
|
||||
const baseUrl = cliManager.getStatus().url
|
||||
if (!baseUrl) throw new Error("Local CodeNomad server is unavailable")
|
||||
const cookieName = cliManager.getAuthCookieName()
|
||||
const cookie = (await mainWindow.webContents.session.cookies.get({ url: baseUrl, name: cookieName }))[0]
|
||||
const headers = cookie ? { Cookie: `${cookie.name}=${cookie.value}` } : undefined
|
||||
const workspaceResponse = await fetch(`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (!workspaceResponse.ok) throw new Error("Workspace is not active")
|
||||
const workspace = await workspaceResponse.json() as { path?: unknown }
|
||||
if (typeof workspace.path !== "string") throw new Error("Workspace path is unavailable")
|
||||
if (worktreeSlug === "root") return workspace.path
|
||||
|
||||
const worktreeResponse = await fetch(
|
||||
`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}/worktrees`,
|
||||
{ headers, signal: AbortSignal.timeout(5_000) },
|
||||
)
|
||||
if (!worktreeResponse.ok) throw new Error("Workspace worktrees are unavailable")
|
||||
const payload = await worktreeResponse.json() as { worktrees?: Array<{ slug?: unknown; directory?: unknown }> }
|
||||
const worktree = payload.worktrees?.find((candidate) => candidate.slug === worktreeSlug)
|
||||
if (!worktree || typeof worktree.directory !== "string") throw new Error("Selected worktree is unavailable")
|
||||
return worktree.directory
|
||||
}
|
||||
|
||||
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
|
||||
cliManager.on("status", (status: CliStatus) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
|
|
@ -88,6 +121,49 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||
return directories
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"workspace:openTarget",
|
||||
async (event, payload: { target?: unknown; instanceId?: unknown; worktreeSlug?: unknown; path?: unknown; editor?: unknown }): Promise<{ ok: true }> => {
|
||||
if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) {
|
||||
throw new Error("Workspace open requests are limited to the local main window")
|
||||
}
|
||||
const localUrl = cliManager.getStatus().url
|
||||
if (!localUrl || new URL(event.senderFrame.url).origin !== new URL(localUrl).origin) {
|
||||
throw new Error("Workspace open requests require the local CodeNomad origin")
|
||||
}
|
||||
const target = payload?.target
|
||||
const instanceId = payload?.instanceId
|
||||
const worktreeSlug = payload?.worktreeSlug
|
||||
const editor = payload?.editor
|
||||
if (
|
||||
(target !== "default" && target !== "reveal" && target !== "terminal" && target !== "editor")
|
||||
|| typeof instanceId !== "string"
|
||||
|| typeof worktreeSlug !== "string"
|
||||
|| (payload.path !== undefined && typeof payload.path !== "string")
|
||||
|| (editor !== undefined && editor !== "vscode" && editor !== "cursor" && editor !== "zed" && editor !== "vscodium")
|
||||
) {
|
||||
throw new Error("Invalid workspace open request")
|
||||
}
|
||||
const workspaceFolder = await resolveLocalWorkspaceFolder(mainWindow, cliManager, instanceId, worktreeSlug)
|
||||
await openWorkspaceTarget(
|
||||
target as WorkspaceOpenTarget,
|
||||
workspaceFolder,
|
||||
payload.path as string | undefined,
|
||||
editor as WorkspaceEditor | undefined,
|
||||
{ openPath: (path) => shell.openPath(path), revealPath: (path) => shell.showItemInFolder(path) },
|
||||
)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle("workspace:setMenuEnabled", (event, enabled: unknown): { ok: true } => {
|
||||
if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) {
|
||||
throw new Error("Workspace menu updates are limited to the local main window")
|
||||
}
|
||||
setWorkspaceMenuEnabled(enabled === true)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||
const next = Boolean(enabled)
|
||||
if (next) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,34 @@
|
|||
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
|
||||
import { app, Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
|
||||
|
||||
interface ApplicationMenuActions {
|
||||
reload(): void
|
||||
forceReload(): void
|
||||
}
|
||||
|
||||
let workspaceActionsRequested = false
|
||||
let applicationMenu: Menu | null = null
|
||||
let localMainWindow: BrowserWindow | null = null
|
||||
|
||||
function updateWorkspaceMenuState() {
|
||||
const enabled = workspaceActionsRequested && BrowserWindow.getFocusedWindow() === localMainWindow
|
||||
for (const id of ["open-workspace-folder", "open-workspace-terminal", "open-workspace-editor"]) {
|
||||
const item = applicationMenu?.getMenuItemById(id)
|
||||
if (item) item.enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
export function setWorkspaceMenuEnabled(enabled: boolean) {
|
||||
workspaceActionsRequested = enabled
|
||||
updateWorkspaceMenuState()
|
||||
}
|
||||
|
||||
export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) {
|
||||
localMainWindow = mainWindow
|
||||
const isMac = process.platform === "darwin"
|
||||
const sendCommand = (id: string) => () => {
|
||||
if (id.startsWith("open-workspace-") && BrowserWindow.getFocusedWindow() !== mainWindow) return
|
||||
mainWindow.webContents.send("menu:action", id)
|
||||
}
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
...(isMac
|
||||
|
|
@ -31,9 +53,20 @@ export function createApplicationMenu(mainWindow: BrowserWindow, actions: Applic
|
|||
{
|
||||
label: "New Instance",
|
||||
accelerator: "CmdOrCtrl+N",
|
||||
click: () => {
|
||||
mainWindow.webContents.send("menu:newInstance")
|
||||
},
|
||||
click: sendCommand("new-instance"),
|
||||
},
|
||||
{ type: "separator" as const },
|
||||
{ id: "open-workspace-folder", label: "Open Project Folder", click: sendCommand("open-workspace-folder") },
|
||||
{ id: "open-workspace-terminal", label: "Open Terminal Here", click: sendCommand("open-workspace-terminal") },
|
||||
{
|
||||
id: "open-workspace-editor",
|
||||
label: "Open Project In",
|
||||
submenu: [
|
||||
{ label: "VS Code", click: sendCommand("open-workspace-editor-vscode") },
|
||||
{ label: "Cursor", click: sendCommand("open-workspace-editor-cursor") },
|
||||
{ label: "Zed", click: sendCommand("open-workspace-editor-zed") },
|
||||
{ label: "VSCodium", click: sendCommand("open-workspace-editor-vscodium") },
|
||||
],
|
||||
},
|
||||
{ type: "separator" as const },
|
||||
isMac ? { role: "close" as const } : { role: "quit" as const },
|
||||
|
|
@ -85,5 +118,14 @@ export function createApplicationMenu(mainWindow: BrowserWindow, actions: Applic
|
|||
]
|
||||
|
||||
const menu = Menu.buildFromTemplate(template)
|
||||
applicationMenu = menu
|
||||
Menu.setApplicationMenu(menu)
|
||||
updateWorkspaceMenuState()
|
||||
mainWindow.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => {
|
||||
if (!isMainFrame) return
|
||||
workspaceActionsRequested = false
|
||||
updateWorkspaceMenuState()
|
||||
})
|
||||
app.on("browser-window-focus", updateWorkspaceMenuState)
|
||||
app.on("browser-window-blur", updateWorkspaceMenuState)
|
||||
}
|
||||
|
|
|
|||
80
packages/electron-app/electron/main/workspace-open.test.ts
Normal file
80
packages/electron-app/electron/main/workspace-open.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import test from "node:test"
|
||||
import type { ChildProcess } from "node:child_process"
|
||||
import { defaultOpenMode, editorCandidates, openWorkspaceTarget } from "./workspace-open"
|
||||
|
||||
test("selects only the requested editor", () => {
|
||||
assert.deepEqual(editorCandidates("zed", "linux", {}), [{ command: "zed", verifyStart: true }])
|
||||
assert.deepEqual(editorCandidates("vscodium", "darwin", {}), [
|
||||
{ command: "/usr/bin/open", args: ["-a", "VSCodium"], waitForExit: true },
|
||||
])
|
||||
})
|
||||
|
||||
test("opens only paths inside the workspace", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codenomad-workspace-"))
|
||||
const outside = mkdtempSync(join(tmpdir(), "codenomad-outside-"))
|
||||
const opened: string[] = []
|
||||
const dependencies = {
|
||||
openPath: async (path: string) => { opened.push(path); return "" },
|
||||
revealPath: () => undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
await openWorkspaceTarget("default", root, ".", undefined, dependencies)
|
||||
assert.deepEqual(opened, [root])
|
||||
await assert.rejects(openWorkspaceTarget("default", root, outside, undefined, dependencies), /outside the workspace/)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
rmSync(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("edits Windows scripts and rejects executable default-open targets", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codenomad-executable-"))
|
||||
const script = join(root, "run.cmd")
|
||||
writeFileSync(script, "echo unsafe")
|
||||
try {
|
||||
assert.equal(defaultOpenMode(script, "win32"), "edit")
|
||||
const document = join(root, "notes.txt")
|
||||
writeFileSync(document, "safe")
|
||||
assert.equal(defaultOpenMode(document, "win32"), "open")
|
||||
const executable = join(root, "run.exe")
|
||||
writeFileSync(executable, "unsafe")
|
||||
assert.equal(defaultOpenMode(executable, "win32"), "choose")
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("launches terminals in the selected directory without a shell", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codenomad-terminal-"))
|
||||
const nested = join(root, "nested")
|
||||
mkdirSync(nested)
|
||||
const launches: Array<{ command: string; args: readonly string[]; cwd: string }> = []
|
||||
const spawnProcess = (command: string, args: readonly string[], options: { cwd: string }) => {
|
||||
launches.push({ command, args, cwd: options.cwd })
|
||||
const child = new EventEmitter() as ChildProcess
|
||||
child.unref = () => child
|
||||
queueMicrotask(() => {
|
||||
child.emit("spawn")
|
||||
child.emit("exit", 0)
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
try {
|
||||
await openWorkspaceTarget("terminal", root, "nested", undefined, {
|
||||
openPath: async () => "",
|
||||
revealPath: () => undefined,
|
||||
spawnProcess,
|
||||
})
|
||||
assert.equal(launches[0]?.cwd, nested)
|
||||
assert.equal(launches[0]?.args.includes(nested), false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
238
packages/electron-app/electron/main/workspace-open.ts
Normal file
238
packages/electron-app/electron/main/workspace-open.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { existsSync, realpathSync, statSync } from "node:fs"
|
||||
import { extname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
|
||||
export type WorkspaceOpenTarget = "default" | "reveal" | "terminal" | "editor"
|
||||
export type WorkspaceEditor = "vscode" | "cursor" | "zed" | "vscodium"
|
||||
|
||||
interface LaunchCandidate {
|
||||
command: string
|
||||
args?: string[]
|
||||
passPath?: boolean
|
||||
waitForExit?: boolean
|
||||
verifyStart?: boolean
|
||||
}
|
||||
|
||||
type SpawnProcess = (command: string, args: readonly string[], options: {
|
||||
cwd: string
|
||||
detached: boolean
|
||||
stdio: "ignore"
|
||||
}) => ChildProcess
|
||||
|
||||
interface WorkspaceOpenDependencies {
|
||||
openPath: (path: string) => Promise<string>
|
||||
revealPath: (path: string) => void
|
||||
spawnProcess?: SpawnProcess
|
||||
}
|
||||
|
||||
const BLOCKED_DEFAULT_OPEN_EXTENSIONS = new Set([
|
||||
".appimage", ".application", ".chm", ".com", ".cpl", ".desktop", ".exe", ".jar", ".lnk", ".msi",
|
||||
".msp", ".pif", ".scr", ".url",
|
||||
])
|
||||
|
||||
const SAFE_WINDOWS_OPEN_EXTENSIONS = new Set([
|
||||
".7z", ".avi", ".bmp", ".c", ".cc", ".cfg", ".conf", ".cpp", ".cs", ".css", ".csv", ".dart",
|
||||
".diff", ".docx", ".env", ".flac", ".fs", ".fsx", ".gif", ".go", ".gz", ".h", ".hpp",
|
||||
".htm", ".html", ".ini", ".java", ".jpeg", ".jpg", ".json", ".jsonc", ".jsx", ".kt", ".kts",
|
||||
".less", ".lock", ".log", ".lua", ".md", ".markdown", ".mkv", ".mov", ".mp3", ".mp4", ".ogg",
|
||||
".patch", ".pdf", ".png", ".pptx", ".r", ".rar", ".rmd", ".rs", ".scss", ".sql", ".svg",
|
||||
".svelte", ".swift", ".tar", ".toml", ".ts", ".tsx", ".txt", ".vue", ".wav", ".webm", ".webp",
|
||||
".xlsx", ".xml", ".yaml", ".yml", ".zip",
|
||||
])
|
||||
|
||||
const WINDOWS_EDIT_EXTENSIONS = new Set([
|
||||
".bat", ".cmd", ".js", ".jse", ".pl", ".ps1", ".psd1", ".psm1", ".py", ".pyw", ".rb", ".reg",
|
||||
".vbe", ".vbs", ".wsf", ".wsh",
|
||||
])
|
||||
|
||||
export function defaultOpenMode(path: string, platform: NodeJS.Platform): "open" | "edit" | "choose" {
|
||||
const stats = statSync(path)
|
||||
const extension = extname(path).toLowerCase()
|
||||
if (stats.isDirectory()) {
|
||||
if (platform === "darwin" && extension === ".app") throw new Error("Application bundles cannot be opened externally")
|
||||
return "open"
|
||||
}
|
||||
if (platform === "win32") {
|
||||
if (SAFE_WINDOWS_OPEN_EXTENSIONS.has(extension)) return "open"
|
||||
if (WINDOWS_EDIT_EXTENSIONS.has(extension)) return "edit"
|
||||
return "choose"
|
||||
}
|
||||
if (BLOCKED_DEFAULT_OPEN_EXTENSIONS.has(extension)) {
|
||||
throw new Error("Executable files cannot be opened externally")
|
||||
}
|
||||
if (stats.isFile() && (stats.mode & 0o111) !== 0) {
|
||||
throw new Error("Executable files cannot be opened externally")
|
||||
}
|
||||
return "open"
|
||||
}
|
||||
|
||||
export function editorCandidates(editor: WorkspaceEditor, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): LaunchCandidate[] {
|
||||
if (platform === "darwin") {
|
||||
const names: Record<WorkspaceEditor, string> = {
|
||||
vscode: "Visual Studio Code",
|
||||
cursor: "Cursor",
|
||||
zed: "Zed",
|
||||
vscodium: "VSCodium",
|
||||
}
|
||||
return [{ command: "/usr/bin/open", args: ["-a", names[editor]], waitForExit: true }]
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
const paths: Record<WorkspaceEditor, Array<string | undefined>> = {
|
||||
vscode: [
|
||||
env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Microsoft VS Code", "Code.exe"),
|
||||
env.ProgramFiles && join(env.ProgramFiles, "Microsoft VS Code", "Code.exe"),
|
||||
],
|
||||
cursor: [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "cursor", "Cursor.exe")],
|
||||
zed: [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Zed", "Zed.exe")],
|
||||
vscodium: [
|
||||
env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "VSCodium", "VSCodium.exe"),
|
||||
env.ProgramFiles && join(env.ProgramFiles, "VSCodium", "VSCodium.exe"),
|
||||
],
|
||||
}
|
||||
return paths[editor]
|
||||
.filter((command): command is string => Boolean(command))
|
||||
.filter((command) => !isAbsolute(command) || existsSync(command))
|
||||
.map((command) => ({ command }))
|
||||
}
|
||||
|
||||
const commands: Record<WorkspaceEditor, string> = { vscode: "code", cursor: "cursor", zed: "zed", vscodium: "codium" }
|
||||
return [{ command: commands[editor], verifyStart: true }]
|
||||
}
|
||||
|
||||
export function terminalCandidates(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, folder = "."): LaunchCandidate[] {
|
||||
if (platform === "darwin") return [{ command: "/usr/bin/open", args: ["-a", "Terminal"], waitForExit: true }]
|
||||
if (platform === "win32") {
|
||||
const systemRoot = env.SystemRoot?.trim() || "C:\\Windows"
|
||||
const command = env.ComSpec?.trim() || join(systemRoot, "System32", "cmd.exe")
|
||||
const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
const script = `Set-Location -LiteralPath '${folder.replace(/'/g, "''")}'`
|
||||
const encoded = Buffer.from(script, "utf16le").toString("base64")
|
||||
return [{
|
||||
command,
|
||||
args: ["/D", "/C", "start", "", powershell, "-NoExit", "-EncodedCommand", encoded],
|
||||
passPath: false,
|
||||
waitForExit: true,
|
||||
}]
|
||||
}
|
||||
|
||||
const configured = env.TERMINAL?.trim()
|
||||
return [
|
||||
...(configured ? [{ command: configured, passPath: false, verifyStart: true }] : []),
|
||||
...["xdg-terminal-exec", "x-terminal-emulator", "gnome-terminal", "konsole", "kitty", "alacritty", "wezterm"]
|
||||
.map((command) => ({ command, passPath: false, verifyStart: true })),
|
||||
]
|
||||
}
|
||||
|
||||
function launch(candidate: LaunchCandidate, selectedPath: string, cwd: string, spawnProcess: SpawnProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let verificationTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (verificationTimer) clearTimeout(verificationTimer)
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
const args = [...(candidate.args ?? []), ...(candidate.passPath === false ? [] : [selectedPath])]
|
||||
const child = spawnProcess(candidate.command, args, {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
if (!candidate.waitForExit) {
|
||||
if (candidate.verifyStart) {
|
||||
verificationTimer = setTimeout(() => {
|
||||
child.unref()
|
||||
finish()
|
||||
}, 250)
|
||||
return
|
||||
}
|
||||
child.unref()
|
||||
finish()
|
||||
}
|
||||
})
|
||||
child.once("exit", (code) => {
|
||||
if (!candidate.waitForExit && !candidate.verifyStart) return
|
||||
if (code === 0) finish()
|
||||
else finish(new Error(`${candidate.command} exited with code ${code ?? "unknown"}`))
|
||||
})
|
||||
child.once("error", (error) => finish(error))
|
||||
})
|
||||
}
|
||||
|
||||
async function launchFirst(candidates: LaunchCandidate[], selectedPath: string, cwd: string, spawnProcess: SpawnProcess): Promise<void> {
|
||||
let lastError: unknown
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await launch(candidate, selectedPath, cwd, spawnProcess)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("No supported application was found")
|
||||
}
|
||||
|
||||
export async function openWorkspaceTarget(
|
||||
target: WorkspaceOpenTarget,
|
||||
workspaceFolder: string,
|
||||
path = ".",
|
||||
editor?: WorkspaceEditor,
|
||||
dependencies?: WorkspaceOpenDependencies,
|
||||
): Promise<void> {
|
||||
if (!dependencies) throw new Error("Native workspace openers are unavailable")
|
||||
const spawnProcess = dependencies.spawnProcess ?? spawn
|
||||
const root = realpathSync(workspaceFolder)
|
||||
if (!statSync(root).isDirectory()) throw new Error("Workspace folder does not exist")
|
||||
const selectedPath = realpathSync(resolve(root, path))
|
||||
const relativePath = relative(root, selectedPath)
|
||||
if (relativePath === ".." || relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(relativePath)) {
|
||||
throw new Error("Selected path is outside the workspace")
|
||||
}
|
||||
const cwd = statSync(selectedPath).isDirectory() ? selectedPath : resolve(selectedPath, "..")
|
||||
|
||||
if (target === "default") {
|
||||
const mode = defaultOpenMode(selectedPath, process.platform)
|
||||
if (mode === "edit" || mode === "choose") {
|
||||
const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows"
|
||||
const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
const rundll32 = join(systemRoot, "System32", "rundll32.exe")
|
||||
const script = `$ErrorActionPreference='Stop'; Start-Process -FilePath '${selectedPath.replace(/'/g, "''")}' -Verb Edit`
|
||||
const encoded = Buffer.from(script, "utf16le").toString("base64")
|
||||
const candidates: LaunchCandidate[] = [
|
||||
...(mode === "edit" ? [{
|
||||
command: powershell,
|
||||
args: ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
|
||||
passPath: false,
|
||||
waitForExit: true,
|
||||
}] : []),
|
||||
{
|
||||
command: rundll32,
|
||||
args: ["shell32.dll,OpenAs_RunDLL"],
|
||||
verifyStart: true,
|
||||
},
|
||||
]
|
||||
await launchFirst(candidates, selectedPath, cwd, spawnProcess)
|
||||
return
|
||||
}
|
||||
const error = await dependencies.openPath(selectedPath)
|
||||
if (error) throw new Error(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (target === "reveal") {
|
||||
dependencies.revealPath(selectedPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (!statSync(selectedPath).isDirectory() && target === "terminal") throw new Error("Terminal target is not a folder")
|
||||
if (target === "editor" && !editor) throw new Error("Editor is required")
|
||||
|
||||
const candidates = target === "terminal"
|
||||
? terminalCandidates(process.platform, process.env, selectedPath)
|
||||
: editorCandidates(editor!, process.platform, process.env)
|
||||
await launchFirst(candidates, selectedPath, cwd, spawnProcess)
|
||||
}
|
||||
|
|
@ -26,6 +26,13 @@ const localElectronAPI = {
|
|||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
||||
openWorkspaceTarget: (payload) => ipcRenderer.invoke("workspace:openTarget", payload),
|
||||
setWorkspaceMenuEnabled: (enabled) => ipcRenderer.invoke("workspace:setMenuEnabled", Boolean(enabled)),
|
||||
onMenuAction: (callback) => {
|
||||
const handler = (_event, action) => callback(action)
|
||||
ipcRenderer.on("menu:action", handler)
|
||||
return () => ipcRenderer.removeListener("menu:action", handler)
|
||||
},
|
||||
getPathForFile: (file) => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"prebuild": "npm run prepare:resources",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts",
|
||||
"test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts",
|
||||
"preview": "electron-vite preview",
|
||||
"build:binaries": "node scripts/build.js",
|
||||
"build:mac": "node scripts/build.js mac",
|
||||
|
|
|
|||
1442
packages/server/package-lock.json
generated
1442
packages/server/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
|
|
|||
58
packages/server/src/settings/service.test.ts
Normal file
58
packages/server/src/settings/service.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { SettingsService } from "./service"
|
||||
|
||||
function serviceWithStore(store: Record<string, unknown>) {
|
||||
const service = Object.create(SettingsService.prototype) as SettingsService
|
||||
Object.assign(service as any, { configStore: store, eventBus: undefined })
|
||||
return service
|
||||
}
|
||||
|
||||
describe("SettingsService config persistence", () => {
|
||||
it("normalizes and persists a document patch once", () => {
|
||||
let writes = 0
|
||||
const service = serviceWithStore({
|
||||
get: () => ({ server: { logLevel: "info" } }),
|
||||
replace: (value: unknown) => {
|
||||
writes += 1
|
||||
return value
|
||||
},
|
||||
mergePatch: () => assert.fail("must not persist an intermediate document"),
|
||||
})
|
||||
|
||||
const result = service.mergePatchDoc("config", { ui: { theme: "dark" } })
|
||||
assert.equal(writes, 1)
|
||||
assert.deepEqual(result, { server: { logLevel: "INFO" }, ui: { theme: "dark" } })
|
||||
})
|
||||
|
||||
it("normalizes and persists a server-owner patch once", () => {
|
||||
let writes = 0
|
||||
const service = serviceWithStore({
|
||||
getOwner: () => ({ logLevel: "info", sidecars: [] }),
|
||||
replaceOwner: (_owner: string, value: unknown) => {
|
||||
writes += 1
|
||||
return value
|
||||
},
|
||||
mergePatchOwner: () => assert.fail("must not persist an intermediate owner"),
|
||||
})
|
||||
|
||||
const result = service.mergePatchOwner("config", "server", { sidecars: [{ id: "one" }] })
|
||||
assert.equal(writes, 1)
|
||||
assert.deepEqual(result, { logLevel: "INFO", sidecars: [{ id: "one" }] })
|
||||
})
|
||||
|
||||
it("does not report a persisted patch as failed when an event listener throws", () => {
|
||||
let warnings = 0
|
||||
const service = serviceWithStore({
|
||||
getOwner: () => ({}),
|
||||
mergePatchOwner: (_owner: string, patch: unknown) => patch,
|
||||
})
|
||||
Object.assign(service as any, {
|
||||
eventBus: { publish: () => { throw new Error("listener failed") } },
|
||||
logger: { warn: () => { warnings += 1 } },
|
||||
})
|
||||
|
||||
assert.deepEqual(service.mergePatchOwner("config", "ui", { theme: "dark" }), { theme: "dark" })
|
||||
assert.equal(warnings, 1)
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,7 @@ import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
|||
import { migrateSettingsLayout } from "./migrate"
|
||||
import type { WorkspaceEventPayload } from "../api-types"
|
||||
import { sanitizeConfigOwner } from "./public-config"
|
||||
import { applyMergePatch } from "./merge-patch"
|
||||
|
||||
export type DocKind = "config" | "state"
|
||||
|
||||
|
|
@ -67,8 +68,16 @@ export class SettingsService {
|
|||
private readonly logger: Logger,
|
||||
) {
|
||||
migrateSettingsLayout(location, logger)
|
||||
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
|
||||
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
|
||||
this.configStore = new YamlDocStore(
|
||||
location.configYamlPath,
|
||||
logger.child({ component: "settings-config" }),
|
||||
{ throwOnPersistError: true },
|
||||
)
|
||||
this.stateStore = new YamlDocStore(
|
||||
location.stateYamlPath,
|
||||
logger.child({ component: "settings-state" }),
|
||||
{ throwOnPersistError: true },
|
||||
)
|
||||
}
|
||||
|
||||
getDoc(kind: DocKind): SettingsDoc {
|
||||
|
|
@ -85,9 +94,12 @@ export class SettingsService {
|
|||
}
|
||||
|
||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const updated =
|
||||
kind === "config"
|
||||
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||
? this.configStore.replace(normalizeConfigDoc(applyMergePatch(this.configStore.get(), patch) as SettingsDoc))
|
||||
: this.stateStore.mergePatch(patch)
|
||||
this.publish(kind, "*")
|
||||
return updated
|
||||
|
|
@ -104,10 +116,16 @@ export class SettingsService {
|
|||
}
|
||||
|
||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const updated =
|
||||
kind === "config"
|
||||
? owner === "server"
|
||||
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||
? this.configStore.replaceOwner(
|
||||
owner,
|
||||
normalizeServerConfigOwner(applyMergePatch(this.configStore.getOwner(owner), patch) as SettingsDoc),
|
||||
)
|
||||
: this.configStore.mergePatchOwner(owner, patch)
|
||||
: this.stateStore.mergePatchOwner(owner, patch)
|
||||
this.publish(kind, owner, updated)
|
||||
|
|
@ -123,6 +141,10 @@ export class SettingsService {
|
|||
owner,
|
||||
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
||||
} as any
|
||||
this.eventBus.publish(payload)
|
||||
try {
|
||||
this.eventBus.publish(payload)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, kind, owner }, "Failed to publish settings change")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
packages/server/src/settings/yaml-doc-store.test.ts
Normal file
84
packages/server/src/settings/yaml-doc-store.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import assert from "node:assert/strict"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
import { YamlDocStore } from "./yaml-doc-store"
|
||||
|
||||
describe("YamlDocStore", () => {
|
||||
it("reports persistence failures without replacing the cached document", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const parent = path.join(root, "settings")
|
||||
const file = path.join(parent, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
fs.rmSync(parent, { recursive: true })
|
||||
fs.writeFileSync(parent, "blocks directory creation")
|
||||
|
||||
assert.throws(() => store.replace({ version: 2 }))
|
||||
assert.deepEqual(store.get(), { version: 1 })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps the live document intact when atomic replacement fails", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const file = path.join(root, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
const renameSync = fs.renameSync
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
;(fs as any).renameSync = () => { throw new Error("replacement failed") }
|
||||
|
||||
assert.throws(() => store.replace({ version: 2 }))
|
||||
assert.match(fs.readFileSync(file, "utf8"), /version: 1/)
|
||||
assert.deepEqual(store.get(), { version: 1 })
|
||||
|
||||
;(fs as any).renameSync = renameSync
|
||||
store.replace({ version: 2 })
|
||||
assert.match(fs.readFileSync(file, "utf8"), /version: 2/)
|
||||
} finally {
|
||||
;(fs as any).renameSync = renameSync
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves private file permissions", { skip: process.platform === "win32" }, () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const file = path.join(root, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
fs.chmodSync(file, 0o664)
|
||||
store.replace({ version: 2 })
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o664)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("updates the final target without replacing a symlink chain", { skip: process.platform === "win32" }, () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const target = path.join(root, "target.yaml")
|
||||
const intermediate = path.join(root, "current.yaml")
|
||||
const link = path.join(root, "config.yaml")
|
||||
fs.writeFileSync(target, "version: 1\n")
|
||||
fs.symlinkSync(target, intermediate)
|
||||
fs.symlinkSync(intermediate, link)
|
||||
const store = new YamlDocStore(link, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 2 })
|
||||
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
|
||||
assert.equal(fs.lstatSync(intermediate).isSymbolicLink(), true)
|
||||
assert.match(fs.readFileSync(target, "utf8"), /version: 2/)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||
import type { Logger } from "../logger"
|
||||
import { applyMergePatch, isPlainObject } from "./merge-patch"
|
||||
|
|
@ -18,6 +19,25 @@ function normalizeDoc(input: unknown): SettingsDoc {
|
|||
return input
|
||||
}
|
||||
|
||||
function resolveWriteDestination(filePath: string): string {
|
||||
let current = path.resolve(filePath)
|
||||
const seen = new Set<string>()
|
||||
|
||||
while (true) {
|
||||
let stat: fs.Stats
|
||||
try {
|
||||
stat = fs.lstatSync(current)
|
||||
} catch (error: any) {
|
||||
if (error?.code === "ENOENT") return current
|
||||
throw error
|
||||
}
|
||||
if (!stat.isSymbolicLink()) return current
|
||||
if (seen.has(current)) throw new Error(`Circular settings symlink: ${filePath}`)
|
||||
seen.add(current)
|
||||
current = path.resolve(path.dirname(current), fs.readlinkSync(current))
|
||||
}
|
||||
}
|
||||
|
||||
export class YamlDocStore {
|
||||
private cache: SettingsDoc = {}
|
||||
private loaded = false
|
||||
|
|
@ -25,6 +45,7 @@ export class YamlDocStore {
|
|||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly logger: Logger,
|
||||
private readonly options: { throwOnPersistError?: boolean } = {},
|
||||
) {}
|
||||
|
||||
load(): SettingsDoc {
|
||||
|
|
@ -58,9 +79,17 @@ export class YamlDocStore {
|
|||
|
||||
replace(next: unknown): SettingsDoc {
|
||||
const normalized = normalizeDoc(next)
|
||||
const previousCache = this.cache
|
||||
const previousLoaded = this.loaded
|
||||
this.cache = normalized
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
try {
|
||||
this.persist()
|
||||
} catch (error) {
|
||||
this.cache = previousCache
|
||||
this.loaded = previousLoaded
|
||||
throw error
|
||||
}
|
||||
return this.cache
|
||||
}
|
||||
|
||||
|
|
@ -99,12 +128,27 @@ export class YamlDocStore {
|
|||
}
|
||||
|
||||
private persist() {
|
||||
let tempPath: string | undefined
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||
const destination = resolveWriteDestination(this.filePath)
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
||||
const yaml = stringifyYaml(this.cache as any)
|
||||
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||
const mode = fs.existsSync(destination) ? fs.statSync(destination).mode & 0o777 : 0o600
|
||||
tempPath = `${destination}.${process.pid}.${randomUUID()}.tmp`
|
||||
fs.writeFileSync(tempPath, ensureTrailingNewline(yaml), { encoding: "utf-8", mode })
|
||||
if (process.platform !== "win32") fs.chmodSync(tempPath, mode)
|
||||
fs.renameSync(tempPath, destination)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
|
||||
if (this.options.throwOnPersistError) throw error
|
||||
} finally {
|
||||
if (tempPath) {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true })
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, tempPath }, "Failed to remove temporary YAML doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
packages/server/src/sidecars/manager.test.ts
Normal file
47
packages/server/src/sidecars/manager.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { SideCarManager } from "./manager"
|
||||
|
||||
const existing = {
|
||||
id: "existing",
|
||||
kind: "port",
|
||||
name: "Existing",
|
||||
port: 65534,
|
||||
insecure: true,
|
||||
prefixMode: "strip",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}
|
||||
|
||||
function manager(sidecars: unknown[] = []) {
|
||||
return new SideCarManager({
|
||||
settings: {
|
||||
getOwner: () => ({ sidecars }),
|
||||
mergePatchOwner: () => { throw new Error("disk full") },
|
||||
} as any,
|
||||
eventBus: { publish() {} } as any,
|
||||
logger: { warn() {} } as any,
|
||||
})
|
||||
}
|
||||
|
||||
describe("SideCarManager persistence rollback", () => {
|
||||
it("restores create, update, and delete state when persistence fails", async () => {
|
||||
const createManager = manager()
|
||||
await assert.rejects(createManager.create({
|
||||
kind: "port",
|
||||
name: "New",
|
||||
port: 65533,
|
||||
insecure: true,
|
||||
prefixMode: "strip",
|
||||
}))
|
||||
assert.deepEqual(await createManager.list(), [])
|
||||
|
||||
const updateManager = manager([existing])
|
||||
await assert.rejects(updateManager.update(existing.id, { name: "Changed" }))
|
||||
assert.equal((await updateManager.get(existing.id))?.name, existing.name)
|
||||
|
||||
const deleteManager = manager([existing])
|
||||
await assert.rejects(deleteManager.delete(existing.id))
|
||||
assert.equal((await deleteManager.get(existing.id))?.id, existing.id)
|
||||
})
|
||||
})
|
||||
|
|
@ -82,7 +82,13 @@ export class SideCarManager {
|
|||
|
||||
this.configs.set(record.id, record)
|
||||
this.runtime.set(record.id, { status: "stopped" })
|
||||
this.persistConfigs()
|
||||
try {
|
||||
this.persistConfigs()
|
||||
} catch (error) {
|
||||
this.configs.delete(record.id)
|
||||
this.runtime.delete(record.id)
|
||||
throw error
|
||||
}
|
||||
await this.refreshPortSideCar(record.id)
|
||||
return this.toSideCar(record)
|
||||
}
|
||||
|
|
@ -98,13 +104,19 @@ export class SideCarManager {
|
|||
): Promise<SideCar> {
|
||||
const record = this.requireConfig(id)
|
||||
|
||||
const previous = { ...record }
|
||||
record.name = typeof input.name === "string" ? input.name.trim() : record.name
|
||||
record.port = typeof input.port === "number" ? input.port : record.port
|
||||
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
|
||||
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
|
||||
record.updatedAt = new Date().toISOString()
|
||||
|
||||
this.persistConfigs()
|
||||
try {
|
||||
this.persistConfigs()
|
||||
} catch (error) {
|
||||
this.configs.set(id, previous)
|
||||
throw error
|
||||
}
|
||||
await this.refreshPortSideCar(id)
|
||||
return this.toSideCar(record)
|
||||
}
|
||||
|
|
@ -113,9 +125,16 @@ export class SideCarManager {
|
|||
const record = this.configs.get(id)
|
||||
if (!record) return false
|
||||
|
||||
const runtime = this.runtime.get(id)
|
||||
this.configs.delete(id)
|
||||
this.runtime.delete(id)
|
||||
this.persistConfigs()
|
||||
try {
|
||||
this.persistConfigs()
|
||||
} catch (error) {
|
||||
this.configs.set(id, record)
|
||||
if (runtime) this.runtime.set(id, runtime)
|
||||
throw error
|
||||
}
|
||||
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
|
@ -8,30 +9,15 @@ import { listWorktrees } from "../git-worktrees"
|
|||
describe("listWorktrees", () => {
|
||||
it("uses the selected workspace folder for the root worktree directory", async () => {
|
||||
const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-worktrees-"))
|
||||
const binDir = path.join(temp, "bin")
|
||||
const repoRoot = path.join(temp, "repo")
|
||||
const workspaceFolder = path.join(repoRoot, "proj-1")
|
||||
const originalPath = process.env.PATH
|
||||
|
||||
try {
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
execFileSync("git", ["init", "--initial-branch=main", repoRoot])
|
||||
mkdirSync(workspaceFolder, { recursive: true })
|
||||
|
||||
const gitPath = path.join(binDir, process.platform === "win32" ? "git.cmd" : "git")
|
||||
const porcelain = [
|
||||
`worktree ${repoRoot}`,
|
||||
"HEAD 1111111",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
if (process.platform === "win32") {
|
||||
writeFileSync(gitPath, `@echo off\r\nif "%1"=="worktree" if "%2"=="list" if "%3"=="--porcelain" (\r\necho ${porcelain.replace(/\n/g, "\r\necho ")}\r\nexit /b 0\r\n)\r\nexit /b 1\r\n`)
|
||||
} else {
|
||||
writeFileSync(gitPath, `#!/bin/sh\nif [ "$1" = "worktree" ] && [ "$2" = "list" ] && [ "$3" = "--porcelain" ]; then\nprintf '%s\n' '${porcelain.replace(/'/g, "'\\''")}'\nexit 0\nfi\nexit 1\n`, { mode: 0o755 })
|
||||
}
|
||||
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`
|
||||
writeFileSync(path.join(repoRoot, "README.md"), "test\n")
|
||||
execFileSync("git", ["-C", repoRoot, "add", "README.md"])
|
||||
execFileSync("git", ["-C", repoRoot, "-c", "user.name=CodeNomad Test", "-c", "user.email=test@codenomad.local", "commit", "-m", "test"])
|
||||
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder })
|
||||
|
||||
|
|
@ -41,7 +27,6 @@ describe("listWorktrees", () => {
|
|||
assert.equal(worktrees[0]?.branch, "main")
|
||||
assert.notEqual(worktrees[0]?.directory, repoRoot)
|
||||
} finally {
|
||||
process.env.PATH = originalPath
|
||||
rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
5
packages/tauri-app/Cargo.lock
generated
5
packages/tauri-app/Cargo.lock
generated
|
|
@ -497,7 +497,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
|
@ -1217,6 +1217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3373,7 +3374,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ serde_json = "1"
|
|||
serde_yaml = "0.9"
|
||||
base64 = "0.22"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
regex = "1"
|
||||
parking_lot = "0.12"
|
||||
anyhow = "1"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/capabilities.json",
|
||||
"identifier": "remote-window-notifications",
|
||||
"description": "Grant remote CodeNomad windows access only to native OS notifications.",
|
||||
"local": false,
|
||||
"remote": {
|
||||
"urls": ["http://*:*", "https://*:*"]
|
||||
},
|
||||
"windows": ["remote-*"],
|
||||
"permissions": [
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify"
|
||||
]
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]},"remote-window-notifications":{"identifier":"remote-window-notifications","description":"Grant remote CodeNomad windows access only to native OS notifications.","remote":{"urls":["http://*:*","https://*:*"]},"local":false,"windows":["remote-*"],"permissions":["notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]}}
|
||||
|
|
@ -479,6 +479,21 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
|||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn is_loopback_http_url(base_url: &str) -> bool {
|
||||
let Ok(parsed) = Url::parse(base_url) else {
|
||||
return false;
|
||||
};
|
||||
if parsed.scheme() != "http" || !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return false;
|
||||
}
|
||||
match parsed.host() {
|
||||
Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
|
||||
Some(url::Host::Ipv4(host)) => host.is_loopback(),
|
||||
Some(url::Host::Ipv6(host)) => host.is_loopback(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn exchange_bootstrap_token(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
|
|
@ -702,6 +717,13 @@ pub struct CliStatus {
|
|||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct LocalCliAccess {
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) cookie_name: String,
|
||||
pub(crate) session_cookie: String,
|
||||
}
|
||||
|
||||
impl Default for CliStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
@ -721,6 +743,7 @@ pub struct CliProcessManager {
|
|||
#[cfg(windows)]
|
||||
job: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
local_access: Arc<Mutex<Option<LocalCliAccess>>>,
|
||||
lifecycle: Arc<Mutex<()>>,
|
||||
generation: Arc<AtomicU64>,
|
||||
}
|
||||
|
|
@ -733,6 +756,7 @@ impl CliProcessManager {
|
|||
#[cfg(windows)]
|
||||
job: Arc::new(Mutex::new(None)),
|
||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||
local_access: Arc::new(Mutex::new(None)),
|
||||
lifecycle: Arc::new(Mutex::new(())),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
|
|
@ -741,9 +765,10 @@ impl CliProcessManager {
|
|||
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
|
||||
let _lifecycle = self.lifecycle.lock();
|
||||
let generation = self.advance_generation();
|
||||
*self.bootstrap_token.lock() = None;
|
||||
*self.local_access.lock() = None;
|
||||
log_line(&format!("start requested (dev={dev})"));
|
||||
self.stop_tracked_child()?;
|
||||
*self.bootstrap_token.lock() = None;
|
||||
{
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Starting;
|
||||
|
|
@ -768,6 +793,8 @@ impl CliProcessManager {
|
|||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
let _lifecycle = self.lifecycle.lock();
|
||||
self.advance_generation();
|
||||
*self.bootstrap_token.lock() = None;
|
||||
*self.local_access.lock() = None;
|
||||
self.stop_tracked_child()?;
|
||||
self.reset_stopped_status();
|
||||
Ok(())
|
||||
|
|
@ -839,10 +866,12 @@ impl CliProcessManager {
|
|||
status.port = None;
|
||||
status.url = None;
|
||||
status.error = None;
|
||||
*self.local_access.lock() = None;
|
||||
}
|
||||
|
||||
fn publish_error(&self, app: &AppHandle, generation: u64, message: String) {
|
||||
self.with_current_generation(generation, || {
|
||||
*self.local_access.lock() = None;
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Error;
|
||||
status.error = Some(message.clone());
|
||||
|
|
@ -857,6 +886,14 @@ impl CliProcessManager {
|
|||
self.status.lock().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn local_cli_access(&self) -> Option<LocalCliAccess> {
|
||||
let _lifecycle = self.lifecycle.lock();
|
||||
if self.status.lock().state != CliState::Ready {
|
||||
return None;
|
||||
}
|
||||
self.local_access.lock().clone()
|
||||
}
|
||||
|
||||
fn spawn_cli(
|
||||
manager: CliProcessManager,
|
||||
app: AppHandle,
|
||||
|
|
@ -1129,6 +1166,7 @@ impl CliProcessManager {
|
|||
}
|
||||
Poll::Exited(code) => {
|
||||
manager.with_current_generation(generation, || {
|
||||
*manager.local_access.lock() = None;
|
||||
let mut status = manager.status.lock();
|
||||
if status.state != CliState::Ready {
|
||||
status.state = CliState::Error;
|
||||
|
|
@ -1261,10 +1299,8 @@ impl CliProcessManager {
|
|||
log_line(&format!("cli ready on {base_url}"));
|
||||
|
||||
if let Some(token) = token {
|
||||
// Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
|
||||
// skip the exchange and let the user authenticate normally.
|
||||
let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
|
||||
if scheme.as_deref() != Some("http") {
|
||||
// Native credentials are only established against the managed loopback listener.
|
||||
if !is_loopback_http_url(&base_url) {
|
||||
navigate_main(manager, generation, app, &base_url);
|
||||
} else {
|
||||
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
||||
|
|
@ -1279,6 +1315,13 @@ impl CliProcessManager {
|
|||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(manager, generation, app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
manager.with_current_generation(generation, || {
|
||||
*manager.local_access.lock() = Some(LocalCliAccess {
|
||||
base_url: base_url.clone(),
|
||||
cookie_name: auth_cookie_name.to_string(),
|
||||
session_cookie: session_id,
|
||||
});
|
||||
});
|
||||
navigate_main(manager, generation, app, &base_url);
|
||||
}
|
||||
}
|
||||
|
|
@ -1721,6 +1764,34 @@ mod tests {
|
|||
assert_eq!(manager.status().state, CliState::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_cli_access_requires_readiness_and_clears_on_stop() {
|
||||
let manager = CliProcessManager::new();
|
||||
let access = LocalCliAccess {
|
||||
base_url: "http://127.0.0.1:3000".into(),
|
||||
cookie_name: "codenomad_session_test".into(),
|
||||
session_cookie: "secret".into(),
|
||||
};
|
||||
*manager.local_access.lock() = Some(access.clone());
|
||||
|
||||
assert_eq!(manager.local_cli_access(), None);
|
||||
manager.status.lock().state = CliState::Ready;
|
||||
assert_eq!(manager.local_cli_access(), Some(access));
|
||||
|
||||
manager.stop().unwrap();
|
||||
assert_eq!(manager.local_cli_access(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_auth_is_limited_to_loopback_http() {
|
||||
assert!(is_loopback_http_url("http://127.0.0.1:3000"));
|
||||
assert!(is_loopback_http_url("http://[::1]:3000"));
|
||||
assert!(is_loopback_http_url("http://localhost:3000"));
|
||||
assert!(!is_loopback_http_url("https://localhost:3000"));
|
||||
assert!(!is_loopback_http_url("http://remote.example:3000"));
|
||||
assert!(!is_loopback_http_url("http://user@localhost:3000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_waits_for_an_authorized_spawn_section() {
|
||||
let manager = CliProcessManager::new();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ mod linux_tls;
|
|||
mod managed_node;
|
||||
mod shutdown;
|
||||
mod windows_update;
|
||||
mod workspace_open;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use keepawake::KeepAwake;
|
||||
|
|
@ -19,7 +20,9 @@ use std::collections::{HashMap, HashSet};
|
|||
use std::future::Future;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::menu::{AboutMetadata, MenuBuilder, MenuItem, PredefinedMenuItem, SubmenuBuilder};
|
||||
use tauri::menu::{
|
||||
AboutMetadata, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder,
|
||||
};
|
||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||
use tauri::webview::{PageLoadEvent, Webview};
|
||||
use tauri::{
|
||||
|
|
@ -43,7 +46,8 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
|||
const ZOOM_STEP: f64 = 0.1;
|
||||
const RELEASES_URL: &str = "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest";
|
||||
const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';";
|
||||
const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';";
|
||||
const REMOTE_WINDOW_CONTEXT_SCRIPT: &str =
|
||||
"window.__CODENOMAD_RUNTIME_HOST__ = 'tauri'; window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';";
|
||||
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
|
|
@ -56,6 +60,70 @@ pub struct AppState {
|
|||
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
|
||||
pub remote_tls_handlers: Mutex<HashSet<String>>,
|
||||
pub remote_titles: Mutex<HashMap<String, String>>,
|
||||
pub workspace_menu_items: Mutex<Option<WorkspaceMenuItems>>,
|
||||
pub workspace_menu_requested_enabled: Mutex<bool>,
|
||||
}
|
||||
|
||||
pub struct WorkspaceMenuItems {
|
||||
folder: MenuItem<Wry>,
|
||||
terminal: MenuItem<Wry>,
|
||||
editor: Submenu<Wry>,
|
||||
}
|
||||
|
||||
fn update_workspace_menu_state(app: &AppHandle) {
|
||||
let state = app.state::<AppState>();
|
||||
let requested = state
|
||||
.workspace_menu_requested_enabled
|
||||
.lock()
|
||||
.map(|value| *value)
|
||||
.unwrap_or(false);
|
||||
let focused = app
|
||||
.get_webview_window("main")
|
||||
.and_then(|window| window.is_focused().ok())
|
||||
.unwrap_or(false);
|
||||
if let Ok(items) = state.workspace_menu_items.lock() {
|
||||
if let Some(items) = items.as_ref() {
|
||||
let enabled = requested && focused;
|
||||
let _ = items.folder.set_enabled(enabled);
|
||||
let _ = items.terminal.set_enabled(enabled);
|
||||
let _ = items.editor.set_enabled(enabled);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_workspace_menu_enabled(
|
||||
window: tauri::WebviewWindow,
|
||||
app: AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
if window.label() != "main" {
|
||||
return Err("Workspace menu updates are limited to the local main window".into());
|
||||
}
|
||||
if !enabled {
|
||||
*state
|
||||
.workspace_menu_requested_enabled
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())? = false;
|
||||
update_workspace_menu_state(&app);
|
||||
return Ok(());
|
||||
}
|
||||
let config = state
|
||||
.manager
|
||||
.local_cli_access()
|
||||
.ok_or("Local CodeNomad server is unavailable")?;
|
||||
let expected = Url::parse(&config.base_url).map_err(|error| error.to_string())?;
|
||||
let current = window.url().map_err(|error| error.to_string())?;
|
||||
if current.origin() != expected.origin() {
|
||||
return Err("Workspace menu updates require the local CodeNomad origin".into());
|
||||
}
|
||||
*state
|
||||
.workspace_menu_requested_enabled
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())? = enabled;
|
||||
update_workspace_menu_state(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -197,19 +265,20 @@ fn should_allow_window_origin<R: Runtime>(
|
|||
window_label: &str,
|
||||
url: &Url,
|
||||
) -> bool {
|
||||
if should_allow_internal(url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let state = app_handle.state::<AppState>();
|
||||
let Ok(allowed) = state.remote_origins.lock() else {
|
||||
return false;
|
||||
};
|
||||
if let Some(origin) = allowed.get(window_label) {
|
||||
return origin == &url.origin().ascii_serialization();
|
||||
}
|
||||
should_allow_registered_origin(allowed.get(window_label).map(String::as_str), url)
|
||||
}
|
||||
|
||||
false
|
||||
fn should_allow_registered_origin(registered_origin: Option<&str>, url: &Url) -> bool {
|
||||
if let Some(origin) = registered_origin {
|
||||
if matches!(url.scheme(), "http" | "https") {
|
||||
return origin == url.origin().ascii_serialization();
|
||||
}
|
||||
}
|
||||
should_allow_internal(url)
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
|
|
@ -345,6 +414,9 @@ async fn open_remote_window_impl(
|
|||
let app_handle = app.clone();
|
||||
let label_for_cleanup = label.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, WindowEvent::Focused(_)) {
|
||||
update_workspace_menu_state(&app_handle);
|
||||
}
|
||||
if let WindowEvent::Destroyed = event {
|
||||
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
||||
origins.remove(&label_for_cleanup);
|
||||
|
|
@ -583,8 +655,21 @@ fn main() {
|
|||
remote_skip_tls_verify: Mutex::new(HashMap::new()),
|
||||
remote_tls_handlers: Mutex::new(HashSet::new()),
|
||||
remote_titles: Mutex::new(HashMap::new()),
|
||||
workspace_menu_items: Mutex::new(None),
|
||||
workspace_menu_requested_enabled: Mutex::new(false),
|
||||
})
|
||||
.on_page_load(|webview, payload| {
|
||||
if webview.label() == "main" && payload.event() == PageLoadEvent::Started {
|
||||
if let Ok(mut enabled) = webview
|
||||
.app_handle()
|
||||
.state::<AppState>()
|
||||
.workspace_menu_requested_enabled
|
||||
.lock()
|
||||
{
|
||||
*enabled = false;
|
||||
}
|
||||
update_workspace_menu_state(&webview.app_handle());
|
||||
}
|
||||
if matches!(
|
||||
payload.event(),
|
||||
PageLoadEvent::Started | PageLoadEvent::Finished
|
||||
|
|
@ -605,6 +690,12 @@ fn main() {
|
|||
shutdown::install_windows_session_end_handler(&window)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?;
|
||||
let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT);
|
||||
let app_handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, WindowEvent::Focused(_)) {
|
||||
update_workspace_menu_state(&app_handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(shortcut) = fullscreen_shortcut() {
|
||||
let shortcut_manager = app.handle().global_shortcut();
|
||||
|
|
@ -649,14 +740,27 @@ fn main() {
|
|||
client_state::client_state_clear,
|
||||
client_state::client_state_renderer_flushed,
|
||||
client_state::client_state_navigation_flushed,
|
||||
windows_update::install_stable_update
|
||||
windows_update::install_stable_update,
|
||||
workspace_open::open_workspace_target,
|
||||
set_workspace_menu_enabled
|
||||
])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
match event.id().0.as_str() {
|
||||
// File menu
|
||||
"new_instance" => {
|
||||
action @ ("new-instance"
|
||||
| "open-workspace-folder"
|
||||
| "open-workspace-terminal"
|
||||
| "open-workspace-editor-vscode"
|
||||
| "open-workspace-editor-cursor"
|
||||
| "open-workspace-editor-zed"
|
||||
| "open-workspace-editor-vscodium") => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.emit("menu:newInstance", ());
|
||||
if action.starts_with("open-workspace-")
|
||||
&& !window.is_focused().unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = window.emit("menu:action", action);
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
|
|
@ -859,22 +963,60 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||
// File menu - create New Instance with accelerator
|
||||
let new_instance_item = MenuItem::with_id(
|
||||
app,
|
||||
"new_instance",
|
||||
"new-instance",
|
||||
"New Instance",
|
||||
true,
|
||||
Some("CmdOrCtrl+N"),
|
||||
)?;
|
||||
let open_folder_item = MenuItem::with_id(
|
||||
app,
|
||||
"open-workspace-folder",
|
||||
"Open Project Folder",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let open_terminal_item = MenuItem::with_id(
|
||||
app,
|
||||
"open-workspace-terminal",
|
||||
"Open Terminal Here",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let open_editor_menu = SubmenuBuilder::new(app, "Open Project In")
|
||||
.text("open-workspace-editor-vscode", "VS Code")
|
||||
.text("open-workspace-editor-cursor", "Cursor")
|
||||
.text("open-workspace-editor-zed", "Zed")
|
||||
.text("open-workspace-editor-vscodium", "VSCodium")
|
||||
.build()?;
|
||||
open_folder_item.set_enabled(false)?;
|
||||
open_terminal_item.set_enabled(false)?;
|
||||
open_editor_menu.set_enabled(false)?;
|
||||
if let Ok(mut items) = app.state::<AppState>().workspace_menu_items.lock() {
|
||||
*items = Some(WorkspaceMenuItems {
|
||||
folder: open_folder_item.clone(),
|
||||
terminal: open_terminal_item.clone(),
|
||||
editor: open_editor_menu.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let file_menu = if is_mac {
|
||||
SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.item(&open_folder_item)
|
||||
.item(&open_terminal_item)
|
||||
.item(&open_editor_menu)
|
||||
.separator()
|
||||
.close_window()
|
||||
.build()?
|
||||
} else {
|
||||
SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.item(&open_folder_item)
|
||||
.item(&open_terminal_item)
|
||||
.item(&open_editor_menu)
|
||||
.separator()
|
||||
.text("quit", "Quit")
|
||||
.build()?
|
||||
};
|
||||
|
|
@ -1035,8 +1177,13 @@ fn build_about_metadata(version: &str, include_update_link: bool) -> AboutMetada
|
|||
|
||||
#[cfg(test)]
|
||||
mod menu_tests {
|
||||
use super::{build_about_metadata, run_update_with_fallback, RELEASES_URL};
|
||||
use super::{
|
||||
build_about_metadata, run_update_with_fallback, should_allow_registered_origin,
|
||||
RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use url::Url;
|
||||
|
||||
#[test]
|
||||
fn failed_update_uses_release_fallback() {
|
||||
|
|
@ -1067,4 +1214,53 @@ mod menu_tests {
|
|||
assert_eq!(metadata.website, None);
|
||||
assert_eq!(metadata.website_label, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_windows_identify_as_remote_tauri_windows() {
|
||||
assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_RUNTIME_HOST__ = 'tauri'"));
|
||||
assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_WINDOW_CONTEXT__ = 'remote'"));
|
||||
|
||||
let capability: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../capabilities/remote-window-notifications.json"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(capability["local"], false);
|
||||
assert_eq!(
|
||||
capability["remote"]["urls"],
|
||||
json!(["http://*:*", "https://*:*"])
|
||||
);
|
||||
assert_eq!(capability["windows"], json!(["remote-*"]));
|
||||
assert_eq!(
|
||||
capability["permissions"],
|
||||
json!([
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify"
|
||||
])
|
||||
);
|
||||
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
assert!(config["app"]["security"]["capabilities"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("remote-window-notifications")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_windows_stay_on_their_registered_http_origin() {
|
||||
let origin = "https://remote.example:9898";
|
||||
assert!(should_allow_registered_origin(
|
||||
Some(origin),
|
||||
&Url::parse("https://remote.example:9898/settings").unwrap()
|
||||
));
|
||||
assert!(!should_allow_registered_origin(
|
||||
Some(origin),
|
||||
&Url::parse("http://localhost:9898/").unwrap()
|
||||
));
|
||||
assert!(should_allow_registered_origin(
|
||||
Some(origin),
|
||||
&Url::parse("about:blank").unwrap()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
634
packages/tauri-app/src-tauri/src/workspace_open.rs
Normal file
634
packages/tauri-app/src-tauri/src/workspace_open.rs
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
use crate::{cli_manager::CliProcessManager, AppState};
|
||||
#[cfg(windows)]
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LaunchCandidate {
|
||||
command: PathBuf,
|
||||
args: Vec<String>,
|
||||
pass_path: bool,
|
||||
wait_for_exit: bool,
|
||||
verify_start: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WorkspaceDescriptor {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WorktreeDescriptor {
|
||||
slug: String,
|
||||
directory: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WorktreeListResponse {
|
||||
worktrees: Vec<WorktreeDescriptor>,
|
||||
}
|
||||
|
||||
fn get_local_json<T: DeserializeOwned>(
|
||||
manager: &CliProcessManager,
|
||||
path: &str,
|
||||
) -> Result<T, String> {
|
||||
let config = manager
|
||||
.local_cli_access()
|
||||
.ok_or("Local CodeNomad server is unavailable")?;
|
||||
let url = format!("{}{}", config.base_url.trim_end_matches('/'), path);
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut request = client.get(url);
|
||||
request = request.header(
|
||||
reqwest::header::COOKIE,
|
||||
format!("{}={}", config.cookie_name, config.session_cookie),
|
||||
);
|
||||
request
|
||||
.send()
|
||||
.and_then(reqwest::blocking::Response::error_for_status)
|
||||
.map_err(|error| error.to_string())?
|
||||
.json::<T>()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn resolve_active_workspace_folder(
|
||||
manager: &CliProcessManager,
|
||||
instance_id: &str,
|
||||
worktree_slug: &str,
|
||||
) -> Result<String, String> {
|
||||
if instance_id.is_empty()
|
||||
|| !instance_id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
|
||||
{
|
||||
return Err("Invalid workspace id".into());
|
||||
}
|
||||
let workspace: WorkspaceDescriptor =
|
||||
get_local_json(manager, &format!("/api/workspaces/{instance_id}"))?;
|
||||
if worktree_slug == "root" {
|
||||
return Ok(workspace.path);
|
||||
}
|
||||
let payload: WorktreeListResponse =
|
||||
get_local_json(manager, &format!("/api/workspaces/{instance_id}/worktrees"))?;
|
||||
payload
|
||||
.worktrees
|
||||
.into_iter()
|
||||
.find(|worktree| worktree.slug == worktree_slug)
|
||||
.map(|worktree| worktree.directory)
|
||||
.ok_or_else(|| "Selected worktree is unavailable".into())
|
||||
}
|
||||
|
||||
fn editor_candidates(editor: &str) -> Result<Vec<LaunchCandidate>, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let name = match editor {
|
||||
"vscode" => "Visual Studio Code",
|
||||
"cursor" => "Cursor",
|
||||
"zed" => "Zed",
|
||||
"vscodium" => "VSCodium",
|
||||
_ => return Err("Unsupported editor".into()),
|
||||
};
|
||||
return Ok(vec![LaunchCandidate {
|
||||
command: "/usr/bin/open".into(),
|
||||
args: vec!["-a".into(), name.into()],
|
||||
pass_path: true,
|
||||
wait_for_exit: true,
|
||||
verify_start: false,
|
||||
}]);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let paths: Vec<PathBuf> = match editor {
|
||||
"vscode" => vec![
|
||||
std::env::var_os("LOCALAPPDATA")
|
||||
.map(PathBuf::from)
|
||||
.map(|path| path.join("Programs/Microsoft VS Code/Code.exe")),
|
||||
std::env::var_os("ProgramFiles")
|
||||
.map(PathBuf::from)
|
||||
.map(|path| path.join("Microsoft VS Code/Code.exe")),
|
||||
],
|
||||
"cursor" => vec![std::env::var_os("LOCALAPPDATA")
|
||||
.map(PathBuf::from)
|
||||
.map(|path| path.join("Programs/cursor/Cursor.exe"))],
|
||||
"zed" => vec![std::env::var_os("LOCALAPPDATA")
|
||||
.map(PathBuf::from)
|
||||
.map(|path| path.join("Programs/Zed/Zed.exe"))],
|
||||
"vscodium" => vec![
|
||||
std::env::var_os("LOCALAPPDATA")
|
||||
.map(PathBuf::from)
|
||||
.map(|path| path.join("Programs/VSCodium/VSCodium.exe")),
|
||||
std::env::var_os("ProgramFiles")
|
||||
.map(PathBuf::from)
|
||||
.map(|path| path.join("VSCodium/VSCodium.exe")),
|
||||
],
|
||||
_ => return Err("Unsupported editor".into()),
|
||||
}
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|path| !path.is_absolute() || path.is_file())
|
||||
.collect();
|
||||
return Ok(paths
|
||||
.into_iter()
|
||||
.map(|command| LaunchCandidate {
|
||||
command,
|
||||
args: Vec::new(),
|
||||
pass_path: true,
|
||||
wait_for_exit: false,
|
||||
verify_start: false,
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
|
||||
#[cfg(all(not(windows), not(target_os = "macos")))]
|
||||
{
|
||||
let command = match editor {
|
||||
"vscode" => "code",
|
||||
"cursor" => "cursor",
|
||||
"zed" => "zed",
|
||||
"vscodium" => "codium",
|
||||
_ => return Err("Unsupported editor".into()),
|
||||
};
|
||||
Ok(vec![LaunchCandidate {
|
||||
command: command.into(),
|
||||
args: Vec::new(),
|
||||
pass_path: true,
|
||||
wait_for_exit: false,
|
||||
verify_start: true,
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_candidates(folder: &Path) -> Vec<LaunchCandidate> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return vec![LaunchCandidate {
|
||||
command: "/usr/bin/open".into(),
|
||||
args: vec!["-a".into(), "Terminal".into()],
|
||||
pass_path: true,
|
||||
wait_for_exit: true,
|
||||
verify_start: false,
|
||||
}];
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let system_root = std::env::var_os("SystemRoot")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| r"C:\Windows".into());
|
||||
let command = std::env::var_os("ComSpec")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| system_root.join("System32/cmd.exe"));
|
||||
let powershell = system_root.join("System32/WindowsPowerShell/v1.0/powershell.exe");
|
||||
let escaped = folder.to_string_lossy().replace('\'', "''");
|
||||
let script = format!("Set-Location -LiteralPath '{escaped}'");
|
||||
return vec![LaunchCandidate {
|
||||
command,
|
||||
args: vec![
|
||||
"/D".into(),
|
||||
"/C".into(),
|
||||
"start".into(),
|
||||
"".into(),
|
||||
powershell.to_string_lossy().into_owned(),
|
||||
"-NoExit".into(),
|
||||
"-EncodedCommand".into(),
|
||||
encode_powershell_script(&script),
|
||||
],
|
||||
pass_path: false,
|
||||
wait_for_exit: true,
|
||||
verify_start: false,
|
||||
}];
|
||||
}
|
||||
|
||||
#[cfg(all(not(windows), not(target_os = "macos")))]
|
||||
{
|
||||
let configured = std::env::var("TERMINAL")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.into_iter();
|
||||
configured
|
||||
.chain(
|
||||
[
|
||||
"xdg-terminal-exec",
|
||||
"x-terminal-emulator",
|
||||
"gnome-terminal",
|
||||
"konsole",
|
||||
"kitty",
|
||||
"alacritty",
|
||||
"wezterm",
|
||||
]
|
||||
.map(str::to_string),
|
||||
)
|
||||
.map(|command| LaunchCandidate {
|
||||
command: command.into(),
|
||||
args: Vec::new(),
|
||||
pass_path: false,
|
||||
wait_for_exit: false,
|
||||
verify_start: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn encode_powershell_script(script: &str) -> String {
|
||||
let bytes: Vec<u8> = script.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
BASE64.encode(bytes)
|
||||
}
|
||||
|
||||
fn spawn_candidate(
|
||||
candidate: &LaunchCandidate,
|
||||
selected_path: &Path,
|
||||
cwd: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
let mut command = Command::new(&candidate.command);
|
||||
command.args(&candidate.args);
|
||||
if candidate.pass_path {
|
||||
command.arg(selected_path);
|
||||
}
|
||||
let mut child = command
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
if candidate.wait_for_exit {
|
||||
let status = child.wait()?;
|
||||
if !status.success() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("{} exited with {status}", candidate.command.display()),
|
||||
));
|
||||
}
|
||||
} else if candidate.verify_start {
|
||||
for _ in 0..10 {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("{} exited with {status}", candidate.command.display()),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn launch_first(
|
||||
candidates: Vec<LaunchCandidate>,
|
||||
selected_path: &Path,
|
||||
cwd: &Path,
|
||||
) -> Result<(), String> {
|
||||
let mut last_error = None;
|
||||
for candidate in candidates {
|
||||
match spawn_candidate(&candidate, selected_path, cwd) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
Err(last_error
|
||||
.map(|error| error.to_string())
|
||||
.unwrap_or_else(|| "No supported application was found".into()))
|
||||
}
|
||||
|
||||
fn resolve_workspace_path(
|
||||
workspace_folder: &str,
|
||||
path: &str,
|
||||
) -> Result<(PathBuf, PathBuf), String> {
|
||||
let root = PathBuf::from(workspace_folder)
|
||||
.canonicalize()
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !root.is_dir() {
|
||||
return Err("Workspace folder does not exist".into());
|
||||
}
|
||||
let selected = root
|
||||
.join(path)
|
||||
.canonicalize()
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !selected.starts_with(&root) {
|
||||
return Err("Selected path is outside the workspace".into());
|
||||
}
|
||||
Ok((root, selected))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn platform_launch_path(path: &Path) -> PathBuf {
|
||||
let value = path.to_string_lossy();
|
||||
if let Some(rest) = value.strip_prefix(r"\\?\UNC\") {
|
||||
return format!(r"\\{rest}").into();
|
||||
}
|
||||
value.strip_prefix(r"\\?\").unwrap_or(&value).into()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum DefaultOpenMode {
|
||||
Open,
|
||||
Edit,
|
||||
Choose,
|
||||
}
|
||||
|
||||
fn default_open_mode(path: &Path) -> Result<DefaultOpenMode, String> {
|
||||
#[cfg(not(windows))]
|
||||
const BLOCKED: &[&str] = &[
|
||||
"appimage",
|
||||
"application",
|
||||
"chm",
|
||||
"com",
|
||||
"cpl",
|
||||
"desktop",
|
||||
"exe",
|
||||
"jar",
|
||||
"lnk",
|
||||
"msi",
|
||||
"msp",
|
||||
"pif",
|
||||
"scr",
|
||||
"url",
|
||||
];
|
||||
#[cfg(windows)]
|
||||
const SAFE_WINDOWS_OPEN: &[&str] = &[
|
||||
"7z", "avi", "bmp", "c", "cc", "cfg", "conf", "cpp", "cs", "css", "csv", "dart", "diff",
|
||||
"docx", "env", "flac", "fs", "fsx", "gif", "go", "gz", "h", "hpp", "htm", "html", "ini",
|
||||
"java", "jpeg", "jpg", "json", "jsonc", "jsx", "kt", "kts", "less", "lock", "log", "lua",
|
||||
"md", "markdown", "mkv", "mov", "mp3", "mp4", "ogg", "patch", "pdf", "png", "pptx", "r",
|
||||
"rar", "rmd", "rs", "scss", "sql", "svg", "svelte", "swift", "tar", "toml", "ts", "tsx",
|
||||
"txt", "vue", "wav", "webm", "webp", "xlsx", "xml", "yaml", "yml", "zip",
|
||||
];
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_EDIT: &[&str] = &[
|
||||
"bat", "cmd", "js", "jse", "pl", "ps1", "psd1", "psm1", "py", "pyw", "rb", "reg", "vbe",
|
||||
"vbs", "wsf", "wsh",
|
||||
];
|
||||
let metadata = path.metadata().map_err(|error| error.to_string())?;
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if metadata.is_dir() {
|
||||
if cfg!(target_os = "macos") && extension == "app" {
|
||||
return Err("Application bundles cannot be opened externally".into());
|
||||
}
|
||||
return Ok(DefaultOpenMode::Open);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return Ok(if SAFE_WINDOWS_OPEN.contains(&extension.as_str()) {
|
||||
DefaultOpenMode::Open
|
||||
} else if WINDOWS_EDIT.contains(&extension.as_str()) {
|
||||
DefaultOpenMode::Edit
|
||||
} else {
|
||||
DefaultOpenMode::Choose
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if BLOCKED.contains(&extension.as_str()) {
|
||||
return Err("Executable files cannot be opened externally".into());
|
||||
}
|
||||
if metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 {
|
||||
return Err("Executable files cannot be opened externally".into());
|
||||
}
|
||||
return Ok(DefaultOpenMode::Open);
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
Ok(DefaultOpenMode::Open)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn platform_launch_path(path: &Path) -> PathBuf {
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
fn open_workspace_target_impl(
|
||||
manager: &CliProcessManager,
|
||||
target: String,
|
||||
instance_id: String,
|
||||
worktree_slug: String,
|
||||
path: Option<String>,
|
||||
editor: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let workspace_folder = resolve_active_workspace_folder(manager, &instance_id, &worktree_slug)?;
|
||||
let (root, selected) =
|
||||
resolve_workspace_path(&workspace_folder, path.as_deref().unwrap_or("."))?;
|
||||
let cwd = if selected.is_dir() {
|
||||
selected.clone()
|
||||
} else {
|
||||
selected.parent().unwrap_or(root.as_path()).to_path_buf()
|
||||
};
|
||||
let launch_path = platform_launch_path(&selected);
|
||||
let launch_cwd = platform_launch_path(&cwd);
|
||||
|
||||
match target.as_str() {
|
||||
"default" => {
|
||||
let mode = default_open_mode(&selected)?;
|
||||
#[cfg(windows)]
|
||||
if mode != DefaultOpenMode::Open {
|
||||
let system_root = std::env::var_os("SystemRoot")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| r"C:\Windows".into());
|
||||
let powershell = system_root.join("System32/WindowsPowerShell/v1.0/powershell.exe");
|
||||
let rundll32 = system_root.join("System32/rundll32.exe");
|
||||
let escaped = launch_path.to_string_lossy().replace('\'', "''");
|
||||
let script = format!(
|
||||
"$ErrorActionPreference='Stop'; Start-Process -FilePath '{escaped}' -Verb Edit"
|
||||
);
|
||||
let mut candidates = Vec::new();
|
||||
if mode == DefaultOpenMode::Edit {
|
||||
candidates.push(LaunchCandidate {
|
||||
command: powershell,
|
||||
args: vec![
|
||||
"-NoProfile".into(),
|
||||
"-NonInteractive".into(),
|
||||
"-EncodedCommand".into(),
|
||||
encode_powershell_script(&script),
|
||||
],
|
||||
pass_path: false,
|
||||
wait_for_exit: true,
|
||||
verify_start: false,
|
||||
});
|
||||
}
|
||||
candidates.push(LaunchCandidate {
|
||||
command: rundll32,
|
||||
args: vec!["shell32.dll,OpenAs_RunDLL".into()],
|
||||
pass_path: true,
|
||||
wait_for_exit: false,
|
||||
verify_start: true,
|
||||
});
|
||||
return launch_first(candidates, &launch_path, &launch_cwd);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let candidates = vec![LaunchCandidate {
|
||||
command: "/usr/bin/open".into(),
|
||||
args: Vec::new(),
|
||||
pass_path: true,
|
||||
wait_for_exit: true,
|
||||
verify_start: false,
|
||||
}];
|
||||
#[cfg(windows)]
|
||||
let candidates = vec![LaunchCandidate {
|
||||
command: "explorer.exe".into(),
|
||||
args: Vec::new(),
|
||||
pass_path: true,
|
||||
wait_for_exit: false,
|
||||
verify_start: false,
|
||||
}];
|
||||
#[cfg(all(not(windows), not(target_os = "macos")))]
|
||||
let candidates = vec![
|
||||
LaunchCandidate {
|
||||
command: "xdg-open".into(),
|
||||
args: Vec::new(),
|
||||
pass_path: true,
|
||||
wait_for_exit: false,
|
||||
verify_start: true,
|
||||
},
|
||||
LaunchCandidate {
|
||||
command: "gio".into(),
|
||||
args: vec!["open".into()],
|
||||
pass_path: true,
|
||||
wait_for_exit: false,
|
||||
verify_start: true,
|
||||
},
|
||||
];
|
||||
launch_first(candidates, &launch_path, &launch_cwd)
|
||||
}
|
||||
"reveal" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
let candidates = vec![LaunchCandidate {
|
||||
command: "/usr/bin/open".into(),
|
||||
args: vec!["-R".into()],
|
||||
pass_path: true,
|
||||
wait_for_exit: true,
|
||||
verify_start: false,
|
||||
}];
|
||||
#[cfg(windows)]
|
||||
let candidates = vec![LaunchCandidate {
|
||||
command: "explorer.exe".into(),
|
||||
args: vec![format!("/select,{}", launch_path.display())],
|
||||
pass_path: false,
|
||||
wait_for_exit: false,
|
||||
verify_start: false,
|
||||
}];
|
||||
#[cfg(all(not(windows), not(target_os = "macos")))]
|
||||
let candidates = vec![
|
||||
LaunchCandidate {
|
||||
command: "xdg-open".into(),
|
||||
args: Vec::new(),
|
||||
pass_path: true,
|
||||
wait_for_exit: false,
|
||||
verify_start: true,
|
||||
},
|
||||
LaunchCandidate {
|
||||
command: "gio".into(),
|
||||
args: vec!["open".into()],
|
||||
pass_path: true,
|
||||
wait_for_exit: false,
|
||||
verify_start: true,
|
||||
},
|
||||
];
|
||||
let reveal_path = if cfg!(all(not(windows), not(target_os = "macos"))) {
|
||||
launch_cwd.as_path()
|
||||
} else {
|
||||
launch_path.as_path()
|
||||
};
|
||||
launch_first(candidates, reveal_path, &launch_cwd)
|
||||
}
|
||||
"terminal" => {
|
||||
if !selected.is_dir() {
|
||||
return Err("Terminal target is not a folder".into());
|
||||
}
|
||||
launch_first(terminal_candidates(&launch_path), &launch_path, &launch_cwd)
|
||||
}
|
||||
"editor" => launch_first(
|
||||
editor_candidates(editor.as_deref().ok_or("Editor is required")?)?,
|
||||
&launch_path,
|
||||
&launch_cwd,
|
||||
),
|
||||
_ => Err("Unsupported workspace open target".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_workspace_target(
|
||||
window: tauri::WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
target: String,
|
||||
instance_id: String,
|
||||
worktree_slug: String,
|
||||
path: Option<String>,
|
||||
editor: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
if window.label() != "main" {
|
||||
return Err("Workspace open requests are limited to the local main window".into());
|
||||
}
|
||||
let config = state
|
||||
.manager
|
||||
.local_cli_access()
|
||||
.ok_or("Local CodeNomad server is unavailable")?;
|
||||
let expected = url::Url::parse(&config.base_url).map_err(|error| error.to_string())?;
|
||||
let current = window.url().map_err(|error| error.to_string())?;
|
||||
if current.origin() != expected.origin() {
|
||||
return Err("Workspace open requests require the local CodeNomad origin".into());
|
||||
}
|
||||
|
||||
let manager = state.manager.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
open_workspace_target_impl(&manager, target, instance_id, worktree_slug, path, editor)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_paths_outside_the_workspace() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let result = resolve_workspace_path(
|
||||
workspace.path().to_str().unwrap(),
|
||||
outside.path().to_str().unwrap(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_windows_scripts_and_rejects_executable_default_open_targets() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let script = workspace.path().join("run.cmd");
|
||||
std::fs::write(&script, "echo unsafe").unwrap();
|
||||
#[cfg(windows)]
|
||||
assert_eq!(default_open_mode(&script).unwrap(), DefaultOpenMode::Edit);
|
||||
let document = workspace.path().join("notes.txt");
|
||||
std::fs::write(&document, "safe").unwrap();
|
||||
#[cfg(windows)]
|
||||
assert_eq!(default_open_mode(&document).unwrap(), DefaultOpenMode::Open);
|
||||
let executable = workspace.path().join("run.exe");
|
||||
std::fs::write(&executable, "unsafe").unwrap();
|
||||
#[cfg(windows)]
|
||||
assert_eq!(
|
||||
default_open_mode(&executable).unwrap(),
|
||||
DefaultOpenMode::Choose
|
||||
);
|
||||
#[cfg(unix)]
|
||||
assert!(default_open_mode(&executable).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"identifier": "ai.neuralnomads.codenomad.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
|
|
@ -37,7 +37,8 @@
|
|||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"main-window-native-dialogs"
|
||||
"main-window-native-dialogs",
|
||||
"remote-window-notifications"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import { initReleaseNotifications } from "./stores/releases"
|
|||
import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
|
||||
import { useI18n } from "./lib/i18n"
|
||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||
import { resolveResolvable } from "./lib/commands"
|
||||
import { setWorkspaceMenuEnabled } from "./lib/workspace-open"
|
||||
import {
|
||||
isSelectingFolder,
|
||||
setIsSelectingFolder,
|
||||
|
|
@ -608,26 +610,42 @@ const App: Component = () => {
|
|||
getActiveSessionIdForInstance: activeSessionIdForInstance,
|
||||
})
|
||||
|
||||
// Listen for Tauri menu events
|
||||
// Native menus execute the same commands as the command palette.
|
||||
onMount(() => {
|
||||
const executeMenuAction = (action: unknown) => {
|
||||
if (typeof action !== "string") return
|
||||
const command = paletteCommands().find((candidate) => candidate.id === action)
|
||||
if (command && !(command.disabled && resolveResolvable(command.disabled))) executeCommand(command)
|
||||
}
|
||||
|
||||
if (isTauriHost()) {
|
||||
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
||||
if (tauriBridge?.event) {
|
||||
let unlistenMenu: (() => void) | null = null
|
||||
|
||||
tauriBridge.event.listen("menu:newInstance", () => {
|
||||
handleNewInstanceRequest()
|
||||
tauriBridge.event.listen("menu:action", (event) => {
|
||||
executeMenuAction(event.payload)
|
||||
}).then((unlisten) => {
|
||||
unlistenMenu = unlisten
|
||||
}).catch((error) => {
|
||||
log.error("Failed to listen for menu:newInstance event", error)
|
||||
log.error("Failed to listen for native menu actions", error)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
unlistenMenu?.()
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const unsubscribe = window.electronAPI?.onMenuAction?.(executeMenuAction)
|
||||
onCleanup(() => unsubscribe?.())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
void setWorkspaceMenuEnabled(Boolean(activeInstance())).catch((error) => {
|
||||
log.warn("Failed to update native workspace menu state", error)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,17 +1,41 @@
|
|||
import { For, Show, Suspense, createEffect, createMemo, createSignal, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
|
||||
import { Copy, RefreshCw, Save, Search, WrapText } from "lucide-solid"
|
||||
import { Copy, ExternalLink, FolderOpen, RefreshCw, Save, Search, TerminalSquare, WrapText } from "lucide-solid"
|
||||
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import { Markdown } from "../../../../markdown"
|
||||
import { copyToClipboard } from "../../../../../lib/clipboard"
|
||||
import { showToastNotification } from "../../../../../lib/notifications"
|
||||
import { useTheme } from "../../../../../lib/theme"
|
||||
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "../../../../action-overflow-menu"
|
||||
import { canOpenWorkspacePaths, openWorkspacePath } from "../../../../../lib/workspace-open"
|
||||
|
||||
const LazyMonacoFileViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
|
||||
)
|
||||
|
||||
const isWindowsDesktop = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent)
|
||||
const isMacDesktop = typeof navigator !== "undefined" && /macintosh|mac os x/i.test(navigator.userAgent)
|
||||
const safeWindowsOpenExtensions = new Set([
|
||||
"7z", "avi", "bmp", "c", "cc", "cfg", "conf", "cpp", "cs", "css", "csv", "dart", "diff", "docx",
|
||||
"env", "flac", "fs", "fsx", "gif", "go", "gz", "h", "hpp", "htm", "html", "ini", "java", "jpeg", "jpg",
|
||||
"json", "jsonc", "jsx", "kt", "kts", "less", "lock", "log", "lua", "md", "markdown", "mkv", "mov", "mp3",
|
||||
"mp4", "ogg", "patch", "pdf", "png", "pptx", "r", "rar", "rmd", "rs", "scss", "sql", "svg", "svelte",
|
||||
"swift", "tar", "toml", "ts", "tsx", "txt", "vue", "wav", "webm", "webp", "xlsx", "xml", "yaml", "yml", "zip",
|
||||
])
|
||||
const windowsEditExtensions = new Set([
|
||||
"bat", "cmd", "js", "jse", "pl", "ps1", "psd1", "psm1", "py", "pyw", "rb", "reg", "vbe", "vbs", "wsf", "wsh",
|
||||
])
|
||||
|
||||
function windowsDefaultAction(path: string): "open" | "edit" | "choose" {
|
||||
const name = path.split(/[\\/]/).pop() ?? ""
|
||||
const dot = name.lastIndexOf(".")
|
||||
const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : ""
|
||||
if (!isWindowsDesktop || (extension && safeWindowsOpenExtensions.has(extension))) return "open"
|
||||
if (extension && windowsEditExtensions.has(extension)) return "edit"
|
||||
return "choose"
|
||||
}
|
||||
|
||||
function isMarkdownPath(path: string | null | undefined): boolean {
|
||||
if (!path) return false
|
||||
return /\.(md|markdown|mdown|mkdn)$/i.test(path)
|
||||
|
|
@ -41,6 +65,8 @@ interface FilesTabProps {
|
|||
|
||||
parentPath: Accessor<string | null>
|
||||
scopeKey: Accessor<string>
|
||||
instanceId: string
|
||||
worktreeSlug: Accessor<string>
|
||||
|
||||
onLoadEntries: (path: string) => void
|
||||
onRequestOpenFile: (path: string) => void
|
||||
|
|
@ -119,6 +145,78 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||
})
|
||||
}
|
||||
|
||||
const handleNativeOpen = async (
|
||||
target: "default" | "reveal" | "terminal",
|
||||
path: string,
|
||||
) => {
|
||||
try {
|
||||
await openWorkspacePath({
|
||||
target,
|
||||
instanceId: props.instanceId,
|
||||
worktreeSlug: props.worktreeSlug(),
|
||||
path,
|
||||
})
|
||||
} catch (error) {
|
||||
showToastNotification({
|
||||
message: props.t("instanceShell.filesShell.toast.openError", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rowActions = (item: FileBrowserEntry): ActionOverflowMenuItem[] => {
|
||||
const items: ActionOverflowMenuItem[] = []
|
||||
if (canOpenWorkspacePaths()) {
|
||||
if (item.type === "directory") {
|
||||
const isMacApp = isMacDesktop && item.path.toLowerCase().endsWith(".app")
|
||||
items.push(
|
||||
{
|
||||
key: "open-folder",
|
||||
label: props.t(isMacApp
|
||||
? "instanceShell.filesShell.actions.showInFolder"
|
||||
: "instanceShell.filesShell.actions.openFolder"),
|
||||
icon: <FolderOpen class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen(isMacApp ? "reveal" : "default", item.path),
|
||||
},
|
||||
{
|
||||
key: "open-terminal",
|
||||
label: props.t("instanceShell.filesShell.actions.openTerminal"),
|
||||
icon: <TerminalSquare class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen("terminal", item.path),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
if (isWindowsDesktop) {
|
||||
items.push({
|
||||
key: "open-default",
|
||||
label: props.t({
|
||||
open: "instanceShell.filesShell.actions.openDefault",
|
||||
edit: "instanceShell.filesShell.actions.editDefault",
|
||||
choose: "instanceShell.filesShell.actions.chooseApplication",
|
||||
}[windowsDefaultAction(item.path)]),
|
||||
icon: <ExternalLink class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen("default", item.path),
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
key: "show-in-folder",
|
||||
label: props.t("instanceShell.filesShell.actions.showInFolder"),
|
||||
icon: <FolderOpen class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleNativeOpen("reveal", item.path),
|
||||
})
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
key: "copy-path",
|
||||
label: props.t("instanceShell.filesShell.actions.copyPath"),
|
||||
icon: <Copy class="w-3.5 h-3.5" />,
|
||||
onSelect: () => handleCopyPath(item.path),
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!showingMarkdownPreview()) return
|
||||
requestAnimationFrame(() => markdownPreviewRef?.focus())
|
||||
|
|
@ -194,15 +292,28 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||
<div class="file-list-item-stats">
|
||||
<span class="text-[10px] text-secondary">{item.type}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="git-change-row-action"
|
||||
title={props.t("instanceShell.filesShell.actions.copyPath")}
|
||||
aria-label={props.t("instanceShell.filesShell.actions.copyPath")}
|
||||
onClick={(event) => void handleCopyPath(item.path, event)}
|
||||
<Show
|
||||
when={canOpenWorkspacePaths()}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
class="git-change-row-action"
|
||||
title={props.t("instanceShell.filesShell.actions.copyPath")}
|
||||
aria-label={props.t("instanceShell.filesShell.actions.copyPath")}
|
||||
onClick={(event) => void handleCopyPath(item.path, event)}
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
<div onClick={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<ActionOverflowMenu
|
||||
items={rowActions(item)}
|
||||
label={props.t("instanceShell.filesShell.actions.more", { name: item.name })}
|
||||
triggerClass="git-change-row-action file-row-action-menu"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -300,6 +300,8 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS
|
|||
wordWrapMode={filesWordWrapMode}
|
||||
parentPath={browserParentPath}
|
||||
scopeKey={browserScopeKey}
|
||||
instanceId={options.instanceId}
|
||||
worktreeSlug={options.worktreeSlug}
|
||||
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
|
||||
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
|
||||
onRefresh={() => void refreshFilesTab()}
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ export default function MessagePart(props: MessagePartProps) {
|
|||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
escapeRawHtml={props.messageType === "user"}
|
||||
escapeRawHtml
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { ChevronDown, PlugZap, Star } from "lucide-solid"
|
|||
import type { Model } from "../types/session"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||
import { getProviderModelVisibilityPreference, uiState, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||
import { ProviderManagerModal } from "./provider-auth/provider-manager-modal"
|
||||
import { isModelVisible, resolvePickerValue } from "../lib/model-visibility"
|
||||
const log = getLogger("session")
|
||||
|
||||
interface ModelSelectorProps {
|
||||
|
|
@ -20,6 +21,7 @@ interface FlatModel extends Model {
|
|||
providerName: string
|
||||
key: string
|
||||
searchText: string
|
||||
unavailable?: boolean
|
||||
}
|
||||
|
||||
interface ModelGroup {
|
||||
|
|
@ -67,7 +69,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
}
|
||||
})
|
||||
|
||||
const allModels = createMemo<FlatModel[]>(() =>
|
||||
const catalogModels = createMemo<FlatModel[]>(() =>
|
||||
instanceProviders().flatMap((p) =>
|
||||
p.models.map((m) => ({
|
||||
...m,
|
||||
|
|
@ -78,6 +80,13 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
),
|
||||
)
|
||||
|
||||
const allModels = createMemo(() => catalogModels().filter((model) =>
|
||||
isModelVisible(
|
||||
getProviderModelVisibilityPreference(model.providerId),
|
||||
model.id,
|
||||
),
|
||||
))
|
||||
|
||||
const sortedModels = createMemo<FlatModel[]>(() =>
|
||||
[...allModels()].sort((left, right) => {
|
||||
const providerComparison = compareIds(left.providerId, right.providerId)
|
||||
|
|
@ -106,9 +115,22 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
|
||||
const hasFavorites = createMemo(() => favoriteModels().length > 0)
|
||||
|
||||
const currentModelValue = createMemo(() =>
|
||||
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
|
||||
)
|
||||
const currentModelValue = createMemo<FlatModel | undefined>(() => {
|
||||
const current = props.currentModel
|
||||
const found = catalogModels().find((model) => model.providerId === current.providerId && model.id === current.modelId)
|
||||
if (found) return found
|
||||
if (!current.providerId || !current.modelId) return undefined
|
||||
const providerName = instanceProviders().find((provider) => provider.id === current.providerId)?.name ?? current.providerId
|
||||
return {
|
||||
id: current.modelId,
|
||||
name: t("modelSelector.unavailableModel", { model: current.modelId }),
|
||||
providerId: current.providerId,
|
||||
providerName,
|
||||
key: `${current.providerId}/${current.modelId}`,
|
||||
searchText: `${current.modelId} ${providerName} ${current.providerId}`,
|
||||
unavailable: true,
|
||||
}
|
||||
})
|
||||
|
||||
const currentModelIsFavorite = createMemo(() => {
|
||||
const current = props.currentModel
|
||||
|
|
@ -134,10 +156,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
})
|
||||
|
||||
const visibleOptions = createMemo<FlatModel[]>(() => {
|
||||
if (!favoritesOnlyEnabled()) {
|
||||
return sortedModels()
|
||||
}
|
||||
return favoriteModels()
|
||||
const visible = favoritesOnlyEnabled() ? favoriteModels() : sortedModels()
|
||||
const current = currentModelValue()
|
||||
return current?.unavailable ? [current, ...visible] : visible
|
||||
})
|
||||
|
||||
const groupedVisibleOptions = createMemo<ModelGroup[]>(() => {
|
||||
|
|
@ -169,8 +190,27 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
]),
|
||||
)
|
||||
|
||||
const comboboxValue = createMemo(() => {
|
||||
const options = pickerOptions().filter((option): option is FlatModel => !isProviderHeaderOption(option))
|
||||
return resolvePickerValue(currentModelValue(), options)
|
||||
})
|
||||
|
||||
const currentModelLabel = createMemo(() =>
|
||||
t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") }),
|
||||
)
|
||||
|
||||
const currentModelAccessibleLabel = createMemo(() => {
|
||||
const current = currentModelValue()
|
||||
if (!current) return currentModelLabel()
|
||||
return t("modelSelector.trigger.ariaLabel", {
|
||||
model: current.name,
|
||||
provider: current.providerName,
|
||||
id: `${current.providerId}/${current.id}`,
|
||||
})
|
||||
})
|
||||
|
||||
const handleChange = async (value: PickerOption | null) => {
|
||||
if (!value || isProviderHeaderOption(value)) return
|
||||
if (!value || isProviderHeaderOption(value) || value.unavailable) return
|
||||
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
||||
}
|
||||
|
||||
|
|
@ -268,7 +308,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
<div class="sidebar-selector">
|
||||
<Combobox<PickerOption>
|
||||
open={isOpen()}
|
||||
value={currentModelValue()}
|
||||
value={comboboxValue()}
|
||||
onChange={handleChange}
|
||||
onOpenChange={(next) => {
|
||||
if (!next && suppressNextClose) return
|
||||
|
|
@ -278,7 +318,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
optionValue="key"
|
||||
optionTextValue="searchText"
|
||||
optionLabel={(option) => (isProviderHeaderOption(option) ? option.providerName : option.name)}
|
||||
optionDisabled={isProviderHeaderOption}
|
||||
optionDisabled={(option) => isProviderHeaderOption(option) || Boolean(option.unavailable)}
|
||||
placeholder={t("modelSelector.placeholder.search")}
|
||||
defaultFilter={customFilter}
|
||||
allowsEmptyCollection
|
||||
|
|
@ -314,56 +354,59 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</Combobox.ItemIndicator>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-option-star"
|
||||
data-active={isFavorite()}
|
||||
aria-label={
|
||||
isFavorite()
|
||||
? t("modelSelector.favorite.remove")
|
||||
: t("modelSelector.favorite.add")
|
||||
}
|
||||
onPointerDown={preventListboxPress}
|
||||
onPointerUp={preventListboxPress}
|
||||
onMouseDown={preventListboxPress}
|
||||
onMouseUp={preventListboxPress}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
suppressNextClose = true
|
||||
setTimeout(() => {
|
||||
suppressNextClose = false
|
||||
}, 0)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleFavoriteModelPreference({
|
||||
providerId: model.providerId,
|
||||
modelId: model.id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
class="w-4 h-4"
|
||||
fill={isFavorite() ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
{!model.unavailable && (
|
||||
<button
|
||||
type="button"
|
||||
class="selector-option-star"
|
||||
data-active={isFavorite()}
|
||||
aria-label={
|
||||
isFavorite()
|
||||
? t("modelSelector.favorite.remove")
|
||||
: t("modelSelector.favorite.add")
|
||||
}
|
||||
onPointerDown={preventListboxPress}
|
||||
onPointerUp={preventListboxPress}
|
||||
onMouseDown={preventListboxPress}
|
||||
onMouseUp={preventListboxPress}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
suppressNextClose = true
|
||||
setTimeout(() => {
|
||||
suppressNextClose = false
|
||||
}, 0)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleFavoriteModelPreference({
|
||||
providerId: model.providerId,
|
||||
modelId: model.id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
class="w-4 h-4"
|
||||
fill={isFavorite() ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
</Combobox.Item>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Combobox.Control class="relative w-full" data-model-selector-control>
|
||||
<Combobox.Input class="sr-only" data-model-selector />
|
||||
<Combobox.Input class="sr-only" data-model-selector aria-label={currentModelAccessibleLabel()} />
|
||||
<Combobox.Trigger
|
||||
ref={triggerRef}
|
||||
class="selector-trigger"
|
||||
aria-label={currentModelAccessibleLabel()}
|
||||
>
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
|
||||
{currentModelLabel()}
|
||||
</span>
|
||||
{currentModelValue() && (
|
||||
<span class="selector-trigger-secondary" dir="ltr">
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { Dialog } from "@kobalte/core/dialog"
|
|||
import { Select } from "@kobalte/core/select"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Component } from "solid-js"
|
||||
import { Check, ChevronDown, ExternalLink, KeyRound, Loader2, PlugZap, RefreshCw, ShieldCheck, X } from "lucide-solid"
|
||||
import type { FormAnswer, FormValue, IntegrationMethod, OpenCodeClient } from "@opencode-ai/client"
|
||||
import type { FormAnswer, FormValue, IntegrationMethod, ModelInfo, OpenCodeClient, ProviderInfo } from "@opencode-ai/client"
|
||||
import { openExternalUrl } from "../../lib/external-url"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { isTauriHost } from "../../lib/runtime-env"
|
||||
import { isLocalTauriHost } from "../../lib/runtime-env"
|
||||
import {
|
||||
extractProviderAuthErrorMessage,
|
||||
genericApiMethod,
|
||||
|
|
@ -20,7 +20,11 @@ import {
|
|||
import { instances } from "../../stores/instances"
|
||||
import { fetchProviders, getActiveCatalogLocation } from "../../stores/sessions"
|
||||
import { ProviderAuthForm } from "./provider-auth-form"
|
||||
import { buildListedProviders, type ListedProvider } from "./provider-options"
|
||||
import { buildListedProviders, buildProviderVisibilityModels, type ListedProvider as ProviderOption } from "./provider-options"
|
||||
import {
|
||||
ProviderModelVisibilityManager,
|
||||
type ProviderVisibilityModel,
|
||||
} from "./provider-model-visibility-manager"
|
||||
|
||||
type AuthStage = "idle" | "prompts" | "authorizing" | "code" | "waiting" | "success" | "error"
|
||||
|
||||
|
|
@ -42,6 +46,9 @@ type ConfigurableProviderOption = {
|
|||
type DisconnectMode = "credential-remove" | "not-disconnectable" | "unknown"
|
||||
type NativeAuthMethod = Exclude<IntegrationMethod, { type: "env" }>
|
||||
type NativeAuthorization = ProviderAuthAuthorization & { attemptID: string }
|
||||
type ListedProvider = ProviderOption & {
|
||||
models: ProviderVisibilityModel[]
|
||||
}
|
||||
|
||||
interface ProviderManagerModalProps {
|
||||
instanceId: string
|
||||
|
|
@ -56,6 +63,9 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
const [availableProviders, setAvailableProviders] = createSignal<ListedProvider[]>([])
|
||||
const [selectedProviderId, setSelectedProviderId] = createSignal<string | null>(null)
|
||||
const [activeProviderId, setActiveProviderId] = createSignal<string | null>(null)
|
||||
const [managedProviderId, setManagedProviderId] = createSignal<string | null>(null)
|
||||
const manageModelButtons = new Map<string, HTMLButtonElement>()
|
||||
let managedProviderTriggerId: string | null = null
|
||||
const [selectedMethodIndex, setSelectedMethodIndex] = createSignal(0)
|
||||
const [apiKey, setApiKey] = createSignal("")
|
||||
const [formAnswer, setFormAnswer] = createSignal<FormAnswer>({})
|
||||
|
|
@ -107,6 +117,10 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
availableProviders().filter((provider) => provider.source !== "unknown"),
|
||||
)
|
||||
|
||||
const managedProvider = createMemo(() =>
|
||||
configuredProviders().find((provider) => provider.id === managedProviderId()) ?? null,
|
||||
)
|
||||
|
||||
const getDisconnectMode = (provider: ListedProvider): DisconnectMode => {
|
||||
if (provider.source === "env") return "not-disconnectable"
|
||||
if (provider.credentialIds.length > 0) return "credential-remove"
|
||||
|
|
@ -175,7 +189,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
}
|
||||
|
||||
function isBrowserHostForOAuth(): boolean {
|
||||
return !isTauriHost() && typeof window !== "undefined"
|
||||
return !isLocalTauriHost() && typeof window !== "undefined"
|
||||
}
|
||||
|
||||
function prepareOAuthPopupWindow(): Window | null {
|
||||
|
|
@ -266,7 +280,10 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
authClient.integration.list(location),
|
||||
])
|
||||
if (version !== loadVersion) return
|
||||
const listed = buildListedProviders(providerResponse.data, modelResponse.data, integrationResponse.data)
|
||||
const listed = buildListedProviders(providerResponse.data, modelResponse.data, integrationResponse.data).map((provider) => ({
|
||||
...provider,
|
||||
models: buildProviderVisibilityModels(provider.id, providerResponse.data, modelResponse.data),
|
||||
}))
|
||||
const methods = Object.fromEntries(integrationResponse.data.map((integration) => [
|
||||
integration.id,
|
||||
integration.methods.filter((method): method is NativeAuthMethod => method.type !== "env"),
|
||||
|
|
@ -295,6 +312,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
setMethodsByProvider({})
|
||||
setAvailableProviders([])
|
||||
setSelectedProviderId(null)
|
||||
setManagedProviderId(null)
|
||||
setLoadError(null)
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -337,6 +355,23 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
await loadProviderData(authClient).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function refreshProviderData() {
|
||||
const authClient = client()
|
||||
const instanceId = props.instanceId
|
||||
if (!authClient) return
|
||||
setLoading(true)
|
||||
await fetchProviders(instanceId).catch(() => undefined)
|
||||
if (client() !== authClient || props.instanceId !== instanceId) return
|
||||
await loadProviderData(authClient)
|
||||
}
|
||||
|
||||
function closeModelManager() {
|
||||
setManagedProviderId(null)
|
||||
queueMicrotask(() => {
|
||||
if (managedProviderTriggerId) manageModelButtons.get(managedProviderTriggerId)?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
async function submitApiAuth(providerId: string, authClient: OpenCodeClient, instanceId: string, operationVersion: number) {
|
||||
await authClient.integration.connect.key({
|
||||
integrationID: providerId,
|
||||
|
|
@ -635,7 +670,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
<button type="button" class="selector-button selector-button-primary" disabled={!selectedProviderOption()?.canConnect} onClick={() => resetFlow(selectedProviderOption()?.id ?? null)}>
|
||||
{t("settings.providers.actions.connect")}
|
||||
</button>
|
||||
<button type="button" class="settings-pill-button" disabled={loading()} onClick={() => client() && void loadProviderData(client()!)}>
|
||||
<button type="button" class="settings-pill-button" disabled={loading()} onClick={() => void refreshProviderData()}>
|
||||
<RefreshCw class={loading() ? "providers-spin-icon" : "providers-button-icon"} />
|
||||
{t("settings.providers.refresh")}
|
||||
</button>
|
||||
|
|
@ -746,16 +781,43 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
|
||||
<section class="providers-list-section">
|
||||
<h3 class="settings-card-title">{t("settings.providers.configured.title")}</h3>
|
||||
<Show when={loading()}><div class="providers-loading-row" role="status"><Loader2 class="providers-spin-icon" /><span>{t("settings.providers.loading")}</span></div></Show>
|
||||
<Show when={!loading() && configuredProviders().length === 0}><div class="settings-card-message" role="status">{t("settings.providers.empty.noConfiguredProviders")}</div></Show>
|
||||
<div class="providers-grid">
|
||||
<For each={configuredProviders()}>{(provider) => (
|
||||
<article class="providers-card">
|
||||
<div class="providers-card-main"><div class="providers-card-mark"><ShieldCheck class="providers-card-mark-icon" /></div><div class="providers-card-copy"><div class="providers-card-title-row"><h4 class="providers-card-title">{provider.name || provider.id}</h4></div><p class="providers-card-meta">{provider.id}</p><p class="providers-card-methods">{methodSummary(provider.id)}</p><p class="providers-card-source">{describeProviderSource(provider)}</p></div></div>
|
||||
<div class="providers-card-footer"><span class="providers-model-count">{provider.modelCount === 1 ? t("settings.providers.models.one", { count: provider.modelCount }) : t("settings.providers.models.other", { count: provider.modelCount })}</span><Show when={getDisconnectMode(provider) === "credential-remove"}><button type="button" class="selector-button selector-button-secondary providers-disconnect-button" disabled={stage() !== "idle"} onClick={() => void disconnectProvider(provider.id)} title={t("settings.providers.actions.disconnect")}>{t("settings.providers.actions.disconnect")}</button></Show></div>
|
||||
</article>
|
||||
)}</For>
|
||||
</div>
|
||||
<Show when={managedProvider()} fallback={
|
||||
<>
|
||||
<Show when={loading()}><div class="providers-loading-row" role="status"><Loader2 class="providers-spin-icon" /><span>{t("settings.providers.loading")}</span></div></Show>
|
||||
<Show when={!loading() && configuredProviders().length === 0}><div class="settings-card-message" role="status">{t("settings.providers.empty.noConfiguredProviders")}</div></Show>
|
||||
<div class="providers-grid">
|
||||
<For each={configuredProviders()}>{(provider) => (
|
||||
<article class="providers-card">
|
||||
<div class="providers-card-main"><div class="providers-card-mark"><ShieldCheck class="providers-card-mark-icon" /></div><div class="providers-card-copy"><div class="providers-card-title-row"><h4 class="providers-card-title">{provider.name || provider.id}</h4></div><p class="providers-card-meta">{provider.id}</p><p class="providers-card-methods">{methodSummary(provider.id)}</p><p class="providers-card-source">{describeProviderSource(provider)}</p></div></div>
|
||||
<div class="providers-card-footer">
|
||||
<span class="providers-model-count">{provider.modelCount === 1 ? t("settings.providers.models.one", { count: provider.modelCount }) : t("settings.providers.models.other", { count: provider.modelCount })}</span>
|
||||
<div class="provider-model-card-actions">
|
||||
<button
|
||||
ref={(element) => manageModelButtons.set(provider.id, element)}
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => {
|
||||
managedProviderTriggerId = provider.id
|
||||
setManagedProviderId(provider.id)
|
||||
}}
|
||||
>{t("settings.providers.actions.manageModels")}</button>
|
||||
<Show when={getDisconnectMode(provider) === "credential-remove"}><button type="button" class="selector-button selector-button-secondary providers-disconnect-button" disabled={stage() !== "idle"} onClick={() => void disconnectProvider(provider.id)} title={t("settings.providers.actions.disconnect")}>{t("settings.providers.actions.disconnect")}</button></Show>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)}</For>
|
||||
</div>
|
||||
</>
|
||||
}>
|
||||
{(provider) => (
|
||||
<ProviderModelVisibilityManager
|
||||
providerId={provider().id}
|
||||
providerName={provider().name || provider().id}
|
||||
models={provider().models}
|
||||
onBack={closeModelManager}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
import { ArrowLeft } from "lucide-solid"
|
||||
import { createEffect, createMemo, createSignal, For, on, onMount, Show, type Component } from "solid-js"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { useConfig } from "../../stores/preferences"
|
||||
|
||||
export interface ProviderVisibilityModel {
|
||||
id: string
|
||||
name: string
|
||||
providerId: string
|
||||
}
|
||||
|
||||
interface ProviderModelVisibilityManagerProps {
|
||||
providerId: string
|
||||
providerName: string
|
||||
models: readonly ProviderVisibilityModel[]
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const compareModel = (left: ProviderVisibilityModel, right: ProviderVisibilityModel) =>
|
||||
left.name.localeCompare(right.name, undefined, { sensitivity: "base" }) ||
|
||||
left.id.localeCompare(right.id, undefined, { sensitivity: "base" })
|
||||
|
||||
export const ProviderModelVisibilityManager: Component<ProviderModelVisibilityManagerProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
getProviderModelVisibilityPreference,
|
||||
providerModelVisibilitySaveFailed,
|
||||
setProviderModelVisibility,
|
||||
} = useConfig()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const headingId = "provider-model-visibility-heading"
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
|
||||
const models = createMemo(() => [...props.models].sort(compareModel))
|
||||
const hiddenIds = createMemo(() => new Set(models()
|
||||
.filter((model) => getProviderModelVisibilityPreference(model.providerId).hiddenModelIds.includes(model.id))
|
||||
.map((model) => `${model.providerId}\0${model.id}`)))
|
||||
const visibleCount = createMemo(() => models().filter((model) => !hiddenIds().has(`${model.providerId}\0${model.id}`)).length)
|
||||
const filteredModels = createMemo(() => {
|
||||
const value = query().trim().toLocaleLowerCase()
|
||||
if (!value) return models()
|
||||
return models().filter((model) =>
|
||||
model.name.toLocaleLowerCase().includes(value) || model.id.toLocaleLowerCase().includes(value),
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(on(() => props.providerId, () => setQuery("")))
|
||||
onMount(() => queueMicrotask(() => searchInput?.focus()))
|
||||
|
||||
const toggleModel = (model: ProviderVisibilityModel, visible: boolean) => {
|
||||
const current = getProviderModelVisibilityPreference(model.providerId).hiddenModelIds
|
||||
void setProviderModelVisibility(model.providerId, {
|
||||
hiddenModelIds: visible ? current.filter((id) => id !== model.id) : [...current, model.id],
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
const setAllVisible = (visible: boolean) => {
|
||||
const modelsByProvider = new Map<string, string[]>()
|
||||
for (const model of models()) modelsByProvider.set(model.providerId, [...(modelsByProvider.get(model.providerId) ?? []), model.id])
|
||||
for (const [providerId, ids] of modelsByProvider) {
|
||||
const current = getProviderModelVisibilityPreference(providerId).hiddenModelIds
|
||||
const hiddenModelIds = visible ? current.filter((id) => !ids.includes(id)) : Array.from(new Set([...current, ...ids]))
|
||||
void setProviderModelVisibility(providerId, { hiddenModelIds }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section class="provider-model-visibility" aria-labelledby={headingId}>
|
||||
<header class="provider-model-visibility-header">
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onBack}>
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
{t("settings.providers.modelVisibility.back")}
|
||||
</button>
|
||||
<div>
|
||||
<h4 id={headingId} class="settings-card-title">
|
||||
{t("settings.providers.modelVisibility.title", { provider: props.providerName })}
|
||||
</h4>
|
||||
<p class="settings-card-subtitle">{t("settings.providers.modelVisibility.subtitle")}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="provider-model-visibility-toolbar">
|
||||
<label class="sr-only" for="provider-model-visibility-search">{t("settings.providers.modelVisibility.search.label")}</label>
|
||||
<input
|
||||
ref={searchInput}
|
||||
id="provider-model-visibility-search"
|
||||
type="search"
|
||||
class="providers-input"
|
||||
value={query()}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={t("settings.providers.modelVisibility.search.placeholder")}
|
||||
/>
|
||||
<span class="provider-model-visibility-count" role="status">
|
||||
{t("settings.providers.modelVisibility.count.visible", { visible: visibleCount(), total: models().length })}
|
||||
</span>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={() => setAllVisible(true)}>
|
||||
{t("settings.providers.modelVisibility.actions.showAll")}
|
||||
</button>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={() => setAllVisible(false)}>
|
||||
{t("settings.providers.modelVisibility.actions.hideAll")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={models().some((model) => providerModelVisibilitySaveFailed(model.providerId))}>
|
||||
<p class="settings-error-message" role="alert">{t("settings.providers.modelVisibility.save.failed")}</p>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={filteredModels().length > 0}
|
||||
fallback={<p class="settings-card-message" role="status">{t("settings.providers.modelVisibility.list.empty")}</p>}
|
||||
>
|
||||
<div
|
||||
class="provider-model-visibility-list"
|
||||
role="list"
|
||||
aria-label={t("settings.providers.modelVisibility.list.label", { provider: props.providerName })}
|
||||
>
|
||||
<For each={filteredModels()}>{(model) => (
|
||||
<label class="provider-model-visibility-item" role="listitem">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!hiddenIds().has(`${model.providerId}\0${model.id}`)}
|
||||
onChange={(event) => toggleModel(model, event.currentTarget.checked)}
|
||||
/>
|
||||
<span><strong>{model.name || model.id}</strong><small dir="ltr">{model.id}</small></span>
|
||||
</label>
|
||||
)}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
import { buildListedProviders } from "./provider-options.ts"
|
||||
import { buildListedProviders, buildProviderVisibilityModels } from "./provider-options.ts"
|
||||
|
||||
test("keeps unmatched custom and model-only providers in the cold-start catalog", () => {
|
||||
const providers = [
|
||||
|
|
@ -32,3 +32,19 @@ test("only integrations with native auth methods offer Connect", () => {
|
|||
assert.equal(listed.find((provider) => provider.id === "env-only")?.canConnect, false)
|
||||
assert.equal(listed.find((provider) => provider.id === "oauth")?.canConnect, true)
|
||||
})
|
||||
|
||||
test("keeps native provider ids when one integration groups multiple providers", () => {
|
||||
const providers = [
|
||||
{ id: "openai-api", integrationID: "openai" },
|
||||
{ id: "openai-subscription", integrationID: "openai" },
|
||||
] as any[]
|
||||
const models = [
|
||||
{ id: "gpt-api", name: "GPT API", providerID: "openai-api" },
|
||||
{ id: "gpt-subscription", name: "GPT Subscription", providerID: "openai-subscription" },
|
||||
] as any[]
|
||||
|
||||
assert.deepEqual(buildProviderVisibilityModels("openai", providers, models), [
|
||||
{ id: "gpt-api", name: "GPT API", providerId: "openai-api" },
|
||||
{ id: "gpt-subscription", name: "GPT Subscription", providerId: "openai-subscription" },
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,22 @@ export type ListedProvider = {
|
|||
canConnect: boolean
|
||||
}
|
||||
|
||||
export function buildProviderVisibilityModels(
|
||||
providerId: string,
|
||||
providers: readonly ProviderInfo[],
|
||||
models: readonly ModelInfo[],
|
||||
): Array<{ id: string; name: string; providerId: string }> {
|
||||
const providerIds = new Set(
|
||||
providers
|
||||
.filter((provider) => (provider.integrationID ?? provider.id) === providerId)
|
||||
.map((provider) => provider.id),
|
||||
)
|
||||
if (providerIds.size === 0) providerIds.add(providerId)
|
||||
return models
|
||||
.filter((model) => providerIds.has(model.providerID))
|
||||
.map((model) => ({ id: model.id, name: model.name || model.id, providerId: model.providerID }))
|
||||
}
|
||||
|
||||
export function buildListedProviders(
|
||||
providers: ProviderInfo[],
|
||||
models: ModelInfo[],
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { isTauriHost } from "./runtime-env"
|
||||
import { isLocalTauriHost } from "./runtime-env"
|
||||
|
||||
export async function openExternalUrl(url: string, context = "ui"): Promise<boolean> {
|
||||
if (typeof window === "undefined") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isTauriHost()) {
|
||||
if (isLocalTauriHost()) {
|
||||
try {
|
||||
const { openUrl } = await import("@tauri-apps/plugin-opener")
|
||||
await openUrl(url)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import { getLogger } from "../logger"
|
|||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||
import { tGlobal } from "../i18n"
|
||||
import { registerBehaviorCommands } from "../settings/behavior-registry"
|
||||
import { canOpenWorkspacePaths, openWorkspacePath, type WorkspaceEditor, type WorkspaceOpenTarget } from "../workspace-open"
|
||||
import { getDefaultWorktreeSlug, getWorktreeSlugForSession } from "../../stores/worktrees"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
|
@ -79,6 +81,36 @@ export function useCommands(options: UseCommandsOptions) {
|
|||
const activeInstance = options.getActiveInstance
|
||||
const activeSessionIdForInstance = options.getActiveSessionIdForInstance
|
||||
|
||||
const activeWorkspace = () => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return null
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
const worktreeSlug = !sessionId || sessionId === "info"
|
||||
? getDefaultWorktreeSlug(instance.id)
|
||||
: getWorktreeSlugForSession(instance.id, sessionId)
|
||||
return { instanceId: instance.id, worktreeSlug }
|
||||
}
|
||||
|
||||
const openActiveWorkspace = async (target: WorkspaceOpenTarget, editor?: WorkspaceEditor) => {
|
||||
const workspace = activeWorkspace()
|
||||
if (!workspace) {
|
||||
showAlertDialog(tGlobal("commands.openWorkspace.projectRequired.message"), {
|
||||
title: tGlobal("commands.openWorkspace.projectRequired.title"),
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
await openWorkspacePath({ target, ...workspace, editor })
|
||||
} catch (error) {
|
||||
showAlertDialog(tGlobal("commands.openWorkspace.failed.message", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}), {
|
||||
title: tGlobal("commands.openWorkspace.failed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
commandRegistry.register({
|
||||
id: "new-instance",
|
||||
label: () => tGlobal("commands.newInstance.label"),
|
||||
|
|
@ -101,6 +133,44 @@ export function useCommands(options: UseCommandsOptions) {
|
|||
},
|
||||
})
|
||||
|
||||
if (canOpenWorkspacePaths()) {
|
||||
commandRegistry.register({
|
||||
id: "open-workspace-folder",
|
||||
label: () => tGlobal("commands.openWorkspace.folder.label"),
|
||||
description: () => tGlobal("commands.openWorkspace.folder.description"),
|
||||
category: "Instance",
|
||||
keywords: () => splitKeywords("commands.openWorkspace.folder.keywords"),
|
||||
disabled: () => !activeInstance(),
|
||||
action: () => openActiveWorkspace("default"),
|
||||
})
|
||||
commandRegistry.register({
|
||||
id: "open-workspace-terminal",
|
||||
label: () => tGlobal("commands.openWorkspace.terminal.label"),
|
||||
description: () => tGlobal("commands.openWorkspace.terminal.description"),
|
||||
category: "Instance",
|
||||
keywords: () => splitKeywords("commands.openWorkspace.terminal.keywords"),
|
||||
disabled: () => !activeInstance(),
|
||||
action: () => openActiveWorkspace("terminal"),
|
||||
})
|
||||
const editors: Array<[WorkspaceEditor, string]> = [
|
||||
["vscode", "VS Code"],
|
||||
["cursor", "Cursor"],
|
||||
["zed", "Zed"],
|
||||
["vscodium", "VSCodium"],
|
||||
]
|
||||
for (const [editor, name] of editors) {
|
||||
commandRegistry.register({
|
||||
id: `open-workspace-editor-${editor}`,
|
||||
label: () => tGlobal("commands.openWorkspace.editor.label", { editor: name }),
|
||||
description: () => tGlobal("commands.openWorkspace.editor.description", { editor: name }),
|
||||
category: "Instance",
|
||||
keywords: () => [name, ...splitKeywords("commands.openWorkspace.editor.keywords")],
|
||||
disabled: () => !activeInstance(),
|
||||
action: () => openActiveWorkspace("editor", editor),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
commandRegistry.register({
|
||||
id: "instance-next",
|
||||
label: () => tGlobal("commands.nextInstance.label"),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ export const commandMessages = {
|
|||
"commands.newInstance.label": "Neue Instanz",
|
||||
"commands.newInstance.description": "Ordnerauswahl öffnen, um eine neue Instanz zu erstellen",
|
||||
"commands.newInstance.keywords": "ordner, projekt, arbeitsbereich",
|
||||
"commands.openWorkspace.folder.label": "Projektordner öffnen",
|
||||
"commands.openWorkspace.folder.description": "Aktuellen Projektordner im Dateimanager öffnen",
|
||||
"commands.openWorkspace.folder.keywords": "Ordner, Explorer, Finder, Dateien",
|
||||
"commands.openWorkspace.terminal.label": "Terminal hier öffnen",
|
||||
"commands.openWorkspace.terminal.description": "Externes Terminal im aktuellen Projektordner öffnen",
|
||||
"commands.openWorkspace.terminal.keywords": "Terminal, Shell, Konsole",
|
||||
"commands.openWorkspace.editor.label": "Projekt in {editor} öffnen",
|
||||
"commands.openWorkspace.editor.description": "Aktuellen Projektordner in {editor} öffnen",
|
||||
"commands.openWorkspace.editor.keywords": "Editor, IDE, Code",
|
||||
"commands.openWorkspace.failed.title": "Projekt konnte nicht geöffnet werden",
|
||||
"commands.openWorkspace.failed.message": "{message}",
|
||||
"commands.openWorkspace.projectRequired.title": "Kein Projekt geöffnet",
|
||||
"commands.openWorkspace.projectRequired.message": "Öffnen Sie zuerst ein Projekt.",
|
||||
|
||||
"commands.closeInstance.label": "Tab schließen",
|
||||
"commands.closeInstance.description": "Den aktuellen obersten Tab schließen",
|
||||
|
|
|
|||
|
|
@ -182,6 +182,15 @@ export const instanceMessages = {
|
|||
"instanceShell.filesShell.search.ariaLabel": "Dateien in diesem Ordner filtern",
|
||||
"instanceShell.filesShell.search.empty": "Keine passenden Dateien.",
|
||||
"instanceShell.filesShell.actions.copyPath": "Pfad kopieren",
|
||||
"instanceShell.filesShell.actions.more": "Weitere Aktionen für {name}",
|
||||
"instanceShell.filesShell.actions.openDefault": "Mit Standardanwendung öffnen",
|
||||
"instanceShell.filesShell.actions.editDefault": "Mit Standardanwendung bearbeiten",
|
||||
"instanceShell.filesShell.actions.chooseApplication": "Öffnen mit...",
|
||||
"instanceShell.filesShell.actions.showInFolder": "Im Ordner anzeigen",
|
||||
"instanceShell.filesShell.actions.openFolder": "Im Dateimanager öffnen",
|
||||
"instanceShell.filesShell.actions.openTerminal": "Terminal hier öffnen",
|
||||
"instanceShell.filesShell.actions.openEditor": "In {editor} öffnen",
|
||||
"instanceShell.filesShell.toast.openError": "Öffnen fehlgeschlagen: {message}",
|
||||
"instanceShell.filesShell.toast.copyPathSuccess": "Pfad kopiert",
|
||||
"instanceShell.filesShell.toast.copyPathError": "Pfad konnte nicht kopiert werden",
|
||||
"instanceShell.filesShell.previewMarkdown": "Markdown-Vorschau",
|
||||
|
|
|
|||
|
|
@ -30,11 +30,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "Modelle suchen...",
|
||||
"modelSelector.none": "Keines",
|
||||
"modelSelector.trigger.primary": "Modell: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "Modell: {model}, Anbieter: {provider}, ID: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "Nur Favoriten umschalten",
|
||||
"modelSelector.favoritesOnly.showAll": "Alle Modelle anzeigen",
|
||||
"modelSelector.favorite.add": "Zu Favoriten hinzufügen",
|
||||
"modelSelector.favorite.remove": "Aus Favoriten entfernen",
|
||||
"modelSelector.manageProviders": "Anbieter verwalten",
|
||||
"modelSelector.unavailableModel": "{model} (nicht verfügbar)",
|
||||
"settings.providers.actions.manageModels": "Modelle verwalten",
|
||||
"settings.providers.modelVisibility.back": "Zurück zu Anbietern",
|
||||
"settings.providers.modelVisibility.title": "Modelle für {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "Wählen Sie, welche Modelle CodeNomad in der Modellauswahl anzeigt.",
|
||||
"settings.providers.modelVisibility.search.label": "Anbietermodelle suchen",
|
||||
"settings.providers.modelVisibility.search.placeholder": "Nach Modellname oder ID suchen...",
|
||||
"settings.providers.modelVisibility.count.visible": "{visible} von {total} sichtbar",
|
||||
"settings.providers.modelVisibility.actions.showAll": "Alle anzeigen",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "Alle ausblenden",
|
||||
"settings.providers.modelVisibility.list.empty": "Keine Modelle entsprechen dieser Suche.",
|
||||
"settings.providers.modelVisibility.list.label": "Modelle für {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "Die Modellsichtbarkeit konnte nicht gespeichert werden.",
|
||||
|
||||
"thinkingSelector.variant.default": "Standard",
|
||||
"thinkingSelector.label": "Denken: {variant}",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ export const commandMessages = {
|
|||
"commands.newInstance.label": "New Instance",
|
||||
"commands.newInstance.description": "Open folder picker to create new instance",
|
||||
"commands.newInstance.keywords": "folder, project, workspace",
|
||||
"commands.openWorkspace.folder.label": "Open Project Folder",
|
||||
"commands.openWorkspace.folder.description": "Open the current project folder in the file manager",
|
||||
"commands.openWorkspace.folder.keywords": "folder, explorer, finder, files",
|
||||
"commands.openWorkspace.terminal.label": "Open Terminal Here",
|
||||
"commands.openWorkspace.terminal.description": "Open an external terminal in the current project folder",
|
||||
"commands.openWorkspace.terminal.keywords": "terminal, shell, console",
|
||||
"commands.openWorkspace.editor.label": "Open Project in {editor}",
|
||||
"commands.openWorkspace.editor.description": "Open the current project folder in {editor}",
|
||||
"commands.openWorkspace.editor.keywords": "editor, ide, code",
|
||||
"commands.openWorkspace.failed.title": "Could not open project",
|
||||
"commands.openWorkspace.failed.message": "{message}",
|
||||
"commands.openWorkspace.projectRequired.title": "No project open",
|
||||
"commands.openWorkspace.projectRequired.message": "Open a project first.",
|
||||
|
||||
"commands.closeInstance.label": "Close Tab",
|
||||
"commands.closeInstance.description": "Close the current top-level tab",
|
||||
|
|
|
|||
|
|
@ -182,6 +182,15 @@ export const instanceMessages = {
|
|||
"instanceShell.filesShell.search.ariaLabel": "Filter files in this folder",
|
||||
"instanceShell.filesShell.search.empty": "No matching files.",
|
||||
"instanceShell.filesShell.actions.copyPath": "Copy path",
|
||||
"instanceShell.filesShell.actions.more": "More actions for {name}",
|
||||
"instanceShell.filesShell.actions.openDefault": "Open with default application",
|
||||
"instanceShell.filesShell.actions.editDefault": "Edit with default application",
|
||||
"instanceShell.filesShell.actions.chooseApplication": "Open with...",
|
||||
"instanceShell.filesShell.actions.showInFolder": "Show in folder",
|
||||
"instanceShell.filesShell.actions.openFolder": "Open in file manager",
|
||||
"instanceShell.filesShell.actions.openTerminal": "Open terminal here",
|
||||
"instanceShell.filesShell.actions.openEditor": "Open in {editor}",
|
||||
"instanceShell.filesShell.toast.openError": "Could not open: {message}",
|
||||
"instanceShell.filesShell.toast.copyPathSuccess": "Copied path",
|
||||
"instanceShell.filesShell.toast.copyPathError": "Failed to copy path",
|
||||
"instanceShell.filesShell.previewMarkdown": "Preview Markdown",
|
||||
|
|
|
|||
|
|
@ -30,11 +30,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "Search models...",
|
||||
"modelSelector.none": "None",
|
||||
"modelSelector.trigger.primary": "Model: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "Model: {model}, provider: {provider}, ID: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "Toggle favorites only",
|
||||
"modelSelector.favoritesOnly.showAll": "Show all models",
|
||||
"modelSelector.favorite.add": "Add to favorites",
|
||||
"modelSelector.favorite.remove": "Remove from favorites",
|
||||
"modelSelector.manageProviders": "Manage Providers",
|
||||
"modelSelector.unavailableModel": "{model} (unavailable)",
|
||||
"settings.providers.actions.manageModels": "Manage models",
|
||||
"settings.providers.modelVisibility.back": "Back to providers",
|
||||
"settings.providers.modelVisibility.title": "Models for {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "Choose which models CodeNomad shows in model selectors.",
|
||||
"settings.providers.modelVisibility.search.label": "Search provider models",
|
||||
"settings.providers.modelVisibility.search.placeholder": "Search by model name or ID...",
|
||||
"settings.providers.modelVisibility.count.visible": "{visible} of {total} visible",
|
||||
"settings.providers.modelVisibility.actions.showAll": "Show all",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "Hide all",
|
||||
"settings.providers.modelVisibility.list.empty": "No models match this search.",
|
||||
"settings.providers.modelVisibility.list.label": "Models for {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "Failed to save model visibility.",
|
||||
|
||||
"thinkingSelector.variant.default": "Default",
|
||||
"thinkingSelector.label": "Thinking: {variant}",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ export const commandMessages = {
|
|||
"commands.newInstance.label": "Nueva instancia",
|
||||
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
|
||||
"commands.newInstance.keywords": "carpeta, proyecto, workspace",
|
||||
"commands.openWorkspace.folder.label": "Abrir carpeta del proyecto",
|
||||
"commands.openWorkspace.folder.description": "Abrir la carpeta del proyecto actual en el explorador de archivos",
|
||||
"commands.openWorkspace.folder.keywords": "carpeta, explorador, archivos",
|
||||
"commands.openWorkspace.terminal.label": "Abrir terminal aquí",
|
||||
"commands.openWorkspace.terminal.description": "Abrir un terminal externo en la carpeta del proyecto actual",
|
||||
"commands.openWorkspace.terminal.keywords": "terminal, shell, consola",
|
||||
"commands.openWorkspace.editor.label": "Abrir proyecto en {editor}",
|
||||
"commands.openWorkspace.editor.description": "Abrir la carpeta del proyecto actual en {editor}",
|
||||
"commands.openWorkspace.editor.keywords": "editor, ide, código",
|
||||
"commands.openWorkspace.failed.title": "No se pudo abrir el proyecto",
|
||||
"commands.openWorkspace.failed.message": "{message}",
|
||||
"commands.openWorkspace.projectRequired.title": "No hay ningún proyecto abierto",
|
||||
"commands.openWorkspace.projectRequired.message": "Abre primero un proyecto.",
|
||||
|
||||
"commands.closeInstance.label": "Cerrar pestaña",
|
||||
"commands.closeInstance.description": "Cerrar la pestaña superior actual",
|
||||
|
|
|
|||
|
|
@ -182,6 +182,15 @@ export const instanceMessages = {
|
|||
"instanceShell.filesShell.search.ariaLabel": "Filtrar archivos de esta carpeta",
|
||||
"instanceShell.filesShell.search.empty": "No hay archivos coincidentes.",
|
||||
"instanceShell.filesShell.actions.copyPath": "Copiar ruta",
|
||||
"instanceShell.filesShell.actions.more": "Más acciones para {name}",
|
||||
"instanceShell.filesShell.actions.openDefault": "Abrir con la aplicación predeterminada",
|
||||
"instanceShell.filesShell.actions.editDefault": "Editar con la aplicación predeterminada",
|
||||
"instanceShell.filesShell.actions.chooseApplication": "Abrir con...",
|
||||
"instanceShell.filesShell.actions.showInFolder": "Mostrar en la carpeta",
|
||||
"instanceShell.filesShell.actions.openFolder": "Abrir en el explorador de archivos",
|
||||
"instanceShell.filesShell.actions.openTerminal": "Abrir terminal aquí",
|
||||
"instanceShell.filesShell.actions.openEditor": "Abrir en {editor}",
|
||||
"instanceShell.filesShell.toast.openError": "No se pudo abrir: {message}",
|
||||
"instanceShell.filesShell.toast.copyPathSuccess": "Ruta copiada",
|
||||
"instanceShell.filesShell.toast.copyPathError": "No se pudo copiar la ruta",
|
||||
"instanceShell.filesShell.previewMarkdown": "Vista previa Markdown",
|
||||
|
|
|
|||
|
|
@ -30,11 +30,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "Buscar modelos...",
|
||||
"modelSelector.none": "Ninguno",
|
||||
"modelSelector.trigger.primary": "Modelo: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "Modelo: {model}, proveedor: {provider}, ID: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "Alternar solo favoritos",
|
||||
"modelSelector.favoritesOnly.showAll": "Mostrar todos los modelos",
|
||||
"modelSelector.favorite.add": "Agregar a favoritos",
|
||||
"modelSelector.favorite.remove": "Quitar de favoritos",
|
||||
"modelSelector.manageProviders": "Gestionar proveedores",
|
||||
"modelSelector.unavailableModel": "{model} (no disponible)",
|
||||
"settings.providers.actions.manageModels": "Gestionar modelos",
|
||||
"settings.providers.modelVisibility.back": "Volver a proveedores",
|
||||
"settings.providers.modelVisibility.title": "Modelos de {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "Elige qué modelos muestra CodeNomad en los selectores.",
|
||||
"settings.providers.modelVisibility.search.label": "Buscar modelos del proveedor",
|
||||
"settings.providers.modelVisibility.search.placeholder": "Buscar por nombre o ID del modelo...",
|
||||
"settings.providers.modelVisibility.count.visible": "{visible} de {total} visibles",
|
||||
"settings.providers.modelVisibility.actions.showAll": "Mostrar todos",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "Ocultar todos",
|
||||
"settings.providers.modelVisibility.list.empty": "Ningún modelo coincide con esta búsqueda.",
|
||||
"settings.providers.modelVisibility.list.label": "Modelos de {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "No se pudo guardar la visibilidad de los modelos.",
|
||||
|
||||
"thinkingSelector.variant.default": "Por defecto",
|
||||
"thinkingSelector.label": "Pensamiento: {variant}",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ export const commandMessages = {
|
|||
"commands.newInstance.label": "Nouvelle instance",
|
||||
"commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance",
|
||||
"commands.newInstance.keywords": "dossier, projet, espace de travail",
|
||||
"commands.openWorkspace.folder.label": "Ouvrir le dossier du projet",
|
||||
"commands.openWorkspace.folder.description": "Ouvrir le dossier du projet actuel dans le gestionnaire de fichiers",
|
||||
"commands.openWorkspace.folder.keywords": "dossier, explorateur, fichiers",
|
||||
"commands.openWorkspace.terminal.label": "Ouvrir un terminal ici",
|
||||
"commands.openWorkspace.terminal.description": "Ouvrir un terminal externe dans le dossier du projet actuel",
|
||||
"commands.openWorkspace.terminal.keywords": "terminal, shell, console",
|
||||
"commands.openWorkspace.editor.label": "Ouvrir le projet dans {editor}",
|
||||
"commands.openWorkspace.editor.description": "Ouvrir le dossier du projet actuel dans {editor}",
|
||||
"commands.openWorkspace.editor.keywords": "éditeur, ide, code",
|
||||
"commands.openWorkspace.failed.title": "Impossible d'ouvrir le projet",
|
||||
"commands.openWorkspace.failed.message": "{message}",
|
||||
"commands.openWorkspace.projectRequired.title": "Aucun projet ouvert",
|
||||
"commands.openWorkspace.projectRequired.message": "Ouvrez d'abord un projet.",
|
||||
|
||||
"commands.closeInstance.label": "Fermer l'onglet",
|
||||
"commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel",
|
||||
|
|
|
|||
|
|
@ -180,6 +180,15 @@ export const instanceMessages = {
|
|||
"instanceShell.filesShell.search.ariaLabel": "Filtrer les fichiers de ce dossier",
|
||||
"instanceShell.filesShell.search.empty": "Aucun fichier correspondant.",
|
||||
"instanceShell.filesShell.actions.copyPath": "Copier le chemin",
|
||||
"instanceShell.filesShell.actions.more": "Plus d'actions pour {name}",
|
||||
"instanceShell.filesShell.actions.openDefault": "Ouvrir avec l'application par défaut",
|
||||
"instanceShell.filesShell.actions.editDefault": "Modifier avec l'application par défaut",
|
||||
"instanceShell.filesShell.actions.chooseApplication": "Ouvrir avec...",
|
||||
"instanceShell.filesShell.actions.showInFolder": "Afficher dans le dossier",
|
||||
"instanceShell.filesShell.actions.openFolder": "Ouvrir dans le gestionnaire de fichiers",
|
||||
"instanceShell.filesShell.actions.openTerminal": "Ouvrir un terminal ici",
|
||||
"instanceShell.filesShell.actions.openEditor": "Ouvrir dans {editor}",
|
||||
"instanceShell.filesShell.toast.openError": "Impossible d'ouvrir : {message}",
|
||||
"instanceShell.filesShell.toast.copyPathSuccess": "Chemin copié",
|
||||
"instanceShell.filesShell.toast.copyPathError": "Impossible de copier le chemin",
|
||||
"instanceShell.filesShell.previewMarkdown": "Aperçu Markdown",
|
||||
|
|
|
|||
|
|
@ -30,11 +30,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "Rechercher des modèles...",
|
||||
"modelSelector.none": "Aucun",
|
||||
"modelSelector.trigger.primary": "Modèle : {model}",
|
||||
"modelSelector.trigger.ariaLabel": "Modèle : {model}, fournisseur : {provider}, identifiant : {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "Basculer en favoris uniquement",
|
||||
"modelSelector.favoritesOnly.showAll": "Afficher tous les modèles",
|
||||
"modelSelector.favorite.add": "Ajouter aux favoris",
|
||||
"modelSelector.favorite.remove": "Retirer des favoris",
|
||||
"modelSelector.manageProviders": "Gérer les fournisseurs",
|
||||
"modelSelector.unavailableModel": "{model} (indisponible)",
|
||||
"settings.providers.actions.manageModels": "Gérer les modèles",
|
||||
"settings.providers.modelVisibility.back": "Retour aux fournisseurs",
|
||||
"settings.providers.modelVisibility.title": "Modèles pour {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "Choisissez les modèles affichés par CodeNomad dans les sélecteurs.",
|
||||
"settings.providers.modelVisibility.search.label": "Rechercher les modèles du fournisseur",
|
||||
"settings.providers.modelVisibility.search.placeholder": "Rechercher par nom ou identifiant...",
|
||||
"settings.providers.modelVisibility.count.visible": "{visible} sur {total} visibles",
|
||||
"settings.providers.modelVisibility.actions.showAll": "Tout afficher",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "Tout masquer",
|
||||
"settings.providers.modelVisibility.list.empty": "Aucun modèle ne correspond à cette recherche.",
|
||||
"settings.providers.modelVisibility.list.label": "Modèles pour {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "Impossible d’enregistrer la visibilité des modèles.",
|
||||
|
||||
"thinkingSelector.variant.default": "Par défaut",
|
||||
"thinkingSelector.label": "Réflexion : {variant}",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ export const commandMessages = {
|
|||
"commands.newInstance.label": "מופע חדש",
|
||||
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
|
||||
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
|
||||
"commands.openWorkspace.folder.label": "פתיחת תיקיית הפרויקט",
|
||||
"commands.openWorkspace.folder.description": "פתיחת תיקיית הפרויקט הנוכחי במנהל הקבצים",
|
||||
"commands.openWorkspace.folder.keywords": "תיקייה, קבצים, סייר",
|
||||
"commands.openWorkspace.terminal.label": "פתיחת מסוף כאן",
|
||||
"commands.openWorkspace.terminal.description": "פתיחת מסוף חיצוני בתיקיית הפרויקט הנוכחי",
|
||||
"commands.openWorkspace.terminal.keywords": "מסוף, shell, קונסולה",
|
||||
"commands.openWorkspace.editor.label": "פתיחת הפרויקט ב-{editor}",
|
||||
"commands.openWorkspace.editor.description": "פתיחת תיקיית הפרויקט הנוכחי ב-{editor}",
|
||||
"commands.openWorkspace.editor.keywords": "עורך, ide, קוד",
|
||||
"commands.openWorkspace.failed.title": "לא ניתן לפתוח את הפרויקט",
|
||||
"commands.openWorkspace.failed.message": "{message}",
|
||||
"commands.openWorkspace.projectRequired.title": "אין פרויקט פתוח",
|
||||
"commands.openWorkspace.projectRequired.message": "יש לפתוח פרויקט תחילה.",
|
||||
|
||||
"commands.closeInstance.label": "סגור לשונית",
|
||||
"commands.closeInstance.description": "סגור את הלשונית העליונה הנוכחית",
|
||||
|
|
|
|||
|
|
@ -182,6 +182,15 @@ export const instanceMessages = {
|
|||
"instanceShell.filesShell.search.ariaLabel": "סנן קבצים בתיקייה הזו",
|
||||
"instanceShell.filesShell.search.empty": "לא נמצאו קבצים תואמים.",
|
||||
"instanceShell.filesShell.actions.copyPath": "העתק נתיב",
|
||||
"instanceShell.filesShell.actions.more": "פעולות נוספות עבור {name}",
|
||||
"instanceShell.filesShell.actions.openDefault": "פתיחה באמצעות יישום ברירת המחדל",
|
||||
"instanceShell.filesShell.actions.editDefault": "עריכה באמצעות יישום ברירת המחדל",
|
||||
"instanceShell.filesShell.actions.chooseApplication": "פתיחה באמצעות...",
|
||||
"instanceShell.filesShell.actions.showInFolder": "הצגה בתיקייה",
|
||||
"instanceShell.filesShell.actions.openFolder": "פתיחה במנהל הקבצים",
|
||||
"instanceShell.filesShell.actions.openTerminal": "פתיחת מסוף כאן",
|
||||
"instanceShell.filesShell.actions.openEditor": "פתיחה ב-{editor}",
|
||||
"instanceShell.filesShell.toast.openError": "לא ניתן לפתוח: {message}",
|
||||
"instanceShell.filesShell.toast.copyPathSuccess": "הנתיב הועתק",
|
||||
"instanceShell.filesShell.toast.copyPathError": "העתקת הנתיב נכשלה",
|
||||
"instanceShell.filesShell.previewMarkdown": "תצוגת Markdown",
|
||||
|
|
|
|||
|
|
@ -30,11 +30,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "חפש מודלים...",
|
||||
"modelSelector.none": "ללא",
|
||||
"modelSelector.trigger.primary": "מודל: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "מודל: {model}, ספק: {provider}, מזהה: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "הצג מועדפים בלבד",
|
||||
"modelSelector.favoritesOnly.showAll": "הצג את כל המודלים",
|
||||
"modelSelector.favorite.add": "הוסף למועדפים",
|
||||
"modelSelector.favorite.remove": "הסר ממועדפים",
|
||||
"modelSelector.manageProviders": "נהל ספקים",
|
||||
"modelSelector.unavailableModel": "{model} (לא זמין)",
|
||||
"settings.providers.actions.manageModels": "ניהול מודלים",
|
||||
"settings.providers.modelVisibility.back": "חזרה לספקים",
|
||||
"settings.providers.modelVisibility.title": "מודלים עבור {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "בחר אילו מודלים CodeNomad יציג בבוררי המודלים.",
|
||||
"settings.providers.modelVisibility.search.label": "חיפוש מודלים של הספק",
|
||||
"settings.providers.modelVisibility.search.placeholder": "חיפוש לפי שם או מזהה מודל...",
|
||||
"settings.providers.modelVisibility.count.visible": "{visible} מתוך {total} גלויים",
|
||||
"settings.providers.modelVisibility.actions.showAll": "הצג הכול",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "הסתר הכול",
|
||||
"settings.providers.modelVisibility.list.empty": "אין מודלים התואמים לחיפוש.",
|
||||
"settings.providers.modelVisibility.list.label": "מודלים עבור {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "שמירת נראות המודלים נכשלה.",
|
||||
|
||||
"thinkingSelector.variant.default": "ברירת מחדל",
|
||||
"thinkingSelector.label": "חשיבה: {variant}",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ export const commandMessages = {
|
|||
"commands.newInstance.label": "新しいインスタンス",
|
||||
"commands.newInstance.description": "フォルダ選択を開いて新しいインスタンスを作成",
|
||||
"commands.newInstance.keywords": "フォルダ, プロジェクト, ワークスペース, folder, project, workspace",
|
||||
"commands.openWorkspace.folder.label": "プロジェクトフォルダーを開く",
|
||||
"commands.openWorkspace.folder.description": "現在のプロジェクトフォルダーをファイルマネージャーで開く",
|
||||
"commands.openWorkspace.folder.keywords": "フォルダー, エクスプローラー, ファイル",
|
||||
"commands.openWorkspace.terminal.label": "ここでターミナルを開く",
|
||||
"commands.openWorkspace.terminal.description": "現在のプロジェクトフォルダーで外部ターミナルを開く",
|
||||
"commands.openWorkspace.terminal.keywords": "ターミナル, シェル, コンソール",
|
||||
"commands.openWorkspace.editor.label": "プロジェクトを{editor}で開く",
|
||||
"commands.openWorkspace.editor.description": "現在のプロジェクトフォルダーを{editor}で開く",
|
||||
"commands.openWorkspace.editor.keywords": "エディター, ide, コード",
|
||||
"commands.openWorkspace.failed.title": "プロジェクトを開けませんでした",
|
||||
"commands.openWorkspace.failed.message": "{message}",
|
||||
"commands.openWorkspace.projectRequired.title": "プロジェクトが開かれていません",
|
||||
"commands.openWorkspace.projectRequired.message": "先にプロジェクトを開いてください。",
|
||||
|
||||
"commands.closeInstance.label": "タブを閉じる",
|
||||
"commands.closeInstance.description": "現在のトップレベルタブを閉じる",
|
||||
|
|
|
|||
|
|
@ -182,6 +182,15 @@ export const instanceMessages = {
|
|||
"instanceShell.filesShell.search.ariaLabel": "このフォルダーのファイルを絞り込む",
|
||||
"instanceShell.filesShell.search.empty": "一致するファイルがありません。",
|
||||
"instanceShell.filesShell.actions.copyPath": "パスをコピー",
|
||||
"instanceShell.filesShell.actions.more": "{name}のその他の操作",
|
||||
"instanceShell.filesShell.actions.openDefault": "既定のアプリケーションで開く",
|
||||
"instanceShell.filesShell.actions.editDefault": "既定のアプリケーションで編集",
|
||||
"instanceShell.filesShell.actions.chooseApplication": "アプリケーションを選択して開く...",
|
||||
"instanceShell.filesShell.actions.showInFolder": "フォルダーに表示",
|
||||
"instanceShell.filesShell.actions.openFolder": "ファイルマネージャーで開く",
|
||||
"instanceShell.filesShell.actions.openTerminal": "ここでターミナルを開く",
|
||||
"instanceShell.filesShell.actions.openEditor": "{editor}で開く",
|
||||
"instanceShell.filesShell.toast.openError": "開けませんでした: {message}",
|
||||
"instanceShell.filesShell.toast.copyPathSuccess": "パスをコピーしました",
|
||||
"instanceShell.filesShell.toast.copyPathError": "パスをコピーできませんでした",
|
||||
"instanceShell.filesShell.previewMarkdown": "Markdown プレビュー",
|
||||
|
|
|
|||
|
|
@ -30,11 +30,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "モデルを検索...",
|
||||
"modelSelector.none": "なし",
|
||||
"modelSelector.trigger.primary": "モデル: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "モデル: {model}、プロバイダー: {provider}、ID: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "お気に入りのみ",
|
||||
"modelSelector.favoritesOnly.showAll": "すべてのモデルを表示",
|
||||
"modelSelector.favorite.add": "お気に入りに追加",
|
||||
"modelSelector.favorite.remove": "お気に入りから削除",
|
||||
"modelSelector.manageProviders": "プロバイダーを管理",
|
||||
"modelSelector.unavailableModel": "{model}(利用不可)",
|
||||
"settings.providers.actions.manageModels": "モデルを管理",
|
||||
"settings.providers.modelVisibility.back": "プロバイダーに戻る",
|
||||
"settings.providers.modelVisibility.title": "{provider} のモデル",
|
||||
"settings.providers.modelVisibility.subtitle": "CodeNomad のモデル選択に表示するモデルを選びます。",
|
||||
"settings.providers.modelVisibility.search.label": "プロバイダーのモデルを検索",
|
||||
"settings.providers.modelVisibility.search.placeholder": "モデル名または ID で検索...",
|
||||
"settings.providers.modelVisibility.count.visible": "{total} 件中 {visible} 件を表示",
|
||||
"settings.providers.modelVisibility.actions.showAll": "すべて表示",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "すべて非表示",
|
||||
"settings.providers.modelVisibility.list.empty": "検索に一致するモデルはありません。",
|
||||
"settings.providers.modelVisibility.list.label": "{provider} のモデル",
|
||||
"settings.providers.modelVisibility.save.failed": "モデルの表示設定を保存できませんでした。",
|
||||
|
||||
"thinkingSelector.variant.default": "デフォルト",
|
||||
"thinkingSelector.label": "思考: {variant}",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ export const commandMessages = {
|
|||
"commands.newInstance.label": "नयाँ उदाहरण (New Instance)",
|
||||
"commands.newInstance.description": "नयाँ उदाहरण सिर्जना गर्न फोल्डर चयनकर्ता खोल्नुहोस्",
|
||||
"commands.newInstance.keywords": "फोल्डर, परियोजना, कार्यस्थान",
|
||||
"commands.openWorkspace.folder.label": "परियोजना फोल्डर खोल्नुहोस्",
|
||||
"commands.openWorkspace.folder.description": "हालको परियोजना फोल्डर फाइल व्यवस्थापकमा खोल्नुहोस्",
|
||||
"commands.openWorkspace.folder.keywords": "फोल्डर, फाइल, एक्सप्लोरर",
|
||||
"commands.openWorkspace.terminal.label": "यहाँ टर्मिनल खोल्नुहोस्",
|
||||
"commands.openWorkspace.terminal.description": "हालको परियोजना फोल्डरमा बाह्य टर्मिनल खोल्नुहोस्",
|
||||
"commands.openWorkspace.terminal.keywords": "टर्मिनल, shell, कन्सोल",
|
||||
"commands.openWorkspace.editor.label": "परियोजना {editor} मा खोल्नुहोस्",
|
||||
"commands.openWorkspace.editor.description": "हालको परियोजना फोल्डर {editor} मा खोल्नुहोस्",
|
||||
"commands.openWorkspace.editor.keywords": "सम्पादक, ide, कोड",
|
||||
"commands.openWorkspace.failed.title": "परियोजना खोल्न सकिएन",
|
||||
"commands.openWorkspace.failed.message": "{message}",
|
||||
"commands.openWorkspace.projectRequired.title": "कुनै परियोजना खुलेको छैन",
|
||||
"commands.openWorkspace.projectRequired.message": "पहिले परियोजना खोल्नुहोस्।",
|
||||
|
||||
"commands.closeInstance.label": "ट्याब बन्द गर्नुहोस्",
|
||||
"commands.closeInstance.description": "हालको शीर्ष-स्तरको ट्याब बन्द गर्नुहोस्",
|
||||
|
|
|
|||
|
|
@ -182,6 +182,15 @@ export const instanceMessages = {
|
|||
"instanceShell.filesShell.search.ariaLabel": "यस फोल्डरमा फाइलहरू फिल्टर गर्नुहोस्",
|
||||
"instanceShell.filesShell.search.empty": "कुनै मिल्दो फाइलहरू छैनन्।",
|
||||
"instanceShell.filesShell.actions.copyPath": "मार्ग (Path) प्रतिलिपि गर्नुहोस्",
|
||||
"instanceShell.filesShell.actions.more": "{name} का लागि थप कार्यहरू",
|
||||
"instanceShell.filesShell.actions.openDefault": "पूर्वनिर्धारित अनुप्रयोगमा खोल्नुहोस्",
|
||||
"instanceShell.filesShell.actions.editDefault": "पूर्वनिर्धारित अनुप्रयोगमा सम्पादन गर्नुहोस्",
|
||||
"instanceShell.filesShell.actions.chooseApplication": "यससँग खोल्नुहोस्...",
|
||||
"instanceShell.filesShell.actions.showInFolder": "फोल्डरमा देखाउनुहोस्",
|
||||
"instanceShell.filesShell.actions.openFolder": "फाइल व्यवस्थापकमा खोल्नुहोस्",
|
||||
"instanceShell.filesShell.actions.openTerminal": "यहाँ टर्मिनल खोल्नुहोस्",
|
||||
"instanceShell.filesShell.actions.openEditor": "{editor} मा खोल्नुहोस्",
|
||||
"instanceShell.filesShell.toast.openError": "खोल्न सकिएन: {message}",
|
||||
"instanceShell.filesShell.toast.copyPathSuccess": "मार्ग प्रतिलिपि गरियो",
|
||||
"instanceShell.filesShell.toast.copyPathError": "मार्ग प्रतिलिपि गर्न असफल भयो",
|
||||
"instanceShell.filesShell.previewMarkdown": "Markdown पूर्वावलोकन",
|
||||
|
|
|
|||
|
|
@ -30,11 +30,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "मोडेलहरू खोज्नुहोस्...",
|
||||
"modelSelector.none": "कुनै छैन",
|
||||
"modelSelector.trigger.primary": "मोडेल: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "मोडेल: {model}, प्रदायक: {provider}, आईडी: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "मनपर्ने मात्र टगल गर्नुहोस्",
|
||||
"modelSelector.favoritesOnly.showAll": "सबै मोडेलहरू देखाउनुहोस्",
|
||||
"modelSelector.favorite.add": "मनपर्नेमा थप्नुहोस्",
|
||||
"modelSelector.favorite.remove": "मनपर्नेबाट हटाउनुहोस्",
|
||||
"modelSelector.manageProviders": "प्रदायकहरू व्यवस्थापन गर्नुहोस्",
|
||||
"modelSelector.unavailableModel": "{model} (उपलब्ध छैन)",
|
||||
"settings.providers.actions.manageModels": "मोडेलहरू व्यवस्थापन गर्नुहोस्",
|
||||
"settings.providers.modelVisibility.back": "प्रदायकहरूमा फर्कनुहोस्",
|
||||
"settings.providers.modelVisibility.title": "{provider} का मोडेलहरू",
|
||||
"settings.providers.modelVisibility.subtitle": "CodeNomad का मोडेल चयनकर्तामा देखिने मोडेलहरू छान्नुहोस्।",
|
||||
"settings.providers.modelVisibility.search.label": "प्रदायकका मोडेलहरू खोज्नुहोस्",
|
||||
"settings.providers.modelVisibility.search.placeholder": "मोडेल नाम वा ID बाट खोज्नुहोस्...",
|
||||
"settings.providers.modelVisibility.count.visible": "{total} मध्ये {visible} देखिने",
|
||||
"settings.providers.modelVisibility.actions.showAll": "सबै देखाउनुहोस्",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "सबै लुकाउनुहोस्",
|
||||
"settings.providers.modelVisibility.list.empty": "यो खोजसँग मिल्ने मोडेल छैन।",
|
||||
"settings.providers.modelVisibility.list.label": "{provider} का मोडेलहरू",
|
||||
"settings.providers.modelVisibility.save.failed": "मोडेल दृश्यता सुरक्षित गर्न असफल भयो।",
|
||||
|
||||
"thinkingSelector.variant.default": "पूर्वनिर्धारित",
|
||||
"thinkingSelector.label": "सोचाइ: {variant}",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ export const commandMessages = {
|
|||
"commands.newInstance.label": "Новый экземпляр",
|
||||
"commands.newInstance.description": "Открыть выбор папки для создания нового экземпляра",
|
||||
"commands.newInstance.keywords": "папка, проект, рабочее пространство",
|
||||
"commands.openWorkspace.folder.label": "Открыть папку проекта",
|
||||
"commands.openWorkspace.folder.description": "Открыть папку текущего проекта в файловом менеджере",
|
||||
"commands.openWorkspace.folder.keywords": "папка, проводник, файлы",
|
||||
"commands.openWorkspace.terminal.label": "Открыть терминал здесь",
|
||||
"commands.openWorkspace.terminal.description": "Открыть внешний терминал в папке текущего проекта",
|
||||
"commands.openWorkspace.terminal.keywords": "терминал, оболочка, консоль",
|
||||
"commands.openWorkspace.editor.label": "Открыть проект в {editor}",
|
||||
"commands.openWorkspace.editor.description": "Открыть папку текущего проекта в {editor}",
|
||||
"commands.openWorkspace.editor.keywords": "редактор, ide, код",
|
||||
"commands.openWorkspace.failed.title": "Не удалось открыть проект",
|
||||
"commands.openWorkspace.failed.message": "{message}",
|
||||
"commands.openWorkspace.projectRequired.title": "Проект не открыт",
|
||||
"commands.openWorkspace.projectRequired.message": "Сначала откройте проект.",
|
||||
|
||||
"commands.closeInstance.label": "Закрыть вкладку",
|
||||
"commands.closeInstance.description": "Закрыть текущую верхнеуровневую вкладку",
|
||||
|
|
|
|||
|
|
@ -182,6 +182,15 @@ export const instanceMessages = {
|
|||
"instanceShell.filesShell.search.ariaLabel": "Фильтровать файлы в этой папке",
|
||||
"instanceShell.filesShell.search.empty": "Совпадающих файлов нет.",
|
||||
"instanceShell.filesShell.actions.copyPath": "Скопировать путь",
|
||||
"instanceShell.filesShell.actions.more": "Дополнительные действия для {name}",
|
||||
"instanceShell.filesShell.actions.openDefault": "Открыть в приложении по умолчанию",
|
||||
"instanceShell.filesShell.actions.editDefault": "Изменить в приложении по умолчанию",
|
||||
"instanceShell.filesShell.actions.chooseApplication": "Открыть с помощью...",
|
||||
"instanceShell.filesShell.actions.showInFolder": "Показать в папке",
|
||||
"instanceShell.filesShell.actions.openFolder": "Открыть в файловом менеджере",
|
||||
"instanceShell.filesShell.actions.openTerminal": "Открыть терминал здесь",
|
||||
"instanceShell.filesShell.actions.openEditor": "Открыть в {editor}",
|
||||
"instanceShell.filesShell.toast.openError": "Не удалось открыть: {message}",
|
||||
"instanceShell.filesShell.toast.copyPathSuccess": "Путь скопирован",
|
||||
"instanceShell.filesShell.toast.copyPathError": "Не удалось скопировать путь",
|
||||
"instanceShell.filesShell.previewMarkdown": "Предпросмотр Markdown",
|
||||
|
|
|
|||
|
|
@ -30,11 +30,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "Поиск моделей…",
|
||||
"modelSelector.none": "Нет",
|
||||
"modelSelector.trigger.primary": "Модель: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "Модель: {model}, провайдер: {provider}, ID: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "Только избранное",
|
||||
"modelSelector.favoritesOnly.showAll": "Показать все модели",
|
||||
"modelSelector.favorite.add": "Добавить в избранное",
|
||||
"modelSelector.favorite.remove": "Удалить из избранного",
|
||||
"modelSelector.manageProviders": "Управление провайдерами",
|
||||
"modelSelector.unavailableModel": "{model} (недоступна)",
|
||||
"settings.providers.actions.manageModels": "Управление моделями",
|
||||
"settings.providers.modelVisibility.back": "Назад к провайдерам",
|
||||
"settings.providers.modelVisibility.title": "Модели {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "Выберите модели, которые CodeNomad показывает в списках выбора.",
|
||||
"settings.providers.modelVisibility.search.label": "Поиск моделей провайдера",
|
||||
"settings.providers.modelVisibility.search.placeholder": "Поиск по имени или ID модели...",
|
||||
"settings.providers.modelVisibility.count.visible": "Видно {visible} из {total}",
|
||||
"settings.providers.modelVisibility.actions.showAll": "Показать все",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "Скрыть все",
|
||||
"settings.providers.modelVisibility.list.empty": "Нет моделей, соответствующих поиску.",
|
||||
"settings.providers.modelVisibility.list.label": "Модели {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "Не удалось сохранить видимость моделей.",
|
||||
|
||||
"thinkingSelector.variant.default": "По умолчанию",
|
||||
"thinkingSelector.label": "Размышления: {variant}",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ export const commandMessages = {
|
|||
"commands.newInstance.label": "新建实例",
|
||||
"commands.newInstance.description": "打开文件夹选择器以创建新实例",
|
||||
"commands.newInstance.keywords": "folder, project, workspace, 文件夹, 项目, 工作区",
|
||||
"commands.openWorkspace.folder.label": "打开项目文件夹",
|
||||
"commands.openWorkspace.folder.description": "在文件管理器中打开当前项目文件夹",
|
||||
"commands.openWorkspace.folder.keywords": "文件夹, 资源管理器, 文件",
|
||||
"commands.openWorkspace.terminal.label": "在此处打开终端",
|
||||
"commands.openWorkspace.terminal.description": "在当前项目文件夹中打开外部终端",
|
||||
"commands.openWorkspace.terminal.keywords": "终端, shell, 控制台",
|
||||
"commands.openWorkspace.editor.label": "在{editor}中打开项目",
|
||||
"commands.openWorkspace.editor.description": "在{editor}中打开当前项目文件夹",
|
||||
"commands.openWorkspace.editor.keywords": "编辑器, ide, 代码",
|
||||
"commands.openWorkspace.failed.title": "无法打开项目",
|
||||
"commands.openWorkspace.failed.message": "{message}",
|
||||
"commands.openWorkspace.projectRequired.title": "没有打开的项目",
|
||||
"commands.openWorkspace.projectRequired.message": "请先打开一个项目。",
|
||||
|
||||
"commands.closeInstance.label": "关闭标签页",
|
||||
"commands.closeInstance.description": "关闭当前顶层标签页",
|
||||
|
|
|
|||
|
|
@ -182,6 +182,15 @@ export const instanceMessages = {
|
|||
"instanceShell.filesShell.search.ariaLabel": "筛选此文件夹中的文件",
|
||||
"instanceShell.filesShell.search.empty": "没有匹配的文件。",
|
||||
"instanceShell.filesShell.actions.copyPath": "复制路径",
|
||||
"instanceShell.filesShell.actions.more": "{name}的更多操作",
|
||||
"instanceShell.filesShell.actions.openDefault": "使用默认应用打开",
|
||||
"instanceShell.filesShell.actions.editDefault": "使用默认应用编辑",
|
||||
"instanceShell.filesShell.actions.chooseApplication": "打开方式...",
|
||||
"instanceShell.filesShell.actions.showInFolder": "在文件夹中显示",
|
||||
"instanceShell.filesShell.actions.openFolder": "在文件管理器中打开",
|
||||
"instanceShell.filesShell.actions.openTerminal": "在此处打开终端",
|
||||
"instanceShell.filesShell.actions.openEditor": "在{editor}中打开",
|
||||
"instanceShell.filesShell.toast.openError": "无法打开:{message}",
|
||||
"instanceShell.filesShell.toast.copyPathSuccess": "路径已复制",
|
||||
"instanceShell.filesShell.toast.copyPathError": "无法复制路径",
|
||||
"instanceShell.filesShell.previewMarkdown": "Markdown 预览",
|
||||
|
|
|
|||
|
|
@ -30,11 +30,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "搜索模型...",
|
||||
"modelSelector.none": "无",
|
||||
"modelSelector.trigger.primary": "模型:{model}",
|
||||
"modelSelector.trigger.ariaLabel": "模型:{model},提供商:{provider},ID:{id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "仅显示收藏",
|
||||
"modelSelector.favoritesOnly.showAll": "显示所有模型",
|
||||
"modelSelector.favorite.add": "添加到收藏",
|
||||
"modelSelector.favorite.remove": "从收藏移除",
|
||||
"modelSelector.manageProviders": "管理提供商",
|
||||
"modelSelector.unavailableModel": "{model}(不可用)",
|
||||
"settings.providers.actions.manageModels": "管理模型",
|
||||
"settings.providers.modelVisibility.back": "返回提供商列表",
|
||||
"settings.providers.modelVisibility.title": "{provider} 的模型",
|
||||
"settings.providers.modelVisibility.subtitle": "选择 CodeNomad 在模型选择器中显示的模型。",
|
||||
"settings.providers.modelVisibility.search.label": "搜索提供商模型",
|
||||
"settings.providers.modelVisibility.search.placeholder": "按模型名称或 ID 搜索...",
|
||||
"settings.providers.modelVisibility.count.visible": "显示 {visible}/{total}",
|
||||
"settings.providers.modelVisibility.actions.showAll": "全部显示",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "全部隐藏",
|
||||
"settings.providers.modelVisibility.list.empty": "没有与搜索匹配的模型。",
|
||||
"settings.providers.modelVisibility.list.label": "{provider} 的模型",
|
||||
"settings.providers.modelVisibility.save.failed": "无法保存模型可见性设置。",
|
||||
|
||||
"thinkingSelector.variant.default": "默认",
|
||||
"thinkingSelector.label": "思考:{variant}",
|
||||
|
|
|
|||
|
|
@ -112,3 +112,18 @@ describe("renderMarkdown bracket math delimiters", () => {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("renderMarkdown raw HTML", () => {
|
||||
it("preserves text after style tags inside malformed inline code", async () => {
|
||||
const content = [
|
||||
"- evidence: [source] `return ",
|
||||
'`<!DOCTYPE html><html><head><meta charset="utf-8"><style>${options.styles.join(";")}</style></head>',
|
||||
'<body>${options.html}</body></html>`;` - and the test remains visible',
|
||||
].join("")
|
||||
|
||||
const html = await renderMarkdown(content, { suppressHighlight: true, escapeRawHtml: true })
|
||||
|
||||
assert.doesNotMatch(html, /<style>/)
|
||||
assert.match(html, /and the test remains visible/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
76
packages/ui/src/lib/model-visibility.test.ts
Normal file
76
packages/ui/src/lib/model-visibility.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import assert from "node:assert/strict"
|
||||
import fs from "node:fs"
|
||||
import test from "node:test"
|
||||
import {
|
||||
isModelVisible,
|
||||
normalizeModelVisibilityPreference,
|
||||
normalizeModelVisibilityPreferences,
|
||||
resolvePickerValue,
|
||||
} from "./model-visibility"
|
||||
|
||||
test("normalizes malformed preferences fail-open", () => {
|
||||
assert.deepEqual(normalizeModelVisibilityPreference(null), { hiddenModelIds: [] })
|
||||
assert.deepEqual(normalizeModelVisibilityPreference({ mode: "custom", modelIds: ["legacy"] }), { hiddenModelIds: [] })
|
||||
assert.deepEqual(normalizeModelVisibilityPreference({ hiddenModelIds: "hidden" }), { hiddenModelIds: [] })
|
||||
assert.deepEqual(normalizeModelVisibilityPreferences([]), {})
|
||||
assert.deepEqual(normalizeModelVisibilityPreferences({ broken: { hiddenModelIds: null } }), {
|
||||
broken: { hiddenModelIds: [] },
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps unique non-empty exact IDs without catalog-based deletion", () => {
|
||||
assert.deepEqual(
|
||||
normalizeModelVisibilityPreference({ hiddenModelIds: ["Model-A", "model-a", "", "Model-A", 7] }),
|
||||
{ hiddenModelIds: ["Model-A", "model-a"] },
|
||||
)
|
||||
})
|
||||
|
||||
test("matches hidden IDs exactly and case-sensitively", () => {
|
||||
const preference = { hiddenModelIds: ["Model-A"] }
|
||||
assert.equal(isModelVisible(preference, "Model-A"), false)
|
||||
assert.equal(isModelVisible(preference, "model-a"), true)
|
||||
})
|
||||
|
||||
test("new models and missing preferences are visible by default", () => {
|
||||
assert.equal(isModelVisible({ hiddenModelIds: ["known"] }, "new"), true)
|
||||
assert.equal(isModelVisible(undefined, "new"), true)
|
||||
})
|
||||
|
||||
test("a current model receives no helper-level visibility override", () => {
|
||||
assert.equal(isModelVisible({ hiddenModelIds: ["current"] }, "current"), false)
|
||||
})
|
||||
|
||||
test("uses the current model as the picker value only while it remains in the collection", () => {
|
||||
const current = { key: "provider/current" }
|
||||
assert.equal(resolvePickerValue(current, [current]), current)
|
||||
assert.equal(resolvePickerValue(current, [{ key: "provider/visible" }]), undefined)
|
||||
})
|
||||
|
||||
test("provider refresh updates shared and modal catalogs without disposing active instances", () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL("../components/provider-auth/provider-manager-modal.tsx", import.meta.url),
|
||||
"utf8",
|
||||
)
|
||||
const start = source.indexOf("async function refreshProviderData()")
|
||||
const end = source.indexOf("function closeModelManager()", start)
|
||||
const refresh = source.slice(start, end)
|
||||
|
||||
assert.ok(start >= 0 && end > start)
|
||||
assert.match(refresh, /await fetchProviders\(instanceId\)/)
|
||||
assert.match(refresh, /await loadProviderData\(authClient\)/)
|
||||
assert.doesNotMatch(refresh, /global\.dispose/)
|
||||
})
|
||||
|
||||
test("model picker wires collection-safe selection and an accessible current-model label", () => {
|
||||
const source = fs.readFileSync(new URL("../components/model-selector.tsx", import.meta.url), "utf8")
|
||||
assert.match(source, /value=\{comboboxValue\(\)\}/)
|
||||
assert.equal(source.match(/aria-label=\{currentModelAccessibleLabel\(\)\}/g)?.length, 2)
|
||||
assert.match(source, /id: `\$\{current\.providerId\}\/\$\{current\.id\}`/)
|
||||
})
|
||||
|
||||
test("serializes visibility writes across providers before installing server snapshots", () => {
|
||||
const source = fs.readFileSync(new URL("../stores/preferences.tsx", import.meta.url), "utf8")
|
||||
assert.match(source, /let modelVisibilityWriteQueue = Promise\.resolve\(\)/)
|
||||
assert.match(source, /const previous = modelVisibilityWriteQueue/)
|
||||
assert.match(source, /modelVisibilityWriteQueue = write/)
|
||||
})
|
||||
39
packages/ui/src/lib/model-visibility.ts
Normal file
39
packages/ui/src/lib/model-visibility.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export type ModelVisibilityPreference = { hiddenModelIds: readonly string[] }
|
||||
|
||||
export type ModelVisibilityPreferences = Record<string, ModelVisibilityPreference>
|
||||
|
||||
export function normalizeModelVisibilityPreference(value: unknown): ModelVisibilityPreference {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return { hiddenModelIds: [] }
|
||||
const hiddenModelIds = (value as { hiddenModelIds?: unknown }).hiddenModelIds
|
||||
if (!Array.isArray(hiddenModelIds)) return { hiddenModelIds: [] }
|
||||
|
||||
return {
|
||||
hiddenModelIds: Array.from(new Set(hiddenModelIds.filter(
|
||||
(id): id is string => typeof id === "string" && id.length > 0,
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeModelVisibilityPreferences(value: unknown): ModelVisibilityPreferences {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
|
||||
const result: ModelVisibilityPreferences = {}
|
||||
for (const [providerId, preference] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!providerId) continue
|
||||
result[providerId] = normalizeModelVisibilityPreference(preference)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function isModelVisible(
|
||||
preference: ModelVisibilityPreference | undefined,
|
||||
modelId: string,
|
||||
): boolean {
|
||||
return !normalizeModelVisibilityPreference(preference).hiddenModelIds.includes(modelId)
|
||||
}
|
||||
|
||||
export function resolvePickerValue<T extends { key: string }>(
|
||||
current: T | undefined,
|
||||
options: readonly { key: string }[],
|
||||
): T | undefined {
|
||||
return current && options.some((option) => option.key === current.key) ? current : undefined
|
||||
}
|
||||
23
packages/ui/src/lib/runtime-env.test.ts
Normal file
23
packages/ui/src/lib/runtime-env.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { isLocalTauriHost, type RuntimeEnvironment } from "./runtime-env.ts"
|
||||
|
||||
const environment = (host: RuntimeEnvironment["host"], windowContext: RuntimeEnvironment["windowContext"]) => ({
|
||||
host,
|
||||
windowContext,
|
||||
})
|
||||
|
||||
describe("isLocalTauriHost", () => {
|
||||
it("enables native-only features in the local Tauri window", () => {
|
||||
assert.equal(isLocalTauriHost(environment("tauri", "local")), true)
|
||||
})
|
||||
|
||||
it("keeps native-only features disabled in remote Tauri windows", () => {
|
||||
assert.equal(isLocalTauriHost(environment("tauri", "remote")), false)
|
||||
})
|
||||
|
||||
it("does not classify web or Electron windows as local Tauri", () => {
|
||||
assert.equal(isLocalTauriHost(environment("web", "remote")), false)
|
||||
assert.equal(isLocalTauriHost(environment("electron", "local")), false)
|
||||
})
|
||||
})
|
||||
|
|
@ -117,6 +117,9 @@ export const runtimeEnv = detectRuntimeEnvironment()
|
|||
export const isElectronHost = () => detectHost() === "electron"
|
||||
export const isTauriHost = () => detectHost() === "tauri"
|
||||
export const isWebHost = () => detectHost() === "web"
|
||||
export const isLocalTauriHost = (
|
||||
environment: Pick<RuntimeEnvironment, "host" | "windowContext"> = detectRuntimeEnvironment(),
|
||||
) => environment.host === "tauri" && environment.windowContext === "local"
|
||||
export const isDesktopHost = () => isElectronHost() || isTauriHost()
|
||||
export const isMobilePlatform = () => detectPlatform() === "mobile"
|
||||
export const isLocalWindow = () => detectWindowContext() === "local"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
} from "../../stores/preferences"
|
||||
import type { Command } from "../commands"
|
||||
import { tGlobal } from "../i18n"
|
||||
import { isWebHost } from "../runtime-env"
|
||||
import { isLocalTauriHost, isWebHost } from "../runtime-env"
|
||||
|
||||
export type BehaviorSettingKind = "toggle" | "enum"
|
||||
|
||||
|
|
@ -36,6 +36,8 @@ export type BehaviorSetting = BehaviorToggleSetting | BehaviorEnumSetting
|
|||
|
||||
export type BehaviorRegistryActions = {
|
||||
preferences: Accessor<Preferences>
|
||||
useTauriNativeEventTransport?: Accessor<boolean>
|
||||
setUseTauriNativeEventTransport?: (next: boolean) => void
|
||||
updatePreferences?: (updates: Partial<Preferences>) => void
|
||||
toggleShowThinkingBlocks: () => void
|
||||
toggleKeyboardShortcutHints: () => void
|
||||
|
|
@ -300,6 +302,20 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
|
|||
}
|
||||
},
|
||||
},
|
||||
...(isLocalTauriHost() && actions.useTauriNativeEventTransport && actions.setUseTauriNativeEventTransport
|
||||
? [
|
||||
{
|
||||
kind: "toggle" as const,
|
||||
id: "behavior.tauriNativeEventTransport",
|
||||
titleKey: "settings.behavior.tauriNativeEventTransport.title",
|
||||
subtitleKey: "settings.behavior.tauriNativeEventTransport.subtitle",
|
||||
get: () => actions.useTauriNativeEventTransport!(),
|
||||
set: (next: boolean) => {
|
||||
actions.setUseTauriNativeEventTransport!(next)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.promptVoiceInput",
|
||||
|
|
|
|||
40
packages/ui/src/lib/workspace-open.ts
Normal file
40
packages/ui/src/lib/workspace-open.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { isDesktopHost, isLocalWindow, isTauriHost } from "./runtime-env"
|
||||
|
||||
export type WorkspaceOpenTarget = "default" | "reveal" | "terminal" | "editor"
|
||||
export type WorkspaceEditor = "vscode" | "cursor" | "zed" | "vscodium"
|
||||
|
||||
export interface WorkspaceOpenRequest {
|
||||
target: WorkspaceOpenTarget
|
||||
instanceId: string
|
||||
worktreeSlug: string
|
||||
path?: string
|
||||
editor?: WorkspaceEditor
|
||||
}
|
||||
|
||||
export function canOpenWorkspacePaths(): boolean {
|
||||
return isDesktopHost() && isLocalWindow()
|
||||
}
|
||||
|
||||
export async function openWorkspacePath(request: WorkspaceOpenRequest): Promise<void> {
|
||||
if (!canOpenWorkspacePaths()) throw new Error("Native workspace actions are unavailable")
|
||||
|
||||
if (isTauriHost()) {
|
||||
const invoke = window.__TAURI__?.core?.invoke
|
||||
if (!invoke) throw new Error("Tauri bridge is unavailable")
|
||||
await invoke("open_workspace_target", { ...request })
|
||||
return
|
||||
}
|
||||
|
||||
const openTarget = window.electronAPI?.openWorkspaceTarget
|
||||
if (!openTarget) throw new Error("Electron bridge is unavailable")
|
||||
await openTarget(request)
|
||||
}
|
||||
|
||||
export async function setWorkspaceMenuEnabled(enabled: boolean): Promise<void> {
|
||||
if (!isDesktopHost() || !isLocalWindow()) return
|
||||
if (isTauriHost()) {
|
||||
await window.__TAURI__?.core?.invoke("set_workspace_menu_enabled", { enabled })
|
||||
return
|
||||
}
|
||||
await window.electronAPI?.setWorkspaceMenuEnabled?.(enabled)
|
||||
}
|
||||
|
|
@ -10,6 +10,12 @@ import {
|
|||
import { getLogger } from "../lib/logger"
|
||||
import { loadSpeechCapabilities, resetSpeechCapabilities } from "./speech"
|
||||
import { buildSpeechPatch } from "../lib/speech-patch"
|
||||
import {
|
||||
normalizeModelVisibilityPreference,
|
||||
normalizeModelVisibilityPreferences,
|
||||
type ModelVisibilityPreference,
|
||||
type ModelVisibilityPreferences,
|
||||
} from "../lib/model-visibility"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
|
@ -99,6 +105,7 @@ export interface UiSettings {
|
|||
usageMetricsExpansion: ExpansionPreference
|
||||
autoCleanupBlankSessions: boolean
|
||||
keepUnseenSubagentIdleStatus: boolean
|
||||
modelVisibility: ModelVisibilityPreferences
|
||||
|
||||
// OS notifications
|
||||
osNotificationsEnabled: boolean
|
||||
|
|
@ -189,6 +196,7 @@ const defaultUiSettings: UiSettings = {
|
|||
usageMetricsExpansion: "collapsed",
|
||||
autoCleanupBlankSessions: true,
|
||||
keepUnseenSubagentIdleStatus: false,
|
||||
modelVisibility: {},
|
||||
|
||||
osNotificationsEnabled: false,
|
||||
osNotificationsAllowWhenVisible: false,
|
||||
|
|
@ -295,6 +303,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
|||
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions,
|
||||
keepUnseenSubagentIdleStatus:
|
||||
sanitized.keepUnseenSubagentIdleStatus ?? defaultUiSettings.keepUnseenSubagentIdleStatus,
|
||||
modelVisibility: normalizeModelVisibilityPreferences(sanitized.modelVisibility),
|
||||
osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled,
|
||||
osNotificationsAllowWhenVisible:
|
||||
sanitized.osNotificationsAllowWhenVisible ?? defaultUiSettings.osNotificationsAllowWhenVisible,
|
||||
|
|
@ -592,7 +601,11 @@ async function patchStateOwner(owner: string, patch: unknown) {
|
|||
function updateUiSettings(updates: Partial<UiSettings>) {
|
||||
const current = uiConfigBucket()
|
||||
const nextSettings = normalizeUiSettings({ ...(current.settings ?? {}), ...updates })
|
||||
const patch = { settings: nextSettings }
|
||||
const patch = {
|
||||
settings: Object.fromEntries(
|
||||
Object.keys(updates).map((key) => [key, nextSettings[key as keyof UiSettings]]),
|
||||
),
|
||||
}
|
||||
void patchConfigOwner("ui", patch).catch((error) => log.error("Failed to patch ui settings", error))
|
||||
}
|
||||
|
||||
|
|
@ -600,6 +613,67 @@ function updatePreferences(updates: Partial<UiSettings>): void {
|
|||
updateUiSettings(updates)
|
||||
}
|
||||
|
||||
const modelVisibilityWriteQueues = new Map<string, Promise<void>>()
|
||||
let modelVisibilityWriteQueue = Promise.resolve()
|
||||
const [pendingModelVisibility, setPendingModelVisibility] = createSignal(new Map<string, ModelVisibilityPreference>())
|
||||
const [modelVisibilityWriteFailures, setModelVisibilityWriteFailures] = createSignal(new Set<string>())
|
||||
|
||||
function getProviderModelVisibilityPreference(providerId: string): ModelVisibilityPreference {
|
||||
return pendingModelVisibility().get(providerId)
|
||||
?? normalizeModelVisibilityPreference(preferences().modelVisibility[providerId])
|
||||
}
|
||||
|
||||
function providerModelVisibilitySaveFailed(providerId: string): boolean {
|
||||
return modelVisibilityWriteFailures().has(providerId)
|
||||
}
|
||||
|
||||
async function setProviderModelVisibility(providerId: string, preference: ModelVisibilityPreference): Promise<void> {
|
||||
if (!providerId) return
|
||||
const normalized = normalizeModelVisibilityPreference(preference)
|
||||
setPendingModelVisibility((current) => new Map(current).set(providerId, normalized))
|
||||
setModelVisibilityWriteFailures((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(providerId)
|
||||
return next
|
||||
})
|
||||
const previous = modelVisibilityWriteQueue
|
||||
const write = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => patchConfigOwner("ui", {
|
||||
settings: {
|
||||
modelVisibility: {
|
||||
[providerId]: normalized,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
modelVisibilityWriteQueue = write
|
||||
modelVisibilityWriteQueues.set(providerId, write)
|
||||
void write.then(
|
||||
() => {
|
||||
if (modelVisibilityWriteQueues.get(providerId) !== write) return
|
||||
modelVisibilityWriteQueues.delete(providerId)
|
||||
setPendingModelVisibility((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(providerId)
|
||||
return next
|
||||
})
|
||||
},
|
||||
(error) => {
|
||||
log.error("Failed to update provider model visibility", error)
|
||||
if (modelVisibilityWriteQueues.get(providerId) !== write) return
|
||||
modelVisibilityWriteQueues.delete(providerId)
|
||||
setPendingModelVisibility((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(providerId)
|
||||
return next
|
||||
})
|
||||
setModelVisibilityWriteFailures((current) => new Set(current).add(providerId))
|
||||
},
|
||||
)
|
||||
await write
|
||||
}
|
||||
|
||||
function setThemePreference(preference: ThemePreference): void {
|
||||
if (themePreference() === preference) return
|
||||
void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error))
|
||||
|
|
@ -914,6 +988,9 @@ interface ConfigContextValue {
|
|||
isLoaded: Accessor<boolean>
|
||||
preferences: typeof preferences
|
||||
updatePreferences: typeof updatePreferences
|
||||
setProviderModelVisibility: typeof setProviderModelVisibility
|
||||
getProviderModelVisibilityPreference: typeof getProviderModelVisibilityPreference
|
||||
providerModelVisibilitySaveFailed: typeof providerModelVisibilitySaveFailed
|
||||
themePreference: typeof themePreference
|
||||
setThemePreference: typeof setThemePreference
|
||||
|
||||
|
|
@ -975,6 +1052,9 @@ const configContextValue: ConfigContextValue = {
|
|||
isLoaded,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
setProviderModelVisibility,
|
||||
getProviderModelVisibilityPreference,
|
||||
providerModelVisibilitySaveFailed,
|
||||
themePreference,
|
||||
setThemePreference,
|
||||
serverSettings,
|
||||
|
|
@ -1070,6 +1150,9 @@ export {
|
|||
themePreference,
|
||||
setThemePreference,
|
||||
updatePreferences,
|
||||
setProviderModelVisibility,
|
||||
getProviderModelVisibilityPreference,
|
||||
providerModelVisibilitySaveFailed,
|
||||
setListeningMode,
|
||||
updateEnvironmentVariables,
|
||||
addEnvironmentVariable,
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@
|
|||
|
||||
.providers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +211,11 @@
|
|||
border-top: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent);
|
||||
}
|
||||
|
||||
.providers-card-footer .selector-button {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.providers-disconnect-button {
|
||||
width: min(9rem, 50%);
|
||||
min-width: 6.5rem;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
.provider-model-visibility {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.provider-model-visibility-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.provider-model-visibility-header > .selector-button {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.provider-model-visibility-item span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.provider-model-visibility-item small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.provider-model-visibility-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 1fr) auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.provider-model-visibility-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.provider-model-visibility-list {
|
||||
height: min(22rem, 45dvh);
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
.provider-model-visibility-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 3.375rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-model-visibility-item:hover {
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.provider-model-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.provider-model-card-actions .selector-button {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.provider-model-visibility-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.provider-model-visibility-header,
|
||||
.provider-model-card-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.provider-model-visibility-toolbar .selector-button,
|
||||
.provider-model-card-actions .selector-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,3 +15,4 @@
|
|||
@import "./components/settings-info.css";
|
||||
@import "./components/config-files-settings.css";
|
||||
@import "./components/provider-auth.css";
|
||||
@import "./components/provider-model-visibility.css";
|
||||
|
|
|
|||
|
|
@ -415,6 +415,10 @@
|
|||
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.file-list-item .action-overflow-trigger.file-row-action-menu {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.git-change-row-action:hover {
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--border-base);
|
||||
|
|
|
|||
9
packages/ui/src/types/global.d.ts
vendored
9
packages/ui/src/types/global.d.ts
vendored
|
|
@ -35,6 +35,15 @@ declare global {
|
|||
restartCli?: () => Promise<unknown>
|
||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||
getDirectoryPaths?: (paths: string[]) => Promise<string[]>
|
||||
openWorkspaceTarget?: (payload: {
|
||||
target: "default" | "reveal" | "terminal" | "editor"
|
||||
instanceId: string
|
||||
worktreeSlug: string
|
||||
path?: string
|
||||
editor?: "vscode" | "cursor" | "zed" | "vscodium"
|
||||
}) => Promise<{ ok: true }>
|
||||
setWorkspaceMenuEnabled?: (enabled: boolean) => Promise<{ ok: true }>
|
||||
onMenuAction?: (callback: (action: string) => void) => () => void
|
||||
getPathForFile?: (file: File) => string | null
|
||||
requestMicrophoneAccess?: () => Promise<{ granted: boolean }>
|
||||
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue