From 83ffb1af34e91128f41a6b4409812faac461b1cb Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 5 Feb 2026 01:59:49 +0200 Subject: [PATCH] refactor(desktop): make Tauri thin shell running web sidecar (#273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What / Why This PR finishes the desktop refactor: the Tauri app is now a thin shell that launches the web server as a sidecar and loads the UI from `http://127.0.0.1:`. All real backend logic lives in `packages/web/server/index.js`; desktop Rust keeps only native integrations (menu/dialog/notifications/updater/deep-link + window chrome). This unblocks: - consistent behavior across web/desktop/vscode (single backend) - simpler desktop maintenance (no duplicated Rust backend) - host switching between Local + remote instances in desktop - reliable cold-start behavior on slow machines (VSCode + desktop) ## Key changes - Desktop sidecar runtime - build pipeline to bundle web dist + `openchamber-server` sidecar (`packages/desktop/scripts/build-sidecar.mjs`) - robust local port selection (prefer saved/default, fallback to random; persisted in `~/.config/openchamber/settings.json`) - improved PATH handling so the sidecar can locate `opencode` CLI (incl `~/.opencode/bin`, overrides, common bins) - disable native right-click context menu in production builds (dev keeps it) - Desktop instance switcher (Tauri-only) - header button + modal to add/edit/delete remote hosts, set default, probe status/ping, switch back to Local escape hatch - auth gate includes host switcher so you can recover when a remote host is broken/auth-required - host list stored desktop-locally (not tied to the currently selected remote server) - Notifications - decision logic moved server-side; desktop notifications emitted via sidecar stdout and shown natively by Tauri - prevent double-notifications on desktop Local origin (UI ignores SSE notification when native path is active) - restore macOS notification sound - Updates - Tauri updater used only when viewing Local instance in desktop shell (avoid “remote web update” triggering desktop restart) - Settings persistence & UX polish - persist model favorites/recents via `/api/config/settings` (works for web + desktop; not origin-dependent) - persist per-project sidebar collapse state in `projects[].sidebarCollapsed` via `/api/config/settings` (with debounce on toggles) - macOS header sizing/traffic-lights offsets fixed (marketing macOS major injected from desktop; MultiRun header aligned) - VSCode cold-start: keep retrying provider/agent loads after connection to avoid empty UI on slow machines - misc lint/type fixes + bun.lock sync - Desktop bootstrap / resiliency - show onboarding screen when OpenCode CLI is missing (desktop Local origin), with retry hook to restart OpenCode after install ## Testing notes - Desktop (macOS): switch Local <-> remote, set default host, verify auth gate recovery, native notifications (with sound), updater gated to Local - Web: favorites/recents + per-project collapsed state persist across reload/restart - VSCode: slow startup no longer results in missing providers/agents/models --- AGENTS.md | 7 +- bun.lock | 22 - package.json | 2 +- packages/desktop/.gitignore | 7 + packages/desktop/index.html | 176 - packages/desktop/noop-dist/index.html | 11 + packages/desktop/package.json | 26 +- packages/desktop/scripts/build-sidecar.mjs | 112 + packages/desktop/scripts/desktop-dev.mjs | 7 - packages/desktop/scripts/dev-web-server.mjs | 74 + packages/desktop/src-tauri/Cargo.lock | 768 +--- packages/desktop/src-tauri/Cargo.toml | 38 +- .../src-tauri/capabilities/default.json | 24 +- packages/desktop/src-tauri/resources/.gitkeep | 0 packages/desktop/src-tauri/sidecars/.gitkeep | 0 .../src-tauri/src/assistant_notifications.rs | 631 --- .../desktop/src-tauri/src/commands/files.rs | 1277 ------ .../desktop/src-tauri/src/commands/git.rs | 2619 ------------ .../desktop/src-tauri/src/commands/github.rs | 2743 ------------ .../desktop/src-tauri/src/commands/logs.rs | 25 - .../desktop/src-tauri/src/commands/mod.rs | 8 - .../src-tauri/src/commands/notifications.rs | 37 - .../src-tauri/src/commands/permissions.rs | 285 -- .../src-tauri/src/commands/settings.rs | 926 ---- .../src-tauri/src/commands/terminal.rs | 501 --- packages/desktop/src-tauri/src/lib.rs | 1 - packages/desktop/src-tauri/src/logging.rs | 20 - packages/desktop/src-tauri/src/main.rs | 3798 +++++------------ .../desktop/src-tauri/src/opencode_auth.rs | 109 - .../desktop/src-tauri/src/opencode_config.rs | 2383 ----------- .../desktop/src-tauri/src/opencode_manager.rs | 751 ---- packages/desktop/src-tauri/src/path_utils.rs | 20 - .../desktop/src-tauri/src/quota_providers.rs | 930 ---- .../desktop/src-tauri/src/session_activity.rs | 527 --- .../desktop/src-tauri/src/skills_catalog.rs | 2041 --------- .../desktop/src-tauri/src/window_state.rs | 167 - packages/desktop/src-tauri/tauri.conf.json | 12 +- packages/desktop/src/api/diagnostics.ts | 32 - packages/desktop/src/api/files.ts | 280 -- packages/desktop/src/api/git.ts | 286 -- packages/desktop/src/api/github.ts | 105 - packages/desktop/src/api/index.ts | 59 - packages/desktop/src/api/notifications.ts | 58 - packages/desktop/src/api/permissions.ts | 49 - packages/desktop/src/api/settings.ts | 58 - packages/desktop/src/api/terminal.ts | 168 - packages/desktop/src/api/tools.ts | 22 - packages/desktop/src/api/updater.ts | 145 - packages/desktop/src/lib/bridge.ts | 157 - .../desktop/src/lib/tauriCallbackManager.ts | 320 -- packages/desktop/src/main.tsx | 372 -- packages/desktop/tsconfig.json | 25 - packages/desktop/vite.config.ts | 81 - packages/ui/src/App.tsx | 59 +- .../src/components/auth/SessionAuthGate.tsx | 16 +- .../ui/src/components/chat/ChatContainer.tsx | 17 +- packages/ui/src/components/chat/ChatInput.tsx | 17 +- .../ui/src/components/chat/ModelControls.tsx | 9 +- packages/ui/src/components/chat/StatusRow.tsx | 26 +- .../chat/message/parts/WorkingPlaceholder.tsx | 356 +- .../desktop/DesktopHostSwitcher.tsx | 704 +++ packages/ui/src/components/layout/Header.tsx | 20 +- .../ui/src/components/layout/MainLayout.tsx | 2 + packages/ui/src/components/layout/Sidebar.tsx | 9 +- .../ui/src/components/layout/VSCodeLayout.tsx | 12 + .../components/multirun/MultiRunLauncher.tsx | 48 +- .../onboarding/OnboardingScreen.tsx | 28 +- .../sections/agents/AgentsSidebar.tsx | 16 +- .../sections/commands/CommandsSidebar.tsx | 16 +- .../git-identities/GitIdentitiesSidebar.tsx | 16 +- .../sections/openchamber/DefaultsSettings.tsx | 41 +- .../sections/openchamber/GitHubSettings.tsx | 11 +- .../sections/openchamber/GitSettings.tsx | 28 +- .../openchamber/MemoryLimitsSettings.tsx | 39 +- .../openchamber/NotificationSettings.tsx | 132 +- .../openchamber/OpenChamberSidebar.tsx | 16 +- .../sections/providers/ProvidersSidebar.tsx | 16 +- .../sections/shared/SettingsSidebarLayout.tsx | 16 +- .../sections/skills/SkillsSidebar.tsx | 16 +- .../skills/catalog/AddCatalogDialog.tsx | 6 +- .../skills/catalog/SkillsCatalogPage.tsx | 5 - .../sections/usage/UsageSidebar.tsx | 19 +- .../src/components/session/DirectoryTree.tsx | 29 +- .../src/components/session/SessionDialogs.tsx | 4 +- .../src/components/session/SessionSidebar.tsx | 145 +- packages/ui/src/components/ui/AboutDialog.tsx | 2 +- .../ui/src/components/ui/MemoryDebugPanel.tsx | 22 +- .../components/ui/OpenCodeStatusDialog.tsx | 60 + .../ui/src/components/ui/ScrollShadow.tsx | 32 +- packages/ui/src/components/views/PlanView.tsx | 2 +- .../ui/src/components/views/SettingsView.tsx | 11 +- .../ui/src/components/views/TerminalView.tsx | 6 +- .../views/git/PullRequestSection.tsx | 18 +- .../ui/src/contexts/ThemeSystemContext.tsx | 8 +- packages/ui/src/hooks/useAssistantStatus.ts | 8 +- packages/ui/src/hooks/useDesktopServerInfo.ts | 38 - packages/ui/src/hooks/useEventStream.ts | 441 +- packages/ui/src/hooks/useFileSystemAccess.ts | 6 +- packages/ui/src/hooks/useKeyboardShortcuts.ts | 32 +- packages/ui/src/hooks/useMenuActions.ts | 154 +- packages/ui/src/hooks/useRuntimeAPIs.ts | 2 - packages/ui/src/hooks/useSessionActivity.ts | 14 +- .../ui/src/hooks/useSessionStatusBootstrap.ts | 11 +- packages/ui/src/index.css | 23 + packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/appearancePersistence.ts | 29 +- packages/ui/src/lib/debug.ts | 12 +- packages/ui/src/lib/desktop.ts | 338 +- packages/ui/src/lib/desktopHosts.ts | 110 + packages/ui/src/lib/device.ts | 17 +- packages/ui/src/lib/modelPrefsAutoSave.ts | 76 + packages/ui/src/lib/openCodeStatus.ts | 161 + packages/ui/src/lib/persistence.ts | 91 +- packages/ui/src/lib/utils.ts | 6 +- packages/ui/src/main.tsx | 20 +- packages/ui/src/stores/types/sessionTypes.ts | 7 +- packages/ui/src/stores/useConfigStore.ts | 17 +- packages/ui/src/stores/useDirectoryStore.ts | 4 +- .../ui/src/stores/useGitIdentitiesStore.ts | 30 +- packages/ui/src/stores/useProjectsStore.ts | 3 + packages/ui/src/stores/useQuotaStore.ts | 7 +- packages/ui/src/stores/useSessionStore.ts | 32 +- packages/ui/src/stores/useUIStore.ts | 15 + packages/ui/src/stores/useUpdateStore.ts | 19 +- packages/ui/src/stores/utils/streamDebug.ts | 9 + packages/ui/src/styles/design-system.css | 11 + packages/ui/src/types/desktop.d.ts | 39 +- packages/vscode/src/opencode.ts | 154 +- packages/web/server/index.js | 382 +- packages/web/src/api/notifications.ts | 42 +- 130 files changed, 4230 insertions(+), 23488 deletions(-) delete mode 100644 packages/desktop/index.html create mode 100644 packages/desktop/noop-dist/index.html create mode 100644 packages/desktop/scripts/build-sidecar.mjs create mode 100644 packages/desktop/scripts/dev-web-server.mjs create mode 100644 packages/desktop/src-tauri/resources/.gitkeep create mode 100644 packages/desktop/src-tauri/sidecars/.gitkeep delete mode 100644 packages/desktop/src-tauri/src/assistant_notifications.rs delete mode 100644 packages/desktop/src-tauri/src/commands/files.rs delete mode 100644 packages/desktop/src-tauri/src/commands/git.rs delete mode 100644 packages/desktop/src-tauri/src/commands/github.rs delete mode 100644 packages/desktop/src-tauri/src/commands/logs.rs delete mode 100644 packages/desktop/src-tauri/src/commands/mod.rs delete mode 100644 packages/desktop/src-tauri/src/commands/notifications.rs delete mode 100644 packages/desktop/src-tauri/src/commands/permissions.rs delete mode 100644 packages/desktop/src-tauri/src/commands/settings.rs delete mode 100644 packages/desktop/src-tauri/src/commands/terminal.rs delete mode 100644 packages/desktop/src-tauri/src/lib.rs delete mode 100644 packages/desktop/src-tauri/src/logging.rs delete mode 100644 packages/desktop/src-tauri/src/opencode_auth.rs delete mode 100644 packages/desktop/src-tauri/src/opencode_config.rs delete mode 100644 packages/desktop/src-tauri/src/opencode_manager.rs delete mode 100644 packages/desktop/src-tauri/src/path_utils.rs delete mode 100644 packages/desktop/src-tauri/src/quota_providers.rs delete mode 100644 packages/desktop/src-tauri/src/session_activity.rs delete mode 100644 packages/desktop/src-tauri/src/skills_catalog.rs delete mode 100644 packages/desktop/src-tauri/src/window_state.rs delete mode 100644 packages/desktop/src/api/diagnostics.ts delete mode 100644 packages/desktop/src/api/files.ts delete mode 100644 packages/desktop/src/api/git.ts delete mode 100644 packages/desktop/src/api/github.ts delete mode 100644 packages/desktop/src/api/index.ts delete mode 100644 packages/desktop/src/api/notifications.ts delete mode 100644 packages/desktop/src/api/permissions.ts delete mode 100644 packages/desktop/src/api/settings.ts delete mode 100644 packages/desktop/src/api/terminal.ts delete mode 100644 packages/desktop/src/api/tools.ts delete mode 100644 packages/desktop/src/api/updater.ts delete mode 100644 packages/desktop/src/lib/bridge.ts delete mode 100644 packages/desktop/src/lib/tauriCallbackManager.ts delete mode 100644 packages/desktop/src/main.tsx delete mode 100644 packages/desktop/tsconfig.json delete mode 100644 packages/desktop/vite.config.ts create mode 100644 packages/ui/src/components/desktop/DesktopHostSwitcher.tsx create mode 100644 packages/ui/src/components/ui/OpenCodeStatusDialog.tsx delete mode 100644 packages/ui/src/hooks/useDesktopServerInfo.ts create mode 100644 packages/ui/src/lib/desktopHosts.ts create mode 100644 packages/ui/src/lib/modelPrefsAutoSave.ts create mode 100644 packages/ui/src/lib/openCodeStatus.ts diff --git a/AGENTS.md b/AGENTS.md index ebe3c174..4825a05f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,11 @@ ## Core purpose OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an OpenCode server (local auto-start or remote URL). UI uses HTTP + SSE via `@opencode-ai/sdk`. +## Runtime architecture (IMPORTANT) +- `Desktop` is a thin Tauri shell that starts the web server sidecar and loads the web UI from `http://127.0.0.1:`. +- All backend logic lives in `packages/web/server/*` (and `packages/vscode/*` for the VS Code runtime). Desktop Rust is not a feature backend. +- Tauri is used only for stable native integrations: menu, dialog (open folder), notifications, updater, deep-links. + ## Tech stack (source of truth: `package.json`, resolved: `bun.lock`) - Runtime/tooling: Bun (`package.json` `packageManager`), Node >=20 (`package.json` `engines`) - UI: React, TypeScript, Vite, Tailwind v4 @@ -31,7 +36,7 @@ All scripts are in `package.json`. - Web bootstrap: `packages/web/src/main.tsx` - Web server: `packages/web/server/index.js` - Web CLI: `packages/web/bin/cli.js` (package bin: `packages/web/package.json`) -- Desktop bootstrap: `packages/desktop/src/main.tsx` +- Desktop: Tauri entry `packages/desktop/src-tauri/src/main.rs` (spawns web server sidecar + loads web UI) - Tauri backend: `packages/desktop/src-tauri/src/main.rs` - VS Code extension host: `packages/vscode/src/extension.ts` - VS Code webview bootstrap: `packages/vscode/webview/main.tsx` diff --git a/bun.lock b/bun.lock index 88c3e0f0..490c2f48 100644 --- a/bun.lock +++ b/bun.lock @@ -97,24 +97,10 @@ "packages/desktop": { "name": "@openchamber/desktop", "version": "1.6.3", - "dependencies": { - "@openchamber/ui": "workspace:*", - "@tauri-apps/plugin-notification": "^2.3.3", - "@tauri-apps/plugin-process": "^2", - "@tauri-apps/plugin-updater": "^2", - "react": "^19.1.1", - "react-dom": "^19.1.1", - }, "devDependencies": { - "@tauri-apps/api": "^2.9.1", "@tauri-apps/cli": "^2", - "@tauri-apps/plugin-dialog": "^2.4.2", "@types/node": "^24.3.1", - "@types/react": "^19.1.10", - "@types/react-dom": "^19.1.7", - "@vitejs/plugin-react": "^5.0.0", "typescript": "~5.8.3", - "vite": "^7.1.2", }, }, "packages/ui": { @@ -1131,14 +1117,6 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], - "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ=="], - - "@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="], - - "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], - - "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.9.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg=="], - "@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.0", "", {}, "sha512-K0LEuuTo4rza8yDrlYkRdXLao8Iz/QBMsQdIxRrOOrLYb4HAtZaypZ78c+J6rDA1UlGxadZVLmkkiv4KV5fMKQ=="], "@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.0", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.0", "@textlint/resolver": "15.5.0", "@textlint/types": "15.5.0", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.21", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-DPTm2+VXKID41qKQWagg/4JynM6hEEpvbq0PlGsEoC4Xm7IqXIxFym3mSf5+ued0cuiIV1hR9kgXjqGdP035tw=="], diff --git a/package.json b/package.json index 883e8605..1a9958f6 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start", "desktop:stop-cli": "node ./packages/desktop/scripts/opencode-cli.mjs stop", "desktop:dev": "node ./packages/desktop/scripts/desktop-dev.mjs", - "desktop:build": "bun run --cwd packages/desktop build && bun run --cwd packages/desktop tauri build", + "desktop:build": "bun run --cwd packages/desktop build:sidecar && bun run --cwd packages/desktop tauri build", "desktop:lint": "bun run --cwd packages/desktop lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings", "desktop:type-check": "bun run --cwd packages/desktop type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings", "vscode:dev": "bun run --cwd packages/vscode dev", diff --git a/packages/desktop/.gitignore b/packages/desktop/.gitignore index 1d5fa289..c68393e9 100644 --- a/packages/desktop/.gitignore +++ b/packages/desktop/.gitignore @@ -7,6 +7,13 @@ src-tauri/target/ # Tauri generated code src-tauri/gen/ +# Desktop sidecar + bundled web assets (generated) +src-tauri/resources/web-dist/ +src-tauri/sidecars/openchamber-server-* +src-tauri/sidecars/*.exe +!src-tauri/resources/.gitkeep +!src-tauri/sidecars/.gitkeep + # OpenCode CLI state tracking .opencode-cli-state.json diff --git a/packages/desktop/index.html b/packages/desktop/index.html deleted file mode 100644 index 3fe4c2c5..00000000 --- a/packages/desktop/index.html +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - - - OpenChamber Desktop - - - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - diff --git a/packages/desktop/noop-dist/index.html b/packages/desktop/noop-dist/index.html new file mode 100644 index 00000000..4efca0fd --- /dev/null +++ b/packages/desktop/noop-dist/index.html @@ -0,0 +1,11 @@ + + + + + + OpenChamber + + + + + diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 73639119..9de07508 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -12,29 +12,15 @@ "tauri": "tauri", "tauri:dev": "tauri dev --features devtools", "tauri:build": "tauri build", - "dev": "vite dev --host 127.0.0.1 --port 1421", - "build": "vite build", - "preview": "vite preview --host 127.0.0.1 --port 5051", - "type-check": "tsc --noEmit", - "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js" - }, - "dependencies": { - "@tauri-apps/plugin-notification": "^2.3.3", - "@tauri-apps/plugin-process": "^2", - "@tauri-apps/plugin-updater": "^2", - "@openchamber/ui": "workspace:*", - "react": "^19.1.1", - "react-dom": "^19.1.1" + "build:sidecar": "node ./scripts/build-sidecar.mjs", + "build": "bun -e \"process.exit(0)\"", + "type-check": "bun -e \"process.exit(0)\"", + "lint": "bun -e \"process.exit(0)\"" }, + "dependencies": {}, "devDependencies": { "@tauri-apps/cli": "^2", - "@tauri-apps/api": "^2.9.1", - "@tauri-apps/plugin-dialog": "^2.4.2", "@types/node": "^24.3.1", - "@types/react": "^19.1.10", - "@types/react-dom": "^19.1.7", - "@vitejs/plugin-react": "^5.0.0", - "typescript": "~5.8.3", - "vite": "^7.1.2" + "typescript": "~5.8.3" } } diff --git a/packages/desktop/scripts/build-sidecar.mjs b/packages/desktop/scripts/build-sidecar.mjs new file mode 100644 index 00000000..21646650 --- /dev/null +++ b/packages/desktop/scripts/build-sidecar.mjs @@ -0,0 +1,112 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const webDir = path.join(repoRoot, 'packages', 'web'); +const desktopTauriDir = path.join(repoRoot, 'packages', 'desktop', 'src-tauri'); + +const resourcesDir = path.join(desktopTauriDir, 'resources'); +const resourcesWebDistDir = path.join(resourcesDir, 'web-dist'); +const webDistDir = path.join(webDir, 'dist'); + +const sidecarsDir = path.join(desktopTauriDir, 'sidecars'); + +const inferTargetTriple = () => { + if (typeof process.env.TAURI_ENV_TARGET_TRIPLE === 'string' && process.env.TAURI_ENV_TARGET_TRIPLE.trim()) { + return process.env.TAURI_ENV_TARGET_TRIPLE.trim(); + } + + if (process.platform === 'darwin') { + return process.arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'; + } + + if (process.platform === 'win32') { + return 'x86_64-pc-windows-msvc'; + } + + if (process.platform === 'linux') { + return process.arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu'; + } + + return `${process.arch}-${process.platform}`; +}; + +const targetTriple = inferTargetTriple(); +const sidecarBaseName = process.platform === 'win32' + ? `openchamber-server-${targetTriple}.exe` + : `openchamber-server-${targetTriple}`; +const sidecarOutPath = path.join(sidecarsDir, sidecarBaseName); + + +const run = (cmd, args, cwd) => { + const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`Command failed: ${cmd} ${args.join(' ')}`); + } +}; + +const resolveBun = () => { + if (typeof process.env.BUN === 'string' && process.env.BUN.trim()) { + return process.env.BUN.trim(); + } + + const result = spawnSync('/bin/bash', ['-lc', 'command -v bun'], { encoding: 'utf8' }); + const resolved = (result.stdout || '').trim(); + if (resolved) { + return resolved; + } + + return 'bun'; +}; + +const bunExe = resolveBun(); + + +const copyDir = async (src, dst) => { + await fs.mkdir(dst, { recursive: true }); + const entries = await fs.readdir(src, { withFileTypes: true }); + for (const entry of entries) { + const from = path.join(src, entry.name); + const to = path.join(dst, entry.name); + if (entry.isDirectory()) { + await copyDir(from, to); + } else if (entry.isSymbolicLink()) { + const link = await fs.readlink(from); + await fs.symlink(link, to); + } else { + await fs.copyFile(from, to); + } + } +}; + +console.log('[desktop] building web UI dist...'); +run(bunExe, ['run', 'build'], webDir); + +console.log('[desktop] preparing tauri resources...'); +await fs.mkdir(resourcesDir, { recursive: true }); +await fs.rm(resourcesWebDistDir, { recursive: true, force: true }); +await copyDir(webDistDir, resourcesWebDistDir); + +console.log('[desktop] building openchamber-server sidecar...'); +await fs.mkdir(sidecarsDir, { recursive: true }); + +run(bunExe, [ + 'build', + '--compile', + path.join(webDir, 'server', 'index.js'), + '--outfile', + sidecarOutPath, +], repoRoot); + +if (process.platform !== 'win32') { + await fs.chmod(sidecarOutPath, 0o755); +} + +console.log(`[desktop] sidecar ready: ${sidecarOutPath}`); +console.log(`[desktop] web assets ready: ${resourcesWebDistDir}`); diff --git a/packages/desktop/scripts/desktop-dev.mjs b/packages/desktop/scripts/desktop-dev.mjs index 33de9cc5..a52dfd61 100644 --- a/packages/desktop/scripts/desktop-dev.mjs +++ b/packages/desktop/scripts/desktop-dev.mjs @@ -2,7 +2,6 @@ import { spawn } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { startCli, stopCli } from './opencode-cli.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -19,8 +18,6 @@ function spawnProcess(command, args, opts = {}) { } async function main() { - await startCli(); - const tauriProcess = spawnProcess('bun', ['--cwd', desktopDir, 'tauri', 'dev', '--features', 'devtools']); let cleaning = false; @@ -44,10 +41,6 @@ async function main() { stopChild(tauriProcess, 'Tauri dev process'); - await stopCli({ silent: true }).catch((error) => { - console.warn('[desktop:dev] Failed to stop OpenCode CLI:', error); - }); - process.exit(typeof code === 'number' ? code : 0); }; diff --git a/packages/desktop/scripts/dev-web-server.mjs b/packages/desktop/scripts/dev-web-server.mjs new file mode 100644 index 00000000..6a1630d4 --- /dev/null +++ b/packages/desktop/scripts/dev-web-server.mjs @@ -0,0 +1,74 @@ +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const desktopDir = path.join(repoRoot, 'packages', 'desktop'); +const tauriDir = path.join(desktopDir, 'src-tauri'); + +const inferTargetTriple = () => { + const fromEnv = typeof process.env.TAURI_ENV_TARGET_TRIPLE === 'string' ? process.env.TAURI_ENV_TARGET_TRIPLE.trim() : ''; + if (fromEnv) return fromEnv; + + if (process.platform === 'darwin') { + return process.arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'; + } + + if (process.platform === 'win32') { + return 'x86_64-pc-windows-msvc'; + } + + if (process.platform === 'linux') { + return process.arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu'; + } + + return `${process.arch}-${process.platform}`; +}; + +const targetTriple = inferTargetTriple(); +const sidecarName = process.platform === 'win32' + ? `openchamber-server-${targetTriple}.exe` + : `openchamber-server-${targetTriple}`; + +const sidecarPath = path.join(tauriDir, 'sidecars', sidecarName); +const distDir = path.join(tauriDir, 'resources', 'web-dist'); + +const run = (cmd, args, cwd) => { + const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`Command failed: ${cmd} ${args.join(' ')}`); + } +}; + +console.log('[desktop] ensuring sidecar + web-dist...'); +run('node', ['./scripts/build-sidecar.mjs'], desktopDir); + +console.log('[desktop] starting dev server on http://127.0.0.1:3001 ...'); + +const child = spawn(sidecarPath, ['--port', '3001'], { + cwd: repoRoot, + stdio: 'inherit', + env: { + ...process.env, + OPENCHAMBER_HOST: '127.0.0.1', + OPENCHAMBER_DIST_DIR: distDir, + NO_PROXY: process.env.NO_PROXY || 'localhost,127.0.0.1', + no_proxy: process.env.no_proxy || 'localhost,127.0.0.1', + }, +}); + +const shutdown = () => { + try { + child.kill('SIGTERM'); + } catch { + // ignore + } +}; + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); +process.on('exit', shutdown); diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 663a33df..c92f6d30 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -8,17 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - [[package]] name = "ahash" version = "0.7.8" @@ -146,19 +135,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-compression" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e86f6d3dc9dc4352edeea6b8e499e13e3f5dc3b964d7ca5fd411415a3498473" -dependencies = [ - "compression-codecs", - "compression-core", - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "async-executor" version = "1.13.3" @@ -301,70 +277,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "axum" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" -dependencies = [ - "axum-core", - "axum-macros", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.110", -] - [[package]] name = "base64" version = "0.21.7" @@ -451,7 +363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" dependencies = [ "borsh-derive", - "cfg_aliases 0.2.1", + "cfg_aliases", ] [[package]] @@ -548,25 +460,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "cairo-rs" version = "0.18.5" @@ -641,8 +534,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -679,12 +570,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - [[package]] name = "cfg_aliases" version = "0.2.1" @@ -698,23 +583,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", "windows-link 0.2.1", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "combine" version = "4.6.7" @@ -725,24 +598,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compression-codecs" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "302266479cb963552d11bd042013a58ef1adc56768016c8b82b4199488f2d4ad" -dependencies = [ - "brotli", - "compression-core", - "flate2", - "memchr", -] - -[[package]] -name = "compression-core" -version = "0.4.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -752,12 +607,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "convert_case" version = "0.4.0" @@ -823,21 +672,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - [[package]] name = "crc32fast" version = "1.5.0" @@ -944,12 +778,6 @@ dependencies = [ "syn 2.0.110", ] -[[package]] -name = "deflate64" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" - [[package]] name = "deranged" version = "0.5.5" @@ -992,16 +820,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", - "subtle", -] - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", ] [[package]] @@ -1010,19 +828,7 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", + "dirs-sys", ] [[package]] @@ -1033,7 +839,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] @@ -1151,7 +957,7 @@ dependencies = [ "rustc_version", "toml 0.9.8", "vswhom", - "winreg 0.55.0", + "winreg", ] [[package]] @@ -1288,17 +1094,6 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - [[package]] name = "filetime" version = "0.2.26" @@ -1813,15 +1608,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "html5ever" version = "0.29.1" @@ -1874,12 +1660,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" version = "1.8.1" @@ -1893,7 +1673,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2117,15 +1896,6 @@ dependencies = [ "cfb", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -2212,16 +1982,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.82" @@ -2244,17 +2004,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "json5" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" -dependencies = [ - "pest", - "pest_derive", - "serde", -] - [[package]] name = "jsonptr" version = "0.6.3" @@ -2391,27 +2140,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lzma-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "mac" version = "0.1.1" @@ -2430,15 +2158,6 @@ dependencies = [ "time", ] -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "markup5ever" version = "0.14.1" @@ -2470,12 +2189,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "memchr" version = "2.7.6" @@ -2581,18 +2294,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases 0.1.1", - "libc", -] - [[package]] name = "nix" version = "0.30.1" @@ -2601,7 +2302,7 @@ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.10.0", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "memoffset", ] @@ -2672,15 +2373,6 @@ dependencies = [ "libc", ] -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - [[package]] name = "objc-sys" version = "0.3.5" @@ -2882,17 +2574,6 @@ dependencies = [ "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-foundation 0.3.2", -] - [[package]] name = "objc2-osa-kit" version = "0.3.2" @@ -2915,7 +2596,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", + "objc2-metal", ] [[package]] @@ -2925,14 +2606,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.10.0", - "block2 0.6.2", - "libc", "objc2 0.6.3", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-video", "objc2-foundation 0.3.2", - "objc2-metal 0.3.2", ] [[package]] @@ -2997,45 +2672,19 @@ name = "openchamber-desktop" version = "1.6.3" dependencies = [ "anyhow", - "axum", - "base64 0.22.1", - "chrono", - "dirs 5.0.1", - "fastrand", - "futures-util", - "json5", "log", - "nix 0.28.0", - "objc", - "objc2 0.6.3", - "objc2-app-kit", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", - "once_cell", - "parking_lot", - "portable-pty", - "portpicker", - "regex", "reqwest", "serde", "serde_json", - "serde_yaml", "tauri", "tauri-build", "tauri-plugin-dialog", - "tauri-plugin-fs", "tauri-plugin-log", "tauri-plugin-notification", - "tauri-plugin-process", "tauri-plugin-shell", "tauri-plugin-updater", "tokio", - "tokio-util", - "tower-http 0.5.2", "url", - "urlencoding", - "uuid", - "zip 2.4.2", ] [[package]] @@ -3138,65 +2787,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] - [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pest" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.110", -] - -[[package]] -name = "pest_meta" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" -dependencies = [ - "pest", - "sha2", -] - [[package]] name = "phf" version = "0.8.0" @@ -3400,36 +2996,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix 0.28.0", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg 0.10.1", -] - -[[package]] -name = "portpicker" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be97d76faf1bfab666e1375477b23fde79eccf0276e9b63b92a39d676a889ba9" -dependencies = [ - "rand 0.8.5", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -3573,7 +3139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -3613,7 +3179,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "once_cell", "socket2", @@ -3767,17 +3333,6 @@ dependencies = [ "bitflags 2.10.0", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.16", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -3853,7 +3408,6 @@ version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ - "async-compression", "base64 0.22.1", "bytes", "futures-core", @@ -3879,7 +3433,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http 0.6.6", + "tower-http", "tower-service", "url", "wasm-bindgen", @@ -4220,17 +3774,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_repr" version = "0.1.20" @@ -4303,30 +3846,6 @@ dependencies = [ "syn 2.0.110", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.12.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "serial2" -version = "0.2.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c" -dependencies = [ - "cfg-if", - "libc", - "winapi", -] - [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4359,17 +3878,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sha2" version = "0.10.9" @@ -4392,22 +3900,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] -name = "shell-words" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" - [[package]] name = "shlex" version = "1.3.0" @@ -4497,7 +3989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" dependencies = [ "bytemuck", - "cfg_aliases 0.2.1", + "cfg_aliases", "core-graphics", "foreign-types", "js-sys", @@ -4736,7 +4228,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs 6.0.0", + "dirs", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4786,7 +4278,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" dependencies = [ "anyhow", "cargo_toml", - "dirs 6.0.0", + "dirs", "glob", "heck 0.5.0", "json-patch", @@ -4939,16 +4431,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-process" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" -dependencies = [ - "tauri", - "tauri-plugin", -] - [[package]] name = "tauri-plugin-shell" version = "2.3.3" @@ -4977,7 +4459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" dependencies = [ "base64 0.22.1", - "dirs 6.0.0", + "dirs", "flate2", "futures-util", "http", @@ -4999,7 +4481,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip 4.6.1", + "zip", ] [[package]] @@ -5249,22 +4731,10 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", - "tokio-macros", "tracing", "windows-sys 0.61.2", ] -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.110", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5397,23 +4867,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags 2.10.0", - "bytes", - "http", - "http-body", - "http-body-util", - "pin-project-lite", - "tower-layer", - "tower-service", ] [[package]] @@ -5452,7 +4905,6 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5485,7 +4937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" dependencies = [ "crossbeam-channel", - "dirs 6.0.0", + "dirs", "libappindicator", "muda", "objc2 0.6.3", @@ -5518,12 +4970,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "uds_windows" version = "1.1.0" @@ -5588,12 +5034,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -5612,12 +5052,6 @@ dependencies = [ "serde", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "urlpattern" version = "0.3.0" @@ -6172,15 +5606,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -6232,21 +5657,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -6304,12 +5714,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6328,12 +5732,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6352,12 +5750,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6388,12 +5780,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6412,12 +5798,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6436,12 +5816,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6460,12 +5834,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6496,15 +5864,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - [[package]] name = "winreg" version = "0.55.0" @@ -6537,7 +5896,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs 6.0.0", + "dirs", "dpi", "dunce", "gdkx11", @@ -6612,15 +5971,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - [[package]] name = "yoke" version = "0.8.1" @@ -6664,7 +6014,7 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix 0.30.1", + "nix", "ordered-stream", "serde", "serde_repr", @@ -6752,20 +6102,6 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.110", -] [[package]] name = "zerotrie" @@ -6800,36 +6136,6 @@ dependencies = [ "syn 2.0.110", ] -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "aes", - "arbitrary", - "bzip2", - "constant_time_eq", - "crc32fast", - "crossbeam-utils", - "deflate64", - "displaydoc", - "flate2", - "getrandom 0.3.4", - "hmac", - "indexmap 2.12.0", - "lzma-rs", - "memchr", - "pbkdf2", - "sha1", - "thiserror 2.0.17", - "time", - "xz2", - "zeroize", - "zopfli", - "zstd", -] - [[package]] name = "zip" version = "4.6.1" @@ -6842,46 +6148,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "zvariant" version = "5.8.0" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 17504bb0..c6be25cf 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -4,10 +4,6 @@ version = "1.6.3" edition = "2021" publish = false -[lib] -name = "openchamber_desktop" -path = "src/lib.rs" - [[bin]] name = "openchamber-desktop" path = "src/main.rs" @@ -18,46 +14,18 @@ devtools = ["tauri/devtools"] [dependencies] anyhow = "1.0.86" -axum = { version = "0.8.4", features = ["macros"] } -chrono = { version = "0.4", features = ["serde"] } -dirs = "5.0" -fastrand = "2.0" -futures-util = "0.3" log = "0.4.28" -nix = { version = "0.28", features = ["signal"] } -objc = "0.2.7" -objc2 = "0.6.3" -objc2-foundation = { version = "0.3.2", features = ["NSProcessInfo", "NSString", "NSObjCRuntime"] } -once_cell = "1.19" -parking_lot = "0.12.3" -portable-pty = "0.9.0" -portpicker = "0.1.1" -regex = "1.10.4" -reqwest = { version = "0.12.4", default-features = false, features = ["json", "stream", "rustls-tls", "gzip", "brotli", "deflate"] } +reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.143" -serde_yaml = "0.9" -json5 = "0.4" tauri = { version = "2.9.4", features = ["macos-private-api"] } tauri-plugin-dialog = "2.4.2" -tauri-plugin-fs = "2.4.4" tauri-plugin-log = "2.7.1" tauri-plugin-shell = "2.3.3" -tokio = { version = "1.38", features = ["macros", "rt-multi-thread", "process", "signal", "sync", "time", "fs"] } -tower-http = { version = "0.5.2", features = ["cors"] } -url = "2.5" -uuid = { version = "1.18.1", features = ["v4"] } -tokio-util = { version = "0.7", features = ["io"] } tauri-plugin-notification = "2.3.3" tauri-plugin-updater = "2" -tauri-plugin-process = "2" -base64 = "0.22.1" -urlencoding = "2.1" -zip = "2.1" +tokio = { version = "1.38", features = ["rt-multi-thread", "time"] } +url = "2.5" [build-dependencies] tauri-build = { version = "2.5.3", features = [] } - -[target.'cfg(target_os = "macos")'.dependencies] -objc2-app-kit = { version = "0.3.2", features = ["NSView", "NSResponder"] } -objc2-quartz-core = { version = "0.3.2", features = ["CALayer"] } diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 8469c243..34566104 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -2,6 +2,16 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capabilities for OpenChamber desktop runtime", + "remote": { + "urls": [ + "http://127.0.0.1:*/*", + "http://localhost:*/*", + "http://*", + "http://*/*", + "https://*", + "https://*/*" + ] + }, "windows": ["main"], "permissions": [ "core:default", @@ -20,24 +30,12 @@ "dialog:allow-message", "dialog:allow-ask", "dialog:allow-confirm", - "fs:allow-read-text-file", - "fs:allow-read-file", - "fs:allow-write-text-file", - "fs:allow-write-file", - "fs:allow-read-dir", - "fs:allow-exists", - "fs:allow-create", - "fs:allow-mkdir", - "fs:allow-remove", - "fs:scope-app-index", - "fs:scope-home", "notification:default", "notification:allow-is-permission-granted", "notification:allow-request-permission", "notification:allow-notify", "updater:default", "updater:allow-check", - "updater:allow-download-and-install", - "process:allow-restart" + "updater:allow-download-and-install" ] } diff --git a/packages/desktop/src-tauri/resources/.gitkeep b/packages/desktop/src-tauri/resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/desktop/src-tauri/sidecars/.gitkeep b/packages/desktop/src-tauri/sidecars/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/desktop/src-tauri/src/assistant_notifications.rs b/packages/desktop/src-tauri/src/assistant_notifications.rs deleted file mode 100644 index 5cfd6ec7..00000000 --- a/packages/desktop/src-tauri/src/assistant_notifications.rs +++ /dev/null @@ -1,631 +0,0 @@ -use std::{collections::{HashMap, HashSet}, path::PathBuf, time::Duration}; - -use anyhow::Result; -use futures_util::TryStreamExt; -use log::{debug, info, warn}; -use reqwest::Client; -use serde::Deserialize; -use serde_json::Value; -use tauri::{AppHandle, Manager}; -use tauri_plugin_notification::NotificationExt; -use tokio::{io::AsyncBufReadExt, sync::Mutex}; -use tokio_util::io::StreamReader; - -use crate::path_utils::expand_tilde_path; -use crate::DesktopRuntime; - -#[derive(Deserialize)] -struct EventEnvelope { - #[serde(rename = "type")] - event_type: String, - #[serde(default)] - properties: Value, -} - -#[derive(Deserialize)] -struct MultiplexedEventEnvelope { - #[serde(default)] - #[allow(dead_code)] - directory: Option, - payload: EventEnvelope, -} - -pub fn spawn_assistant_notifications( - app: AppHandle, - runtime: DesktopRuntime, -) -> tauri::async_runtime::JoinHandle<()> { - tauri::async_runtime::spawn(async move { - let client = Client::builder() - // Give SSE a very long overall timeout so idle periods don't abort the stream. - .timeout(Duration::from_secs(24 * 60 * 60)) - .tcp_keepalive(Some(Duration::from_secs(30))) - .build() - .expect("failed to build reqwest client"); - - let mut shutdown_rx = runtime.subscribe_shutdown(); - let notified_messages = Mutex::new(HashSet::::new()); - let notified_questions = Mutex::new(HashSet::::new()); - let session_parent_cache = Mutex::new(HashMap::>::new()); - - loop { - tokio::select! { - _ = shutdown_rx.recv() => { - info!("[desktop:notify] Shutdown received, stopping SSE listener"); - break; - } - _ = async { - if let Err(err) = run_once( - &app, - &runtime, - &client, - ¬ified_messages, - ¬ified_questions, - &session_parent_cache, - ).await { - warn!("[desktop:notify] SSE loop error: {err:?}"); - } - tokio::time::sleep(Duration::from_secs(2)).await; - } => {} - } - } - }) -} - -async fn run_once( - app: &AppHandle, - runtime: &DesktopRuntime, - client: &Client, - notified_messages: &Mutex>, - notified_questions: &Mutex>, - session_parent_cache: &Mutex>>, -) -> Result<()> { - let opencode = runtime.opencode_manager(); - - let port = match opencode.current_port() { - Some(port) => port, - None => { - warn!("[desktop:notify] OpenCode port unavailable; will retry"); - tokio::time::sleep(Duration::from_secs(2)).await; - return Ok(()); - } - }; - - let prefix = opencode.api_prefix(); - let base = format!("http://127.0.0.1:{port}{prefix}"); - let response = connect_notifications_sse(runtime, client, &base).await?; - - let stream = response - .bytes_stream() - .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)); - let mut reader = StreamReader::new(stream); - let mut buf = Vec::new(); - let mut data_lines: Vec = Vec::new(); - - loop { - buf.clear(); - let bytes_read = match reader.read_until(b'\n', &mut buf).await { - Ok(n) => n, - Err(err) => { - warn!("[desktop:notify] Read error in SSE stream: {err:?}"); - return Err(err.into()); - } - }; - if bytes_read == 0 { - break; - } - - let line = match std::str::from_utf8(&buf) { - Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(), - Err(err) => { - warn!("[desktop:notify] Non-UTF8 SSE chunk: {err}"); - continue; - } - }; - - if line.is_empty() { - if data_lines.is_empty() { - continue; - } - let raw = data_lines.join("\n"); - data_lines.clear(); - - match parse_event_envelope(&raw) { - Ok(event) => { - handle_event( - app, - runtime, - client, - &base, - event, - notified_messages, - notified_questions, - session_parent_cache, - ) - .await - } - Err(err) => { - warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}"); - } - } - continue; - } - - if let Some(rest) = line.strip_prefix("data:") { - data_lines.push(rest.trim_start().to_string()); - } - } - - Ok(()) -} - -fn parse_event_envelope(raw: &str) -> Result { - if let Ok(event) = serde_json::from_str::(raw) { - return Ok(event); - } - - let multiplexed = serde_json::from_str::(raw)?; - Ok(multiplexed.payload) -} - -async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option { - let settings = runtime.settings().load().await.ok()?; - - if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) { - if let Some(projects) = settings.get("projects").and_then(Value::as_array) { - if let Some(path) = projects.iter().find_map(|entry| { - let id = entry.get("id").and_then(Value::as_str)?; - if id != active_id { - return None; - } - entry.get("path").and_then(Value::as_str) - }) { - return Some(expand_tilde_path(path)); - } - } - } - - settings - .get("lastDirectory") - .and_then(Value::as_str) - .map(expand_tilde_path) -} - -async fn connect_notifications_sse( - runtime: &DesktopRuntime, - client: &Client, - base: &str, -) -> Result { - let global_url = format!("{base}/global/event"); - match try_connect_sse(client, &global_url, "[desktop:notify]").await { - Ok(response) => { - debug!("[desktop:notify] Using SSE endpoint: {global_url}"); - return Ok(response); - } - Err(err) => { - debug!( - "[desktop:notify] SSE endpoint unavailable: {global_url} ({err:?}); falling back" - ); - } - } - - let event_url = format!("{base}/event"); - match try_connect_sse(client, &event_url, "[desktop:notify]").await { - Ok(response) => { - debug!("[desktop:notify] Using SSE endpoint: {event_url}"); - return Ok(response); - } - Err(err) => { - debug!( - "[desktop:notify] SSE endpoint unavailable: {event_url} ({err:?}); falling back" - ); - } - } - - let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else { - anyhow::bail!("No project directory available for SSE fallback"); - }; - let directory = working_dir.to_string_lossy().to_string(); - let mut parsed = reqwest::Url::parse(&event_url)?; - parsed - .query_pairs_mut() - .append_pair("directory", &directory); - let directory_url = parsed.to_string(); - - let response = try_connect_sse(client, &directory_url, "[desktop:notify]").await?; - debug!("[desktop:notify] Using directory-scoped SSE endpoint: {directory_url}"); - Ok(response) -} - -async fn try_connect_sse( - client: &Client, - url: &str, - log_prefix: &str, -) -> Result { - debug!("{log_prefix} Connecting SSE: {url}"); - - let response = client - .get(url) - .header("accept", "text/event-stream") - .header("accept-encoding", "identity") - .send() - .await?; - - debug!( - "{log_prefix} SSE response status={} headers={:?}", - response.status(), - response.headers() - ); - - if !response.status().is_success() { - anyhow::bail!("SSE connect failed with status {}", response.status()); - } - - Ok(response) -} - -async fn handle_event( - app: &AppHandle, - runtime: &DesktopRuntime, - client: &Client, - base: &str, - event: EventEnvelope, - notified_messages: &Mutex>, - notified_questions: &Mutex>, - session_parent_cache: &Mutex>>, -) { - match event.event_type.as_str() { - "message.updated" => { - handle_message_updated( - app, - runtime, - client, - base, - &event.properties, - notified_messages, - session_parent_cache, - ) - .await; - } - "question.asked" => { - handle_question_asked(app, &event.properties, notified_questions).await; - } - "permission.asked" => { - handle_permission_asked(app, &event.properties, notified_questions).await; - } - _ => {} - } -} - -async fn resolve_session_parent_id( - client: &Client, - base: &str, - session_id: &str, - cache: &Mutex>>, -) -> Option> { - { - let locked = cache.lock().await; - if let Some(existing) = locked.get(session_id) { - return Some(existing.clone()); - } - } - - // Fail open: on any error, return None (unknown) - let sessions_url = format!("{base}/session"); - let response = match tokio::time::timeout( - Duration::from_secs(2), - client.get(&sessions_url).header("accept", "application/json").send(), - ) - .await - { - Ok(Ok(resp)) => resp, - _ => return None, - }; - - if !response.status().is_success() { - return None; - } - - let data: Value = match response.json().await { - Ok(v) => v, - Err(_) => return None, - }; - - let parent = data - .as_array() - .and_then(|arr| { - arr.iter().find_map(|entry| { - let id = entry.get("id").and_then(Value::as_str)?; - if id != session_id { - return None; - } - let parent = entry.get("parentID").and_then(Value::as_str); - Some(parent.filter(|s| !s.is_empty()).map(|s| s.to_string())) - }) - }) - .flatten(); - - { - let mut locked = cache.lock().await; - locked.insert(session_id.to_string(), parent.clone()); - } - Some(parent) -} - -async fn handle_question_asked( - app: &AppHandle, - properties: &Value, - notified_questions: &Mutex>, -) { - let session_id = properties.get("sessionID").and_then(Value::as_str); - let question_id = properties.get("id").and_then(Value::as_str); - - let (session_id, question_id) = match (session_id, question_id) { - (Some(s), Some(q)) => (s, q), - _ => return, - }; - - let key = format!("{}:{}", session_id, question_id); - { - let mut notified = notified_questions.lock().await; - if notified.contains(&key) { - return; - } - notified.insert(key); - } - - let should_notify = app - .get_webview_window("main") - .map(|window| { - let focused = window.is_focused().unwrap_or(false); - let minimized = window.is_minimized().unwrap_or(false); - !focused || minimized - }) - .unwrap_or(true); - - if should_notify { - let (title, body) = properties - .get("questions") - .and_then(Value::as_array) - .and_then(|questions| questions.first()) - .and_then(Value::as_object) - .map(|first| { - let header = first - .get("header") - .and_then(Value::as_str) - .unwrap_or("") - .trim(); - let question = first - .get("question") - .and_then(Value::as_str) - .unwrap_or("") - .trim(); - - let title = if header.to_ascii_lowercase().contains("plan mode") { - "Switch to plan mode".to_string() - } else if header.to_ascii_lowercase().contains("build agent") { - "Switch to build mode".to_string() - } else if !header.is_empty() { - header.to_string() - } else { - "Input needed".to_string() - }; - - let body = if !question.is_empty() { - question.to_string() - } else { - "Agent is waiting for your response".to_string() - }; - - (title, body) - }) - .unwrap_or_else(|| { - ( - "Input needed".to_string(), - "Agent is waiting for your response".to_string(), - ) - }); - - let _ = app - .notification() - .builder() - .title(title) - .body(body) - .sound("Glass") - .show(); - } -} - -async fn handle_permission_asked( - app: &AppHandle, - properties: &Value, - notified_requests: &Mutex>, -) { - let session_id = properties.get("sessionID").and_then(Value::as_str); - let request_id = properties.get("id").and_then(Value::as_str); - - let (session_id, request_id) = match (session_id, request_id) { - (Some(s), Some(r)) => (s, r), - _ => return, - }; - - let key = format!("{}:{}", session_id, request_id); - { - let mut notified = notified_requests.lock().await; - if notified.contains(&key) { - return; - } - notified.insert(key); - } - - let permission = properties - .get("permission") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .unwrap_or("Agent requested permission"); - - let should_notify = app - .get_webview_window("main") - .map(|window| { - let focused = window.is_focused().unwrap_or(false); - let minimized = window.is_minimized().unwrap_or(false); - !focused || minimized - }) - .unwrap_or(true); - - if should_notify { - let _ = app - .notification() - .builder() - .title("Permission required") - .body(permission) - .sound("Glass") - .show(); - } -} - -async fn handle_message_updated( - app: &AppHandle, - runtime: &DesktopRuntime, - client: &Client, - base: &str, - properties: &Value, - notified_messages: &Mutex>, - session_parent_cache: &Mutex>>, -) { - let Some(info) = properties.get("info") else { - return; - }; - - let role = info.get("role").and_then(Value::as_str).unwrap_or_default(); - if role != "assistant" { - return; - } - - let finish = info.get("finish").and_then(Value::as_str); - if finish != Some("stop") { - return; - } - - let message_id = match info.get("id").and_then(Value::as_str) { - Some(id) => id.to_string(), - None => return, - }; - - // Subtask filtering (fail open) - let notify_on_subtasks = runtime - .settings() - .load() - .await - .ok() - .and_then(|settings| settings.get("notifyOnSubtasks").and_then(Value::as_bool)) - .unwrap_or(true); - - if !notify_on_subtasks { - let session_id = info - .get("sessionID") - .and_then(Value::as_str) - .or_else(|| properties.get("sessionID").and_then(Value::as_str)) - .or_else(|| properties.get("sessionId").and_then(Value::as_str)); - - if let Some(session_id) = session_id { - if let Some(parent) = resolve_session_parent_id(client, base, session_id, session_parent_cache).await { - if parent.is_some() { - return; - } - } - } - } - - { - let mut notified = notified_messages.lock().await; - if notified.contains(&message_id) { - return; - } - notified.insert(message_id.clone()); - } - - let raw_mode = info - .get("mode") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .unwrap_or("agent"); - let raw_model = info - .get("modelID") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .unwrap_or("assistant"); - - let title = format!("{} agent is ready", format_mode(raw_mode)); - let body = format!("{} completed the task", format_model_id(raw_model)); - - let should_notify = app - .get_webview_window("main") - .map(|window| { - let focused = window.is_focused().unwrap_or(false); - let minimized = window.is_minimized().unwrap_or(false); - // Only notify when the app is not in the foreground or is minimized - !focused || minimized - }) - .unwrap_or(true); - - if should_notify { - let _ = app - .notification() - .builder() - .title(title) - .body(body) - .sound("Glass") - .show(); - } -} - -fn format_mode(raw: &str) -> String { - if raw.is_empty() { - return "Agent".to_string(); - } - raw.split(&['-', '_', ' '][..]) - .filter(|s| !s.is_empty()) - .map(capitalize) - .collect::>() - .join(" ") -} - -fn format_model_id(raw: &str) -> String { - if raw.is_empty() { - return "Assistant".to_string(); - } - - let tokens: Vec<&str> = raw.split(&['-', '_'][..]).collect(); - let mut result: Vec = Vec::new(); - let mut i = 0; - - while i < tokens.len() { - let current = tokens[i]; - - if current.chars().all(|c| c.is_ascii_digit()) { - if i + 1 < tokens.len() && tokens[i + 1].chars().all(|c| c.is_ascii_digit()) { - let combined = format!("{}.{}", current, tokens[i + 1]); - result.push(combined); - i += 2; - continue; - } - } - - result.push(current.to_string()); - i += 1; - } - - result - .into_iter() - .map(|part| capitalize(&part)) - .collect::>() - .join(" ") -} - -fn capitalize(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - Some(first) => first.to_uppercase().collect::() + chars.as_str(), - None => String::new(), - } -} diff --git a/packages/desktop/src-tauri/src/commands/files.rs b/packages/desktop/src-tauri/src/commands/files.rs deleted file mode 100644 index 7026f462..00000000 --- a/packages/desktop/src-tauri/src/commands/files.rs +++ /dev/null @@ -1,1277 +0,0 @@ -use crate::path_utils::expand_tilde_path; -use crate::{DesktopRuntime, SettingsStore}; -use serde::{Deserialize, Serialize}; -use std::{ - collections::{HashMap, HashSet, VecDeque}, - path::{Path, PathBuf}, - process::Command, - sync::OnceLock, - time::UNIX_EPOCH, -}; -use tokio::fs; - -const DEFAULT_FILE_SEARCH_LIMIT: usize = 60; -const MAX_FILE_SEARCH_LIMIT: usize = 400; -const FILE_SEARCH_MAX_CONCURRENCY: usize = 5; -const FILE_SEARCH_EXCLUDED_DIRS: &[&str] = &[ - "node_modules", - ".git", - "dist", - "build", - ".next", - ".turbo", - ".cache", - "coverage", - "tmp", - "logs", -]; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FileListEntry { - name: String, - path: String, - is_directory: bool, - is_file: bool, - is_symbolic_link: bool, - size: Option, - modified_time: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DirectoryListResult { - directory: String, - path: String, - entries: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateDirectoryResponse { - success: bool, - path: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DeletePathResponse { - success: bool, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RenamePathResponse { - success: bool, - path: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FileSearchHit { - name: String, - path: String, - relative_path: String, - extension: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SearchFilesResponse { - root: String, - count: usize, - files: Vec, -} - -#[derive(Debug)] -enum FsCommandError { - NotFound, - AccessDenied, - NotDirectory, - OutsideWorkspace, - Other(String), -} - -impl FsCommandError { - fn to_list_message(&self) -> String { - match self { - FsCommandError::NotFound => "Directory not found".to_string(), - FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => { - "Access to directory denied".to_string() - } - FsCommandError::NotDirectory => "Specified path is not a directory".to_string(), - FsCommandError::Other(message) => { - let _ = message; - "Failed to list directory".to_string() - } - } - } - - fn to_search_message(&self) -> String { - match self { - FsCommandError::NotFound => "Directory not found".to_string(), - FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => { - "Access to directory denied".to_string() - } - FsCommandError::NotDirectory => "Specified path is not a directory".to_string(), - FsCommandError::Other(message) => { - let _ = message; - "Failed to search files".to_string() - } - } - } - - fn to_create_message(&self) -> String { - match self { - FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => { - "Access to directory denied".to_string() - } - FsCommandError::NotDirectory => "Parent path must be a directory".to_string(), - FsCommandError::Other(message) => { - let _ = message; - "Failed to create directory".to_string() - } - FsCommandError::NotFound => "Parent directory not found".to_string(), - } - } - - fn to_delete_message(&self) -> String { - match self { - FsCommandError::NotFound => "File or directory not found".to_string(), - FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => { - "Access to path denied".to_string() - } - FsCommandError::NotDirectory => "Specified path is not a directory".to_string(), - FsCommandError::Other(message) => { - let _ = message; - "Failed to delete path".to_string() - } - } - } - - fn to_rename_message(&self) -> String { - match self { - FsCommandError::NotFound => "Source path not found".to_string(), - FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => { - "Access to path denied".to_string() - } - FsCommandError::NotDirectory => "Parent path must be a directory".to_string(), - FsCommandError::Other(message) => { - let _ = message; - "Failed to rename path".to_string() - } - } - } -} - -impl From for FsCommandError { - fn from(error: std::io::Error) -> Self { - match error.kind() { - std::io::ErrorKind::NotFound => FsCommandError::NotFound, - std::io::ErrorKind::PermissionDenied => FsCommandError::AccessDenied, - _ => FsCommandError::Other(error.to_string()), - } - } -} - -#[tauri::command] -pub async fn list_directory( - path: Option, - respect_gitignore: Option, - state: tauri::State<'_, DesktopRuntime>, -) -> Result { - let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_path = resolve_sandboxed_path(path, &workspace_roots, default_root.as_ref()) - .await - .map_err(|err| err.to_list_message())?; - - let metadata = match fs::metadata(&resolved_path).await { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Ok(DirectoryListResult { - directory: normalize_path(&resolved_path), - path: normalize_path(&resolved_path), - entries: Vec::new(), - }); - } - Err(err) => return Err(FsCommandError::from(err).to_list_message()), - }; - - if !metadata.is_dir() { - return Err(FsCommandError::NotDirectory.to_list_message()); - } - - // Re-check boundary after canonicalization to guard against traversal - if !workspace_roots.is_empty() - && !workspace_roots - .iter() - .any(|root| resolved_path.starts_with(root)) - { - return Err(FsCommandError::OutsideWorkspace.to_list_message()); - } - - let mut entries = Vec::new(); - let mut dir_entries = fs::read_dir(&resolved_path) - .await - .map_err(|err| FsCommandError::from(err).to_list_message())?; - - // Collect all entry names first for gitignore check - let mut all_entries: Vec<(tokio::fs::DirEntry, String)> = Vec::new(); - while let Some(entry) = dir_entries - .next_entry() - .await - .map_err(|err| FsCommandError::from(err).to_list_message())? - { - let name = entry.file_name().to_string_lossy().to_string(); - all_entries.push((entry, name)); - } - - // Get gitignored paths if requested - let ignored_names: HashSet = if respect_gitignore.unwrap_or(false) { - let names: Vec = all_entries.iter().map(|(_, name)| name.clone()).collect(); - if names.is_empty() { - HashSet::new() - } else { - let cwd = resolved_path.clone(); - tokio::task::spawn_blocking(move || { - let output = Command::new("git") - .arg("check-ignore") - .arg("--") - .args(&names) - .current_dir(&cwd) - .output(); - - match output { - Ok(out) => String::from_utf8_lossy(&out.stdout) - .lines() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(), - Err(_) => HashSet::new(), - } - }) - .await - .unwrap_or_default() - } - } else { - HashSet::new() - }; - - for (entry, name) in all_entries { - // Skip gitignored entries - if !ignored_names.is_empty() && ignored_names.contains(&name) { - continue; - } - - let file_type = entry - .file_type() - .await - .map_err(|err| FsCommandError::from(err).to_list_message())?; - - let entry_path = entry.path(); - - let mut is_directory = file_type.is_dir(); - let is_symlink = file_type.is_symlink(); - - if !is_directory && is_symlink { - if let Ok(link_meta) = fs::metadata(&entry_path).await { - is_directory = link_meta.is_dir(); - } - } - - let metadata = fs::metadata(&entry_path).await.ok(); - let size = metadata - .as_ref() - .filter(|meta| meta.is_file()) - .map(|meta| meta.len()); - let modified_time = metadata - .and_then(|meta| meta.modified().ok()) - .and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis() as i64); - - entries.push(FileListEntry { - name, - path: normalize_path(&entry_path), - is_directory, - is_file: file_type.is_file(), - is_symbolic_link: is_symlink, - size, - modified_time, - }); - } - - Ok(DirectoryListResult { - directory: normalize_path(&resolved_path), - path: normalize_path(&resolved_path), - entries, - }) -} - -struct ScoredFileHit { - hit: FileSearchHit, - score: i32, -} - -#[tauri::command] -pub async fn search_files( - directory: Option, - query: Option, - max_results: Option, - include_hidden: Option, - respect_gitignore: Option, - state: tauri::State<'_, DesktopRuntime>, -) -> Result { - let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_root = resolve_sandboxed_path(directory, &workspace_roots, default_root.as_ref()) - .await - .map_err(|err| err.to_search_message())?; - - let limit = clamp_search_limit(max_results); - let normalized_query = query.unwrap_or_default().trim().to_lowercase(); - let match_all = normalized_query.is_empty(); - let include_hidden = include_hidden.unwrap_or(false); - let respect_gitignore = respect_gitignore.unwrap_or(true); - - // Collect more candidates for fuzzy matching, then sort and trim - let collect_limit = if match_all { - limit - } else { - (limit * 3).max(200) - }; - - let mut candidates: Vec = Vec::new(); - let mut queue = VecDeque::new(); - let mut visited = HashSet::new(); - - queue.push_back(resolved_root.clone()); - visited.insert(resolved_root.clone()); - - while !queue.is_empty() && candidates.len() < collect_limit { - for _ in 0..FILE_SEARCH_MAX_CONCURRENCY { - let Some(dir) = queue.pop_front() else { - break; - }; - - let mut entries = match fs::read_dir(&dir).await { - Ok(entries) => entries, - Err(_) => continue, - }; - - let mut all_entries = Vec::new(); - while let Ok(Some(entry)) = entries.next_entry().await { - let name = entry.file_name().to_string_lossy().to_string(); - all_entries.push((entry, name)); - } - - let ignored_names: HashSet = if respect_gitignore { - let names: Vec = all_entries.iter().map(|(_, name)| name.clone()).collect(); - if names.is_empty() { - HashSet::new() - } else { - let cwd = dir.clone(); - tokio::task::spawn_blocking(move || { - let output = Command::new("git") - .arg("check-ignore") - .arg("--") - .args(&names) - .current_dir(&cwd) - .output(); - - match output { - Ok(out) => String::from_utf8_lossy(&out.stdout) - .lines() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(), - Err(_) => HashSet::new(), - } - }) - .await - .unwrap_or_default() - } - } else { - HashSet::new() - }; - - for (entry, name) in all_entries { - let Ok(file_type) = entry.file_type().await else { - continue; - }; - - let name_str = name.as_str(); - if name_str.is_empty() || (!include_hidden && name_str.starts_with('.')) { - continue; - } - - if respect_gitignore && ignored_names.contains(name_str) { - continue; - } - - let entry_path = entry.path(); - if file_type.is_dir() { - if should_skip_directory(name_str, include_hidden) { - continue; - } - if visited.insert(entry_path.clone()) && candidates.len() < collect_limit { - queue.push_back(entry_path); - } - continue; - } - - if !file_type.is_file() { - continue; - } - - let relative_path = relative_path(&resolved_root, &entry_path); - let extension = entry_path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_lowercase()); - - let hit = FileSearchHit { - name: name_str.to_string(), - path: normalize_path(&entry_path), - relative_path: relative_path.replace('\\', "/"), - extension, - }; - - if match_all { - candidates.push(ScoredFileHit { hit, score: 0 }); - } else { - // Try fuzzy match against relative path (includes filename) - if let Some(score) = fuzzy_match_score(&normalized_query, &relative_path) { - candidates.push(ScoredFileHit { hit, score }); - } - } - - if candidates.len() >= collect_limit { - break; - } - } - } - } - - // Sort by score descending, then by path length, then alphabetically - if !match_all { - candidates.sort_by(|a, b| match b.score.cmp(&a.score) { - std::cmp::Ordering::Equal => { - match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) { - std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path), - other => other, - } - } - other => other, - }); - } - - let files: Vec = candidates - .into_iter() - .take(limit) - .map(|scored| scored.hit) - .collect(); - - Ok(SearchFilesResponse { - root: normalize_path(&resolved_root), - count: files.len(), - files, - }) -} - -#[tauri::command] -pub async fn create_directory( - path: String, - state: tauri::State<'_, DesktopRuntime>, -) -> Result { - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err("Path is required".to_string()); - } - - let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref()) - .await - .map_err(|err| err.to_create_message())?; - - fs::create_dir_all(&resolved_path) - .await - .map_err(|err| FsCommandError::from(err).to_create_message())?; - - Ok(CreateDirectoryResponse { - success: true, - path: normalize_path(&resolved_path), - }) -} - -#[tauri::command] -pub async fn delete_path( - path: String, - state: tauri::State<'_, DesktopRuntime>, -) -> Result { - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err("Path is required".to_string()); - } - - let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_path = resolve_sandboxed_path( - Some(trimmed.to_string()), - &workspace_roots, - default_root.as_ref(), - ) - .await - .map_err(|err| err.to_delete_message())?; - - let metadata = fs::metadata(&resolved_path) - .await - .map_err(|err| FsCommandError::from(err).to_delete_message())?; - - if metadata.is_dir() { - fs::remove_dir_all(&resolved_path) - .await - .map_err(|err| FsCommandError::from(err).to_delete_message())?; - } else { - fs::remove_file(&resolved_path) - .await - .map_err(|err| FsCommandError::from(err).to_delete_message())?; - } - - Ok(DeletePathResponse { success: true }) -} - -#[tauri::command] -pub async fn rename_path( - old_path: String, - new_path: String, - state: tauri::State<'_, DesktopRuntime>, -) -> Result { - let trimmed_old = old_path.trim(); - if trimmed_old.is_empty() { - return Err("oldPath is required".to_string()); - } - let trimmed_new = new_path.trim(); - if trimmed_new.is_empty() { - return Err("newPath is required".to_string()); - } - - let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_old = resolve_sandboxed_path( - Some(trimmed_old.to_string()), - &workspace_roots, - default_root.as_ref(), - ) - .await - .map_err(|err| err.to_rename_message())?; - let resolved_new = resolve_creatable_path(trimmed_new, &workspace_roots, default_root.as_ref()) - .await - .map_err(|err| err.to_rename_message())?; - - fs::rename(&resolved_old, &resolved_new) - .await - .map_err(|err| FsCommandError::from(err).to_rename_message())?; - - Ok(RenamePathResponse { - success: true, - path: normalize_path(&resolved_new), - }) -} - -async fn resolve_sandboxed_path( - path: Option, - workspace_roots: &[PathBuf], - default_root: Option<&PathBuf>, -) -> Result { - let candidate_input = path - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()); - - let fallback_root = default_root - .or_else(|| workspace_roots.first()) - .cloned() - .unwrap_or_else(default_home_directory); - - let candidate_path = match candidate_input { - Some(value) => expand_tilde_path(value), - None => fallback_root.clone(), - }; - - let resolved = if candidate_path.is_absolute() { - candidate_path - } else { - fallback_root.join(candidate_path) - }; - - let canonicalized = fs::canonicalize(&resolved) - .await - .map_err(FsCommandError::from)?; - - // Allow OpenChamber per-project config under ~/.config/openchamber. - if is_within_openchamber_user_config(&canonicalized) { - return Ok(canonicalized); - } - - if !workspace_roots.is_empty() - && !workspace_roots - .iter() - .any(|root| canonicalized.starts_with(root)) - { - return Err(FsCommandError::OutsideWorkspace); - } - - Ok(canonicalized) -} - -async fn resolve_creatable_path( - path: &str, - workspace_roots: &[PathBuf], - default_root: Option<&PathBuf>, -) -> Result { - let candidate = expand_tilde_path(path); - if candidate.as_os_str().is_empty() { - return Err(FsCommandError::Other("Path is required".to_string())); - } - - let fallback_root = default_root - .or_else(|| workspace_roots.first()) - .cloned() - .unwrap_or_else(default_home_directory); - - let absolute = if candidate.is_absolute() { - candidate - } else { - fallback_root.join(candidate) - }; - - // Allow OpenChamber per-project config under ~/.config/openchamber. - // Needed because Desktop FS commands are sandboxed to workspace roots. - if is_within_openchamber_user_config(&absolute) { - return Ok(absolute); - } - - let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?; - - let canonical_parent = canonicalize_existing_ancestor(&parent.to_path_buf()).await?; - - if !workspace_roots.is_empty() - && !workspace_roots - .iter() - .any(|root| canonical_parent.starts_with(root)) - { - return Err(FsCommandError::OutsideWorkspace); - } - - Ok(absolute) -} - -async fn resolve_workspace_roots(settings: &SettingsStore) -> (Vec, Option) { - let mut roots: Vec = Vec::new(); - let mut default_root: Option = None; - - let settings_value = settings.load().await.ok(); - - if let Some(value) = settings_value.as_ref() { - if let Some(active_id) = value.get("activeProjectId").and_then(|v| v.as_str()) { - if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) { - if let Some(active_path) = projects.iter().find_map(|entry| { - let id = entry.get("id").and_then(|v| v.as_str())?; - if id != active_id { - return None; - } - entry.get("path").and_then(|v| v.as_str()) - }) { - if let Ok(canonicalized) = - fs::canonicalize(expand_tilde_path(active_path)).await - { - default_root = Some(canonicalized.clone()); - roots.push(canonicalized); - } - } - } - } - - if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) { - for entry in projects { - if let Some(path) = entry.get("path").and_then(|v| v.as_str()) { - if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(path)).await { - roots.push(canonicalized); - } - } - } - } - - if let Some(last_dir) = value.get("lastDirectory").and_then(|v| v.as_str()) { - if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(last_dir)).await { - if default_root.is_none() { - default_root = Some(canonicalized.clone()); - } - roots.push(canonicalized); - } - } - } - - if default_root.is_none() { - if let Ok(Some(last_dir)) = settings.last_directory().await { - if let Ok(canonicalized) = fs::canonicalize(last_dir).await { - default_root = Some(canonicalized); - } - } - } - - let mut deduped: Vec = Vec::new(); - for root in roots { - if !deduped.iter().any(|existing| existing == &root) { - deduped.push(root); - } - } - - (deduped, default_root) -} - -fn default_home_directory() -> PathBuf { - dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) -} - -fn openchamber_user_config_root() -> PathBuf { - default_home_directory().join(".config").join("openchamber") -} - -fn is_within_openchamber_user_config(path: &PathBuf) -> bool { - path.starts_with(&openchamber_user_config_root()) -} - -async fn canonicalize_existing_ancestor(path: &PathBuf) -> Result { - let mut current = Some(path.as_path()); - while let Some(candidate) = current { - match fs::canonicalize(candidate).await { - Ok(canon) => return Ok(canon), - Err(err) => { - if err.kind() != std::io::ErrorKind::NotFound { - return Err(FsCommandError::from(err)); - } - } - } - current = candidate.parent(); - } - Err(FsCommandError::NotDirectory) -} - -fn clamp_search_limit(value: Option) -> usize { - let limit = value.unwrap_or(DEFAULT_FILE_SEARCH_LIMIT); - limit.clamp(1, MAX_FILE_SEARCH_LIMIT) -} - -fn should_skip_directory(name: &str, include_hidden: bool) -> bool { - if !include_hidden && name.starts_with('.') { - return true; - } - FILE_SEARCH_EXCLUDED_DIRS - .iter() - .any(|dir| dir.eq_ignore_ascii_case(name)) -} - -/// Fuzzy match scoring function. -/// Returns Some(score) if the query fuzzy-matches the candidate, None otherwise. -/// Higher scores indicate better matches. -fn fuzzy_match_score(query: &str, candidate: &str) -> Option { - if query.is_empty() { - return Some(0); - } - - let q: Vec = query.to_lowercase().chars().collect(); - let c: Vec = candidate.to_lowercase().chars().collect(); - let c_str = candidate.to_lowercase(); - - // Fast path: exact substring match gets high score - if c_str.contains(query) { - if let Some(idx) = c_str.find(query) { - let mut bonus: i32 = 0; - if idx == 0 { - bonus = 20; - } else if let Some(prev) = c.get(idx.saturating_sub(1)) { - if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' { - bonus = 15; - } - } - return Some(100 + bonus - (idx.min(20) as i32) - (c.len() as i32 / 5)); - } - } - - // Fuzzy match: all query chars must appear in order - let mut score: i32 = 0; - let mut last_index: i32 = -1; - let mut consecutive: i32 = 0; - - for ch in &q { - if *ch == ' ' { - continue; - } - - let search_start = if last_index < 0 { - 0 - } else { - (last_index + 1) as usize - }; - let idx = c[search_start..].iter().position(|&c_char| c_char == *ch); - - match idx { - None => return None, // No match - Some(relative_idx) => { - let idx = search_start + relative_idx; - let gap = idx as i32 - last_index - 1; - - if gap == 0 { - consecutive += 1; - } else { - consecutive = 0; - } - - score += 10; - score += (18 - idx as i32).max(0); // Prefer matches near start - score -= gap.min(10); // Penalize gaps - - // Bonus for word boundary matches - if idx == 0 { - score += 12; - } else if let Some(prev) = c.get(idx - 1) { - if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' - { - score += 10; - } - } - - score += if consecutive > 0 { 12 } else { 0 }; // Bonus for consecutive matches - last_index = idx as i32; - } - } - } - - // Prefer shorter paths - score += (24 - c.len() as i32 / 3).max(0); - - Some(score) -} - -fn normalize_path(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") -} - -fn relative_path(root: &Path, target: &Path) -> String { - target - .strip_prefix(root) - .map(|relative| normalize_path(relative)) - .unwrap_or_else(|_| normalize_path(target)) -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReadFileResponse { - content: String, - path: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReadFileBinaryResponse { - data_url: String, - path: String, -} - -fn get_image_mime_type(file_path: &str) -> &'static str { - let lower = file_path.to_lowercase(); - if lower.ends_with(".png") { - return "image/png"; - } - if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { - return "image/jpeg"; - } - if lower.ends_with(".gif") { - return "image/gif"; - } - if lower.ends_with(".svg") { - return "image/svg+xml"; - } - if lower.ends_with(".webp") { - return "image/webp"; - } - if lower.ends_with(".ico") { - return "image/x-icon"; - } - if lower.ends_with(".bmp") { - return "image/bmp"; - } - if lower.ends_with(".avif") { - return "image/avif"; - } - - "application/octet-stream" -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WriteFileResponse { - success: bool, - path: String, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CommandResult { - command: String, - success: bool, - exit_code: Option, - stdout: Option, - stderr: Option, - error: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ExecCommandsResponse { - success: bool, - results: Vec, -} - -#[tauri::command] -pub async fn read_file( - path: String, - state: tauri::State<'_, DesktopRuntime>, -) -> Result { - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err("Path is required".to_string()); - } - - let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_path = resolve_sandboxed_path( - Some(trimmed.to_string()), - &workspace_roots, - default_root.as_ref(), - ) - .await - .map_err(|_| "File not found or access denied".to_string())?; - - let metadata = fs::metadata(&resolved_path) - .await - .map_err(|_| "File not found".to_string())?; - - if !metadata.is_file() { - return Err("Specified path is not a file".to_string()); - } - - let content = fs::read_to_string(&resolved_path) - .await - .map_err(|err| format!("Failed to read file: {}", err))?; - - Ok(ReadFileResponse { - content, - path: normalize_path(&resolved_path), - }) -} - -#[tauri::command] -pub async fn read_file_binary( - path: String, - state: tauri::State<'_, DesktopRuntime>, -) -> Result { - use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; - - const MAX_BYTES: u64 = 10 * 1024 * 1024; - - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err("Path is required".to_string()); - } - - let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_path = resolve_sandboxed_path( - Some(trimmed.to_string()), - &workspace_roots, - default_root.as_ref(), - ) - .await - .map_err(|_| "File not found or access denied".to_string())?; - - let metadata = fs::metadata(&resolved_path) - .await - .map_err(|_| "File not found".to_string())?; - - if !metadata.is_file() { - return Err("Specified path is not a file".to_string()); - } - - if metadata.len() > MAX_BYTES { - return Err("File too large".to_string()); - } - - let bytes = fs::read(&resolved_path) - .await - .map_err(|err| format!("Failed to read file: {}", err))?; - - let mime_type = get_image_mime_type(trimmed); - let data_url = format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)); - - Ok(ReadFileBinaryResponse { - data_url, - path: normalize_path(&resolved_path), - }) -} - -#[tauri::command] -pub async fn write_file( - path: String, - content: String, - state: tauri::State<'_, DesktopRuntime>, -) -> Result { - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err("Path is required".to_string()); - } - - let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref()) - .await - .map_err(|err| err.to_create_message())?; - - // Ensure parent directory exists - if let Some(parent) = resolved_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|err| format!("Failed to create parent directory: {}", err))?; - } - - fs::write(&resolved_path, content) - .await - .map_err(|err| format!("Failed to write file: {}", err))?; - - Ok(WriteFileResponse { - success: true, - path: normalize_path(&resolved_path), - }) -} - -static CACHED_LOGIN_SHELL_PATH: OnceLock> = OnceLock::new(); - -#[cfg(target_os = "macos")] -fn get_user_shell() -> Option { - let username = - dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?; - - let output = Command::new("dscl") - .args([".", "-read", &format!("/Users/{}", username), "UserShell"]) - .output() - .ok()?; - - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.split(':').nth(1).map(|s| s.trim().to_string()) - } else { - None - } -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn get_user_shell() -> Option { - std::env::var("SHELL").ok() -} - -#[cfg(not(unix))] -fn get_user_shell() -> Option { - None -} - -fn build_shell_path_command(shell: &str) -> Vec { - let shell_name = std::path::Path::new(shell) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("sh"); - - match shell_name { - "nu" | "nushell" => vec![ - "-l".to_string(), - "-i".to_string(), - "-c".to_string(), - "echo $\"__PATH__=($env.PATH | str join (char esep))\"".to_string(), - ], - "bash" => vec![ - "-lic".to_string(), - "source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"".to_string(), - ], - "fish" => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string()], - _ => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string()], - } -} - -fn detect_login_shell_path() -> Option { - #[cfg(not(unix))] - { - None - } - #[cfg(unix)] - { - let shell = get_user_shell().unwrap_or_else(|| "/bin/zsh".into()); - let args = build_shell_path_command(&shell); - - let output = match Command::new(&shell).args(&args).output() { - Ok(o) => o, - Err(_) => return None, - }; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if let Some(path) = line.strip_prefix("__PATH__=") { - if !path.is_empty() { - return Some(path.to_string()); - } - } - } - None - } -} - -fn get_cached_login_shell_path() -> Option<&'static String> { - CACHED_LOGIN_SHELL_PATH - .get_or_init(detect_login_shell_path) - .as_ref() -} - -fn merge_paths(login_path: &str, current: &str) -> String { - let mut segments = Vec::new(); - let mut seen = HashSet::new(); - - for part in login_path.split(':').chain(current.split(':')) { - if !part.is_empty() && !seen.contains(part) { - seen.insert(part.to_string()); - segments.push(part); - } - } - - segments.join(":") -} - -fn build_augmented_env() -> HashMap { - let mut env: HashMap = std::env::vars().collect(); - - if let Some(login_path) = get_cached_login_shell_path() { - let current = env.get("PATH").cloned().unwrap_or_default(); - env.insert("PATH".to_string(), merge_paths(login_path, ¤t)); - } - - env -} - -#[tauri::command] -pub async fn exec_commands( - commands: Vec, - cwd: String, - state: tauri::State<'_, DesktopRuntime>, -) -> Result { - if commands.is_empty() { - return Err("Commands array is required".to_string()); - } - - let cwd_trimmed = cwd.trim(); - if cwd_trimmed.is_empty() { - return Err("Working directory (cwd) is required".to_string()); - } - - let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_cwd = resolve_sandboxed_path( - Some(cwd_trimmed.to_string()), - &workspace_roots, - default_root.as_ref(), - ) - .await - .map_err(|_| "Working directory not found or access denied".to_string())?; - - let metadata = fs::metadata(&resolved_cwd) - .await - .map_err(|_| "Working directory not found".to_string())?; - - if !metadata.is_dir() { - return Err("Specified cwd is not a directory".to_string()); - } - - let shell = std::env::var("SHELL").unwrap_or_else(|_| { - if cfg!(windows) { - "cmd.exe".to_string() - } else { - "/bin/sh".to_string() - } - }); - - let shell_flag = if cfg!(windows) { "/c" } else { "-c" }; - - let augmented_env = build_augmented_env(); - - let mut results = Vec::new(); - - for cmd in commands { - let cmd_trimmed = cmd.trim(); - if cmd_trimmed.is_empty() { - results.push(CommandResult { - command: cmd.clone(), - success: false, - exit_code: None, - stdout: None, - stderr: None, - error: Some("Invalid command".to_string()), - }); - continue; - } - - let cwd_clone = resolved_cwd.clone(); - let shell_clone = shell.clone(); - let cmd_clone = cmd_trimmed.to_string(); - let env_clone = augmented_env.clone(); - - // Run command synchronously in blocking task - let result = tokio::task::spawn_blocking(move || { - match Command::new(&shell_clone) - .arg(shell_flag) - .arg(&cmd_clone) - .current_dir(&cwd_clone) - .envs(&env_clone) - .output() - { - Ok(output) => CommandResult { - command: cmd_clone, - success: output.status.success(), - exit_code: output.status.code(), - stdout: Some(String::from_utf8_lossy(&output.stdout).trim().to_string()), - stderr: Some(String::from_utf8_lossy(&output.stderr).trim().to_string()), - error: None, - }, - Err(err) => CommandResult { - command: cmd_clone, - success: false, - exit_code: None, - stdout: None, - stderr: None, - error: Some(err.to_string()), - }, - } - }) - .await - .unwrap_or_else(|err| CommandResult { - command: cmd.clone(), - success: false, - exit_code: None, - stdout: None, - stderr: None, - error: Some(format!("Task failed: {}", err)), - }); - - results.push(result); - } - - let all_succeeded = results.iter().all(|r| r.success); - - Ok(ExecCommandsResponse { - success: all_succeeded, - results, - }) -} diff --git a/packages/desktop/src-tauri/src/commands/git.rs b/packages/desktop/src-tauri/src/commands/git.rs deleted file mode 100644 index fe72c160..00000000 --- a/packages/desktop/src-tauri/src/commands/git.rs +++ /dev/null @@ -1,2619 +0,0 @@ -use crate::path_utils::expand_tilde_path; -use crate::{DesktopRuntime, SettingsStore}; -use anyhow::{anyhow, Context, Result}; -use log::{error, info, warn}; -use regex::Regex; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::{HashMap, HashSet}; -use std::path::{Component, Path, PathBuf}; -use std::process::Stdio; -use std::sync::LazyLock; -use tauri::State; -use tokio::fs; -use tokio::io::AsyncReadExt; -use tokio::process::Command; - -fn extract_json_object(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - return None; - } - - let mut start = match trimmed.find('{') { - Some(index) => index, - None => return None, - }; - - while start < trimmed.len() { - let mut end = match trimmed[start..].find('}') { - Some(index) => start + index, - None => break, - }; - - loop { - let candidate = &trimmed[start..=end]; - if serde_json::from_str::(candidate).is_ok() { - return Some(candidate.to_string()); - } - - end = match trimmed[end + 1..].find('}') { - Some(index) => end + 1 + index, - None => break, - }; - } - - start = match trimmed[start + 1..].find('{') { - Some(index) => start + 1 + index, - None => break, - }; - } - - None -} - -const GIT_IDENTITY_STORAGE_FILE: &str = "git-identities.json"; -const GIT_FILE_DIFF_TIMEOUT_MS: u64 = 15_000; -const GIT_LS_REMOTE_TIMEOUT_MS: u64 = 5_000; -const GIT_FILE_TEXT_MAX_BYTES: u64 = 2_000_000; -const GIT_FILE_IMAGE_MAX_BYTES: u64 = 10_000_000; -// Tauri invoke payloads can become unstable with very large strings (e.g. huge blobs or base64 data URLs). -// Keep a conservative upper bound to ensure the diff IPC response always returns. -const GIT_FILE_IPC_MAX_CHARS: usize = 600_000; - -// --- Structs mirroring TypeScript types --- - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitStatusFile { - pub path: String, - pub index: String, - pub working_dir: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitStatus { - pub current: String, - pub tracking: Option, - pub ahead: i32, - pub behind: i32, - pub files: Vec, - pub is_clean: bool, - pub diff_stats: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct DiffStat { - pub insertions: i32, - pub deletions: i32, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitBranchDetails { - pub current: bool, - pub name: String, - pub commit: String, - pub label: String, - pub tracking: Option, - pub ahead: Option, - pub behind: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitBranch { - pub all: Vec, - pub current: String, - pub branches: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitCommitSummary { - pub changes: i32, - pub insertions: i32, - pub deletions: i32, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitCommitResult { - pub success: bool, - pub commit: String, - pub branch: String, - pub summary: GitCommitSummary, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitPushResult { - pub success: bool, - pub pushed: Vec, - pub repo: String, - #[serde(rename = "ref")] - pub ref_: Option, // "ref" is a keyword in Rust -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitPushRef { - pub local: String, - pub remote: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitPullResult { - pub success: bool, - pub summary: GitCommitSummary, - pub files: Vec, - pub insertions: i32, - pub deletions: i32, -} - -fn parse_shortstat(output: &str) -> GitCommitSummary { - let mut summary = GitCommitSummary { - changes: 0, - insertions: 0, - deletions: 0, - }; - - for line in output - .split('\n') - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - { - for part in line.split(',') { - let token = part.trim(); - if token.is_empty() { - continue; - } - - if token.contains("file changed") { - if let Some(value) = token.split_whitespace().next() { - summary.changes = value.parse().unwrap_or(0); - } - } else if token.contains("insertion") { - if let Some(value) = token.split_whitespace().next() { - summary.insertions = value.parse().unwrap_or(0); - } - } else if token.contains("deletion") { - if let Some(value) = token.split_whitespace().next() { - summary.deletions = value.parse().unwrap_or(0); - } - } - } - } - - summary -} - -async fn get_head_hash(root: &Path) -> Result { - let output = run_git(&["rev-parse", "HEAD"], root).await?; - Ok(output.trim().to_string()) -} - -async fn get_current_branch_name(root: &Path) -> Result { - let output = run_git(&["rev-parse", "--abbrev-ref", "HEAD"], root).await?; - Ok(output.trim().to_string()) -} - -async fn collect_shortstat_for_range(root: &Path, range: &str) -> Result { - let args = ["diff", "--shortstat", range]; - let output = run_git(&args, root).await.unwrap_or_default(); - Ok(parse_shortstat(&output)) -} - -async fn collect_changed_files_for_range(root: &Path, range: &str) -> Result> { - let args = ["diff", "--name-only", range]; - let output = run_git(&args, root).await.unwrap_or_default(); - Ok(output - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .map(|line| line.to_string()) - .collect()) -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitIdentityProfile { - pub id: String, - pub name: String, - pub user_name: String, - pub user_email: String, - pub auth_type: Option, - pub ssh_key: Option, - pub host: Option, - pub color: Option, - pub icon: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct DiscoveredGitCredential { - pub host: String, - pub username: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitIdentityProfilesWrapper { - pub profiles: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitIdentitySummary { - pub user_name: Option, - pub user_email: Option, - pub ssh_command: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitLogEntry { - pub hash: String, - pub date: String, - pub message: String, - pub refs: String, - pub body: String, - #[serde(rename = "author_name")] - pub author_name: String, - #[serde(rename = "author_email")] - pub author_email: String, - pub files_changed: i32, - pub insertions: i32, - pub deletions: i32, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitLogResponse { - pub all: Vec, - pub latest: Option, - pub total: i32, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitWorktreeInfo { - pub worktree: String, - pub head: Option, - pub branch: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GeneratedCommitMessage { - pub subject: String, - pub highlights: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct CommitFileEntry { - pub path: String, - pub insertions: i32, - pub deletions: i32, - pub is_binary: bool, - pub change_type: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct GitCommitFilesResponse { - pub files: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CommitMessageResponse { - pub message: GeneratedCommitMessage, -} - -// --- Constants & Regexes --- - -static WORKTREE_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^worktree (.+)$").unwrap()); -static HEAD_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^HEAD (.+)$").unwrap()); -static BRANCH_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^branch (.+)$").unwrap()); -static FILES_CHANGED_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r"(\d+)\s+files?\s+changed").unwrap()); -static INSERTIONS_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r"(\d+)\s+insertions?\(\+\)").unwrap()); -static DELETIONS_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r"(\d+)\s+deletions?\(-\)").unwrap()); - -// --- Helpers --- - -fn metadata_is_socket(metadata: &std::fs::Metadata) -> bool { - #[cfg(unix)] - { - use std::os::unix::fs::FileTypeExt; - return metadata.file_type().is_socket(); - } - #[cfg(not(unix))] - { - false - } -} - -fn is_launchd_listeners_socket(path: &str) -> bool { - path.contains("/com.apple.launchd.") && path.ends_with("/Listeners") -} - -async fn run_gpgconf(args: &[&str]) -> Option> { - let candidates = [ - "gpgconf", - "/opt/homebrew/bin/gpgconf", - "/usr/local/bin/gpgconf", - ]; - for candidate in candidates { - info!("git: trying gpgconf at {}", candidate); - let output = Command::new(candidate) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await; - if let Ok(result) = output { - if result.status.success() { - info!("git: gpgconf succeeded at {}", candidate); - return Some(result.stdout); - } - } - } - None -} - -async fn resolve_ssh_auth_sock() -> Option { - let mut launchd_fallback: Option = None; - if let Ok(value) = std::env::var("SSH_AUTH_SOCK") { - let trimmed = value.trim(); - if !trimmed.is_empty() { - if let Ok(metadata) = fs::metadata(trimmed).await { - if metadata_is_socket(&metadata) { - if is_launchd_listeners_socket(trimmed) { - info!( - "git: SSH_AUTH_SOCK points to launchd listeners: {}", - trimmed - ); - launchd_fallback = Some(trimmed.to_string()); - } else { - info!("git: using SSH_AUTH_SOCK from environment: {}", trimmed); - return Some(trimmed.to_string()); - } - } - } - } - } - - let home_dir = dirs::home_dir()?; - let gpg_agent_sock = home_dir.join(".gnupg").join("S.gpg-agent.ssh"); - if let Ok(metadata) = fs::metadata(&gpg_agent_sock).await { - if metadata_is_socket(&metadata) { - info!( - "git: using gpg-agent SSH socket: {}", - gpg_agent_sock.to_string_lossy() - ); - return Some(gpg_agent_sock.to_string_lossy().to_string()); - } - } - - let mut gpgconf_path: Option = None; - if let Some(stdout) = run_gpgconf(&["--list-dirs", "agent-ssh-socket"]).await { - let candidate = String::from_utf8_lossy(&stdout).trim().to_string(); - if !candidate.is_empty() { - let path = PathBuf::from(candidate); - info!("git: gpgconf reported SSH socket at {}", path.to_string_lossy()); - if let Ok(metadata) = fs::metadata(&path).await { - if metadata_is_socket(&metadata) { - info!("git: gpgconf socket exists and is a socket"); - return Some(path.to_string_lossy().to_string()); - } - } - gpgconf_path = Some(path); - } - } - - if gpgconf_path.is_some() { - info!("git: launching gpg-agent via gpgconf"); - let _ = run_gpgconf(&["--launch", "gpg-agent"]).await; - if let Some(stdout) = run_gpgconf(&["--list-dirs", "agent-ssh-socket"]).await { - let candidate = String::from_utf8_lossy(&stdout).trim().to_string(); - if !candidate.is_empty() { - let path = PathBuf::from(candidate); - info!("git: gpgconf retried SSH socket at {}", path.to_string_lossy()); - if let Ok(metadata) = fs::metadata(&path).await { - if metadata_is_socket(&metadata) { - info!("git: gpgconf socket exists and is a socket after launch"); - return Some(path.to_string_lossy().to_string()); - } - } - } - } - } - - if let Some(fallback) = launchd_fallback { - info!("git: falling back to launchd SSH_AUTH_SOCK: {}", fallback); - return Some(fallback); - } - - warn!("git: no SSH_AUTH_SOCK resolved"); - None -} - -async fn run_git(args: &[&str], cwd: &Path) -> Result { - run_git_with_allowed_exit(args, cwd, &[]).await -} - -async fn run_git_with_allowed_exit( - args: &[&str], - cwd: &Path, - allowed_codes: &[i32], -) -> Result { - info!("git: running command {:?}", args); - let ssh_auth_sock = resolve_ssh_auth_sock().await; - let mut command = Command::new("git"); - command - .args(args) - .current_dir(cwd) - .stdin(Stdio::null()) - .kill_on_drop(true) - .env("GIT_OPTIONAL_LOCKS", "0") - .env("GIT_TERMINAL_PROMPT", "0") - .env("GCM_INTERACTIVE", "Never") - .env("LC_ALL", "C"); - if let Some(sock) = ssh_auth_sock.as_deref() { - info!("git: setting SSH_AUTH_SOCK for command: {}", sock); - command.env("SSH_AUTH_SOCK", sock); - } - let output = command - .output() - .await - .context("Failed to execute git command")?; - - if !output.status.success() { - if let Some(code) = output.status.code() { - if allowed_codes.contains(&code) { - return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); - } - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(anyhow!("{}", stderr)); - } - - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - -async fn run_git_bytes_with_allowed_exit_timeout( - args: &[&str], - cwd: &Path, - allowed_codes: &[i32], - timeout_ms: u64, -) -> Result> { - info!("git: running command {:?}", args); - let ssh_auth_sock = resolve_ssh_auth_sock().await; - let mut command = Command::new("git"); - command - .args(args) - .current_dir(cwd) - .env("GIT_OPTIONAL_LOCKS", "0") - .env("GIT_TERMINAL_PROMPT", "0") - .env("GCM_INTERACTIVE", "Never") - .env("LC_ALL", "C") - .stdin(Stdio::null()) - .kill_on_drop(true); - if let Some(sock) = ssh_auth_sock.as_deref() { - info!("git: setting SSH_AUTH_SOCK for command: {}", sock); - command.env("SSH_AUTH_SOCK", sock); - } - let output = tokio::time::timeout( - std::time::Duration::from_millis(timeout_ms), - command.output(), - ) - .await - .map_err(|_| anyhow!("Git command timed out after {}ms", timeout_ms))? - .context("Failed to execute git command")?; - - if !output.status.success() { - if let Some(code) = output.status.code() { - if allowed_codes.contains(&code) { - return Ok(output.stdout); - } - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(anyhow!("{}", stderr)); - } - - Ok(output.stdout) -} - -async fn read_file_bytes_limited(path: &Path, max_bytes: u64) -> Result<(Vec, bool)> { - let file = tokio::fs::File::open(path).await?; - let mut buf = Vec::new(); - let mut limited = file.take(max_bytes); - limited.read_to_end(&mut buf).await?; - - let truncated = match tokio::fs::metadata(path).await { - Ok(meta) => meta.len() > max_bytes, - Err(_) => false, - }; - - Ok((buf, truncated)) -} - -async fn read_file_bytes_limited_with_timeout( - path: &Path, - max_bytes: u64, - timeout_ms: u64, -) -> Result<(Vec, bool)> { - tokio::time::timeout( - std::time::Duration::from_millis(timeout_ms), - read_file_bytes_limited(path, max_bytes), - ) - .await - .map_err(|_| anyhow!("File read timed out after {}ms", timeout_ms))? -} - -async fn metadata_with_timeout(path: &Path, timeout_ms: u64) -> Result { - tokio::time::timeout( - std::time::Duration::from_millis(timeout_ms), - fs::metadata(path), - ) - .await - .map_err(|_| anyhow!("Metadata read timed out after {}ms", timeout_ms))? - .map_err(|e| e.into()) -} - -fn normalize_relative_path(path: &Path) -> PathBuf { - let mut result = PathBuf::new(); - for component in path.components() { - match component { - Component::ParentDir => { - result.pop(); - } - Component::CurDir => {} - Component::Normal(part) => result.push(part), - _ => {} - } - } - result -} - -async fn resolve_repo_root(root: &Path) -> PathBuf { - match run_git(&["rev-parse", "--show-toplevel"], root).await { - Ok(output) => { - let trimmed = output.trim(); - if trimmed.is_empty() { - root.to_path_buf() - } else { - PathBuf::from(trimmed) - } - } - Err(_) => root.to_path_buf(), - } -} - -async fn resolve_git_paths(root: &Path, path_str: &str) -> (PathBuf, PathBuf, String) { - let repo_root = resolve_repo_root(root).await; - let path_candidate = if path_str.contains(" -> ") { - path_str - .split(" -> ") - .last() - .unwrap_or(path_str) - .trim() - .to_string() - } else { - path_str.to_string() - }; - - let input_path = Path::new(&path_candidate); - let absolute_path = if input_path.is_absolute() { - input_path.to_path_buf() - } else { - let from_root = root.join(input_path); - if metadata_with_timeout(&from_root, GIT_FILE_DIFF_TIMEOUT_MS) - .await - .is_ok() - { - from_root - } else { - repo_root.join(input_path) - } - }; - - let relative_path = absolute_path - .strip_prefix(&repo_root) - .unwrap_or(input_path) - .to_path_buf(); - let normalized_relative = normalize_relative_path(&relative_path); - let mut relative_str = normalized_relative.to_string_lossy().replace('\\', "/"); - if relative_str.is_empty() || Path::new(&relative_str).is_absolute() { - relative_str = path_candidate; - } - - (repo_root, absolute_path, relative_str) -} - -async fn resolve_path_for_git_show(root: &Path, path_str: &str) -> (PathBuf, PathBuf, String) { - // Prefer asking git for the repo-root-relative path when possible. - // This avoids subtle worktree/subdir path prefix issues. - let path_candidate = if path_str.contains(" -> ") { - path_str - .split(" -> ") - .last() - .unwrap_or(path_str) - .trim() - .to_string() - } else { - path_str.to_string() - }; - - let ls_files = run_git(&["ls-files", "--full-name", "--", &path_candidate], root) - .await - .unwrap_or_default(); - let resolved = ls_files.lines().next().unwrap_or("").trim(); - - let (repo_root, full_path, fallback_relative) = resolve_git_paths(root, &path_candidate).await; - if !resolved.is_empty() { - return ( - repo_root, - full_path, - resolved.to_string().replace('\\', "/"), - ); - } - - (repo_root, full_path, fallback_relative) -} - -fn append_git_option(args: &mut Vec, value: &Value) { - match value { - Value::Null => {} - Value::Bool(false) => {} - Value::Bool(true) => {} - Value::Number(num) => args.push(num.to_string()), - Value::String(text) => { - let trimmed = text.trim(); - if !trimmed.is_empty() { - args.push(trimmed.to_string()); - } - } - Value::Array(items) => { - for item in items { - append_git_option(args, item); - } - } - Value::Object(map) => append_git_option_map(args, map), - } -} - -fn append_git_option_map(args: &mut Vec, map: &serde_json::Map) { - for (key, value) in map { - let flag = key.trim(); - if flag.is_empty() { - continue; - } - - match value { - Value::Null | Value::Bool(true) => args.push(flag.to_string()), - Value::Bool(false) => {} - Value::String(text) => args.push(format!("{flag}={text}")), - Value::Number(num) => args.push(format!("{flag}={num}")), - Value::Array(items) => { - if items.is_empty() { - args.push(flag.to_string()); - } else { - for item in items { - match item { - Value::Null | Value::Bool(true) => args.push(flag.to_string()), - Value::Bool(false) => {} - Value::String(text) => args.push(format!("{flag}={text}")), - Value::Number(num) => args.push(format!("{flag}={num}")), - other => append_git_option(args, other), - } - } - } - } - other => args.push(format!("{flag}={other}")), - } - } -} - -// Removed unused resolve_workspace_root function - -async fn validate_git_path(path: &str, _settings: &SettingsStore) -> Result { - let path_buf = expand_tilde_path(path); - if !path_buf.exists() { - return Err(anyhow!("Directory does not exist: {}", path)); - } - - if !path_buf.is_absolute() { - return Err(anyhow!("Path must be absolute")); - } - - Ok(path_buf) -} - -// --- Identity Storage --- - -async fn get_identity_storage_path() -> Result { - let mut path = dirs::home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?; - path.push(".config"); - path.push("openchamber"); - fs::create_dir_all(&path).await?; - path.push(GIT_IDENTITY_STORAGE_FILE); - Ok(path) -} - -async fn load_identities() -> Result> { - let path = get_identity_storage_path().await?; - info!("Loading identities from {:?}", path); - - if !path.exists() { - info!("Identities file does not exist at {:?}", path); - return Ok(Vec::new()); - } - - let content = fs::read_to_string(&path).await?; - info!("Read {} bytes from identities file", content.len()); - - let wrapper: serde_json::Value = match serde_json::from_str(&content) { - Ok(w) => w, - Err(e) => { - error!("Failed to parse identities JSON: {}", e); - return Err(e.into()); - } - }; - - // Handle both array and object wrapper format if needed, but spec says object with profiles array - if let Some(profiles) = wrapper.get("profiles") { - match serde_json::from_value::>(profiles.clone()) { - Ok(p) => { - info!("Successfully loaded {} profiles", p.len()); - Ok(p) - } - Err(e) => { - error!("Failed to deserialize profiles array: {}", e); - // Log the failing JSON segment for debugging - warn!("Profiles JSON: {}", profiles); - Err(e.into()) - } - } - } else { - warn!("No 'profiles' key found in identities JSON"); - Ok(Vec::new()) - } -} - -async fn save_identities(profiles: Vec) -> Result<()> { - let path = get_identity_storage_path().await?; - let wrapper = GitIdentityProfilesWrapper { profiles }; - let content = serde_json::to_string_pretty(&wrapper)?; - fs::write(path, content).await?; - Ok(()) -} - -// --- Commands --- - -#[tauri::command] -pub async fn check_is_git_repository( - directory: String, - state: State<'_, DesktopRuntime>, -) -> Result { - let path = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - match run_git(&["rev-parse", "--is-inside-work-tree"], &path).await { - Ok(output) => Ok(output.trim().eq_ignore_ascii_case("true")), - Err(_) => Ok(false), - } -} - -#[tauri::command] -pub async fn get_git_status( - directory: String, - state: State<'_, DesktopRuntime>, -) -> Result { - let path = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - // 1. Get porcelain status - // Use -uall to show all untracked files individually, not just directories - let status_output = run_git(&["status", "--porcelain", "-b", "-z", "-uall"], &path) - .await - .map_err(|e| e.to_string())?; - - // Parse status output - let mut files = Vec::new(); - let mut current = String::new(); - let mut tracking = None; - let mut ahead = 0; - let mut behind = 0; - - let entries: Vec<&str> = status_output.split('\0').collect(); - let mut i = 0usize; - - while i < entries.len() { - let entry = entries[i]; - i += 1; - - if entry.is_empty() { - continue; - } - - if entry.starts_with("## ") { - // Branch info: ## main...origin/main [ahead 1, behind 2] - let branch_line = &entry[3..]; - if let Some((local, remote_part)) = branch_line.split_once("...") { - current = local.to_string(); - // Parse remote part for ahead/behind - // Format: origin/main [ahead 1, behind 2] or origin/main - if let Some(bracket_start) = remote_part.find('[') { - tracking = Some(remote_part[..bracket_start].trim().to_string()); - let stats = &remote_part[bracket_start + 1..remote_part.len() - 1]; // inside brackets - for part in stats.split(", ") { - if let Some(val) = part.strip_prefix("ahead ") { - ahead = val.parse().unwrap_or(0); - } else if let Some(val) = part.strip_prefix("behind ") { - behind = val.parse().unwrap_or(0); - } - } - } else { - tracking = Some(remote_part.trim().to_string()); - } - } else { - // No remote or initial commit - current = branch_line.to_string(); - } - continue; - } - - // File entries (porcelain v1, -z): - // - Normal: XYpath - // - Rename/Copy: XYold_pathnew_path - if entry.len() >= 4 { - let index_status = &entry[0..1]; - let working_status = &entry[1..2]; - let mut file_path = &entry[3..]; - - // Handle rename/copy by consuming the next NUL-terminated token as the new path. - let is_rename_or_copy = index_status == "R" - || working_status == "R" - || index_status == "C" - || working_status == "C"; - if is_rename_or_copy && i < entries.len() { - let next_path = entries[i]; - if !next_path.is_empty() { - file_path = next_path; - i += 1; - } - } - - // Simple-git parsing logic approximation - files.push(GitStatusFile { - path: file_path.to_string(), - index: index_status.trim().to_string(), - working_dir: working_status.trim().to_string(), - }); - } - } - - // 2. Get diff stats (staged and unstaged) - let mut diff_stats = HashMap::new(); - - let collect_stats = |output: String| { - let mut stats = HashMap::new(); - for line in output.lines() { - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() >= 3 { - let insertions = if parts[0] == "-" { - 0 - } else { - parts[0].parse().unwrap_or(0) - }; - let deletions = if parts[1] == "-" { - 0 - } else { - parts[1].parse().unwrap_or(0) - }; - let path = parts[2].to_string(); - stats.insert( - path, - DiffStat { - insertions, - deletions, - }, - ); - } - } - stats - }; - - let staged_stats_raw = run_git(&["diff", "--cached", "--numstat"], &path) - .await - .unwrap_or_default(); - let working_stats_raw = run_git(&["diff", "--numstat"], &path) - .await - .unwrap_or_default(); - - let staged_stats = collect_stats(staged_stats_raw); - let working_stats = collect_stats(working_stats_raw); - - // Merge stats - let mut all_paths: HashSet = staged_stats.keys().cloned().collect(); - all_paths.extend(working_stats.keys().cloned()); - - for p in all_paths { - let s = staged_stats.get(&p).unwrap_or(&DiffStat { - insertions: 0, - deletions: 0, - }); - let w = working_stats.get(&p).unwrap_or(&DiffStat { - insertions: 0, - deletions: 0, - }); - diff_stats.insert( - p, - DiffStat { - insertions: s.insertions + w.insertions, - deletions: s.deletions + w.deletions, - }, - ); - } - - // 3. Handle new/untracked files (manual calculation if needed, or skip if complex) - // Node implementation does manual read. For now, let's assume files with '??' or 'A' - // might need stats if they aren't in numstat. - // NOTE: untracked files don't show up in `git diff --numstat`. - // We can try `wc -l` logic but Rust fs read is safer. - - for file in &files { - if (file.working_dir == "?" || file.index == "A") && !diff_stats.contains_key(&file.path) { - let full_path = path.join(&file.path); - if let Ok(metadata) = fs::metadata(&full_path).await { - if metadata.is_file() { - if let Ok(content) = fs::read_to_string(&full_path).await { - let lines = content.lines().count() as i32; - diff_stats.insert( - file.path.clone(), - DiffStat { - insertions: lines, - deletions: 0, - }, - ); - } - } - } - } - } - - // When there's no upstream yet (e.g. a freshly-created local worktree branch), - // git status doesn't report ahead/behind. We still want to surface unpublished commits. - if tracking.is_none() && !current.trim().is_empty() { - let mut base_candidates: Vec = Vec::new(); - - let origin_head = run_git_with_allowed_exit( - &["symbolic-ref", "-q", "refs/remotes/origin/HEAD"], - &path, - &[1], - ) - .await - .unwrap_or_default(); - - if !origin_head.trim().is_empty() { - base_candidates.push(origin_head.trim().replace("refs/remotes/", "")); - } - - base_candidates.push("origin/main".to_string()); - base_candidates.push("origin/master".to_string()); - base_candidates.push("main".to_string()); - base_candidates.push("master".to_string()); - - let mut selected_base: Option = None; - for candidate in base_candidates { - let verified = - run_git_with_allowed_exit(&["rev-parse", "--verify", &candidate], &path, &[1]) - .await - .unwrap_or_default(); - - if !verified.trim().is_empty() { - selected_base = Some(candidate); - break; - } - } - - if let Some(base_ref) = selected_base { - let range = format!("{}..HEAD", base_ref); - if let Ok(raw) = run_git(&["rev-list", "--count", &range], &path).await { - if let Ok(count) = raw.trim().parse::() { - ahead = count; - behind = 0; - } - } - } - } - - Ok(GitStatus { - current, - tracking, - ahead, - behind, - is_clean: files.is_empty(), - files, - diff_stats: Some(diff_stats), - }) -} - -#[tauri::command] -pub async fn get_git_diff( - directory: String, - path_str: String, - staged: Option, - context_lines: Option, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - let mut args = vec!["diff", "--no-color"]; - let context = format!("-U{}", context_lines.unwrap_or(3)); - args.push(&context); - - if staged.unwrap_or(false) { - args.push("--cached"); - } - - args.push("--"); - args.push(&path_str); - - let output = run_git(&args, &root).await.unwrap_or_default(); - - if output.trim().is_empty() && !staged.unwrap_or(false) { - // Try --no-index for untracked files - // git diff --no-index -- /dev/null path - let full_path = root.join(&path_str); - if full_path.exists() { - let args_no_index = vec![ - "diff", - "--no-color", - &context, - "--no-index", - "--", - "/dev/null", - &path_str, - ]; - return run_git_with_allowed_exit(&args_no_index, &root, &[1]) - .await - .map_err(|e| e.to_string()); - } - } - - Ok(output) -} - -const IMAGE_EXTENSIONS: &[&str] = &[ - "png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp", "avif", -]; - -fn is_image_file(path: &str) -> bool { - if let Some(ext) = path.rsplit('.').next() { - IMAGE_EXTENSIONS.contains(&ext.to_lowercase().as_str()) - } else { - false - } -} - -fn get_image_mime_type(path: &str) -> &'static str { - let ext = path.rsplit('.').next().unwrap_or("").to_lowercase(); - match ext.as_str() { - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "svg" => "image/svg+xml", - "webp" => "image/webp", - "ico" => "image/x-icon", - "bmp" => "image/bmp", - "avif" => "image/avif", - _ => "application/octet-stream", - } -} - -fn truncate_string_to_char_boundary(mut value: String, max_chars: usize, suffix: &str) -> String { - if value.len() <= max_chars { - return value; - } - - let mut cut = max_chars.min(value.len()); - while cut > 0 && !value.is_char_boundary(cut) { - cut -= 1; - } - - value.truncate(cut); - value.push_str(suffix); - value -} - -fn cap_ipc_payload(value: String) -> String { - if value.is_empty() { - return value; - } - - // Truncating base64 data URLs would produce invalid images; drop instead. - if value.starts_with("data:") { - return if value.len() <= GIT_FILE_IPC_MAX_CHARS { - value - } else { - String::new() - }; - } - - truncate_string_to_char_boundary( - value, - GIT_FILE_IPC_MAX_CHARS, - "\n…(truncated for desktop)\n", - ) -} - -async fn run_git_binary(args: &[&str], cwd: &Path) -> Result> { - run_git_bytes_with_allowed_exit_timeout(args, cwd, &[0, 128], GIT_FILE_DIFF_TIMEOUT_MS).await -} - -#[tauri::command] -pub async fn get_git_file_diff( - directory: String, - path_str: String, - state: State<'_, DesktopRuntime>, -) -> Result<(String, String), String> { - use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; - use tokio::fs; - - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - let (repo_root, full_path, relative_path) = resolve_path_for_git_show(&root, &path_str).await; - let is_image = is_image_file(&relative_path); - let mime_type = if is_image { - get_image_mime_type(&relative_path) - } else { - "" - }; - - // Original from HEAD - let original = if is_image { - // For images, get binary content and convert to data URL - let original_spec = format!("HEAD:{}", relative_path); - match run_git_binary(&["show", &original_spec], &repo_root).await { - Ok(bytes) if !bytes.is_empty() => { - if bytes.len() as u64 > GIT_FILE_IMAGE_MAX_BYTES { - String::new() - } else { - format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)) - } - } - _ => String::new(), - } - } else { - let original_spec = format!("HEAD:{}", relative_path); - match run_git_bytes_with_allowed_exit_timeout( - &["show", original_spec.as_str()], - &repo_root, - &[0, 128], - GIT_FILE_DIFF_TIMEOUT_MS, - ) - .await - { - Ok(bytes) if !bytes.is_empty() => { - if bytes.len() as u64 > GIT_FILE_TEXT_MAX_BYTES { - let mut text = String::from_utf8_lossy( - &bytes[..(GIT_FILE_TEXT_MAX_BYTES as usize).min(bytes.len())], - ) - .to_string(); - text.push_str("\n…(truncated)\n"); - text - } else { - String::from_utf8_lossy(&bytes).to_string() - } - } - _ => String::new(), - } - }; - - // Modified from working tree (if file exists) - let modified = - if let Ok(metadata) = metadata_with_timeout(&full_path, GIT_FILE_DIFF_TIMEOUT_MS).await { - if metadata.is_file() { - if is_image { - // For images, read as binary and convert to data URL - if metadata.len() > GIT_FILE_IMAGE_MAX_BYTES { - String::new() - } else { - match tokio::time::timeout( - std::time::Duration::from_millis(GIT_FILE_DIFF_TIMEOUT_MS), - fs::read(&full_path), - ) - .await - { - Ok(Ok(bytes)) => { - format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)) - } - _ => String::new(), - } - } - } else { - match read_file_bytes_limited_with_timeout( - &full_path, - GIT_FILE_TEXT_MAX_BYTES, - GIT_FILE_DIFF_TIMEOUT_MS, - ) - .await - { - Ok((bytes, truncated)) => { - let mut text = String::from_utf8_lossy(&bytes).to_string(); - if truncated { - text.push_str("\n…(truncated)\n"); - } - text - } - Err(_) => String::new(), - } - } - } else { - String::new() - } - } else { - String::new() - }; - - Ok((cap_ipc_payload(original), cap_ipc_payload(modified))) -} - -#[tauri::command] -pub async fn revert_git_file( - directory: String, - file_path: String, - state: State<'_, DesktopRuntime>, -) -> Result<(), String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - // Check if tracked - let is_tracked = run_git(&["ls-files", "--error-unmatch", &file_path], &root) - .await - .is_ok(); - - if !is_tracked { - // Clean untracked - let _ = run_git(&["clean", "-f", "-d", "--", &file_path], &root).await; - // Fallback fs remove if git clean failed (e.g. ignored files) - let full_path = root.join(&file_path); - if full_path.exists() { - if full_path.is_dir() { - let _ = fs::remove_dir_all(full_path).await; - } else { - let _ = fs::remove_file(full_path).await; - } - } - } else { - // Restore staged - let _ = run_git(&["restore", "--staged", &file_path], &root).await; - // Restore working - let _ = run_git(&["restore", &file_path], &root).await; - } - - Ok(()) -} - -#[tauri::command] -pub async fn is_linked_worktree( - directory: String, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - let git_dir = run_git(&["rev-parse", "--git-dir"], &root) - .await - .unwrap_or_default(); - let common_dir = run_git(&["rev-parse", "--git-common-dir"], &root) - .await - .unwrap_or_default(); - Ok(git_dir.trim() != common_dir.trim()) -} - -#[tauri::command] -pub async fn get_git_branches( - directory: String, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - // Discover actual remote heads so we can drop stale remote-tracking refs - let allowed_remote_heads: Option> = - match run_git_bytes_with_allowed_exit_timeout( - &["ls-remote", "--heads", "origin"], - &root, - &[0], - GIT_LS_REMOTE_TIMEOUT_MS, - ) - .await - { - Ok(bytes) => { - let ls_remote = String::from_utf8_lossy(&bytes); - let mut set = HashSet::new(); - for line in ls_remote.lines() { - if let Some((_, ref_name)) = line.split_once('\t') { - if let Some(stripped) = ref_name.trim().strip_prefix("refs/heads/") { - set.insert(stripped.to_string()); - } - } - } - Some(set) - } - Err(err) => { - warn!("Failed to list remote heads: {}", err); - None - } - }; - - // Structured for-each-ref output so we can mark remotes consistently with the web runtime - let output = run_git( - &[ - "for-each-ref", - "--format=%(refname)|%(refname:short)|%(objectname)|%(upstream:short)|%(HEAD)|%(upstream:track)", - "refs/heads", - "refs/remotes", - ], - &root, - ) - .await - .map_err(|e| e.to_string())?; - - let mut all = Vec::new(); - let mut current_branch = String::new(); - let mut branches = HashMap::new(); - - for line in output.lines() { - let parts: Vec<&str> = line.split('|').collect(); - if parts.len() < 6 { - continue; - } - - let full_ref = parts[0].trim(); - let short_name = parts[1].trim(); - let commit = parts[2].to_string(); - let upstream = parts[3].trim(); - let is_current = parts[4] == "*"; - let track_info = parts[5]; - - let is_remote = full_ref.starts_with("refs/remotes/"); - - let normalized_name = if is_remote { - let (remote_name, branch_name) = match short_name.split_once('/') { - Some(parts) => parts, - None => continue, // skip malformed remote ref without branch - }; - - if branch_name == "HEAD" { - continue; - } - - if let Some(allowed) = &allowed_remote_heads { - if !allowed.contains(branch_name) { - continue; - } - } - - format!("remotes/{}/{}", remote_name, branch_name) - } else { - short_name.to_string() - }; - - let tracking = if upstream.is_empty() { - None - } else { - Some(upstream.to_string()) - }; - - if is_current { - current_branch = normalized_name.clone(); - } - all.push(normalized_name.clone()); - - let mut ahead = None; - let mut behind = None; - - // Parse track info like "[ahead 1, behind 2]" - if !track_info.is_empty() { - let content = track_info.trim_matches(|c| c == '[' || c == ']'); - for part in content.split(", ") { - if let Some(val) = part.strip_prefix("ahead ") { - ahead = val.parse().ok(); - } else if let Some(val) = part.strip_prefix("behind ") { - behind = val.parse().ok(); - } - } - } - - branches.insert( - normalized_name.clone(), - GitBranchDetails { - current: is_current, - name: normalized_name, - commit, - label: short_name.to_string(), - tracking, - ahead, - behind, - }, - ); - } - - Ok(GitBranch { - all, - current: current_branch, - branches, - }) -} - -#[tauri::command] -pub async fn delete_git_branch( - directory: String, - branch: String, - force: Option, - state: State<'_, DesktopRuntime>, -) -> Result<(), String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - let flag = if force.unwrap_or(false) { "-D" } else { "-d" }; - run_git(&["branch", flag, &branch], &root) - .await - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub async fn delete_remote_branch( - directory: String, - branch: String, - remote: Option, - state: State<'_, DesktopRuntime>, -) -> Result<(), String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - let remote_name = remote.unwrap_or_else(|| "origin".to_string()); - - // branch might be refs/heads/foo or just foo - let clean_branch = branch.trim_start_matches("refs/heads/"); - - run_git(&["push", &remote_name, "--delete", clean_branch], &root) - .await - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub async fn list_git_worktrees( - directory: String, - state: State<'_, DesktopRuntime>, -) -> Result, String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - let output = run_git(&["worktree", "list", "--porcelain"], &root) - .await - .map_err(|e| e.to_string())?; - - let mut worktrees = Vec::new(); - let mut current = GitWorktreeInfo { - worktree: String::new(), - head: None, - branch: None, - }; - - for line in output.lines() { - if let Some(cap) = WORKTREE_REGEX.captures(line) { - if !current.worktree.is_empty() { - worktrees.push(current.clone()); - current = GitWorktreeInfo { - worktree: String::new(), - head: None, - branch: None, - }; - } - current.worktree = cap[1].to_string(); - } else if let Some(cap) = HEAD_REGEX.captures(line) { - current.head = Some(cap[1].to_string()); - } else if let Some(cap) = BRANCH_REGEX.captures(line) { - current.branch = Some(cap[1].trim_start_matches("refs/heads/").to_string()); - } else if line.is_empty() { - if !current.worktree.is_empty() { - worktrees.push(current.clone()); - current = GitWorktreeInfo { - worktree: String::new(), - head: None, - branch: None, - }; - } - } - } - if !current.worktree.is_empty() { - worktrees.push(current); - } - - Ok(worktrees) -} - -#[tauri::command] -pub async fn add_git_worktree( - directory: String, - path_str: String, - branch: String, - create_branch: Option, - start_point: Option, - state: State<'_, DesktopRuntime>, -) -> Result<(), String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - let mut args = vec!["worktree", "add"]; - if create_branch.unwrap_or(false) { - args.push("-b"); - args.push(&branch); - } - args.push(&path_str); - - if !create_branch.unwrap_or(false) { - args.push(&branch); - } else if let Some(start_point) = start_point.as_deref() { - let start_point = start_point.trim(); - if !start_point.is_empty() { - args.push(start_point); - } - } - - run_git(&args, &root).await.map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub async fn remove_git_worktree( - directory: String, - path_str: String, - force: Option, - state: State<'_, DesktopRuntime>, -) -> Result<(), String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - let mut args = vec!["worktree", "remove", &path_str]; - if force.unwrap_or(false) { - args.push("--force"); - } - run_git(&args, &root).await.map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub async fn ensure_openchamber_ignored( - // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. - directory: String, - state: State<'_, DesktopRuntime>, -) -> Result<(), String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - let exclude_path = root.join(".git/info/exclude"); - - if let Some(parent) = exclude_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| e.to_string())?; - } - - let entry = "/.openchamber/\n"; - let mut content = fs::read_to_string(&exclude_path).await.unwrap_or_default(); - - if !content.contains("/.openchamber/") { - if !content.ends_with('\n') && !content.is_empty() { - content.push('\n'); - } - content.push_str(entry); - fs::write(&exclude_path, content) - .await - .map_err(|e| e.to_string())?; - } - Ok(()) -} - -#[tauri::command] -pub async fn create_git_commit( - directory: String, - message: String, - add_all: Option, - files: Option>, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - if add_all.unwrap_or(false) { - run_git(&["add", "."], &root) - .await - .map_err(|e| e.to_string())?; - } else if let Some(file_list) = files { - if !file_list.is_empty() { - let mut args = vec!["add"]; - args.extend(file_list.iter().map(|s| s.as_str())); - run_git(&args, &root).await.map_err(|e| e.to_string())?; - } - } - - run_git(&["commit", "-m", &message], &root) - .await - .map_err(|e| e.to_string())?; - - let commit_hash = get_head_hash(&root).await.map_err(|e| e.to_string())?; - let branch_name = get_current_branch_name(&root) - .await - .unwrap_or_else(|_| "HEAD".to_string()); - - let stat_output = run_git(&["log", "-1", "--pretty=", "--shortstat"], &root) - .await - .unwrap_or_default(); - let summary = parse_shortstat(&stat_output); - - Ok(GitCommitResult { - success: true, - commit: commit_hash, - branch: branch_name, - summary, - }) -} - -#[tauri::command] -pub async fn git_push( - directory: String, - remote: Option, - branch: Option, - options: Option, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - let remote_name = remote.unwrap_or_else(|| "origin".to_string()); - let explicit_branch = branch - .as_deref() - .map(|value| !value.trim().is_empty()) - .unwrap_or(false); - let mut branch_name = branch.unwrap_or_default(); - - let mut args = vec!["push".to_string(), remote_name.clone()]; - if branch_name.is_empty() { - branch_name = get_current_branch_name(&root).await.unwrap_or_default(); - } - - if !branch_name.is_empty() { - // If caller didn't specify a branch and there's no upstream configured yet, - // publish on first push so future pushes/pulls work without extra prompts. - if !explicit_branch { - let remote_key = format!("branch.{}.remote", branch_name); - let merge_key = format!("branch.{}.merge", branch_name); - - let upstream_remote = - run_git_with_allowed_exit(&["config", "--get", &remote_key], &root, &[1]) - .await - .unwrap_or_default(); - - let upstream_merge = - run_git_with_allowed_exit(&["config", "--get", &merge_key], &root, &[1]) - .await - .unwrap_or_default(); - - if upstream_remote.trim().is_empty() || upstream_merge.trim().is_empty() { - args.push("--set-upstream".to_string()); - } - } - - args.push(branch_name.clone()); - } - - if let Some(extra) = options.as_ref() { - append_git_option(&mut args, extra); - } - - let arg_refs: Vec<&str> = args.iter().map(|value| value.as_str()).collect(); - - // TODO: Streaming? Frontend types.ts defines GitPushResult, but doesn't mention streaming response for this call, - // but Stage 2 plan says "streaming progress events for long operations". - // Implementing simple await for now as `simple-git` wrapper does in `git-service.js`. - - run_git(&arg_refs, &root).await.map_err(|e| e.to_string())?; - - Ok(GitPushResult { - success: true, - pushed: if branch_name.is_empty() { - vec![] - } else { - vec![GitPushRef { - local: branch_name.clone(), - remote: format!("{}/{}", remote_name, branch_name), - }] - }, - repo: remote_name, - ref_: if branch_name.is_empty() { - None - } else { - Some(branch_name) - }, - }) -} - -#[tauri::command] -pub async fn git_pull( - directory: String, - remote: Option, - branch: Option, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - let r = remote.unwrap_or_else(|| "origin".to_string()); - let b = branch.unwrap_or_default(); - - let mut args = vec!["pull", &r]; - if !b.is_empty() { - args.push(&b); - } - - let previous_head = get_head_hash(&root).await.ok(); - - run_git(&args, &root).await.map_err(|e| e.to_string())?; - - let (summary, files) = if let Some(previous) = previous_head { - let new_head = get_head_hash(&root).await.unwrap_or(previous.clone()); - if new_head != previous { - let range = format!("{previous}..{new_head}"); - let summary = collect_shortstat_for_range(&root, &range) - .await - .unwrap_or_else(|_| GitCommitSummary { - changes: 0, - insertions: 0, - deletions: 0, - }); - let files = collect_changed_files_for_range(&root, &range) - .await - .unwrap_or_default(); - (summary, files) - } else { - ( - GitCommitSummary { - changes: 0, - insertions: 0, - deletions: 0, - }, - vec![], - ) - } - } else { - ( - GitCommitSummary { - changes: 0, - insertions: 0, - deletions: 0, - }, - vec![], - ) - }; - - Ok(GitPullResult { - success: true, - summary: summary.clone(), - files, - insertions: summary.insertions, - deletions: summary.deletions, - }) -} - -#[tauri::command] -pub async fn git_fetch( - directory: String, - remote: Option, - state: State<'_, DesktopRuntime>, -) -> Result<(), String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - let r = remote.unwrap_or_else(|| "origin".to_string()); - run_git(&["fetch", &r], &root) - .await - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub async fn checkout_branch( - directory: String, - branch: String, - state: State<'_, DesktopRuntime>, -) -> Result<(), String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - run_git(&["checkout", &branch], &root) - .await - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub async fn create_branch( - directory: String, - name: String, - start_point: Option, - state: State<'_, DesktopRuntime>, -) -> Result<(), String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - let start = start_point.unwrap_or_else(|| "HEAD".to_string()); - run_git(&["checkout", "-b", &name, &start], &root) - .await - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub async fn rename_branch( - directory: String, - old_name: String, - new_name: String, - state: State<'_, DesktopRuntime>, -) -> Result<(), String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - run_git(&["branch", "-m", &old_name, &new_name], &root) - .await - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub async fn get_git_log( - directory: String, - max_count: Option, - from: Option, - to: Option, - file: Option, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - let max = max_count.unwrap_or(50).to_string(); - let mut args = vec![ - "log", - "--max-count", - &max, - "--date=iso", - "--pretty=format:%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1e", - "--shortstat", - ]; - - let range; - if let (Some(f), Some(t)) = (&from, &to) { - range = format!("{}..{}", f, t); - args.push(&range); - } else if let Some(f) = &from { - range = format!("{}..HEAD", f); - args.push(&range); - } else if let Some(t) = &to { - args.push(t); - } - - if let Some(f) = &file { - args.push("--"); - args.push(f); - } - - let output = run_git(&args, &root).await.map_err(|e| e.to_string())?; - - let mut entries = Vec::new(); - let entries_raw: Vec<&str> = output.split('\x1e').collect(); - - let mut current_header = if !entries_raw.is_empty() { - entries_raw[0].trim() - } else { - "" - }; - - for i in 1..entries_raw.len() { - let chunk = entries_raw[i]; - - if current_header.is_empty() { - break; - } - - let header_parts: Vec<&str> = current_header.split('\x1f').collect(); - if header_parts.len() < 5 { - // Try to recover next header anyway before skipping - // But current_header is invalid, so we can't push an entry. - // We still need to update current_header for the next loop. - } else { - let hash = header_parts[0]; - let name = header_parts[1]; - let email = header_parts[2]; - let date = header_parts[3]; - let subject = header_parts[4]; - - let mut files_changed = 0; - let mut insertions = 0; - let mut deletions = 0; - - if let Some(cap) = FILES_CHANGED_REGEX.captures(chunk) { - files_changed = cap[1].parse().unwrap_or(0); - } - if let Some(cap) = INSERTIONS_REGEX.captures(chunk) { - insertions = cap[1].parse().unwrap_or(0); - } - if let Some(cap) = DELETIONS_REGEX.captures(chunk) { - deletions = cap[1].parse().unwrap_or(0); - } - - entries.push(GitLogEntry { - hash: hash.to_string(), - author_name: name.to_string(), - author_email: email.to_string(), - date: date.to_string(), - message: subject.to_string(), - body: String::new(), - refs: String::new(), - files_changed, - insertions, - deletions, - }); - } - - // Find next header by looking for the line containing \x1f (separator used in format) - // The chunk contains stats then the next header. - current_header = ""; - for line in chunk.lines().rev() { - let trimmed = line.trim(); - if !trimmed.is_empty() && trimmed.contains('\x1f') { - current_header = trimmed; - break; - } - } - } - - if entries.is_empty() && !output.is_empty() { - for line in output.lines() { - let parts: Vec<&str> = line.split('\x1f').collect(); - if parts.len() >= 5 { - entries.push(GitLogEntry { - hash: parts[0].to_string(), - author_name: parts[1].to_string(), - author_email: parts[2].to_string(), - date: parts[3].to_string(), - message: parts[4].to_string(), - body: "".to_string(), - refs: "".to_string(), - files_changed: 0, - insertions: 0, - deletions: 0, - }); - } - } - } - - Ok(GitLogResponse { - all: entries.clone(), - latest: entries.first().cloned(), - total: entries.len() as i32, - }) -} - -#[tauri::command] -pub async fn get_commit_files( - directory: String, - hash: String, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - // Get numstat for insertions/deletions per file - let numstat_output = run_git(&["show", "--numstat", "--format=", &hash], &root) - .await - .map_err(|e| e.to_string())?; - - let mut files = Vec::new(); - - for line in numstat_output.lines() { - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() < 3 { - continue; - } - - let insertions_raw = parts[0]; - let deletions_raw = parts[1]; - let file_path = parts[2..].join("\t"); - - if file_path.is_empty() { - continue; - } - - // Binary files show '-' for stats - let is_binary = insertions_raw == "-" && deletions_raw == "-"; - let insertions = if insertions_raw == "-" { - 0 - } else { - insertions_raw.parse().unwrap_or(0) - }; - let deletions = if deletions_raw == "-" { - 0 - } else { - deletions_raw.parse().unwrap_or(0) - }; - - files.push(CommitFileEntry { - path: file_path, - insertions, - deletions, - is_binary, - change_type: "M".to_string(), // Default, will update below - }); - } - - // Get accurate change types using --name-status - let name_status_output = run_git(&["show", "--name-status", "--format=", &hash], &root) - .await - .unwrap_or_default(); - - let mut status_map: HashMap = HashMap::new(); - for line in name_status_output.lines() { - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() >= 2 { - let status = parts[0].chars().next().unwrap_or('M').to_string(); - let path = parts.last().unwrap_or(&"").to_string(); - status_map.insert(path, status); - } - } - - // Update change types - for file in &mut files { - let base_path = if file.path.contains(" => ") { - file.path - .split(" => ") - .last() - .unwrap_or(&file.path) - .replace(['{', '}'], "") - } else { - file.path.clone() - }; - - if let Some(status) = status_map - .get(&base_path) - .or_else(|| status_map.get(&file.path)) - { - file.change_type = status.clone(); - } - } - - Ok(GitCommitFilesResponse { files }) -} - -#[tauri::command] -pub async fn get_git_identities() -> Result, String> { - load_identities().await.map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn create_git_identity( - profile: GitIdentityProfile, -) -> Result { - let mut profiles = load_identities().await.map_err(|e| e.to_string())?; - if profiles.iter().any(|p| p.id == profile.id) { - return Err(format!("Profile with ID {} already exists", profile.id)); - } - profiles.push(profile.clone()); - save_identities(profiles).await.map_err(|e| e.to_string())?; - Ok(profile) -} - -#[tauri::command] -pub async fn update_git_identity( - id: String, - updates: GitIdentityProfile, -) -> Result { - let mut profiles = load_identities().await.map_err(|e| e.to_string())?; - if let Some(idx) = profiles.iter().position(|p| p.id == id) { - profiles[idx] = updates.clone(); - save_identities(profiles).await.map_err(|e| e.to_string())?; - Ok(updates) - } else { - Err(format!("Profile with ID {} not found", id)) - } -} - -#[tauri::command] -pub async fn delete_git_identity(id: String) -> Result<(), String> { - let mut profiles = load_identities().await.map_err(|e| e.to_string())?; - let len = profiles.len(); - profiles.retain(|p| p.id != id); - if profiles.len() == len { - return Err(format!("Profile with ID {} not found", id)); - } - save_identities(profiles).await.map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub async fn get_remote_url( - directory: String, - remote: Option, - state: State<'_, DesktopRuntime>, -) -> Result, String> { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - let remote_name = remote.unwrap_or_else(|| "origin".to_string()); - let url = run_git(&["remote", "get-url", &remote_name], &root) - .await - .ok(); - - Ok(url.filter(|s| !s.is_empty())) -} - -#[tauri::command] -pub async fn get_current_git_identity( - directory: String, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - let user_name = run_git(&["config", "user.name"], &root).await.ok(); - let user_email = run_git(&["config", "user.email"], &root).await.ok(); - let ssh_command = run_git(&["config", "core.sshCommand"], &root).await.ok(); - - Ok(GitIdentitySummary { - user_name: user_name.filter(|s| !s.is_empty()), - user_email: user_email.filter(|s| !s.is_empty()), - ssh_command: ssh_command.filter(|s| !s.is_empty()), - }) -} - -#[tauri::command] -pub async fn has_local_identity( - directory: String, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - let user_name = run_git(&["config", "--local", "--get", "user.name"], &root) - .await - .ok() - .filter(|s| !s.is_empty()); - let user_email = run_git(&["config", "--local", "--get", "user.email"], &root) - .await - .ok() - .filter(|s| !s.is_empty()); - - Ok(user_name.is_some() || user_email.is_some()) -} - -#[tauri::command] -pub async fn get_global_git_identity() -> Result { - let user_name = tokio::process::Command::new("git") - .args(["config", "--global", "user.name"]) - .output() - .await - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - - let user_email = tokio::process::Command::new("git") - .args(["config", "--global", "user.email"]) - .output() - .await - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - - let ssh_command = tokio::process::Command::new("git") - .args(["config", "--global", "core.sshCommand"]) - .output() - .await - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - - Ok(GitIdentitySummary { - user_name, - user_email, - ssh_command, - }) -} - -#[tauri::command] -pub async fn set_git_identity( - directory: String, - profile_id: String, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - let profiles = load_identities().await.map_err(|e| e.to_string())?; - - let profile = profiles - .into_iter() - .find(|p| p.id == profile_id) - .ok_or_else(|| format!("Profile {} not found", profile_id))?; - - run_git( - &["config", "--local", "user.name", &profile.user_name], - &root, - ) - .await - .map_err(|e| e.to_string())?; - run_git( - &["config", "--local", "user.email", &profile.user_email], - &root, - ) - .await - .map_err(|e| e.to_string())?; - - let auth_type = profile.auth_type.as_deref().unwrap_or("ssh"); - - if auth_type == "ssh" { - if let Some(key) = &profile.ssh_key { - let cmd = format!("ssh -i {}", key); - run_git(&["config", "--local", "core.sshCommand", &cmd], &root) - .await - .map_err(|e| e.to_string())?; - } - let _ = run_git( - &["config", "--local", "--unset", "credential.helper"], - &root, - ) - .await; - } else if auth_type == "token" && profile.host.is_some() { - run_git(&["config", "--local", "credential.helper", "store"], &root) - .await - .map_err(|e| e.to_string())?; - let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await; - } else { - let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await; - } - - Ok(profile) -} - -#[tauri::command] -pub async fn discover_git_credentials() -> Result, String> { - let home = dirs::home_dir().ok_or_else(|| "Could not find home directory".to_string())?; - let credentials_path = home.join(".git-credentials"); - - if !credentials_path.exists() { - return Ok(Vec::new()); - } - - let content = fs::read_to_string(&credentials_path) - .await - .map_err(|e| format!("Failed to read .git-credentials: {}", e))?; - - let mut credentials = Vec::new(); - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - if let Ok(url) = url::Url::parse(trimmed) { - let hostname = url.host_str().unwrap_or("").to_string(); - let path = url.path(); - let host = if path.is_empty() || path == "/" { - hostname - } else { - format!("{}{}", hostname, path) - }; - let username = url.username().to_string(); - - if !host.is_empty() && !username.is_empty() { - let exists = credentials - .iter() - .any(|c: &DiscoveredGitCredential| c.host == host && c.username == username); - if !exists { - credentials.push(DiscoveredGitCredential { host, username }); - } - } - } - } - - Ok(credentials) -} - -#[tauri::command] -pub async fn generate_commit_message( - directory: String, - files: Vec, - state: State<'_, DesktopRuntime>, -) -> Result { - let _root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - // 1. Collect diffs - let mut diff_summaries = String::new(); - for file in files { - if let Ok(diff) = - get_git_diff(directory.clone(), file.clone(), None, None, state.clone()).await - { - let trimmed = if diff.len() > 4000 { - format!("{}\n...", &diff[..4000]) - } else { - diff - }; - diff_summaries.push_str(&format!("FILE: {}\n{}\n\n", file, trimmed)); - } - } - - if diff_summaries.is_empty() { - return Err("No diffs available for selected files".to_string()); - } - - // 2. Construct prompt (matching server/index.js) - let prompt = format!( - r#"You are drafting git commit notes for this codebase. Respond in JSON of the shape {{"subject": string, "highlights": string[]}} (ONLY the JSON in response, no markdown wrappers or anything except JSON) with these rules: -- subject follows our convention: type[optional-scope]: summary (examples: "feat: add diff virtualization", "fix(chat): restore enter key handling") -- allowed types: feat, fix, chore, style, refactor, perf, docs, test, build, ci (choose the best match or fallback to chore) -- summary must be imperative, concise, <= 70 characters, no trailing punctuation -- scope is optional; include only when obvious from filenames/folders; do not invent scopes -- focus on the most impactful user-facing change; if multiple capabilities ship together, align the subject with the dominant theme and use highlights to cover the other major outcomes -- highlights array should contain 2-3 plain sentences (<= 90 chars each) that describe distinct features or UI changes users will notice (e.g. "Add per-file revert action in Changes list"). Avoid subjective benefit statements, marketing tone, repeating the subject, or referencing helper function names. Highlight additions such as new controls/buttons, new actions (e.g. revert), or stored state changes explicitly. Skip highlights if fewer than two meaningful points exist. -- text must be plain (no markdown bullets); each highlight should start with an uppercase verb - -Diff summary: -{}"#, - diff_summaries - ); - - let model = "gpt-5-nano"; - - // 3. Call API - let client = Client::new(); - let res = client - .post("https://opencode.ai/zen/v1/responses") - .json(&serde_json::json!({ - "model": model, - "input": [{ "role": "user", "content": prompt }], - "max_output_tokens": 1000, - "stream": false, - "reasoning": { - "effort": "low" - } - })) - .send() - .await - .map_err(|e| e.to_string())?; - - if !res.status().is_success() { - return Err(format!("API request failed: {}", res.status())); - } - - let body: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; - let raw_content = body["output"] - .as_array() - .and_then(|items| items.iter().find(|item| item["type"] == "message")) - .and_then(|item| item["content"].as_array()) - .and_then(|content| content.iter().find(|entry| entry["type"] == "output_text")) - .and_then(|entry| entry["text"].as_str()) - .unwrap_or("") - .trim(); - - // 4. Parse JSON - // Strip markdown code blocks if present - let cleaned = raw_content - .trim_start_matches("```json") - .trim_start_matches("```") - .trim_end_matches("```") - .trim(); - - let extracted = extract_json_object(cleaned); - - let mut last_error: Option = None; - - if let Some(candidate) = extracted.as_deref() { - if candidate.starts_with('{') || candidate.starts_with('[') { - match serde_json::from_str::(candidate) { - Ok(message) => return Ok(CommitMessageResponse { message }), - Err(err) => last_error = Some(err.to_string()), - } - } - } - - if cleaned.starts_with('{') || cleaned.starts_with('[') { - match serde_json::from_str::(cleaned) { - Ok(message) => return Ok(CommitMessageResponse { message }), - Err(err) => last_error = Some(err.to_string()), - } - } - - Err(format!( - "Failed to parse AI response: {}", - last_error.unwrap_or_else(|| "unknown error".to_string()) - )) -} - -#[tauri::command] -pub async fn generate_pr_description( - directory: String, - base: String, - head: String, - context: Option, - state: State<'_, DesktopRuntime>, -) -> Result { - let root = validate_git_path(&directory, state.settings()) - .await - .map_err(|e| e.to_string())?; - - if base.trim().is_empty() || head.trim().is_empty() { - return Err("base and head are required".to_string()); - } - - // 1. Collect PR range diffs (base...head) - let base_ref = base.trim(); - let head_ref = head.trim(); - let origin_candidate = format!("refs/remotes/origin/{}", base_ref); - let resolved_base = if run_git(&["rev-parse", "--verify", &origin_candidate], &root).await.is_ok() { - format!("origin/{}", base_ref) - } else { - base_ref.to_string() - }; - - let range = format!("{}...{}", resolved_base, head_ref); - let files = { - let args = vec!["diff", "--name-only", range.as_str()]; - let raw = run_git(&args, &root).await.unwrap_or_default(); - raw.lines() - .map(|l| l.trim().to_string()) - .filter(|l| !l.is_empty()) - .collect::>() - }; - if files.is_empty() { - return Err("No diffs available for base...head".to_string()); - } - let mut diff_summaries = String::new(); - for file in files.iter() { - let context = "-U3"; - let args = vec![ - "diff", - "--no-color", - context, - range.as_str(), - "--", - file.as_str(), - ]; - if let Ok(diff) = run_git(&args, &root).await { - if !diff.trim().is_empty() { - diff_summaries.push_str(&format!("FILE: {}\n{}\n\n", file, diff)); - } - } - } - - if diff_summaries.is_empty() { - return Err("No diffs available for selected files".to_string()); - } - - // 2. Construct PR-specific prompt - let mut prompt = format!( - r#"You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {{"title": string, "body": string}} (ONLY JSON in response, no markdown fences) with these rules: -- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no \"feat:\", \"fix:\") -- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes -- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names -- Testing: bullet list (\"- Not tested\" allowed) -- Notes: bullet list; include breaking/rollout notes only when relevant -Context: -- base branch: {base} -- head branch: {head}"#, - base = base.trim(), - head = head.trim() - ); - - // Include additional context if provided - if let Some(ctx) = context { - let trimmed = ctx.trim(); - if !trimmed.is_empty() { - prompt.push_str(&format!("\n\nAdditional context provided by user:\n{}", trimmed)); - } - } - - prompt.push_str(&format!("\n\nDiff summary:\n{}", diff_summaries)); - - let model = "gpt-5-nano"; - - // 3. Call API - let client = Client::new(); - let res = client - .post("https://opencode.ai/zen/v1/responses") - .json(&serde_json::json!({ - "model": model, - "input": [{ "role": "user", "content": prompt }], - "max_output_tokens": 1200, - "stream": false, - "reasoning": { "effort": "low" } - })) - .send() - .await - .map_err(|e| e.to_string())?; - - if !res.status().is_success() { - return Err(format!("API request failed: {}", res.status())); - } - - let body_json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; - let raw_content = body_json["output"] - .as_array() - .and_then(|items| items.iter().find(|item| item["type"] == "message")) - .and_then(|item| item["content"].as_array()) - .and_then(|content| content.iter().find(|entry| entry["type"] == "output_text")) - .and_then(|entry| entry["text"].as_str()) - .unwrap_or("") - .trim(); - - if raw_content.is_empty() { - return Err("No PR description returned by generator".to_string()); - } - - let cleaned = raw_content - .trim_start_matches("```json") - .trim_start_matches("```") - .trim_end_matches("```") - .trim(); - - let extracted = extract_json_object(cleaned); - let candidates = [ - Some(cleaned.to_string()), - extracted, - Some(raw_content.to_string()), - ]; - - for candidate in candidates.iter().flatten() { - if !(candidate.starts_with('{') || candidate.starts_with('[')) { - continue; - } - if let Ok(parsed) = serde_json::from_str::(candidate) { - let title = parsed.get("title").and_then(|v| v.as_str()).unwrap_or(""); - let body = parsed.get("body").and_then(|v| v.as_str()).unwrap_or(""); - return Ok(serde_json::json!({ "title": title, "body": body })); - } - } - - Ok(serde_json::json!({ "title": "", "body": raw_content })) -} diff --git a/packages/desktop/src-tauri/src/commands/github.rs b/packages/desktop/src-tauri/src/commands/github.rs deleted file mode 100644 index b47e044c..00000000 --- a/packages/desktop/src-tauri/src/commands/github.rs +++ /dev/null @@ -1,2743 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::path::PathBuf; -use tauri::State; -use tokio::fs; -use tokio::process::Command; - -use crate::DesktopRuntime; - -const DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; -const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; -const API_USER_URL: &str = "https://api.github.com/user"; -const API_EMAILS_URL: &str = "https://api.github.com/user/emails"; -const API_PULLS_URL_PREFIX: &str = "https://api.github.com/repos"; -const API_GRAPHQL_URL: &str = "https://api.github.com/graphql"; -const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; - -const DEFAULT_GITHUB_CLIENT_ID: &str = "Ov23liNd8TxDcMXtAHHM"; -const DEFAULT_GITHUB_SCOPES: &str = "repo read:org workflow read:user user:email"; - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubRepoRef { - owner: String, - repo: String, - url: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubChecksSummary { - state: String, - total: u64, - success: u64, - failure: u64, - pending: u64, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubPullRequestSummary { - number: u64, - title: String, - url: String, - state: String, - draft: bool, - base: String, - head: String, - #[serde(skip_serializing_if = "Option::is_none")] - head_sha: Option, - #[serde(skip_serializing_if = "Option::is_none")] - mergeable: Option, - #[serde(skip_serializing_if = "Option::is_none")] - mergeable_state: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubPullRequestHeadRepo { - owner: String, - repo: String, - url: String, - #[serde(skip_serializing_if = "Option::is_none")] - clone_url: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubPullRequestContextResult { - connected: bool, - #[serde(skip_serializing_if = "Option::is_none")] - repo: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pr: Option, - #[serde(skip_serializing_if = "Option::is_none")] - issue_comments: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - review_comments: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - files: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - diff: Option, - #[serde(skip_serializing_if = "Option::is_none")] - checks: Option, - #[serde(skip_serializing_if = "Option::is_none")] - check_runs: Option>, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubCheckRun { - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - app: Option, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - conclusion: Option, - #[serde(skip_serializing_if = "Option::is_none")] - details_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - output: Option, - #[serde(skip_serializing_if = "Option::is_none")] - job: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubCheckRunApp { - #[serde(skip_serializing_if = "Option::is_none")] - name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - slug: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubCheckRunJob { - #[serde(skip_serializing_if = "Option::is_none")] - run_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - job_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - conclusion: Option, - #[serde(skip_serializing_if = "Option::is_none")] - steps: Option>, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubCheckRunJobStep { - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - conclusion: Option, - #[serde(skip_serializing_if = "Option::is_none")] - number: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubCheckRunOutput { - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] - text: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubPullRequestsListResult { - connected: bool, - #[serde(skip_serializing_if = "Option::is_none")] - repo: Option, - #[serde(skip_serializing_if = "Option::is_none")] - prs: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - page: Option, - #[serde(skip_serializing_if = "Option::is_none")] - has_more: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubPullRequestContext { - #[serde(flatten)] - summary: GitHubPullRequestSummary, - #[serde(skip_serializing_if = "Option::is_none")] - author: Option, - #[serde(skip_serializing_if = "Option::is_none")] - head_label: Option, - #[serde(skip_serializing_if = "Option::is_none")] - head_repo: Option, - #[serde(skip_serializing_if = "Option::is_none")] - body: Option, - #[serde(skip_serializing_if = "Option::is_none")] - created_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - updated_at: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubPullRequestFile { - filename: String, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - additions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - deletions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - changes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - patch: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubPullRequestReviewComment { - id: u64, - url: String, - body: String, - #[serde(skip_serializing_if = "Option::is_none")] - author: Option, - #[serde(skip_serializing_if = "Option::is_none")] - path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - line: Option, - #[serde(skip_serializing_if = "Option::is_none")] - position: Option, - #[serde(skip_serializing_if = "Option::is_none")] - created_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - updated_at: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubPullRequestStatus { - connected: bool, - #[serde(skip_serializing_if = "Option::is_none")] - repo: Option, - #[serde(skip_serializing_if = "Option::is_none")] - branch: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pr: Option, - #[serde(skip_serializing_if = "Option::is_none")] - checks: Option, - #[serde(skip_serializing_if = "Option::is_none")] - can_merge: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubPullRequestMergeResult { - merged: bool, - #[serde(skip_serializing_if = "Option::is_none")] - message: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubPullRequestReadyResult { - ready: bool, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubIssueLabel { - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - color: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubIssueSummary { - number: u64, - title: String, - url: String, - state: String, - #[serde(skip_serializing_if = "Option::is_none")] - author: Option, - #[serde(skip_serializing_if = "Option::is_none")] - labels: Option>, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubIssue { - #[serde(flatten)] - summary: GitHubIssueSummary, - #[serde(skip_serializing_if = "Option::is_none")] - body: Option, - #[serde(skip_serializing_if = "Option::is_none")] - assignees: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - created_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - updated_at: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubIssueComment { - id: u64, - url: String, - body: String, - #[serde(skip_serializing_if = "Option::is_none")] - author: Option, - #[serde(skip_serializing_if = "Option::is_none")] - created_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - updated_at: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubIssuesListResult { - connected: bool, - #[serde(skip_serializing_if = "Option::is_none")] - repo: Option, - #[serde(skip_serializing_if = "Option::is_none")] - issues: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - page: Option, - #[serde(skip_serializing_if = "Option::is_none")] - has_more: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubIssueGetResult { - connected: bool, - #[serde(skip_serializing_if = "Option::is_none")] - repo: Option, - #[serde(skip_serializing_if = "Option::is_none")] - issue: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubIssueCommentsResult { - connected: bool, - #[serde(skip_serializing_if = "Option::is_none")] - repo: Option, - #[serde(skip_serializing_if = "Option::is_none")] - comments: Option>, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubUserSummary { - login: String, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - avatar_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - email: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubAuthAccount { - id: String, - user: GitHubUserSummary, - #[serde(skip_serializing_if = "Option::is_none")] - scope: Option, - #[serde(skip_serializing_if = "Option::is_none")] - current: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubAuthStatus { - connected: bool, - #[serde(skip_serializing_if = "Option::is_none")] - user: Option, - #[serde(skip_serializing_if = "Option::is_none")] - scope: Option, - #[serde(skip_serializing_if = "Option::is_none")] - accounts: Option>, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubDeviceFlowStart { - device_code: String, - user_code: String, - verification_uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - verification_uri_complete: Option, - expires_in: u64, - interval: u64, - #[serde(skip_serializing_if = "Option::is_none")] - scope: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubDeviceFlowCompleteSuccess { - connected: bool, - user: GitHubUserSummary, - #[serde(skip_serializing_if = "Option::is_none")] - scope: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GitHubDeviceFlowCompletePending { - connected: bool, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(untagged)] -pub enum GitHubDeviceFlowComplete { - Success(GitHubDeviceFlowCompleteSuccess), - Pending(GitHubDeviceFlowCompletePending), -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GitHubDisconnectResult { - removed: bool, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -struct StoredAuth { - access_token: String, - #[serde(skip_serializing_if = "Option::is_none")] - scope: Option, - #[serde(skip_serializing_if = "Option::is_none")] - token_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - created_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - user: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - account_id: Option, - #[serde(default)] - current: bool, -} - -#[derive(Debug, Deserialize)] -struct DeviceCodeResponse { - device_code: String, - user_code: String, - verification_uri: String, - #[serde(default)] - verification_uri_complete: Option, - expires_in: u64, - interval: u64, -} - -#[derive(Debug, Deserialize)] -struct TokenResponse { - #[serde(default)] - access_token: Option, - #[serde(default)] - scope: Option, - #[serde(default)] - token_type: Option, - #[serde(default)] - error: Option, - #[serde(default)] - error_description: Option, -} - -#[derive(Debug, Deserialize)] -struct ApiUserResponse { - login: String, - id: u64, - #[serde(default)] - avatar_url: Option, - #[serde(default)] - name: Option, - #[serde(default)] - email: Option, -} - -#[derive(Debug, Deserialize)] -struct IssueUser { - login: String, - #[serde(default)] - id: Option, - #[serde(default)] - avatar_url: Option, -} - -#[derive(Debug, Deserialize)] -struct IssueLabel { - name: String, - #[serde(default)] - color: Option, -} - -#[derive(Debug, Deserialize)] -struct IssueListItem { - number: u64, - title: String, - html_url: String, - state: String, - #[serde(default)] - user: Option, - #[serde(default)] - labels: Vec, - #[serde(default)] - pull_request: Option, -} - -#[derive(Debug, Deserialize)] -struct IssueDetailsResponse { - number: u64, - title: String, - html_url: String, - state: String, - #[serde(default)] - user: Option, - #[serde(default)] - labels: Vec, - #[serde(default)] - assignees: Vec, - #[serde(default)] - body: Option, - #[serde(default)] - created_at: Option, - #[serde(default)] - updated_at: Option, - #[serde(default)] - pull_request: Option, -} - -#[derive(Debug, Deserialize)] -struct IssueCommentResponse { - id: u64, - html_url: String, - #[serde(default)] - body: Option, - #[serde(default)] - user: Option, - #[serde(default)] - created_at: Option, - #[serde(default)] - updated_at: Option, -} - -#[derive(Debug, Deserialize)] -struct PullFileResponse { - filename: String, - #[serde(default)] - status: Option, - #[serde(default)] - additions: Option, - #[serde(default)] - deletions: Option, - #[serde(default)] - changes: Option, - #[serde(default)] - patch: Option, -} - -#[derive(Debug, Deserialize)] -struct PullReviewCommentResponse { - id: u64, - html_url: String, - #[serde(default)] - body: Option, - #[serde(default)] - user: Option, - #[serde(default)] - path: Option, - #[serde(default)] - line: Option, - #[serde(default)] - position: Option, - #[serde(default)] - created_at: Option, - #[serde(default)] - updated_at: Option, -} - -#[derive(Debug, Deserialize)] -struct PrListItem { - number: u64, -} - -#[derive(Debug, Deserialize)] -struct PullRef { - #[serde(rename = "ref")] - ref_name: String, - sha: String, -} - -#[derive(Debug, Deserialize)] -struct PullBaseRef { - #[serde(rename = "ref")] - ref_name: String, -} - -#[derive(Debug, Deserialize)] -struct PullDetailsResponse { - number: u64, - title: String, - html_url: String, - state: String, - #[serde(default)] - draft: bool, - #[serde(default)] - merged: bool, - #[serde(default)] - mergeable: Option, - #[serde(default)] - mergeable_state: Option, - head: PullRef, - base: PullBaseRef, - #[serde(default)] - node_id: Option, -} - -#[derive(Debug, Deserialize)] -struct CombinedStatusEntry { - state: String, -} - -#[derive(Debug, Deserialize)] -struct CombinedStatusResponse { - #[serde(default)] - statuses: Vec, -} - -#[derive(Debug, Deserialize)] -struct CheckRunEntry { - #[serde(default)] - name: Option, - #[serde(default)] - app: Option, - #[serde(default)] - status: Option, - #[serde(default)] - conclusion: Option, - #[serde(default)] - details_url: Option, - #[serde(default)] - output: Option, -} - -#[derive(Debug, Deserialize)] -struct CheckRunApp { - #[serde(default)] - name: Option, - #[serde(default)] - slug: Option, -} - -#[derive(Debug, Deserialize)] -struct CheckRunOutput { - #[serde(default)] - title: Option, - #[serde(default)] - summary: Option, - #[serde(default)] - text: Option, -} - -#[derive(Debug, Deserialize)] -struct CheckRunsResponse { - #[serde(default)] - check_runs: Vec, -} - -#[derive(Debug, Deserialize)] -struct PermissionResponse { - permission: String, -} - -#[derive(Debug, Serialize)] -struct PullCreateRequest<'a> { - title: &'a str, - head: &'a str, - base: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - body: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - draft: Option, -} - -#[derive(Debug, Deserialize)] -struct PullCreateResponse { - number: u64, - title: String, - html_url: String, - state: String, - #[serde(default)] - draft: bool, - head: PullRef, - base: PullBaseRef, - #[serde(default)] - mergeable: Option, - #[serde(default)] - mergeable_state: Option, -} - -#[derive(Debug, Serialize)] -struct PullMergeRequest<'a> { - merge_method: &'a str, -} - -#[derive(Debug, Deserialize)] -struct PullMergeResponse { - merged: bool, - #[serde(default)] - message: Option, -} - -#[derive(Debug, Deserialize)] -struct ApiEmailEntry { - email: String, - #[serde(default)] - primary: bool, - #[serde(default)] - verified: bool, -} - -fn github_auth_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| "No home directory".to_string())?; - let mut dir = home; - dir.push(".config"); - dir.push("openchamber"); - dir.push("github-auth.json"); - Ok(dir) -} - -fn resolve_account_id(auth: &StoredAuth) -> Option { - if let Some(account_id) = auth.account_id.as_ref().map(|id| id.trim()).filter(|id| !id.is_empty()) { - return Some(account_id.to_string()); - } - if let Some(user) = auth.user.as_ref() { - if !user.login.trim().is_empty() { - return Some(user.login.trim().to_string()); - } - if let Some(id) = user.id { - return Some(id.to_string()); - } - } - if !auth.access_token.trim().is_empty() { - return Some(format!("token:{}", &auth.access_token[..auth.access_token.len().min(8)])); - } - None -} - -fn normalize_auth_list(list: &mut Vec) -> bool { - let mut changed = false; - let mut has_current = false; - for entry in list.iter_mut() { - if entry.account_id.is_none() { - entry.account_id = resolve_account_id(entry); - changed = true; - } - if entry.current && !has_current { - has_current = true; - } else if entry.current && has_current { - entry.current = false; - changed = true; - } - } - if !has_current { - if let Some(first) = list.first_mut() { - first.current = true; - changed = true; - } - } - changed -} - -fn build_auth_accounts(list: &[StoredAuth]) -> Option> { - let mut accounts = Vec::new(); - for entry in list.iter() { - let Some(user) = entry.user.clone() else { continue; }; - let Some(id) = resolve_account_id(entry) else { continue; }; - accounts.push(GitHubAuthAccount { - id, - user, - scope: entry.scope.clone(), - current: Some(entry.current), - }); - } - if accounts.is_empty() { - None - } else { - Some(accounts) - } -} - -async fn resolve_auth_status() -> Result { - let list = read_auth_list().await; - let accounts = build_auth_accounts(&list); - let current = list.iter().find(|entry| entry.current).cloned().or_else(|| list.first().cloned()); - let Some(stored) = current else { - return Ok(GitHubAuthStatus { - connected: false, - user: None, - scope: None, - accounts, - }); - }; - - if stored.access_token.trim().is_empty() { - let _ = clear_auth_file().await; - return Ok(GitHubAuthStatus { - connected: false, - user: None, - scope: None, - accounts: build_auth_accounts(&read_auth_list().await), - }); - } - - match fetch_me(&stored.access_token).await { - Ok(user) => Ok(GitHubAuthStatus { - connected: true, - user: Some(user), - scope: stored.scope, - accounts, - }), - Err(err) if err == "unauthorized" => { - let _ = clear_auth_file().await; - Ok(GitHubAuthStatus { - connected: false, - user: None, - scope: None, - accounts: build_auth_accounts(&read_auth_list().await), - }) - } - Err(err) => Err(err), - } -} - -async fn read_auth_list() -> Vec { - let path = match github_auth_path() { - Ok(path) => path, - Err(_) => return Vec::new(), - }; - let bytes = match fs::read(&path).await { - Ok(bytes) => bytes, - Err(_) => return Vec::new(), - }; - - let mut list = if let Ok(list) = serde_json::from_slice::>(&bytes) { - list - } else if let Ok(entry) = serde_json::from_slice::(&bytes) { - vec![entry] - } else { - Vec::new() - }; - - let changed = normalize_auth_list(&mut list); - if changed { - let _ = persist_auth_list(&list).await; - } - list -} - -async fn persist_auth_list(list: &Vec) -> Result<(), String> { - let path = github_auth_path()?; - if let Some(parent) = path.parent() { - let _ = fs::create_dir_all(parent).await; - } - let bytes = serde_json::to_vec_pretty(list).map_err(|e| e.to_string())?; - fs::write(&path, bytes).await.map_err(|e| e.to_string())?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(metadata) = std::fs::metadata(&path) { - let mut perms = metadata.permissions(); - perms.set_mode(0o600); - let _ = std::fs::set_permissions(&path, perms); - } - } - - Ok(()) -} - -async fn read_auth_file() -> Option { - let list = read_auth_list().await; - let current = list.iter().find(|entry| entry.current).cloned(); - current.or_else(|| list.into_iter().next()) -} - -async fn write_auth_file(auth: &StoredAuth) -> Result<(), String> { - let mut list = read_auth_list().await; - let mut next = auth.clone(); - next.current = true; - next.account_id = resolve_account_id(&next); - let account_id = next.account_id.clone(); - - if let Some(account_id) = account_id.as_ref() { - if let Some(index) = list.iter().position(|entry| entry.account_id.as_ref() == Some(account_id)) { - list[index] = next; - } else { - list.push(next); - } - } else { - list.push(next); - } - - for entry in list.iter_mut() { - entry.current = account_id.is_some() && entry.account_id.as_ref() == account_id.as_ref(); - } - - persist_auth_list(&list).await -} - -async fn clear_auth_file() -> bool { - let path = match github_auth_path() { - Ok(p) => p, - Err(_) => return false, - }; - - let mut list = read_auth_list().await; - if list.is_empty() { - return true; - } - list.retain(|entry| !entry.current); - if list.is_empty() { - return fs::remove_file(&path).await.is_ok() || !path.exists(); - } - normalize_auth_list(&mut list); - persist_auth_list(&list).await.is_ok() -} - -fn read_string_setting(settings: &Value, key: &str) -> Option { - settings - .get(key)? - .as_str() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) -} - -async fn resolve_client_config(state: &DesktopRuntime) -> (String, String) { - let settings = state - .settings() - .load() - .await - .unwrap_or(Value::Object(Default::default())); - let client_id = read_string_setting(&settings, "githubClientId") - .unwrap_or_else(|| DEFAULT_GITHUB_CLIENT_ID.to_string()); - let scopes = read_string_setting(&settings, "githubScopes") - .unwrap_or_else(|| DEFAULT_GITHUB_SCOPES.to_string()); - (client_id, scopes) -} - -async fn fetch_primary_email(access_token: &str) -> Result, String> { - let client = reqwest::Client::new(); - let resp = client - .get(API_EMAILS_URL) - .header("Accept", "application/vnd.github+json") - .header("Authorization", format!("Bearer {}", access_token)) - .header("User-Agent", "OpenChamber") - .send() - .await - .map_err(|e| e.to_string())?; - - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - return Err("unauthorized".to_string()); - } - - if !resp.status().is_success() { - return Ok(None); - } - - let list = resp - .json::>() - .await - .map_err(|e| e.to_string())?; - - let primary_verified = list - .iter() - .find(|e| e.primary && e.verified) - .map(|e| e.email.clone()); - if primary_verified.is_some() { - return Ok(primary_verified); - } - - let any_verified = list.iter().find(|e| e.verified).map(|e| e.email.clone()); - Ok(any_verified) -} - -async fn get_origin_remote_url(directory: &str) -> Option { - let output = Command::new("git") - .arg("-C") - .arg(directory) - .arg("remote") - .arg("get-url") - .arg("origin") - .output() - .await - .ok()?; - - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) -} - -fn parse_github_remote_url(remote_url: &str) -> Option { - let trimmed = remote_url.trim(); - if trimmed.is_empty() { - return None; - } - - if let Some(rest) = trimmed.strip_prefix("git@github.com:") { - let cleaned = rest.trim_end_matches(".git"); - let (owner, repo) = cleaned.split_once('/')?; - if owner.is_empty() || repo.is_empty() { - return None; - } - return Some(GitHubRepoRef { - owner: owner.to_string(), - repo: repo.to_string(), - url: format!("https://github.com/{}/{}", owner, repo), - }); - } - - if let Some(rest) = trimmed.strip_prefix("ssh://git@github.com/") { - let cleaned = rest.trim_end_matches(".git"); - let (owner, repo) = cleaned.split_once('/')?; - if owner.is_empty() || repo.is_empty() { - return None; - } - return Some(GitHubRepoRef { - owner: owner.to_string(), - repo: repo.to_string(), - url: format!("https://github.com/{}/{}", owner, repo), - }); - } - - if let Ok(url) = url::Url::parse(trimmed) { - if url.host_str() != Some("github.com") { - return None; - } - let path = url.path().trim_matches('/').trim_end_matches(".git"); - let (owner, repo) = path.split_once('/')?; - if owner.is_empty() || repo.is_empty() { - return None; - } - return Some(GitHubRepoRef { - owner: owner.to_string(), - repo: repo.to_string(), - url: format!("https://github.com/{}/{}", owner, repo), - }); - } - - None -} - -async fn resolve_repo_from_directory(directory: &str) -> Option { - let remote = get_origin_remote_url(directory).await?; - parse_github_remote_url(&remote) -} - -async fn github_get_json Deserialize<'de>>( - url: &str, - access_token: &str, -) -> Result { - let client = reqwest::Client::new(); - let resp = client - .get(url) - .header("Accept", "application/vnd.github+json") - .header("Authorization", format!("Bearer {}", access_token)) - .header("User-Agent", "OpenChamber") - .send() - .await - .map_err(|e| e.to_string())?; - - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - return Err("unauthorized".to_string()); - } - if !resp.status().is_success() { - return Err(format!("GitHub request failed: {}", resp.status())); - } - resp.json::().await.map_err(|e| e.to_string()) -} - -async fn github_post_json Deserialize<'de>, B: Serialize>( - url: &str, - access_token: &str, - body: &B, -) -> Result { - let client = reqwest::Client::new(); - let resp = client - .post(url) - .header("Accept", "application/vnd.github+json") - .header("Authorization", format!("Bearer {}", access_token)) - .header("User-Agent", "OpenChamber") - .json(body) - .send() - .await - .map_err(|e| e.to_string())?; - - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - return Err("unauthorized".to_string()); - } - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - return Err(format!("GitHub request failed: {} {}", status, text)); - } - resp.json::().await.map_err(|e| e.to_string()) -} - - -async fn fetch_me(access_token: &str) -> Result { - let client = reqwest::Client::new(); - let resp = client - .get(API_USER_URL) - .header("Accept", "application/vnd.github+json") - .header("Authorization", format!("Bearer {}", access_token)) - .header("User-Agent", "OpenChamber") - .send() - .await - .map_err(|e| e.to_string())?; - - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - return Err("unauthorized".to_string()); - } - - if !resp.status().is_success() { - return Err(format!("GitHub /user failed: {}", resp.status())); - } - - let payload = resp - .json::() - .await - .map_err(|e| e.to_string())?; - - let email = match payload.email.clone() { - Some(v) if !v.trim().is_empty() => Some(v), - _ => fetch_primary_email(access_token).await.ok().flatten(), - }; - - Ok(GitHubUserSummary { - login: payload.login, - id: Some(payload.id), - avatar_url: payload.avatar_url, - name: payload.name, - email, - }) -} - -fn map_issue_user(user: &IssueUser) -> GitHubUserSummary { - GitHubUserSummary { - login: user.login.clone(), - id: user.id, - avatar_url: user.avatar_url.clone(), - name: None, - email: None, - } -} - -fn map_issue_labels(labels: Vec) -> Vec { - labels - .into_iter() - .filter(|l| !l.name.trim().is_empty()) - .map(|l| GitHubIssueLabel { - name: l.name, - color: l.color, - }) - .collect() -} - -#[tauri::command] -pub async fn github_auth_status( - _state: State<'_, DesktopRuntime>, -) -> Result { - resolve_auth_status().await -} - -#[tauri::command] -pub async fn github_auth_start( - state: State<'_, DesktopRuntime>, -) -> Result { - let (client_id, scopes) = resolve_client_config(state.inner()).await; - - let client = reqwest::Client::new(); - let resp = client - .post(DEVICE_CODE_URL) - .header("Accept", "application/json") - .header("User-Agent", "OpenChamber") - .form(&[ - ("client_id", client_id.as_str()), - ("scope", scopes.as_str()), - ]) - .send() - .await - .map_err(|e| e.to_string())?; - - if !resp.status().is_success() { - return Err(format!("GitHub device code failed: {}", resp.status())); - } - - let payload = resp - .json::() - .await - .map_err(|e| e.to_string())?; - Ok(GitHubDeviceFlowStart { - device_code: payload.device_code, - user_code: payload.user_code, - verification_uri: payload.verification_uri, - verification_uri_complete: payload.verification_uri_complete, - expires_in: payload.expires_in, - interval: payload.interval, - scope: Some(scopes), - }) -} - -#[tauri::command] -pub async fn github_auth_complete( - #[allow(non_snake_case)] - deviceCode: String, - state: State<'_, DesktopRuntime>, -) -> Result { - let device_code = deviceCode; - if device_code.trim().is_empty() { - return Err("deviceCode is required".to_string()); - } - - let (client_id, _) = resolve_client_config(state.inner()).await; - - let client = reqwest::Client::new(); - let resp = client - .post(ACCESS_TOKEN_URL) - .header("Accept", "application/json") - .header("User-Agent", "OpenChamber") - .form(&[ - ("client_id", client_id.as_str()), - ("device_code", device_code.as_str()), - ("grant_type", DEVICE_GRANT_TYPE), - ]) - .send() - .await - .map_err(|e| e.to_string())?; - - if !resp.status().is_success() { - return Err(format!("GitHub token exchange failed: {}", resp.status())); - } - - let payload = resp - .json::() - .await - .map_err(|e| e.to_string())?; - if let Some(error) = payload.error.clone() { - return Ok(GitHubDeviceFlowComplete::Pending( - GitHubDeviceFlowCompletePending { - connected: false, - status: Some(error.clone()), - error: Some(payload.error_description.unwrap_or(error)), - }, - )); - } - - let access_token = payload.access_token.unwrap_or_default(); - if access_token.trim().is_empty() { - return Err("Missing access_token from GitHub".to_string()); - } - - let user = fetch_me(&access_token).await.map_err(|e| { - if e == "unauthorized" { - "GitHub token invalid".to_string() - } else { - e - } - })?; - - let stored = StoredAuth { - access_token: access_token.clone(), - scope: payload.scope.clone(), - token_type: payload.token_type.clone(), - created_at: Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64, - ), - user: Some(user.clone()), - account_id: None, - current: true, - }; - write_auth_file(&stored).await?; - - Ok(GitHubDeviceFlowComplete::Success( - GitHubDeviceFlowCompleteSuccess { - connected: true, - user, - scope: payload.scope, - }, - )) -} - -#[tauri::command] -pub async fn github_auth_disconnect( - _state: State<'_, DesktopRuntime>, -) -> Result { - let removed = clear_auth_file().await; - Ok(GitHubDisconnectResult { removed }) -} - -#[tauri::command] -#[allow(non_snake_case)] -pub async fn github_auth_activate( - accountId: String, - _state: State<'_, DesktopRuntime>, -) -> Result { - let account_id = accountId.trim().to_string(); - if account_id.is_empty() { - return Err("accountId is required".to_string()); - } - - let mut list = read_auth_list().await; - if list.is_empty() { - return Ok(GitHubAuthStatus { - connected: false, - user: None, - scope: None, - accounts: None, - }); - } - - let mut found = false; - for entry in list.iter_mut() { - let entry_id = resolve_account_id(entry); - if entry_id.as_deref() == Some(account_id.as_str()) { - entry.current = true; - found = true; - } else { - entry.current = false; - } - } - - if !found { - return Err("GitHub account not found".to_string()); - } - - persist_auth_list(&list).await?; - resolve_auth_status().await -} - -#[tauri::command] -pub async fn github_me(_state: State<'_, DesktopRuntime>) -> Result { - let stored = read_auth_file().await; - let Some(stored) = stored else { - return Err("GitHub not connected".to_string()); - }; - match fetch_me(&stored.access_token).await { - Ok(user) => Ok(user), - Err(err) if err == "unauthorized" => { - let _ = clear_auth_file().await; - Err("GitHub token expired or revoked".to_string()) - } - Err(err) => Err(err), - } -} - -#[tauri::command] -pub async fn github_pr_status( - directory: String, - branch: String, - _state: State<'_, DesktopRuntime>, -) -> Result { - let directory = directory.trim().to_string(); - let branch = branch.trim().to_string(); - if directory.is_empty() || branch.is_empty() { - return Err("directory and branch are required".to_string()); - } - - let stored = read_auth_file().await; - let Some(stored) = stored else { - return Ok(GitHubPullRequestStatus { - connected: false, - repo: None, - branch: Some(branch), - pr: None, - checks: None, - can_merge: None, - }); - }; - - if stored.access_token.trim().is_empty() { - let _ = clear_auth_file().await; - return Ok(GitHubPullRequestStatus { - connected: false, - repo: None, - branch: Some(branch), - pr: None, - checks: None, - can_merge: None, - }); - } - - let repo = resolve_repo_from_directory(&directory).await; - let Some(repo) = repo else { - return Ok(GitHubPullRequestStatus { - connected: true, - repo: None, - branch: Some(branch), - pr: None, - checks: None, - can_merge: Some(false), - }); - }; - - let head = format!("{}:{}", repo.owner, branch); - let head_encoded = urlencoding::encode(&head); - let list_url = format!( - "{}/{}/{}/pulls?state=open&head={}&per_page=10", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, head_encoded - ); - - let list = github_get_json::>(&list_url, &stored.access_token).await; - let list = match list { - Ok(v) => v, - Err(err) if err == "unauthorized" => { - let _ = clear_auth_file().await; - return Ok(GitHubPullRequestStatus { - connected: false, - repo: None, - branch: Some(branch), - pr: None, - checks: None, - can_merge: None, - }); - } - Err(err) => return Err(err), - }; - - let mut first_number = list.first().map(|p| p.number); - - // Fork PR support: if head owner differs, head filter returns empty. - // Fall back to listing open PRs and matching by head ref name. - if first_number.is_none() { - let open_list_url = format!( - "{}/{}/{}/pulls?state=open&per_page=100", - API_PULLS_URL_PREFIX, repo.owner, repo.repo - ); - let open_list = github_get_json::>(&open_list_url, &stored.access_token).await; - if let Ok(items) = open_list { - for item in items.iter() { - let head_ref = item - .get("head") - .and_then(|h| h.get("ref")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - if head_ref == branch { - first_number = item.get("number").and_then(|v| v.as_u64()); - break; - } - } - } - } - - let Some(first_number) = first_number else { - return Ok(GitHubPullRequestStatus { - connected: true, - repo: Some(repo), - branch: Some(branch), - pr: None, - checks: None, - can_merge: Some(false), - }); - }; - - let pr_url = format!( - "{}/{}/{}/pulls/{}", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, first_number - ); - let pr = github_get_json::(&pr_url, &stored.access_token).await?; - - // Checks summary: prefer check-runs (Actions), fallback to classic statuses - let mut checks: Option = None; - - let check_runs_url = format!( - "{}/{}/{}/commits/{}/check-runs", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, pr.head.sha - ); - - if let Ok(runs) = github_get_json::(&check_runs_url, &stored.access_token).await { - if !runs.check_runs.is_empty() { - let mut success = 0; - let mut failure = 0; - let mut pending = 0; - - for run in runs.check_runs.iter() { - let status = run.status.as_deref().unwrap_or(""); - let conclusion = run.conclusion.as_deref().unwrap_or(""); - if status == "queued" || status == "in_progress" { - pending += 1; - continue; - } - if conclusion.is_empty() { - pending += 1; - continue; - } - if conclusion == "success" || conclusion == "neutral" || conclusion == "skipped" { - success += 1; - } else { - failure += 1; - } - } - - let total = success + failure + pending; - let state = if failure > 0 { - "failure" - } else if pending > 0 { - "pending" - } else if total > 0 { - "success" - } else { - "unknown" - }; - checks = Some(GitHubChecksSummary { - state: state.to_string(), - total, - success, - failure, - pending, - }); - } - } - - if checks.is_none() { - let status_url = format!( - "{}/{}/{}/commits/{}/status", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, pr.head.sha - ); - if let Ok(status) = github_get_json::(&status_url, &stored.access_token).await { - let mut success = 0; - let mut failure = 0; - let mut pending = 0; - for s in status.statuses.iter() { - match s.state.as_str() { - "success" => success += 1, - "failure" | "error" => failure += 1, - "pending" => pending += 1, - _ => {} - } - } - let total = success + failure + pending; - let state = if failure > 0 { - "failure" - } else if pending > 0 { - "pending" - } else if total > 0 { - "success" - } else { - "unknown" - }; - checks = Some(GitHubChecksSummary { - state: state.to_string(), - total, - success, - failure, - pending, - }); - } - } - - // Permissions (best-effort) - let mut can_merge = None; - if let Some(user) = stored.user.as_ref() { - if !user.login.is_empty() { - let perm_url = format!( - "{}/{}/{}/collaborators/{}/permission", - API_PULLS_URL_PREFIX, - repo.owner, - repo.repo, - urlencoding::encode(&user.login) - ); - if let Ok(perm) = github_get_json::(&perm_url, &stored.access_token).await { - let p = perm.permission; - can_merge = Some(p == "admin" || p == "maintain" || p == "write"); - } - } - } - - let state = if pr.merged { - "merged" - } else if pr.state == "closed" { - "closed" - } else { - "open" - }; - - Ok(GitHubPullRequestStatus { - connected: true, - repo: Some(repo), - branch: Some(branch), - pr: Some(GitHubPullRequestSummary { - number: pr.number, - title: pr.title, - url: pr.html_url, - state: state.to_string(), - draft: pr.draft, - base: pr.base.ref_name, - head: pr.head.ref_name, - head_sha: Some(pr.head.sha), - mergeable: pr.mergeable, - mergeable_state: pr.mergeable_state, - }), - checks, - can_merge, - }) -} - -#[tauri::command] -pub async fn github_pr_create( - directory: String, - title: String, - head: String, - base: String, - body: Option, - draft: Option, - _state: State<'_, DesktopRuntime>, -) -> Result { - let directory = directory.trim().to_string(); - let title = title.trim().to_string(); - let head = head.trim().to_string(); - let base = base.trim().to_string(); - if directory.is_empty() || title.is_empty() || head.is_empty() || base.is_empty() { - return Err("directory, title, head, base are required".to_string()); - } - - let stored = read_auth_file().await; - let Some(stored) = stored else { - return Err("GitHub not connected".to_string()); - }; - if stored.access_token.trim().is_empty() { - let _ = clear_auth_file().await; - return Err("GitHub not connected".to_string()); - } - - let repo = resolve_repo_from_directory(&directory) - .await - .ok_or_else(|| "Unable to resolve GitHub repo from git remote".to_string())?; - - let url = format!("{}/{}/{}/pulls", API_PULLS_URL_PREFIX, repo.owner, repo.repo); - let request = PullCreateRequest { - title: &title, - head: &head, - base: &base, - body: body.as_deref(), - draft, - }; - - let created = github_post_json::(&url, &stored.access_token, &request).await?; - - Ok(GitHubPullRequestSummary { - number: created.number, - title: created.title, - url: created.html_url, - state: if created.state == "closed" { - "closed".to_string() - } else { - "open".to_string() - }, - draft: created.draft, - base: created.base.ref_name, - head: created.head.ref_name, - head_sha: Some(created.head.sha), - mergeable: created.mergeable, - mergeable_state: created.mergeable_state, - }) -} - -#[tauri::command] -pub async fn github_pr_merge( - directory: String, - number: u64, - method: String, - _state: State<'_, DesktopRuntime>, -) -> Result { - let directory = directory.trim().to_string(); - let method = method.trim().to_string(); - if directory.is_empty() { - return Err("directory is required".to_string()); - } - if number == 0 { - return Err("number is required".to_string()); - } - - let stored = read_auth_file().await; - let Some(stored) = stored else { - return Err("GitHub not connected".to_string()); - }; - if stored.access_token.trim().is_empty() { - let _ = clear_auth_file().await; - return Err("GitHub not connected".to_string()); - } - - let repo = resolve_repo_from_directory(&directory) - .await - .ok_or_else(|| "Unable to resolve GitHub repo from git remote".to_string())?; - - let url = format!( - "{}/{}/{}/pulls/{}/merge", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, number - ); - let merge_method = if method.is_empty() { "merge" } else { method.as_str() }; - let request = PullMergeRequest { merge_method }; - - let client = reqwest::Client::new(); - let resp = client - .put(url) - .header("Accept", "application/vnd.github+json") - .header("Authorization", format!("Bearer {}", stored.access_token)) - .header("User-Agent", "OpenChamber") - .json(&request) - .send() - .await - .map_err(|e| e.to_string())?; - - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - let _ = clear_auth_file().await; - return Err("GitHub token expired or revoked".to_string()); - } - if resp.status() == reqwest::StatusCode::FORBIDDEN { - return Err("Not authorized to merge this PR".to_string()); - } - if resp.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED - || resp.status() == reqwest::StatusCode::CONFLICT - { - return Ok(GitHubPullRequestMergeResult { - merged: false, - message: Some("PR not mergeable".to_string()), - }); - } - if !resp.status().is_success() { - return Err(format!("GitHub merge failed: {}", resp.status())); - } - - let parsed = resp.json::().await.map_err(|e| e.to_string())?; - Ok(GitHubPullRequestMergeResult { - merged: parsed.merged, - message: parsed.message, - }) -} - -#[tauri::command] -pub async fn github_pr_ready( - directory: String, - number: u64, - _state: State<'_, DesktopRuntime>, -) -> Result { - let directory = directory.trim().to_string(); - if directory.is_empty() { - return Err("directory is required".to_string()); - } - if number == 0 { - return Err("number is required".to_string()); - } - - let stored = read_auth_file().await; - let Some(stored) = stored else { - return Err("GitHub not connected".to_string()); - }; - if stored.access_token.trim().is_empty() { - let _ = clear_auth_file().await; - return Err("GitHub not connected".to_string()); - } - - let repo = resolve_repo_from_directory(&directory) - .await - .ok_or_else(|| "Unable to resolve GitHub repo from git remote".to_string())?; - - let pr_url = format!( - "{}/{}/{}/pulls/{}", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, number - ); - let pr = github_get_json::(&pr_url, &stored.access_token).await?; - let node_id = pr - .node_id - .ok_or_else(|| "Failed to resolve PR node id".to_string())?; - - if !pr.draft { - return Ok(GitHubPullRequestReadyResult { ready: true }); - } - - let query = "mutation($pullRequestId: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { pullRequest { id isDraft } } }"; - let payload = serde_json::json!({ - "query": query, - "variables": { "pullRequestId": node_id } - }); - - let client = reqwest::Client::new(); - let resp = client - .post(API_GRAPHQL_URL) - .header("Accept", "application/vnd.github+json") - .header("Authorization", format!("Bearer {}", stored.access_token)) - .header("User-Agent", "OpenChamber") - .json(&payload) - .send() - .await - .map_err(|e| e.to_string())?; - - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - let _ = clear_auth_file().await; - return Err("GitHub token expired or revoked".to_string()); - } - if resp.status() == reqwest::StatusCode::FORBIDDEN { - return Err("Not authorized to mark PR ready".to_string()); - } - if !resp.status().is_success() { - return Err(format!("GitHub request failed: {}", resp.status())); - } - - let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; - if body.get("errors").is_some() { - return Err("GitHub GraphQL error".to_string()); - } - - Ok(GitHubPullRequestReadyResult { ready: true }) -} - -#[tauri::command] -pub async fn github_issues_list( - directory: String, - page: Option, - _state: State<'_, DesktopRuntime>, -) -> Result { - let directory = directory.trim().to_string(); - if directory.is_empty() { - return Err("directory is required".to_string()); - } - - let stored = read_auth_file().await; - let Some(stored) = stored else { - return Ok(GitHubIssuesListResult { - connected: false, - repo: None, - issues: None, - page: None, - has_more: None, - }); - }; - if stored.access_token.trim().is_empty() { - let _ = clear_auth_file().await; - return Ok(GitHubIssuesListResult { - connected: false, - repo: None, - issues: None, - page: None, - has_more: None, - }); - } - - let repo = resolve_repo_from_directory(&directory).await; - let Some(repo) = repo else { - return Ok(GitHubIssuesListResult { - connected: true, - repo: None, - issues: Some(vec![]), - page: Some(page.unwrap_or(1).max(1) as u64), - has_more: Some(false), - }); - }; - - let page = page.unwrap_or(1).max(1); - let url = format!( - "{}/{}/{}/issues?state=open&per_page=50&page={}", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, page - ); - - let resp = reqwest::Client::new() - .get(url) - .header("Accept", "application/vnd.github+json") - .header("Authorization", format!("Bearer {}", stored.access_token)) - .header("User-Agent", "OpenChamber") - .send() - .await - .map_err(|e| e.to_string())?; - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - let _ = clear_auth_file().await; - return Ok(GitHubIssuesListResult { - connected: false, - repo: None, - issues: None, - page: None, - has_more: None, - }); - } - if !resp.status().is_success() { - return Err(format!("GitHub request failed: {}", resp.status())); - } - let link = resp - .headers() - .get("link") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - let has_more = link.contains("rel=\"next\""); - let list = resp.json::>().await.map_err(|e| e.to_string())?; - - let issues = list - .into_iter() - .filter(|item| item.pull_request.is_none()) - .map(|item| GitHubIssueSummary { - number: item.number, - title: item.title, - url: item.html_url, - state: item.state, - author: item.user.as_ref().map(map_issue_user), - labels: Some(map_issue_labels(item.labels)), - }) - .collect::>(); - - Ok(GitHubIssuesListResult { - connected: true, - repo: Some(repo), - issues: Some(issues), - page: Some(page as u64), - has_more: Some(has_more), - }) -} - -#[tauri::command] -pub async fn github_issue_get( - directory: String, - number: u64, - _state: State<'_, DesktopRuntime>, -) -> Result { - let directory = directory.trim().to_string(); - if directory.is_empty() { - return Err("directory is required".to_string()); - } - if number == 0 { - return Err("number is required".to_string()); - } - - let stored = read_auth_file().await; - let Some(stored) = stored else { - return Ok(GitHubIssueGetResult { - connected: false, - repo: None, - issue: None, - }); - }; - if stored.access_token.trim().is_empty() { - let _ = clear_auth_file().await; - return Ok(GitHubIssueGetResult { - connected: false, - repo: None, - issue: None, - }); - } - - let repo = resolve_repo_from_directory(&directory).await; - let Some(repo) = repo else { - return Ok(GitHubIssueGetResult { - connected: true, - repo: None, - issue: None, - }); - }; - - let url = format!( - "{}/{}/{}/issues/{}", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, number - ); - - let issue = github_get_json::(&url, &stored.access_token).await; - let issue = match issue { - Ok(v) => v, - Err(err) if err == "unauthorized" => { - let _ = clear_auth_file().await; - return Ok(GitHubIssueGetResult { - connected: false, - repo: None, - issue: None, - }); - } - Err(err) => return Err(err), - }; - - if issue.pull_request.is_some() { - return Err("Not a GitHub issue".to_string()); - } - - let summary = GitHubIssueSummary { - number: issue.number, - title: issue.title, - url: issue.html_url, - state: issue.state, - author: issue.user.as_ref().map(map_issue_user), - labels: Some(map_issue_labels(issue.labels)), - }; - let assignees = issue - .assignees - .iter() - .map(map_issue_user) - .collect::>(); - - Ok(GitHubIssueGetResult { - connected: true, - repo: Some(repo), - issue: Some(GitHubIssue { - summary, - body: issue.body, - assignees: Some(assignees), - created_at: issue.created_at, - updated_at: issue.updated_at, - }), - }) -} - -#[tauri::command] -pub async fn github_issue_comments( - directory: String, - number: u64, - _state: State<'_, DesktopRuntime>, -) -> Result { - let directory = directory.trim().to_string(); - if directory.is_empty() { - return Err("directory is required".to_string()); - } - if number == 0 { - return Err("number is required".to_string()); - } - - let stored = read_auth_file().await; - let Some(stored) = stored else { - return Ok(GitHubIssueCommentsResult { - connected: false, - repo: None, - comments: None, - }); - }; - if stored.access_token.trim().is_empty() { - let _ = clear_auth_file().await; - return Ok(GitHubIssueCommentsResult { - connected: false, - repo: None, - comments: None, - }); - } - - let repo = resolve_repo_from_directory(&directory).await; - let Some(repo) = repo else { - return Ok(GitHubIssueCommentsResult { - connected: true, - repo: None, - comments: Some(vec![]), - }); - }; - - let url = format!( - "{}/{}/{}/issues/{}/comments?per_page=100", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, number - ); - - let comments = github_get_json::>(&url, &stored.access_token).await; - let comments = match comments { - Ok(v) => v, - Err(err) if err == "unauthorized" => { - let _ = clear_auth_file().await; - return Ok(GitHubIssueCommentsResult { - connected: false, - repo: None, - comments: None, - }); - } - Err(err) => return Err(err), - }; - - let mapped = comments - .into_iter() - .map(|c| GitHubIssueComment { - id: c.id, - url: c.html_url, - body: c.body.unwrap_or_default(), - author: c.user.as_ref().map(map_issue_user), - created_at: c.created_at, - updated_at: c.updated_at, - }) - .collect::>(); - - Ok(GitHubIssueCommentsResult { - connected: true, - repo: Some(repo), - comments: Some(mapped), - }) -} - -fn read_string_field(value: &Value, key: &str) -> String { - value - .get(key) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string() -} - -fn read_bool_field(value: &Value, key: &str) -> Option { - value.get(key).and_then(|v| v.as_bool()) -} - -fn read_number_field(value: &Value, key: &str) -> Option { - value.get(key).and_then(|v| v.as_u64()) -} - -fn map_pr_user(value: &Value) -> Option { - let login = value.get("login").and_then(|v| v.as_str()).unwrap_or(""); - if login.trim().is_empty() { - return None; - } - Some(GitHubUserSummary { - login: login.to_string(), - id: value.get("id").and_then(|v| v.as_u64()), - avatar_url: value - .get("avatar_url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - name: None, - email: None, - }) -} - -fn map_pr_head_repo(value: &Value) -> Option { - let owner = value - .get("owner") - .and_then(|o| o.get("login")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let repo = value.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let url = value.get("html_url").and_then(|v| v.as_str()).unwrap_or(""); - if owner.trim().is_empty() || repo.trim().is_empty() || url.trim().is_empty() { - return None; - } - Some(GitHubPullRequestHeadRepo { - owner: owner.to_string(), - repo: repo.to_string(), - url: url.to_string(), - clone_url: value - .get("clone_url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - }) -} - -async fn github_get_text(url: &str, access_token: &str, accept: &str) -> Result { - let client = reqwest::Client::new(); - let resp = client - .get(url) - .header("Accept", accept) - .header("Authorization", format!("Bearer {}", access_token)) - .header("User-Agent", "OpenChamber") - .send() - .await - .map_err(|e| e.to_string())?; - - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - return Err("unauthorized".to_string()); - } - if !resp.status().is_success() { - return Err(format!("GitHub request failed: {}", resp.status())); - } - resp.text().await.map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn github_prs_list( - directory: String, - page: Option, - _state: State<'_, DesktopRuntime>, -) -> Result { - let directory = directory.trim().to_string(); - if directory.is_empty() { - return Err("directory is required".to_string()); - } - - let stored = read_auth_file().await; - let Some(stored) = stored else { - return Ok(GitHubPullRequestsListResult { - connected: false, - repo: None, - prs: None, - page: None, - has_more: None, - }); - }; - if stored.access_token.trim().is_empty() { - let _ = clear_auth_file().await; - return Ok(GitHubPullRequestsListResult { - connected: false, - repo: None, - prs: None, - page: None, - has_more: None, - }); - } - - let repo = resolve_repo_from_directory(&directory).await; - let Some(repo) = repo else { - return Ok(GitHubPullRequestsListResult { - connected: true, - repo: None, - prs: Some(vec![]), - page: Some(page.unwrap_or(1).max(1) as u64), - has_more: Some(false), - }); - }; - - let page = page.unwrap_or(1).max(1); - let url = format!( - "{}/{}/{}/pulls?state=open&per_page=50&page={}", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, page - ); - - let resp = reqwest::Client::new() - .get(url) - .header("Accept", "application/vnd.github+json") - .header("Authorization", format!("Bearer {}", stored.access_token)) - .header("User-Agent", "OpenChamber") - .send() - .await - .map_err(|e| e.to_string())?; - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - let _ = clear_auth_file().await; - return Ok(GitHubPullRequestsListResult { - connected: false, - repo: None, - prs: None, - page: None, - has_more: None, - }); - } - if !resp.status().is_success() { - return Err(format!("GitHub request failed: {}", resp.status())); - } - let link = resp - .headers() - .get("link") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - let has_more = link.contains("rel=\"next\""); - let list = resp.json::>().await.map_err(|e| e.to_string())?; - - let prs = list - .into_iter() - .filter_map(|pr| { - let number = read_number_field(&pr, "number")?; - let head = pr.get("head")?; - let base = pr.get("base")?; - let head_ref = read_string_field(head, "ref"); - let base_ref = read_string_field(base, "ref"); - let merged = read_bool_field(&pr, "merged").unwrap_or(false); - let state_raw = read_string_field(&pr, "state"); - let state = if merged { - "merged".to_string() - } else if state_raw == "closed" { - "closed".to_string() - } else { - "open".to_string() - }; - let head_sha = read_string_field(head, "sha"); - let head_sha = if head_sha.trim().is_empty() { None } else { Some(head_sha) }; - let mergeable = read_bool_field(&pr, "mergeable"); - let mergeable_state = pr - .get("mergeable_state") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let author = pr.get("user").and_then(map_pr_user); - let head_label = head.get("label").and_then(|v| v.as_str()).map(|s| s.to_string()); - let head_repo = head.get("repo").and_then(map_pr_head_repo); - - Some(GitHubPullRequestContext { - summary: GitHubPullRequestSummary { - number, - title: read_string_field(&pr, "title"), - url: read_string_field(&pr, "html_url"), - state, - draft: read_bool_field(&pr, "draft").unwrap_or(false), - base: base_ref, - head: head_ref, - head_sha, - mergeable, - mergeable_state, - }, - author, - head_label, - head_repo, - body: None, - created_at: None, - updated_at: None, - }) - }) - .collect::>(); - - Ok(GitHubPullRequestsListResult { - connected: true, - repo: Some(repo), - prs: Some(prs), - page: Some(page as u64), - has_more: Some(has_more), - }) -} - -#[tauri::command] -pub async fn github_pr_context( - directory: String, - number: u64, - #[allow(non_snake_case)] - includeDiff: bool, - #[allow(non_snake_case)] - includeCheckDetails: Option, - _state: State<'_, DesktopRuntime>, -) -> Result { - let directory = directory.trim().to_string(); - if directory.is_empty() { - return Err("directory is required".to_string()); - } - if number == 0 { - return Err("number is required".to_string()); - } - - let stored = read_auth_file().await; - let Some(stored) = stored else { - return Ok(GitHubPullRequestContextResult { - connected: false, - repo: None, - pr: None, - issue_comments: None, - review_comments: None, - files: None, - diff: None, - checks: None, - check_runs: None, - }); - }; - if stored.access_token.trim().is_empty() { - let _ = clear_auth_file().await; - return Ok(GitHubPullRequestContextResult { - connected: false, - repo: None, - pr: None, - issue_comments: None, - review_comments: None, - files: None, - diff: None, - checks: None, - check_runs: None, - }); - } - - let repo = resolve_repo_from_directory(&directory).await; - let Some(repo) = repo else { - return Ok(GitHubPullRequestContextResult { - connected: true, - repo: None, - pr: None, - issue_comments: None, - review_comments: None, - files: None, - diff: None, - checks: None, - check_runs: None, - }); - }; - - let pr_url = format!("{}/{}/{}/pulls/{}", API_PULLS_URL_PREFIX, repo.owner, repo.repo, number); - let pr_json = github_get_json::(&pr_url, &stored.access_token).await; - let pr_json = match pr_json { - Ok(v) => v, - Err(err) if err == "unauthorized" => { - let _ = clear_auth_file().await; - return Ok(GitHubPullRequestContextResult { - connected: false, - repo: None, - pr: None, - issue_comments: None, - review_comments: None, - files: None, - diff: None, - checks: None, - check_runs: None, - }); - } - Err(err) => return Err(err), - }; - - let head = pr_json.get("head").cloned().unwrap_or(Value::Null); - let base = pr_json.get("base").cloned().unwrap_or(Value::Null); - let head_ref = read_string_field(&head, "ref"); - let base_ref = read_string_field(&base, "ref"); - let merged = read_bool_field(&pr_json, "merged").unwrap_or(false); - let state_raw = read_string_field(&pr_json, "state"); - let state = if merged { - "merged".to_string() - } else if state_raw == "closed" { - "closed".to_string() - } else { - "open".to_string() - }; - let head_sha = read_string_field(&head, "sha"); - let head_sha = if head_sha.trim().is_empty() { None } else { Some(head_sha) }; - - let pr = GitHubPullRequestContext { - summary: GitHubPullRequestSummary { - number, - title: read_string_field(&pr_json, "title"), - url: read_string_field(&pr_json, "html_url"), - state, - draft: read_bool_field(&pr_json, "draft").unwrap_or(false), - base: base_ref, - head: head_ref, - head_sha, - mergeable: read_bool_field(&pr_json, "mergeable"), - mergeable_state: pr_json - .get("mergeable_state") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - }, - author: pr_json.get("user").and_then(map_pr_user), - head_label: head.get("label").and_then(|v| v.as_str()).map(|s| s.to_string()), - head_repo: head.get("repo").and_then(map_pr_head_repo), - body: pr_json.get("body").and_then(|v| v.as_str()).map(|s| s.to_string()), - created_at: pr_json.get("created_at").and_then(|v| v.as_str()).map(|s| s.to_string()), - updated_at: pr_json.get("updated_at").and_then(|v| v.as_str()).map(|s| s.to_string()), - }; - - let issue_comments_url = format!( - "{}/{}/{}/issues/{}/comments?per_page=100", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, number - ); - let issue_comments = github_get_json::>(&issue_comments_url, &stored.access_token).await?; - let issue_comments = issue_comments - .into_iter() - .map(|c| GitHubIssueComment { - id: c.id, - url: c.html_url, - body: c.body.unwrap_or_default(), - author: c.user.as_ref().map(map_issue_user), - created_at: c.created_at, - updated_at: c.updated_at, - }) - .collect::>(); - - let review_comments_url = format!( - "{}/{}/{}/pulls/{}/comments?per_page=100", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, number - ); - let review_comments = github_get_json::>(&review_comments_url, &stored.access_token).await?; - let review_comments = review_comments - .into_iter() - .map(|c| GitHubPullRequestReviewComment { - id: c.id, - url: c.html_url, - body: c.body.unwrap_or_default(), - author: c.user.as_ref().map(map_issue_user), - path: c.path, - line: c.line, - position: c.position, - created_at: c.created_at, - updated_at: c.updated_at, - }) - .collect::>(); - - let files_url = format!( - "{}/{}/{}/pulls/{}/files?per_page=100", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, number - ); - let files = github_get_json::>(&files_url, &stored.access_token).await?; - let files = files - .into_iter() - .map(|f| GitHubPullRequestFile { - filename: f.filename, - status: f.status, - additions: f.additions, - deletions: f.deletions, - changes: f.changes, - patch: f.patch, - }) - .collect::>(); - - // checks summary (same as github_pr_status) - let mut checks: Option = None; - let mut check_runs_out: Option> = None; - let include_check_details = includeCheckDetails.unwrap_or(false); - - // actions jobs cache per run_id - let mut jobs_by_run_id: std::collections::HashMap> = std::collections::HashMap::new(); - - if let Some(ref sha) = pr.summary.head_sha { - let check_runs_url = format!( - "{}/{}/{}/commits/{}/check-runs", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, sha - ); - if let Ok(runs) = github_get_json::(&check_runs_url, &stored.access_token).await { - if !runs.check_runs.is_empty() { - let mut out: Vec = Vec::new(); - - for run in runs.check_runs.iter() { - let name = run.name.clone().unwrap_or_default(); - if name.trim().is_empty() { - continue; - } - - let mut job: Option = None; - if include_check_details { - if let Some(details_url) = &run.details_url { - let (run_id, job_id) = (|| { - let marker = "/actions/runs/"; - let idx = details_url.find(marker)?; - let rest = &details_url[(idx + marker.len())..]; - let mut iter = rest.split('/'); - let run_id_str = iter.next()?; - let run_id_val = run_id_str.parse::().ok()?; - let mut job_id_val: Option = None; - let next = iter.next().unwrap_or(""); - if next == "job" { - job_id_val = iter.next().and_then(|s| s.parse::().ok()); - } - Some((run_id_val, job_id_val)) - })().unwrap_or((0, None)); - - if run_id > 0 { - if !jobs_by_run_id.contains_key(&run_id) { - let jobs_url = format!( - "{}/{}/{}/actions/runs/{}/jobs?per_page=100", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, run_id - ); - let jobs_json = github_get_json::(&jobs_url, &stored.access_token).await; - let jobs = jobs_json - .ok() - .and_then(|v| v.get("jobs").cloned()) - .and_then(|v| v.as_array().cloned()) - .unwrap_or_default(); - jobs_by_run_id.insert(run_id, jobs); - } - - let jobs = jobs_by_run_id.get(&run_id).cloned().unwrap_or_default(); - let picked = if let Some(job_id_val) = job_id { - jobs.iter() - .find(|j| j.get("id").and_then(|v| v.as_u64()) == Some(job_id_val)) - .cloned() - } else { - jobs.iter() - .find(|j| j.get("name").and_then(|v| v.as_str()) == Some(name.as_str())) - .cloned() - }; - - if let Some(picked) = picked { - let steps = picked - .get("steps") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|s| { - let step_name = s - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if step_name.trim().is_empty() { - return None; - } - Some(GitHubCheckRunJobStep { - name: step_name.to_string(), - status: s - .get("status") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - conclusion: s - .get("conclusion") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - number: s.get("number").and_then(|v| v.as_u64()), - }) - }) - .collect::>() - }); - - job = Some(GitHubCheckRunJob { - run_id: Some(run_id), - job_id: picked.get("id").and_then(|v| v.as_u64()), - url: picked - .get("html_url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - name: picked - .get("name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - conclusion: picked - .get("conclusion") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - steps, - }); - } else { - job = Some(GitHubCheckRunJob { - run_id: Some(run_id), - job_id, - url: Some(details_url.clone()), - name: None, - conclusion: None, - steps: None, - }); - } - } - } - } - - out.push(GitHubCheckRun { - name, - app: run.app.as_ref().map(|a| GitHubCheckRunApp { - name: a.name.clone(), - slug: a.slug.clone(), - }), - status: run.status.clone(), - conclusion: run.conclusion.clone(), - details_url: run.details_url.clone(), - output: run.output.as_ref().map(|o| GitHubCheckRunOutput { - title: o.title.clone(), - summary: o.summary.clone(), - text: o.text.clone(), - }), - job, - }); - } - - check_runs_out = Some(out); - - let mut success = 0; - let mut failure = 0; - let mut pending = 0; - for run in runs.check_runs.iter() { - let status = run.status.as_deref().unwrap_or(""); - let conclusion = run.conclusion.as_deref().unwrap_or(""); - if status == "queued" || status == "in_progress" { - pending += 1; - continue; - } - if conclusion.is_empty() { - pending += 1; - continue; - } - if conclusion == "success" || conclusion == "neutral" || conclusion == "skipped" { - success += 1; - } else { - failure += 1; - } - } - let total = success + failure + pending; - let state = if failure > 0 { - "failure" - } else if pending > 0 { - "pending" - } else if total > 0 { - "success" - } else { - "unknown" - }; - checks = Some(GitHubChecksSummary { - state: state.to_string(), - total, - success, - failure, - pending, - }); - } - } - - if checks.is_none() { - let status_url = format!( - "{}/{}/{}/commits/{}/status", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, sha - ); - if let Ok(status) = github_get_json::(&status_url, &stored.access_token).await { - let mut success = 0; - let mut failure = 0; - let mut pending = 0; - for s in status.statuses.iter() { - match s.state.as_str() { - "success" => success += 1, - "failure" | "error" => failure += 1, - "pending" => pending += 1, - _ => {} - } - } - let total = success + failure + pending; - let state = if failure > 0 { - "failure" - } else if pending > 0 { - "pending" - } else if total > 0 { - "success" - } else { - "unknown" - }; - checks = Some(GitHubChecksSummary { - state: state.to_string(), - total, - success, - failure, - pending, - }); - } - } - } - - let diff = if includeDiff { - let diff_text = github_get_text(&pr_url, &stored.access_token, "application/vnd.github.v3.diff").await; - match diff_text { - Ok(v) => Some(v), - Err(err) if err == "unauthorized" => { - let _ = clear_auth_file().await; - return Ok(GitHubPullRequestContextResult { - connected: false, - repo: None, - pr: None, - issue_comments: None, - review_comments: None, - files: None, - diff: None, - checks: None, - check_runs: None, - }); - } - Err(_) => None, - } - } else { - None - }; - - Ok(GitHubPullRequestContextResult { - connected: true, - repo: Some(repo), - pr: Some(pr), - issue_comments: Some(issue_comments), - review_comments: Some(review_comments), - files: Some(files), - diff, - checks, - check_runs: check_runs_out, - }) -} diff --git a/packages/desktop/src-tauri/src/commands/logs.rs b/packages/desktop/src-tauri/src/commands/logs.rs deleted file mode 100644 index 1a23ac40..00000000 --- a/packages/desktop/src-tauri/src/commands/logs.rs +++ /dev/null @@ -1,25 +0,0 @@ -use crate::logging::log_file_path; -use serde::Serialize; -use tokio::fs; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopLogFile { - pub file_name: String, - pub content: String, -} - -#[tauri::command] -pub async fn fetch_desktop_logs() -> Result { - let path = log_file_path().ok_or_else(|| "Log location unavailable".to_string())?; - let content = fs::read_to_string(&path) - .await - .map_err(|err| format!("Failed to read log file: {err}"))?; - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("openchamber.log") - .to_string(); - - Ok(DesktopLogFile { file_name, content }) -} diff --git a/packages/desktop/src-tauri/src/commands/mod.rs b/packages/desktop/src-tauri/src/commands/mod.rs deleted file mode 100644 index e37bceef..00000000 --- a/packages/desktop/src-tauri/src/commands/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod files; -pub mod git; -pub mod github; -pub mod logs; -pub mod notifications; -pub mod permissions; -pub mod settings; -pub mod terminal; diff --git a/packages/desktop/src-tauri/src/commands/notifications.rs b/packages/desktop/src-tauri/src/commands/notifications.rs deleted file mode 100644 index 7ed331cf..00000000 --- a/packages/desktop/src-tauri/src/commands/notifications.rs +++ /dev/null @@ -1,37 +0,0 @@ -use serde::Deserialize; -use tauri::{AppHandle, Runtime}; -use tauri_plugin_notification::NotificationExt; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NotificationPayload { - pub title: Option, - pub body: Option, -} - -#[tauri::command] -pub async fn desktop_notify( - app: AppHandle, - payload: Option, -) -> Result { - let title = payload - .as_ref() - .and_then(|p| p.title.as_deref()) - .unwrap_or("OpenChamber"); - let body = payload - .as_ref() - .and_then(|p| p.body.as_deref()) - .unwrap_or("Task completed"); - - match app - .notification() - .builder() - .title(title) - .body(body) - .sound("Glass") - .show() - { - Ok(_) => Ok(true), - Err(e) => Err(e.to_string()), - } -} diff --git a/packages/desktop/src-tauri/src/commands/permissions.rs b/packages/desktop/src-tauri/src/commands/permissions.rs deleted file mode 100644 index fdefef18..00000000 --- a/packages/desktop/src-tauri/src/commands/permissions.rs +++ /dev/null @@ -1,285 +0,0 @@ -use chrono::Utc; -use log::{info, warn}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tauri::AppHandle; -use tauri::State; -use uuid::Uuid; - -use crate::path_utils::expand_tilde_path; -use crate::DesktopRuntime; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DirectoryPermissionRequest { - path: String, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DirectoryPermissionResult { - success: bool, - path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - project_id: Option, - error: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StartAccessingResult { - success: bool, - error: Option, -} - -/// Process directory selection from frontend. -/// Updates settings (projects, activeProjectId, lastDirectory). -#[tauri::command] -pub async fn process_directory_selection( - path: String, - state: State<'_, DesktopRuntime>, -) -> Result { - // Validate directory exists - let mut path_buf = expand_tilde_path(&path); - if let Ok(canonicalized) = std::fs::canonicalize(&path_buf) { - path_buf = canonicalized; - } - let normalized_path = path_buf.to_string_lossy().to_string(); - if !path_buf.exists() { - return Ok(DirectoryPermissionResult { - success: false, - path: None, - project_id: None, - error: Some("Directory does not exist".to_string()), - }); - } - - if !path_buf.is_dir() { - return Ok(DirectoryPermissionResult { - success: false, - path: None, - project_id: None, - error: Some("Path is not a directory".to_string()), - }); - } - - // Update settings with projects + activeProjectId + lastDirectory - let now = Utc::now().timestamp_millis(); - let normalized_path_for_update = normalized_path.clone(); - - let (_, project_id) = state - .settings() - .update_with(|mut settings| { - if !settings.is_object() { - settings = json!({}); - } - - let project_id = { - let obj = settings.as_object_mut().unwrap(); - - let projects_value = obj.entry("projects").or_insert_with(|| json!([])); - if !projects_value.is_array() { - *projects_value = json!([]); - } - - let projects = projects_value.as_array_mut().unwrap(); - - let existing_index = projects.iter().position(|entry| { - entry - .get("path") - .and_then(|value| value.as_str()) - .map(|value| value == normalized_path_for_update) - .unwrap_or(false) - }); - - if let Some(index) = existing_index { - let entry = projects - .get_mut(index) - .and_then(|value| value.as_object_mut()); - if let Some(entry) = entry { - entry.insert("lastOpenedAt".to_string(), json!(now)); - if let Some(id) = entry.get("id").and_then(|value| value.as_str()) { - id.to_string() - } else { - let id = Uuid::new_v4().to_string(); - entry.insert("id".to_string(), json!(id)); - id - } - } else { - let id = Uuid::new_v4().to_string(); - projects[index] = json!({ - "id": id, - "path": normalized_path_for_update, - "addedAt": now, - "lastOpenedAt": now - }); - id - } - } else { - let id = Uuid::new_v4().to_string(); - projects.push(json!({ - "id": id, - "path": normalized_path_for_update, - "addedAt": now, - "lastOpenedAt": now - })); - id - } - }; - - if let Some(obj) = settings.as_object_mut() { - obj.insert("activeProjectId".to_string(), json!(project_id.clone())); - obj.insert( - "lastDirectory".to_string(), - json!(normalized_path_for_update), - ); - } - - (settings, project_id) - }) - .await - .map_err(|e| format!("Failed to save updated settings: {}", e))?; - - info!( - "[permissions] Updated settings with active project {}: {}", - project_id, normalized_path - ); - - Ok(DirectoryPermissionResult { - success: true, - path: Some(normalized_path), - project_id: Some(project_id), - error: None, - }) -} - -/// Legacy directory picker command (frontend handles actual dialog) -#[tauri::command] -pub async fn pick_directory( - _app_handle: AppHandle, - _state: State<'_, DesktopRuntime>, -) -> Result { - Ok(DirectoryPermissionResult { - success: false, - path: None, - project_id: None, - error: Some( - "Use requestDirectoryAccess instead - it handles native dialog properly".to_string(), - ), - }) -} - -/// Request directory access (desktop implementation) -/// For unsandboxed apps, just validates the path is accessible -#[tauri::command] -pub async fn request_directory_access( - request: DirectoryPermissionRequest, - _state: State<'_, DesktopRuntime>, -) -> Result { - let path = request.path; - - let mut path_buf = expand_tilde_path(&path); - if let Ok(canonicalized) = std::fs::canonicalize(&path_buf) { - path_buf = canonicalized; - } - let normalized_path = path_buf.to_string_lossy().to_string(); - if !path_buf.exists() { - return Ok(DirectoryPermissionResult { - success: false, - path: None, - project_id: None, - error: Some("Directory does not exist".to_string()), - }); - } - - if !path_buf.is_dir() { - return Ok(DirectoryPermissionResult { - success: false, - path: None, - project_id: None, - error: Some("Path is not a directory".to_string()), - }); - } - - // For unsandboxed apps, no bookmark needed - just verify access - match std::fs::read_dir(&path_buf) { - Ok(_) => Ok(DirectoryPermissionResult { - success: true, - path: Some(normalized_path), - project_id: None, - error: None, - }), - Err(e) => Ok(DirectoryPermissionResult { - success: false, - path: None, - project_id: None, - error: Some(format!("Cannot access directory: {}", e)), - }), - } -} - -/// Start accessing directory (desktop implementation) -#[tauri::command] -pub async fn start_accessing_directory( - path: String, - _state: State<'_, DesktopRuntime>, -) -> Result { - // Check if directory exists and is accessible - let path_buf = std::path::PathBuf::from(&path); - - if !path_buf.exists() { - return Ok(StartAccessingResult { - success: false, - error: Some("Directory does not exist".to_string()), - }); - } - - if !path_buf.is_dir() { - return Ok(StartAccessingResult { - success: false, - error: Some("Path is not a directory".to_string()), - }); - } - - // Try to read the directory to verify access - match std::fs::read_dir(&path_buf) { - Ok(_) => { - info!("Successfully started accessing directory: {}", path); - Ok(StartAccessingResult { - success: true, - error: None, - }) - } - Err(e) => { - warn!("Failed to access directory {}: {}", path, e); - Ok(StartAccessingResult { - success: false, - error: Some(format!("Failed to access directory: {}", e)), - }) - } - } -} - -/// Stop accessing directory (desktop implementation) -#[tauri::command] -pub async fn stop_accessing_directory( - _path: String, - _state: State<'_, DesktopRuntime>, -) -> Result { - // For Stage 1, just confirm the operation - // Full implementation would call stopAccessingSecurityScopedResource - info!("Stopped accessing directory"); - Ok(StartAccessingResult { - success: true, - error: None, - }) -} - -/// Restore bookmarks on app startup (no-op for unsandboxed apps) -#[tauri::command] -pub async fn restore_bookmarks_on_startup(_state: State<'_, DesktopRuntime>) -> Result<(), String> { - // For unsandboxed apps, no bookmarks needed - // Directory access is restored from settings.lastDirectory - info!("[permissions] Bookmark restore not needed for unsandboxed app"); - Ok(()) -} diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs deleted file mode 100644 index 75343ccb..00000000 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ /dev/null @@ -1,926 +0,0 @@ -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::HashSet; -use tauri::State; -use uuid::Uuid; - -use crate::path_utils::expand_tilde_path; -use crate::DesktopRuntime; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SettingsLoadResult { - settings: Value, - source: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RestartResult { - restarted: bool, -} - -/// Load settings from disk. -#[tauri::command] -pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result { - let (settings, _) = state - .settings() - .update_with(|mut settings| { - migrate_legacy_project_settings(&mut settings); - migrate_legacy_theme_settings(&mut settings); - normalize_project_selection(&mut settings); - (settings, ()) - }) - .await - .map_err(|e| format!("Failed to load settings: {}", e))?; - - Ok(SettingsLoadResult { - settings: format_settings_response(&settings), - source: "desktop".to_string(), - }) -} - -/// Save settings to disk with merge logic. -#[tauri::command] -pub async fn save_settings( - changes: Value, - state: State<'_, DesktopRuntime>, -) -> Result { - let sanitized_changes = sanitize_settings_update(&changes); - - let (merged, _) = state - .settings() - .update_with(|current| { - let mut merged = merge_persisted_settings(¤t, &sanitized_changes); - migrate_legacy_theme_settings(&mut merged); - normalize_project_selection(&mut merged); - (merged, ()) - }) - .await - .map_err(|e| format!("Failed to save settings: {}", e))?; - - Ok(format_settings_response(&merged)) -} - -/// Restart the backend process (config reload). -#[tauri::command] -pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result { - state - .opencode - .restart() - .await - .map_err(|e| format!("Failed to restart OpenCode: {}", e))?; - - Ok(RestartResult { restarted: true }) -} - -fn sanitize_projects(value: &Value) -> Option { - let arr = value.as_array()?; - let mut seen_ids = HashSet::new(); - let mut seen_paths = HashSet::new(); - let mut result = Vec::new(); - - for entry in arr { - let Some(obj) = entry.as_object() else { - continue; - }; - - let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim(); - let raw_path = obj - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - if id.is_empty() || raw_path.is_empty() { - continue; - } - - let expanded = expand_tilde_path(raw_path).to_string_lossy().to_string(); - let normalized = if expanded == "/" { - expanded - } else { - expanded.trim_end_matches('/').replace('\\', "/") - }; - - if normalized.is_empty() { - continue; - } - - if seen_ids.contains(id) || seen_paths.contains(&normalized) { - continue; - } - seen_ids.insert(id.to_string()); - seen_paths.insert(normalized.clone()); - - let mut project = serde_json::Map::new(); - project.insert("id".to_string(), json!(id)); - project.insert("path".to_string(), json!(normalized)); - - if let Some(Value::String(label)) = obj.get("label") { - if !label.trim().is_empty() { - project.insert("label".to_string(), json!(label.trim())); - } - } - if let Some(Value::Number(num)) = obj.get("addedAt") { - if let Some(value) = num.as_i64() { - if value >= 0 { - project.insert("addedAt".to_string(), json!(value)); - } - } - } - if let Some(Value::Number(num)) = obj.get("lastOpenedAt") { - if let Some(value) = num.as_i64() { - if value >= 0 { - project.insert("lastOpenedAt".to_string(), json!(value)); - } - } - } - - // Preserve worktreeDefaults - if let Some(Value::Object(wt)) = obj.get("worktreeDefaults") { - let mut defaults = serde_json::Map::new(); - if let Some(Value::String(s)) = wt.get("branchPrefix") { - if !s.trim().is_empty() { - defaults.insert("branchPrefix".to_string(), json!(s.trim())); - } - } - if let Some(Value::String(s)) = wt.get("baseBranch") { - if !s.trim().is_empty() { - defaults.insert("baseBranch".to_string(), json!(s.trim())); - } - } - if let Some(Value::Bool(b)) = wt.get("autoCreateWorktree") { - defaults.insert("autoCreateWorktree".to_string(), json!(b)); - } - if !defaults.is_empty() { - project.insert("worktreeDefaults".to_string(), Value::Object(defaults)); - } - } - - result.push(Value::Object(project)); - } - - if arr.is_empty() { - return Some(Value::Array(vec![])); - } - - if result.is_empty() { - None - } else { - Some(Value::Array(result)) - } -} - -/// Sanitize settings update payload (port of Express sanitizeSettingsUpdate) -fn sanitize_settings_update(payload: &Value) -> Value { - let mut result = json!({}); - - if let Some(obj) = payload.as_object() { - let result_obj = result.as_object_mut().unwrap(); - - // String fields - if let Some(Value::String(s)) = obj.get("themeId") { - if !s.is_empty() { - result_obj.insert("themeId".to_string(), json!(s)); - } - } - if let Some(Value::String(s)) = obj.get("themeVariant") { - if s == "light" || s == "dark" { - result_obj.insert("themeVariant".to_string(), json!(s)); - } - } - if let Some(Value::String(s)) = obj.get("lightThemeId") { - if !s.is_empty() { - result_obj.insert("lightThemeId".to_string(), json!(s)); - } - } - if let Some(Value::String(s)) = obj.get("darkThemeId") { - if !s.is_empty() { - result_obj.insert("darkThemeId".to_string(), json!(s)); - } - } - if let Some(Value::String(s)) = obj.get("lastDirectory") { - if !s.is_empty() { - let expanded = expand_tilde_path(s).to_string_lossy().to_string(); - result_obj.insert("lastDirectory".to_string(), json!(expanded)); - } - } - if let Some(Value::String(s)) = obj.get("homeDirectory") { - if !s.is_empty() { - let expanded = expand_tilde_path(s).to_string_lossy().to_string(); - result_obj.insert("homeDirectory".to_string(), json!(expanded)); - } - } - if let Some(projects) = obj.get("projects").and_then(sanitize_projects) { - result_obj.insert("projects".to_string(), projects); - } - if let Some(Value::String(s)) = obj.get("activeProjectId") { - if !s.is_empty() { - result_obj.insert("activeProjectId".to_string(), json!(s)); - } - } - if let Some(Value::String(s)) = obj.get("uiFont") { - if !s.is_empty() { - result_obj.insert("uiFont".to_string(), json!(s)); - } - } - if let Some(Value::String(s)) = obj.get("monoFont") { - if !s.is_empty() { - result_obj.insert("monoFont".to_string(), json!(s)); - } - } - if let Some(Value::String(s)) = obj.get("markdownDisplayMode") { - if !s.is_empty() { - result_obj.insert("markdownDisplayMode".to_string(), json!(s)); - } - } - - // GitHub OAuth config (non-secret) - if let Some(Value::String(s)) = obj.get("githubClientId") { - let trimmed = s.trim(); - if !trimmed.is_empty() { - result_obj.insert("githubClientId".to_string(), json!(trimmed)); - } - } - if let Some(Value::String(s)) = obj.get("githubScopes") { - let trimmed = s.trim(); - if !trimmed.is_empty() { - result_obj.insert("githubScopes".to_string(), json!(trimmed)); - } - } - if let Some(Value::String(s)) = obj.get("defaultModel") { - let trimmed = s.trim(); - if trimmed.is_empty() { - result_obj.insert("defaultModel".to_string(), Value::Null); - } else { - result_obj.insert("defaultModel".to_string(), json!(trimmed)); - } - } - if let Some(Value::String(s)) = obj.get("defaultVariant") { - let trimmed = s.trim(); - if trimmed.is_empty() { - result_obj.insert("defaultVariant".to_string(), Value::Null); - } else { - result_obj.insert("defaultVariant".to_string(), json!(trimmed)); - } - } - if let Some(Value::String(s)) = obj.get("defaultAgent") { - let trimmed = s.trim(); - if trimmed.is_empty() { - result_obj.insert("defaultAgent".to_string(), Value::Null); - } else { - result_obj.insert("defaultAgent".to_string(), json!(trimmed)); - } - } - if let Some(Value::String(s)) = obj.get("defaultGitIdentityId") { - let trimmed = s.trim(); - if trimmed.is_empty() { - result_obj.insert("defaultGitIdentityId".to_string(), Value::Null); - } else { - result_obj.insert("defaultGitIdentityId".to_string(), json!(trimmed)); - } - } - // Boolean fields - if let Some(Value::Bool(b)) = obj.get("gitmojiEnabled") { - result_obj.insert("gitmojiEnabled".to_string(), json!(b)); - } - if let Some(Value::Bool(b)) = obj.get("useSystemTheme") { - result_obj.insert("useSystemTheme".to_string(), json!(b)); - } - if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") { - result_obj.insert("showReasoningTraces".to_string(), json!(b)); - } - if let Some(Value::Bool(b)) = obj.get("showTextJustificationActivity") { - result_obj.insert("showTextJustificationActivity".to_string(), json!(b)); - } - if let Some(Value::Bool(b)) = obj.get("nativeNotificationsEnabled") { - result_obj.insert("nativeNotificationsEnabled".to_string(), json!(b)); - } - if let Some(Value::Bool(b)) = obj.get("notifyOnSubtasks") { - result_obj.insert("notifyOnSubtasks".to_string(), json!(b)); - } - if let Some(Value::Bool(b)) = obj.get("usageAutoRefresh") { - result_obj.insert("usageAutoRefresh".to_string(), json!(b)); - } - if let Some(Value::String(s)) = obj.get("notificationMode") { - let trimmed = s.trim(); - if trimmed == "always" || trimmed == "hidden-only" { - result_obj.insert("notificationMode".to_string(), json!(trimmed)); - } - } - if let Some(Value::Bool(b)) = obj.get("autoDeleteEnabled") { - result_obj.insert("autoDeleteEnabled".to_string(), json!(b)); - } - if let Some(Value::Bool(b)) = obj.get("queueModeEnabled") { - result_obj.insert("queueModeEnabled".to_string(), json!(b)); - } - if let Some(Value::Bool(b)) = obj.get("autoCreateWorktree") { - result_obj.insert("autoCreateWorktree".to_string(), json!(b)); - } - if let Some(Value::String(s)) = obj.get("toolCallExpansion") { - let trimmed = s.trim(); - if trimmed == "collapsed" || trimmed == "activity" || trimmed == "detailed" { - result_obj.insert("toolCallExpansion".to_string(), json!(trimmed)); - } - } - - // Number fields - if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") { - let parsed = n - .as_u64() - .or_else(|| { - n.as_i64() - .and_then(|value| if value >= 0 { Some(value as u64) } else { None }) - }) - .or_else(|| n.as_f64().map(|value| value.round().max(0.0) as u64)); - if let Some(value) = parsed { - let clamped = value.max(1).min(365); - result_obj.insert("autoDeleteAfterDays".to_string(), json!(clamped)); - } - } - - if let Some(Value::Number(n)) = obj.get("fontSize") { - let parsed = n - .as_u64() - .or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None })) - .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); - if let Some(value) = parsed { - let clamped = value.max(50).min(200); - result_obj.insert("fontSize".to_string(), json!(clamped)); - } - } - if let Some(Value::Number(n)) = obj.get("padding") { - let parsed = n - .as_u64() - .or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None })) - .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); - if let Some(value) = parsed { - let clamped = value.max(50).min(200); - result_obj.insert("padding".to_string(), json!(clamped)); - } - } - if let Some(Value::Number(n)) = obj.get("cornerRadius") { - let parsed = n - .as_u64() - .or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None })) - .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); - if let Some(value) = parsed { - let clamped = value.max(0).min(32); - result_obj.insert("cornerRadius".to_string(), json!(clamped)); - } - } - if let Some(Value::Number(n)) = obj.get("inputBarOffset") { - let parsed = n - .as_u64() - .or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None })) - .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); - if let Some(value) = parsed { - let clamped = value.max(0).min(100); - result_obj.insert("inputBarOffset".to_string(), json!(clamped)); - } - } - if let Some(Value::Number(n)) = obj.get("usageRefreshIntervalMs") { - let parsed = n - .as_u64() - .or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None })) - .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); - if let Some(value) = parsed { - let clamped = value.max(30000).min(300000); - result_obj.insert("usageRefreshIntervalMs".to_string(), json!(clamped)); - } - } - - // Memory limit fields - if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") { - let parsed = n - .as_u64() - .or_else(|| { - n.as_i64() - .and_then(|v| if v >= 0 { Some(v as u64) } else { None }) - }) - .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); - if let Some(value) = parsed { - let clamped = value.max(10).min(500); - result_obj.insert("memoryLimitHistorical".to_string(), json!(clamped)); - } - } - if let Some(Value::Number(n)) = obj.get("memoryLimitViewport") { - let parsed = n - .as_u64() - .or_else(|| { - n.as_i64() - .and_then(|v| if v >= 0 { Some(v as u64) } else { None }) - }) - .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); - if let Some(value) = parsed { - let clamped = value.max(20).min(500); - result_obj.insert("memoryLimitViewport".to_string(), json!(clamped)); - } - } - if let Some(Value::Number(n)) = obj.get("memoryLimitActiveSession") { - let parsed = n - .as_u64() - .or_else(|| { - n.as_i64() - .and_then(|v| if v >= 0 { Some(v as u64) } else { None }) - }) - .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); - if let Some(value) = parsed { - let clamped = value.max(30).min(1000); - result_obj.insert("memoryLimitActiveSession".to_string(), json!(clamped)); - } - } - - if let Some(Value::String(s)) = obj.get("diffLayoutPreference") { - let trimmed = s.trim(); - if trimmed == "dynamic" || trimmed == "inline" || trimmed == "side-by-side" { - result_obj.insert("diffLayoutPreference".to_string(), json!(trimmed)); - } - } - if let Some(Value::String(s)) = obj.get("diffViewMode") { - let trimmed = s.trim(); - if trimmed == "single" || trimmed == "stacked" { - result_obj.insert("diffViewMode".to_string(), json!(trimmed)); - } - } - if let Some(Value::Bool(b)) = obj.get("directoryShowHidden") { - result_obj.insert("directoryShowHidden".to_string(), json!(b)); - } - if let Some(Value::Bool(b)) = obj.get("filesViewShowGitignored") { - result_obj.insert("filesViewShowGitignored".to_string(), json!(b)); - } - - // Array fields - if let Some(arr) = obj.get("approvedDirectories") { - result_obj.insert( - "approvedDirectories".to_string(), - normalize_string_array(arr), - ); - } - if let Some(arr) = obj.get("securityScopedBookmarks") { - result_obj.insert( - "securityScopedBookmarks".to_string(), - normalize_string_array(arr), - ); - } - if let Some(arr) = obj.get("pinnedDirectories") { - result_obj.insert("pinnedDirectories".to_string(), normalize_string_array(arr)); - } - - // Typography sizes object (partial) - if let Some(typo) = obj.get("typographySizes") { - if let Some(sanitized) = sanitize_typography_sizes_partial(typo) { - result_obj.insert("typographySizes".to_string(), sanitized); - } - } - - // Skill catalogs (array of objects) - if let Some(Value::Array(arr)) = obj.get("skillCatalogs") { - let mut seen: HashSet = HashSet::new(); - let mut catalogs: Vec = vec![]; - - for entry in arr { - let Some(obj) = entry.as_object() else { - continue; - }; - - let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim(); - let label = obj - .get("label") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - let source = obj - .get("source") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - let subpath = obj - .get("subpath") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - let git_identity_id = obj - .get("gitIdentityId") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - - if id.is_empty() || label.is_empty() || source.is_empty() { - continue; - } - - if seen.contains(id) { - continue; - } - seen.insert(id.to_string()); - - let mut catalog = serde_json::Map::new(); - catalog.insert("id".to_string(), json!(id)); - catalog.insert("label".to_string(), json!(label)); - catalog.insert("source".to_string(), json!(source)); - if !subpath.is_empty() { - catalog.insert("subpath".to_string(), json!(subpath)); - } - if !git_identity_id.is_empty() { - catalog.insert("gitIdentityId".to_string(), json!(git_identity_id)); - } - - catalogs.push(Value::Object(catalog)); - } - - if !catalogs.is_empty() { - result_obj.insert("skillCatalogs".to_string(), Value::Array(catalogs)); - } - } - } - - result -} - -fn migrate_legacy_project_settings(settings: &mut Value) { - if !settings.is_object() { - *settings = json!({}); - } - - let now = Utc::now().timestamp_millis(); - let obj = settings.as_object_mut().unwrap(); - - let has_projects = obj - .get("projects") - .and_then(|value| value.as_array()) - .map(|arr| !arr.is_empty()) - .unwrap_or(false); - - if has_projects { - return; - } - - let last_directory = obj - .get("lastDirectory") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(expand_tilde_path); - - let Some(mut last_directory) = last_directory else { - return; - }; - - if let Ok(canonicalized) = std::fs::canonicalize(&last_directory) { - last_directory = canonicalized; - } - - let Ok(stats) = std::fs::metadata(&last_directory) else { - return; - }; - if !stats.is_dir() { - return; - } - - let normalized_path = last_directory.to_string_lossy().to_string(); - if normalized_path.trim().is_empty() { - return; - } - - let project_id = Uuid::new_v4().to_string(); - let active_project_id = project_id.clone(); - let project_path = normalized_path.clone(); - - let projects_value = obj.entry("projects").or_insert_with(|| json!([])); - *projects_value = json!([ - { - "id": project_id, - "path": project_path, - "addedAt": now, - "lastOpenedAt": now - } - ]); - - obj.insert("activeProjectId".to_string(), json!(active_project_id)); - - // Ensure approvedDirectories includes the migrated project root. - let approved_value = obj - .entry("approvedDirectories") - .or_insert_with(|| json!([])); - if !approved_value.is_array() { - *approved_value = json!([]); - } - - if let Some(array) = approved_value.as_array_mut() { - array.push(json!(normalized_path.clone())); - array.retain(|entry| entry.as_str().is_some_and(|value| !value.trim().is_empty())); - let mut seen = HashSet::new(); - array.retain(|entry| { - let Some(value) = entry.as_str() else { - return false; - }; - if seen.contains(value) { - return false; - } - seen.insert(value.to_string()); - true - }); - } -} - -fn migrate_legacy_theme_settings(settings: &mut Value) { - if !settings.is_object() { - *settings = json!({}); - } - - let obj = settings.as_object_mut().unwrap(); - - let theme_id = obj - .get("themeId") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()); - - let theme_variant = obj - .get("themeVariant") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| *value == "light" || *value == "dark") - .map(|value| value.to_string()); - - let has_light = obj - .get("lightThemeId") - .and_then(|value| value.as_str()) - .is_some_and(|value| !value.trim().is_empty()); - let has_dark = obj - .get("darkThemeId") - .and_then(|value| value.as_str()) - .is_some_and(|value| !value.trim().is_empty()); - - if has_light && has_dark { - return; - } - - let default_light = "flexoki-light".to_string(); - let default_dark = "flexoki-dark".to_string(); - - if !has_light { - let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) { - if variant == "light" { - id.clone() - } else { - default_light.clone() - } - } else { - default_light.clone() - }; - obj.insert("lightThemeId".to_string(), json!(next)); - } - - if !has_dark { - let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) { - if variant == "dark" { - id.clone() - } else { - default_dark.clone() - } - } else { - default_dark.clone() - }; - obj.insert("darkThemeId".to_string(), json!(next)); - } -} - -fn normalize_project_selection(settings: &mut Value) { - let Some(obj) = settings.as_object_mut() else { - return; - }; - - let Some(projects) = obj.get("projects").and_then(|value| value.as_array()) else { - return; - }; - - if projects.is_empty() { - obj.remove("activeProjectId"); - return; - } - - let current_active = obj - .get("activeProjectId") - .and_then(|value| value.as_str()) - .unwrap_or(""); - - let has_active = projects.iter().any(|entry| { - entry - .get("id") - .and_then(|value| value.as_str()) - .map(|id| id == current_active) - .unwrap_or(false) - }); - - if has_active { - return; - } - - let first_id = projects - .first() - .and_then(|entry| entry.get("id")) - .and_then(|value| value.as_str()); - - if let Some(id) = first_id { - obj.insert("activeProjectId".to_string(), json!(id)); - } else { - obj.remove("activeProjectId"); - } -} - -/// Merge persisted settings (port of Express mergePersistedSettings) -fn merge_persisted_settings(current: &Value, changes: &Value) -> Value { - let mut result = current.clone(); - - if let (Some(result_obj), Some(changes_obj)) = (result.as_object_mut(), changes.as_object()) { - // First apply all changes - for (key, value) in changes_obj { - result_obj.insert(key.clone(), value.clone()); - } - - // Build approvedDirectories from base + additional - let base_approved = if let Some(arr) = changes_obj.get("approvedDirectories") { - extract_string_vec(arr) - } else if let Some(arr) = current.get("approvedDirectories") { - extract_string_vec(arr) - } else { - vec![] - }; - - let mut additional_approved = vec![]; - if let Some(Value::String(s)) = changes_obj.get("lastDirectory") { - if !s.is_empty() { - additional_approved.push(s.clone()); - } - } - if let Some(Value::String(s)) = changes_obj.get("homeDirectory") { - if !s.is_empty() { - additional_approved.push(s.clone()); - } - } - - let project_source = if let Some(Value::Array(arr)) = changes_obj.get("projects") { - Some(arr) - } else { - current.get("projects").and_then(|v| v.as_array()) - }; - - if let Some(entries) = project_source { - for entry in entries { - if let Some(path) = entry.get("path").and_then(|v| v.as_str()) { - if !path.trim().is_empty() { - additional_approved.push(path.trim().to_string()); - } - } - } - } - - let mut approved_set: HashSet = base_approved.into_iter().collect(); - for item in additional_approved { - approved_set.insert(item); - } - let approved_vec: Vec = approved_set.into_iter().collect(); - result_obj.insert("approvedDirectories".to_string(), json!(approved_vec)); - - // Security scoped bookmarks - let base_bookmarks = if let Some(arr) = changes_obj.get("securityScopedBookmarks") { - extract_string_vec(arr) - } else if let Some(arr) = current.get("securityScopedBookmarks") { - extract_string_vec(arr) - } else { - vec![] - }; - let bookmarks_set: HashSet = base_bookmarks.into_iter().collect(); - let bookmarks_vec: Vec = bookmarks_set.into_iter().collect(); - result_obj.insert("securityScopedBookmarks".to_string(), json!(bookmarks_vec)); - - // Merge typography sizes if present - if changes_obj.contains_key("typographySizes") { - let current_typo = current - .get("typographySizes") - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_default(); - let changes_typo = changes_obj - .get("typographySizes") - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_default(); - - let mut merged_typo = current_typo; - for (key, value) in changes_typo { - merged_typo.insert(key, value); - } - result_obj.insert("typographySizes".to_string(), json!(merged_typo)); - } - } - - result -} - -/// Format settings response (port of Express formatSettingsResponse) -fn format_settings_response(settings: &Value) -> Value { - let mut result = sanitize_settings_update(settings); - - if let Some(obj) = result.as_object_mut() { - // Ensure array fields are normalized - obj.insert( - "approvedDirectories".to_string(), - normalize_string_array(settings.get("approvedDirectories").unwrap_or(&json!([]))), - ); - obj.insert( - "securityScopedBookmarks".to_string(), - normalize_string_array( - settings - .get("securityScopedBookmarks") - .unwrap_or(&json!([])), - ), - ); - obj.insert( - "pinnedDirectories".to_string(), - normalize_string_array(settings.get("pinnedDirectories").unwrap_or(&json!([]))), - ); - - // Typography sizes - if let Some(sanitized_typo) = sanitize_typography_sizes_partial( - settings.get("typographySizes").unwrap_or(&json!(null)), - ) { - obj.insert("typographySizes".to_string(), sanitized_typo); - } - - // showReasoningTraces with fallback - let show_reasoning = settings - .get("showReasoningTraces") - .and_then(|v| v.as_bool()) - .or_else(|| { - // Get showReasoningTraces from sanitized result instead of the current mutable borrow - if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") { - Some(*b) - } else { - None - } - }) - .unwrap_or(false); - obj.insert("showReasoningTraces".to_string(), json!(show_reasoning)); - } - - result -} - -/// Normalize string array helper -fn normalize_string_array(input: &Value) -> Value { - if let Some(arr) = input.as_array() { - let strings: Vec = arr - .iter() - .filter_map(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); - let unique: HashSet = strings.into_iter().collect(); - json!(unique.into_iter().collect::>()) - } else { - json!([]) - } -} - -/// Sanitize typography sizes partial helper -fn sanitize_typography_sizes_partial(input: &Value) -> Option { - if let Some(obj) = input.as_object() { - let mut result = serde_json::Map::new(); - let mut populated = false; - - for key in &["markdown", "code", "uiHeader", "uiLabel", "meta", "micro"] { - if let Some(Value::String(s)) = obj.get(*key) { - if !s.is_empty() { - result.insert(key.to_string(), json!(s)); - populated = true; - } - } - } - - if populated { - Some(json!(result)) - } else { - None - } - } else { - None - } -} - -/// Extract string vector from JSON value -fn extract_string_vec(value: &Value) -> Vec { - if let Some(arr) = value.as_array() { - arr.iter() - .filter_map(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() - } else { - vec![] - } -} diff --git a/packages/desktop/src-tauri/src/commands/terminal.rs b/packages/desktop/src-tauri/src/commands/terminal.rs deleted file mode 100644 index a8866ae9..00000000 --- a/packages/desktop/src-tauri/src/commands/terminal.rs +++ /dev/null @@ -1,501 +0,0 @@ -use log::error; -use parking_lot::Mutex; -use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; -use serde::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - env, - io::{Read, Write}, - path::{Path, PathBuf}, - sync::Arc, - thread, - time::Duration, -}; -use tauri::{Emitter, State, Window}; - -const DEFAULT_SHELL: &str = "/bin/zsh"; -const DEFAULT_TERM: &str = "xterm-256color"; -const DEFAULT_COLORTERM: &str = "truecolor"; -const DEFAULT_LOCALE: &str = "en_US.UTF-8"; -const TERM_PROGRAM_NAME: &str = "OpenChamber"; -const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION"); - -// Emit at most ~60fps and avoid tiny payload spam. -const EMIT_INTERVAL: Duration = Duration::from_millis(16); -const EMIT_MAX_BUFFER_BYTES: usize = 64 * 1024; - -pub struct TerminalSession { - pub master: Box, - pub writer: Arc>>, - pub child: Arc>>, -} - -pub struct TerminalState { - pub sessions: Arc>>, -} - -impl TerminalState { - pub fn new() -> Self { - Self { - sessions: Arc::new(Mutex::new(HashMap::new())), - } - } -} - -#[derive(Deserialize)] -pub struct CreateTerminalPayload { - pub cols: u16, - pub rows: u16, - pub cwd: Option, -} - -#[derive(Serialize)] -pub struct CreateTerminalResponse { - pub session_id: String, -} - -#[tauri::command] -pub async fn create_terminal_session( - payload: CreateTerminalPayload, - state: State<'_, TerminalState>, - window: Window, -) -> Result { - let pty_system = NativePtySystem::default(); - let size = PtySize { - rows: payload.rows, - cols: payload.cols, - pixel_width: 0, - pixel_height: 0, - }; - - let working_dir = resolve_working_directory(payload.cwd.as_deref())?; - let shell_path = resolve_shell(); - - let mut cmd = CommandBuilder::new(&shell_path); - if shell_accepts_login_flag(&shell_path) { - cmd.arg("-l"); - } - if let Some(cwd) = working_dir.to_str() { - cmd.cwd(cwd); - } - apply_terminal_environment(&mut cmd, &shell_path); - - let pair = pty_system.openpty(size).map_err(|e| e.to_string())?; - let child = pair - .slave - .spawn_command(cmd) - .map_err(|e| format!("Failed to spawn shell: {e}"))?; - drop(pair.slave); - - let reader = pair - .master - .try_clone_reader() - .map_err(|e| format!("Failed to clone PTY reader: {e}"))?; - let writer = Arc::new(Mutex::new( - pair.master - .take_writer() - .map_err(|e| format!("Failed to take PTY writer: {e}"))?, - )); - let master = pair.master; - let child = Arc::new(Mutex::new(child)); - - let session_id = uuid::Uuid::new_v4().to_string(); - state.sessions.lock().insert( - session_id.clone(), - TerminalSession { - master, - writer: writer.clone(), - child: child.clone(), - }, - ); - - spawn_reader_thread(reader, window.clone(), session_id.clone()); - spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone()); - - Ok(CreateTerminalResponse { session_id }) -} - -#[tauri::command] -pub async fn send_terminal_input( - session_id: String, - data: String, - state: State<'_, TerminalState>, -) -> Result<(), String> { - let writer = { - let sessions = state.sessions.lock(); - let Some(session) = sessions.get(&session_id) else { - return Err("Terminal session not found".to_string()); - }; - session.writer.clone() - }; - - let mut guard = writer.lock(); - guard - .write_all(data.as_bytes()) - .map_err(|e| format!("Failed to write to terminal: {e}"))?; - Ok(()) -} - -#[tauri::command] -pub async fn resize_terminal( - session_id: String, - cols: u16, - rows: u16, - state: State<'_, TerminalState>, -) -> Result<(), String> { - let mut sessions = state.sessions.lock(); - let Some(session) = sessions.get_mut(&session_id) else { - return Err("Terminal session not found".to_string()); - }; - - session - .master - .resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| format!("Failed to resize terminal: {e}"))?; - - Ok(()) -} - -#[tauri::command] -pub async fn close_terminal( - session_id: String, - state: State<'_, TerminalState>, -) -> Result<(), String> { - let session = { state.sessions.lock().remove(&session_id) }; - - if let Some(session) = session { - let _ = session.child.lock().kill(); - } - - Ok(()) -} - -#[derive(Deserialize)] -pub struct RestartTerminalPayload { - pub session_id: String, - pub cols: u16, - pub rows: u16, - pub cwd: String, -} - -#[tauri::command] -pub async fn restart_terminal_session( - payload: RestartTerminalPayload, - state: State<'_, TerminalState>, - window: Window, -) -> Result { - { - let session = state.sessions.lock().remove(&payload.session_id); - if let Some(session) = session { - let _ = session.child.lock().kill(); - } - } - - let pty_system = NativePtySystem::default(); - let size = PtySize { - rows: payload.rows, - cols: payload.cols, - pixel_width: 0, - pixel_height: 0, - }; - - let working_dir = resolve_working_directory(Some(&payload.cwd))?; - let shell_path = resolve_shell(); - - let mut cmd = CommandBuilder::new(&shell_path); - if shell_accepts_login_flag(&shell_path) { - cmd.arg("-l"); - } - if let Some(cwd) = working_dir.to_str() { - cmd.cwd(cwd); - } - apply_terminal_environment(&mut cmd, &shell_path); - - let pair = pty_system.openpty(size).map_err(|e| e.to_string())?; - let child = pair - .slave - .spawn_command(cmd) - .map_err(|e| format!("Failed to spawn shell: {e}"))?; - drop(pair.slave); - - let reader = pair - .master - .try_clone_reader() - .map_err(|e| format!("Failed to clone PTY reader: {e}"))?; - let writer = Arc::new(Mutex::new( - pair.master - .take_writer() - .map_err(|e| format!("Failed to take PTY writer: {e}"))?, - )); - let master = pair.master; - let child = Arc::new(Mutex::new(child)); - - let session_id = uuid::Uuid::new_v4().to_string(); - state.sessions.lock().insert( - session_id.clone(), - TerminalSession { - master, - writer: writer.clone(), - child: child.clone(), - }, - ); - - spawn_reader_thread(reader, window.clone(), session_id.clone()); - spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone()); - - Ok(CreateTerminalResponse { session_id }) -} - -#[derive(Deserialize)] -pub struct ForceKillPayload { - pub session_id: Option, - pub cwd: Option, -} - -#[tauri::command] -pub async fn force_kill_terminal( - payload: ForceKillPayload, - state: State<'_, TerminalState>, -) -> Result<(), String> { - let mut sessions = state.sessions.lock(); - - if let Some(session_id) = payload.session_id { - if let Some(session) = sessions.remove(&session_id) { - let _ = session.child.lock().kill(); - } - return Ok(()); - } - - // Current API ignores cwd; keep behavior but avoid holding poisoned locks. - let _ = payload.cwd; - - let ids: Vec = sessions.keys().cloned().collect(); - for id in ids { - if let Some(session) = sessions.remove(&id) { - let _ = session.child.lock().kill(); - } - } - - Ok(()) -} - -fn spawn_reader_thread(reader: Box, window: Window, session_id: String) { - thread::spawn(move || { - use std::sync::mpsc; - - let event_name = format!("terminal://{}", session_id); - let (tx, rx) = mpsc::channel::>(); - - // Dedicated blocking reader thread. - let reader_handle = thread::spawn(move || { - let mut reader = reader; - let mut buffer = [0u8; 16384]; - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(n) => { - if tx.send(buffer[..n].to_vec()).is_err() { - break; - } - } - Err(_) => break, - } - } - }); - - let mut pending = String::new(); - let mut pending_bytes: Vec = Vec::new(); - - let flush = |pending: &mut String| -> bool { - if pending.is_empty() { - return true; - } - - let payload_data = std::mem::take(pending); - let payload = serde_json::json!({ "type": "data", "data": payload_data }); - - match window.emit(&event_name, payload) { - Ok(_) => true, - Err(error) => { - error!("Failed to emit terminal data: {error}"); - false - } - } - }; - - let decode_pending = |pending_bytes: &mut Vec, pending: &mut String| { - loop { - match std::str::from_utf8(pending_bytes) { - Ok(text) => { - if !text.is_empty() { - pending.push_str(text); - } - pending_bytes.clear(); - break; - } - Err(error) => { - let valid = error.valid_up_to(); - if valid > 0 { - let text = std::str::from_utf8(&pending_bytes[..valid]).unwrap_or(""); - if !text.is_empty() { - pending.push_str(text); - } - pending_bytes.drain(..valid); - continue; - } - - // Incomplete UTF-8 at end; wait for more bytes. - if error.error_len().is_none() { - break; - } - - // Invalid leading byte; consume 1 byte and replace. - if !pending_bytes.is_empty() { - pending_bytes.drain(..1); - pending.push('\u{FFFD}'); - continue; - } - - break; - } - } - } - }; - - loop { - match rx.recv_timeout(EMIT_INTERVAL) { - Ok(bytes) => { - pending_bytes.extend_from_slice(&bytes); - decode_pending(&mut pending_bytes, &mut pending); - - if pending.len() >= EMIT_MAX_BUFFER_BYTES { - if !flush(&mut pending) { - break; - } - } - } - Err(mpsc::RecvTimeoutError::Timeout) => { - // Flush any buffered output even if the PTY is idle. - if !pending_bytes.is_empty() { - pending.push_str(&String::from_utf8_lossy(&pending_bytes)); - pending_bytes.clear(); - } - if !flush(&mut pending) { - break; - } - } - Err(mpsc::RecvTimeoutError::Disconnected) => { - if !pending_bytes.is_empty() { - pending.push_str(&String::from_utf8_lossy(&pending_bytes)); - pending_bytes.clear(); - } - let _ = flush(&mut pending); - break; - } - } - } - - let _ = reader_handle.join(); - }); -} - -fn spawn_exit_watcher( - child: Arc>>, - window: Window, - sessions: Arc>>, - session_id: String, -) { - thread::spawn(move || { - let status = { child.lock().wait() }; - - let (exit_code, signal) = match status { - Ok(status) => ( - status.exit_code() as i32, - status.signal().map(|sig| sig.to_string()), - ), - Err(err) => { - error!("Failed to wait for terminal exit: {err}"); - (1, Some("Terminal crashed".to_string())) - } - }; - - let event_name = format!("terminal://{}", session_id); - let payload = serde_json::json!({ - "type": "exit", - "exitCode": exit_code, - "signal": signal - }); - let _ = window.emit(&event_name, payload); - - sessions.lock().remove(&session_id); - }); -} - -fn resolve_shell() -> String { - env::var("SHELL") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| DEFAULT_SHELL.to_string()) -} - -fn shell_accepts_login_flag(shell_path: &str) -> bool { - let shell_name = Path::new(shell_path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(shell_path) - .to_lowercase(); - - matches!( - shell_name.as_str(), - name if name.contains("zsh") - || name.contains("bash") - || name.contains("sh") - || name.contains("fish") - || name.contains("ksh") - ) -} - -fn resolve_working_directory(input: Option<&str>) -> Result { - let maybe_path = input.map(PathBuf::from).or_else(|| dirs::home_dir()); - - let Some(path) = maybe_path else { - return Err("Unable to determine working directory".to_string()); - }; - - if !path.exists() || !path.is_dir() { - return Err(format!( - "Working directory is not accessible: {}", - path.display() - )); - } - - Ok(path) -} - -fn apply_terminal_environment(cmd: &mut CommandBuilder, shell_path: &str) { - cmd.env( - "TERM", - env::var("TERM").unwrap_or_else(|_| DEFAULT_TERM.to_string()), - ); - cmd.env( - "COLORTERM", - env::var("COLORTERM").unwrap_or_else(|_| DEFAULT_COLORTERM.to_string()), - ); - cmd.env( - "LC_ALL", - env::var("LC_ALL").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()), - ); - cmd.env( - "LANG", - env::var("LANG").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()), - ); - cmd.env("TERM_PROGRAM", TERM_PROGRAM_NAME); - cmd.env("TERM_PROGRAM_VERSION", TERM_PROGRAM_VERSION); - cmd.env("OPENCHAMBER_DESKTOP", "1"); - cmd.env("SHELL", shell_path); -} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs deleted file mode 100644 index 8b137891..00000000 --- a/packages/desktop/src-tauri/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/desktop/src-tauri/src/logging.rs b/packages/desktop/src-tauri/src/logging.rs deleted file mode 100644 index f9ffa69f..00000000 --- a/packages/desktop/src-tauri/src/logging.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::path::PathBuf; - -#[cfg(target_os = "macos")] -const PLATFORM_LOG_SEGMENTS: &[&str] = &["Library", "Logs", "OpenChamber"]; -#[cfg(not(target_os = "macos"))] -const PLATFORM_LOG_SEGMENTS: &[&str] = &[".config", "openchamber", "logs"]; - -pub fn log_directory() -> Option { - let mut path = dirs::home_dir()?; - for segment in PLATFORM_LOG_SEGMENTS { - path.push(segment); - } - Some(path) -} - -pub fn log_file_path() -> Option { - let mut dir = log_directory()?; - dir.push("openchamber.log"); - Some(dir) -} diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 6dcf8a5c..dbaef615 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1,390 +1,97 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -mod assistant_notifications; -mod commands; -mod logging; -mod opencode_auth; -mod opencode_config; -mod opencode_manager; -mod path_utils; -mod quota_providers; -mod session_activity; -mod skills_catalog; -mod window_state; - -use std::{ - collections::HashMap, - path::PathBuf, - sync::Arc, - time::{Duration, Instant}, -}; - use anyhow::{anyhow, Result}; -use assistant_notifications::spawn_assistant_notifications; -use axum::{ - body::{to_bytes, Body}, - extract::{Path, Request, State}, - http::{Method, StatusCode}, - response::{IntoResponse, Response}, - routing::{any, get, post}, - Json, Router, -}; -use commands::files::{ - create_directory, delete_path, exec_commands, list_directory, read_file, read_file_binary, - rename_path, search_files, write_file, -}; -use commands::git::{ - add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, - create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch, - discover_git_credentials, ensure_openchamber_ignored, generate_commit_message, - get_commit_files, get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff, - get_git_identities, get_git_log, get_git_status, get_global_git_identity, get_remote_url, - git_fetch, git_pull, git_push, has_local_identity, is_linked_worktree, list_git_worktrees, - remove_git_worktree, rename_branch, revert_git_file, set_git_identity, update_git_identity, - generate_pr_description, -}; -use commands::logs::fetch_desktop_logs; - -use commands::github::{ - github_auth_activate, github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me, - github_issue_comments, github_issue_get, github_issues_list, - github_pr_context, github_prs_list, - github_pr_create, github_pr_merge, github_pr_ready, github_pr_status, -}; -use commands::notifications::desktop_notify; -use commands::permissions::{ - pick_directory, process_directory_selection, request_directory_access, - restore_bookmarks_on_startup, start_accessing_directory, stop_accessing_directory, -}; -use commands::settings::{load_settings, restart_opencode, save_settings}; -use commands::terminal::{ - close_terminal, create_terminal_session, force_kill_terminal, resize_terminal, - restart_terminal_session, send_terminal_input, TerminalState, -}; -use futures_util::StreamExt as FuturesStreamExt; -use log::{error, info, warn}; -use opencode_manager::OpenCodeManager; -use path_utils::expand_tilde_path; -use portpicker::pick_unused_port; -use reqwest::{header, Body as ReqwestBody, Client}; use serde::{Deserialize, Serialize}; -use serde_json::Value; -use session_activity::spawn_session_activity_tracker; -#[cfg(feature = "devtools")] -use tauri::WebviewWindow; -use tauri::{Emitter, Manager}; -use tauri_plugin_dialog::init as dialog_plugin; -use tauri_plugin_fs::init as fs_plugin; -use tauri_plugin_log::{Target, TargetKind}; -use tauri_plugin_notification::init as notification_plugin; -use tauri_plugin_shell::init as shell_plugin; -use tokio::{ - fs, +use std::{ net::TcpListener, - sync::{broadcast, Mutex}, + process::Command, + sync::Mutex, + time::Duration, }; -use tower_http::cors::CorsLayer; -use window_state::{load_window_state, persist_window_state, WindowStateManager}; +use std::{fs, path::PathBuf}; +use std::env; +use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; +fn eval_in_main_window(app: &tauri::AppHandle, script: &str) { + let Some(window) = app.get_webview_window("main") else { + return; + }; + let _ = window.eval(script); +} -const PROXY_BODY_LIMIT: usize = 50 * 1024 * 1024; // 50MB -const CLIENT_RELOAD_DELAY_MS: u64 = 800; -const MODELS_DEV_API_URL: &str = "https://models.dev/api.json"; -const MODELS_METADATA_CACHE_TTL: Duration = Duration::from_secs(5 * 60); -const MODELS_METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(8); +fn dispatch_menu_action(app: &tauri::AppHandle, action: &str) { + let _ = app.emit("openchamber:menu-action", action); -const MAX_THEME_JSON_BYTES: u64 = 512 * 1024; + let event = serde_json::to_string("openchamber:menu-action") + .unwrap_or_else(|_| "\"openchamber:menu-action\"".into()); + let detail = serde_json::to_string(action).unwrap_or_else(|_| "\"\"".into()); + let script = format!("window.dispatchEvent(new CustomEvent({event}, {{ detail: {detail} }}));"); + eval_in_main_window(app, &script); +} -const CHECK_FOR_UPDATES_EVENT: &str = "openchamber:check-for-updates"; +fn dispatch_check_for_updates(app: &tauri::AppHandle) { + let _ = app.emit("openchamber:check-for-updates", ()); + + let event = serde_json::to_string("openchamber:check-for-updates") + .unwrap_or_else(|_| "\"openchamber:check-for-updates\"".into()); + let script = format!("window.dispatchEvent(new Event({event}));"); + eval_in_main_window(app, &script); +} +use tauri_plugin_shell::{process::CommandChild, process::CommandEvent, ShellExt}; +use tauri_plugin_updater::UpdaterExt; #[cfg(target_os = "macos")] -const MENU_ITEM_CHECK_FOR_UPDATES_ID: &str = "openchamber_check_for_updates"; +const MENU_ITEM_ABOUT_ID: &str = "menu_about"; #[cfg(target_os = "macos")] -const MENU_ITEM_REPORT_BUG_ID: &str = "openchamber_report_bug"; +const MENU_ITEM_CHECK_FOR_UPDATES_ID: &str = "menu_check_for_updates"; #[cfg(target_os = "macos")] -const MENU_ITEM_REQUEST_FEATURE_ID: &str = "openchamber_request_feature"; +const MENU_ITEM_SETTINGS_ID: &str = "menu_settings"; #[cfg(target_os = "macos")] -const MENU_ITEM_JOIN_DISCORD_ID: &str = "openchamber_join_discord"; +const MENU_ITEM_COMMAND_PALETTE_ID: &str = "menu_command_palette"; +#[cfg(target_os = "macos")] +const MENU_ITEM_NEW_SESSION_ID: &str = "menu_new_session"; +#[cfg(target_os = "macos")] +const MENU_ITEM_WORKTREE_CREATOR_ID: &str = "menu_worktree_creator"; +#[cfg(target_os = "macos")] +const MENU_ITEM_CHANGE_WORKSPACE_ID: &str = "menu_change_workspace"; +#[cfg(target_os = "macos")] +const MENU_ITEM_OPEN_GIT_TAB_ID: &str = "menu_open_git_tab"; +#[cfg(target_os = "macos")] +const MENU_ITEM_OPEN_DIFF_TAB_ID: &str = "menu_open_diff_tab"; +#[cfg(target_os = "macos")] +const MENU_ITEM_OPEN_FILES_TAB_ID: &str = "menu_open_files_tab"; +#[cfg(target_os = "macos")] +const MENU_ITEM_OPEN_TERMINAL_TAB_ID: &str = "menu_open_terminal_tab"; +#[cfg(target_os = "macos")] +const MENU_ITEM_THEME_LIGHT_ID: &str = "menu_theme_light"; +#[cfg(target_os = "macos")] +const MENU_ITEM_THEME_DARK_ID: &str = "menu_theme_dark"; +#[cfg(target_os = "macos")] +const MENU_ITEM_THEME_SYSTEM_ID: &str = "menu_theme_system"; +#[cfg(target_os = "macos")] +const MENU_ITEM_TOGGLE_SIDEBAR_ID: &str = "menu_toggle_sidebar"; +#[cfg(target_os = "macos")] +const MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID: &str = "menu_toggle_memory_debug"; +#[cfg(target_os = "macos")] +const MENU_ITEM_HELP_DIALOG_ID: &str = "menu_help_dialog"; +#[cfg(target_os = "macos")] +const MENU_ITEM_DOWNLOAD_LOGS_ID: &str = "menu_download_logs"; +#[cfg(target_os = "macos")] +const MENU_ITEM_REPORT_BUG_ID: &str = "menu_report_bug"; +#[cfg(target_os = "macos")] +const MENU_ITEM_REQUEST_FEATURE_ID: &str = "menu_request_feature"; +#[cfg(target_os = "macos")] +const MENU_ITEM_JOIN_DISCORD_ID: &str = "menu_join_discord"; -// App menu #[cfg(target_os = "macos")] -const MENU_ITEM_ABOUT_ID: &str = "openchamber_about"; -#[cfg(target_os = "macos")] -const MENU_ITEM_SETTINGS_ID: &str = "openchamber_settings"; -#[cfg(target_os = "macos")] -const MENU_ITEM_COMMAND_PALETTE_ID: &str = "openchamber_command_palette"; - -// File menu -#[cfg(target_os = "macos")] -const MENU_ITEM_NEW_SESSION_ID: &str = "openchamber_new_session"; -#[cfg(target_os = "macos")] -const MENU_ITEM_WORKTREE_CREATOR_ID: &str = "openchamber_worktree_creator"; -#[cfg(target_os = "macos")] -const MENU_ITEM_CHANGE_WORKSPACE_ID: &str = "openchamber_change_workspace"; - -// View menu -#[cfg(target_os = "macos")] -const MENU_ITEM_OPEN_GIT_TAB_ID: &str = "openchamber_open_git_tab"; -#[cfg(target_os = "macos")] -const MENU_ITEM_OPEN_DIFF_TAB_ID: &str = "openchamber_open_diff_tab"; -#[cfg(target_os = "macos")] -const MENU_ITEM_OPEN_TERMINAL_TAB_ID: &str = "openchamber_open_terminal_tab"; -#[cfg(target_os = "macos")] -const MENU_ITEM_THEME_LIGHT_ID: &str = "openchamber_theme_light"; -#[cfg(target_os = "macos")] -const MENU_ITEM_THEME_DARK_ID: &str = "openchamber_theme_dark"; -#[cfg(target_os = "macos")] -const MENU_ITEM_THEME_SYSTEM_ID: &str = "openchamber_theme_system"; -#[cfg(target_os = "macos")] -const MENU_ITEM_TOGGLE_SIDEBAR_ID: &str = "openchamber_toggle_sidebar"; -#[cfg(target_os = "macos")] -const MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID: &str = "openchamber_toggle_memory_debug"; - -// Help menu -#[cfg(target_os = "macos")] -const MENU_ITEM_HELP_DIALOG_ID: &str = "openchamber_help_dialog"; -#[cfg(target_os = "macos")] -const MENU_ITEM_DOWNLOAD_LOGS_ID: &str = "openchamber_download_logs"; - const GITHUB_BUG_REPORT_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml"; +#[cfg(target_os = "macos")] const GITHUB_FEATURE_REQUEST_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml"; +#[cfg(target_os = "macos")] const DISCORD_INVITE_URL: &str = "https://discord.gg/ZYRSdnwwKA"; -#[derive(Clone)] -pub(crate) struct DesktopRuntime { - server_port: u16, - shutdown_tx: broadcast::Sender<()>, - opencode: Arc, - settings: Arc, -} - -impl DesktopRuntime { - fn initialize_sync() -> Result { - let settings = Arc::new(SettingsStore::new()?); - let opencode = Arc::new(OpenCodeManager::new_with_directory(None)); - - let client = Client::builder().build()?; - - let (shutdown_tx, shutdown_rx) = broadcast::channel(2); - let server_port = - pick_unused_port().ok_or_else(|| anyhow!("No free port available"))? as u16; - let server_state = ServerState { - client, - opencode: opencode.clone(), - settings: settings.clone(), - server_port, - directory_change_lock: Arc::new(Mutex::new(())), - models_metadata_cache: Arc::new(Mutex::new(ModelsMetadataCache::default())), - }; - - spawn_http_server(server_port, server_state, shutdown_rx); - - Ok(Self { - server_port, - shutdown_tx, - opencode, - settings, - }) - } - - async fn start_opencode(&self) { - if self.opencode.is_cli_available() { - if let Err(e) = self.opencode.ensure_running().await { - warn!("[desktop] Failed to start OpenCode: {}", e); - } - } else { - info!("[desktop] OpenCode CLI not available - running in limited mode"); - } - } - - async fn shutdown(&self) { - let _ = self.shutdown_tx.send(()); - let _ = self.opencode.shutdown().await; - } - - pub(crate) fn settings(&self) -> &SettingsStore { - self.settings.as_ref() - } - - pub(crate) fn subscribe_shutdown(&self) -> broadcast::Receiver<()> { - self.shutdown_tx.subscribe() - } - - pub(crate) fn opencode_manager(&self) -> Arc { - self.opencode.clone() - } -} - -#[derive(Clone)] -struct ServerState { - client: Client, - opencode: Arc, - settings: Arc, - server_port: u16, - directory_change_lock: Arc>, - models_metadata_cache: Arc>, -} - -#[derive(Default)] -struct ModelsMetadataCache { - payload: Option, - fetched_at: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ConfigActionResponse { - success: bool, - requires_reload: bool, - message: String, - reload_delay_ms: u64, -} - -#[derive(Serialize)] -struct ConfigErrorResponse { - error: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ConfigMetadataResponse { - name: String, - sources: opencode_config::ConfigSources, - scope: Option, - is_built_in: bool, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct HealthResponse { - status: &'static str, - server_port: u16, - opencode_port: Option, - api_prefix: String, - is_opencode_ready: bool, - cli_available: bool, -} - -#[derive(Serialize)] -struct ServerInfoPayload { - server_port: u16, - opencode_port: Option, - api_prefix: String, - cli_available: bool, - has_last_directory: bool, -} - -#[derive(Serialize)] -struct QuotaProvidersResponse { - providers: Vec, -} - -#[tauri::command] -async fn desktop_server_info( - state: tauri::State<'_, DesktopRuntime>, -) -> Result { - let has_last_directory = state - .settings() - .last_directory() - .await - .ok() - .flatten() - .is_some(); - Ok(ServerInfoPayload { - server_port: state.server_port, - opencode_port: state.opencode.current_port(), - api_prefix: state.opencode.api_prefix(), - cli_available: state.opencode.is_cli_available(), - has_last_directory, - }) -} - -#[tauri::command] -async fn desktop_restart_opencode(state: tauri::State<'_, DesktopRuntime>) -> Result<(), String> { - state - .opencode - .restart() - .await - .map_err(|err| err.to_string()) -} - -#[cfg(feature = "devtools")] -#[tauri::command] -async fn desktop_open_devtools(window: WebviewWindow) -> Result<(), String> { - window.open_devtools(); - Ok(()) -} - -#[cfg(target_os = "macos")] -fn get_macos_major_version() -> isize { - use objc2_foundation::NSProcessInfo; - let process_info = NSProcessInfo::processInfo(); - let version = process_info.operatingSystemVersion(); - version.majorVersion -} - -#[cfg(not(target_os = "macos"))] -fn get_macos_major_version() -> isize { - 0 -} - -#[tauri::command] -fn desktop_get_macos_version() -> isize { - get_macos_major_version() -} - -#[cfg(target_os = "macos")] -fn optimize_webview_layer(window: &tauri::WebviewWindow) { - use objc2::msg_send; - use objc2::runtime::AnyObject; - - if let Ok(ns_view) = window.ns_view() { - unsafe { - let view: *mut AnyObject = ns_view.cast(); - if view.is_null() { - warn!("[macos:layer] NSView is null"); - return; - } - - // Enable layer-backing for GPU compositing - let _: () = msg_send![view, setWantsLayer: true]; - - // Get the layer - let layer: *mut AnyObject = msg_send![view, layer]; - if !layer.is_null() { - // Enable asynchronous drawing for better scroll performance - let _: () = msg_send![layer, setDrawsAsynchronously: true]; - - // Disable implicit animations that can cause jitter - let _: () = msg_send![layer, setActions: std::ptr::null::()]; - - info!("[macos:layer] WebView layer optimizations applied"); - } else { - warn!("[macos:layer] Layer is null after setWantsLayer"); - } - } - } else { - warn!("[macos:layer] Failed to get NSView"); - } -} - -#[cfg(target_os = "macos")] -fn prevent_app_nap() { - use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; - - let options = NSActivityOptions(0x00FFFFFF | 0xFF00000000); - let reason = NSString::from_str("Prevent App Nap"); - - let process_info = NSProcessInfo::processInfo(); - let activity = process_info.beginActivityWithOptions_reason(options, &reason); - - std::mem::forget(activity); - - info!("[macos] App Nap prevention enabled via objc2"); -} - #[cfg(target_os = "macos")] fn build_macos_menu( app: &tauri::AppHandle, @@ -395,6 +102,14 @@ fn build_macos_menu( let pkg_info = app.package_info(); + let auto_worktree = app + .try_state::() + .map(|state| *state.auto_worktree.lock().expect("menu state mutex")) + .unwrap_or(false); + + let new_session_shortcut = if auto_worktree { "Cmd+Shift+N" } else { "Cmd+N" }; + let new_worktree_shortcut = if auto_worktree { "Cmd+N" } else { "Cmd+Shift+N" }; + let about = MenuItem::with_id( app, MENU_ITEM_ABOUT_ID, @@ -411,7 +126,6 @@ fn build_macos_menu( None::<&str>, )?; - // App menu items let settings = MenuItem::with_id(app, MENU_ITEM_SETTINGS_ID, "Settings", true, Some("Cmd+,"))?; let command_palette = MenuItem::with_id( @@ -422,13 +136,12 @@ fn build_macos_menu( Some("Cmd+K"), )?; - // File menu items let new_session = MenuItem::with_id( app, MENU_ITEM_NEW_SESSION_ID, "New Session", true, - Some("Cmd+N"), + Some(new_session_shortcut), )?; let worktree_creator = MenuItem::with_id( @@ -436,24 +149,23 @@ fn build_macos_menu( MENU_ITEM_WORKTREE_CREATOR_ID, "New Worktree", true, - Some("Cmd+Shift+N"), + Some(new_worktree_shortcut), )?; let change_workspace = MenuItem::with_id( app, MENU_ITEM_CHANGE_WORKSPACE_ID, - "Change Workspace", + "Add Workspace", true, None::<&str>, )?; - // View menu items let open_git_tab = MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Cmd+G"))?; - let open_diff_tab = MenuItem::with_id(app, MENU_ITEM_OPEN_DIFF_TAB_ID, "Diff", true, Some("Cmd+E"))?; - + let open_files_tab = + MenuItem::with_id(app, MENU_ITEM_OPEN_FILES_TAB_ID, "Files", true, None::<&str>)?; let open_terminal_tab = MenuItem::with_id( app, MENU_ITEM_OPEN_TERMINAL_TAB_ID, @@ -462,29 +174,12 @@ fn build_macos_menu( Some("Cmd+T"), )?; - let theme_light = MenuItem::with_id( - app, - MENU_ITEM_THEME_LIGHT_ID, - "Light Theme", - true, - None::<&str>, - )?; - - let theme_dark = MenuItem::with_id( - app, - MENU_ITEM_THEME_DARK_ID, - "Dark Theme", - true, - None::<&str>, - )?; - - let theme_system = MenuItem::with_id( - app, - MENU_ITEM_THEME_SYSTEM_ID, - "System Theme", - true, - None::<&str>, - )?; + let theme_light = + MenuItem::with_id(app, MENU_ITEM_THEME_LIGHT_ID, "Light Theme", true, None::<&str>)?; + let theme_dark = + MenuItem::with_id(app, MENU_ITEM_THEME_DARK_ID, "Dark Theme", true, None::<&str>)?; + let theme_system = + MenuItem::with_id(app, MENU_ITEM_THEME_SYSTEM_ID, "System Theme", true, None::<&str>)?; let toggle_sidebar = MenuItem::with_id( app, @@ -502,7 +197,6 @@ fn build_macos_menu( Some("Cmd+Shift+D"), )?; - // Help menu items let help_dialog = MenuItem::with_id( app, MENU_ITEM_HELP_DIALOG_ID, @@ -514,19 +208,13 @@ fn build_macos_menu( let download_logs = MenuItem::with_id( app, MENU_ITEM_DOWNLOAD_LOGS_ID, - "Download Logs", + "Show Diagnostics", true, Some("Cmd+Shift+L"), )?; - let report_bug = MenuItem::with_id( - app, - MENU_ITEM_REPORT_BUG_ID, - "Report a Bug", - true, - None::<&str>, - )?; - + let report_bug = + MenuItem::with_id(app, MENU_ITEM_REPORT_BUG_ID, "Report a Bug", true, None::<&str>)?; let request_feature = MenuItem::with_id( app, MENU_ITEM_REQUEST_FEATURE_ID, @@ -534,21 +222,11 @@ fn build_macos_menu( true, None::<&str>, )?; + let join_discord = + MenuItem::with_id(app, MENU_ITEM_JOIN_DISCORD_ID, "Join Discord", true, None::<&str>)?; - let join_discord = MenuItem::with_id( - app, - MENU_ITEM_JOIN_DISCORD_ID, - "Join Discord", - true, - None::<&str>, - )?; - - let theme_submenu = Submenu::with_items( - app, - "Theme", - true, - &[&theme_light, &theme_dark, &theme_system], - )?; + let theme_submenu = + Submenu::with_items(app, "Theme", true, &[&theme_light, &theme_dark, &theme_system])?; let window_menu = Submenu::with_id_and_items( app, @@ -635,6 +313,7 @@ fn build_macos_menu( &[ &open_git_tab, &open_diff_tab, + &open_files_tab, &open_terminal_tab, &PredefinedMenuItem::separator(app)?, &theme_submenu, @@ -651,279 +330,888 @@ fn build_macos_menu( ) } -fn main() { - let mut log_builder = tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .clear_targets() - .target(Target::new(TargetKind::Stdout)) - .target(Target::new(TargetKind::Webview)); +#[tauri::command] +fn desktop_set_auto_worktree_menu(app: tauri::AppHandle, enabled: bool) -> Result<(), String> { + let Some(state) = app.try_state::() else { + return Ok(()); + }; - if let Some(dir) = logging::log_directory() { - log_builder = log_builder.target(Target::new(TargetKind::Folder { - path: dir, - file_name: Some("openchamber".into()), - })); + { + let mut guard = state.auto_worktree.lock().expect("menu state mutex"); + *guard = enabled; } - let app = tauri::Builder::default() - .plugin(shell_plugin()) - .plugin(dialog_plugin()) - .plugin(fs_plugin()) - .plugin(notification_plugin()) + #[cfg(target_os = "macos")] + { + use tauri::menu::MenuItemKind; + + let new_session_shortcut = if enabled { "Cmd+Shift+N" } else { "Cmd+N" }; + let new_worktree_shortcut = if enabled { "Cmd+N" } else { "Cmd+Shift+N" }; + + if let Some(menu) = app.menu() { + if let Some(MenuItemKind::MenuItem(item)) = menu.get(MENU_ITEM_NEW_SESSION_ID) { + item.set_accelerator(Some(new_session_shortcut)) + .map_err(|err| err.to_string())?; + } + if let Some(MenuItemKind::MenuItem(item)) = menu.get(MENU_ITEM_WORKTREE_CREATOR_ID) { + item.set_accelerator(Some(new_worktree_shortcut)) + .map_err(|err| err.to_string())?; + } + } else { + // Should not happen on macOS, but keep as fallback. + let menu = build_macos_menu(&app).map_err(|err| err.to_string())?; + app.set_menu(menu).map_err(|err| err.to_string())?; + } + } + + Ok(()) +} + +const SIDECAR_NAME: &str = "openchamber-server"; +const SIDECAR_NOTIFY_PREFIX: &str = "[OpenChamberDesktopNotify] "; +const HEALTH_TIMEOUT: Duration = Duration::from_secs(20); +const HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(250); + +const DEFAULT_DESKTOP_PORT: u16 = 57123; + +const LOCAL_HOST_ID: &str = "local"; + +#[derive(Default)] +struct SidecarState { + child: Mutex>, + url: Mutex>, +} + +#[derive(Default)] +struct DesktopUiInjectionState { + script: Mutex>, +} + +struct WindowFocusState { + focused: Mutex, +} + +impl Default for WindowFocusState { + fn default() -> Self { + Self { + focused: Mutex::new(true), + } + } +} + +#[derive(Default)] +struct MenuRuntimeState { + auto_worktree: Mutex, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DesktopHost { + id: String, + label: String, + url: String, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DesktopHostsConfig { + hosts: Vec, + default_host_id: Option, +} + +fn normalize_host_url(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let parsed = url::Url::parse(trimmed).ok()?; + let scheme = parsed.scheme(); + if scheme != "http" && scheme != "https" { + return None; + } + let host = parsed.host_str()?; + let mut normalized = format!("{}://{}", scheme, host); + if let Some(port) = parsed.port() { + normalized.push(':'); + normalized.push_str(&port.to_string()); + } + Some(normalized) +} + +fn settings_file_path() -> PathBuf { + if let Ok(dir) = env::var("OPENCHAMBER_DATA_DIR") { + if !dir.trim().is_empty() { + return PathBuf::from(dir.trim()).join("settings.json"); + } + } + let home = env::var("HOME").unwrap_or_default(); + PathBuf::from(home) + .join(".config") + .join("openchamber") + .join("settings.json") +} + +fn read_desktop_local_port_from_disk() -> Option { + let path = settings_file_path(); + let raw = fs::read_to_string(path).ok(); + let parsed = raw + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()); + parsed + .as_ref() + .and_then(|v| v.get("desktopLocalPort")) + .and_then(|v| v.as_u64()) + .and_then(|v| if v > 0 && v <= u16::MAX as u64 { Some(v as u16) } else { None }) +} + +fn write_desktop_local_port_to_disk(port: u16) -> Result<()> { + let path = settings_file_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut root: serde_json::Value = if let Ok(raw) = fs::read_to_string(&path) { + serde_json::from_str(&raw).unwrap_or(serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + if !root.is_object() { + root = serde_json::json!({}); + } + + root["desktopLocalPort"] = serde_json::Value::Number(serde_json::Number::from(port)); + fs::write(&path, serde_json::to_string_pretty(&root)?)?; + Ok(()) +} + + +fn read_desktop_hosts_config_from_disk() -> DesktopHostsConfig { + let path = settings_file_path(); + let raw = fs::read_to_string(path).ok(); + let parsed = raw + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()); + + let hosts_value = parsed + .as_ref() + .and_then(|v| v.get("desktopHosts")) + .cloned() + .unwrap_or(serde_json::Value::Null); + let default_value = parsed + .as_ref() + .and_then(|v| v.get("desktopDefaultHostId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let mut hosts: Vec = Vec::new(); + if let serde_json::Value::Array(items) = hosts_value { + for item in items { + if let Ok(host) = serde_json::from_value::(item) { + if host.id.trim().is_empty() || host.id == LOCAL_HOST_ID { + continue; + } + if let Some(url) = normalize_host_url(&host.url) { + hosts.push(DesktopHost { + id: host.id, + label: if host.label.trim().is_empty() { + url.clone() + } else { + host.label + }, + url, + }); + } + } + } + } + + DesktopHostsConfig { + hosts, + default_host_id: default_value, + } +} + +fn write_desktop_hosts_config_to_disk(config: &DesktopHostsConfig) -> Result<()> { + let path = settings_file_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut root: serde_json::Value = if let Ok(raw) = fs::read_to_string(&path) { + serde_json::from_str(&raw).unwrap_or(serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + if !root.is_object() { + root = serde_json::json!({}); + } + + let hosts: Vec = config + .hosts + .iter() + .filter_map(|h| { + let id = h.id.trim(); + if id.is_empty() || id == LOCAL_HOST_ID { + return None; + } + let url = normalize_host_url(&h.url)?; + Some(DesktopHost { + id: id.to_string(), + label: if h.label.trim().is_empty() { + url.clone() + } else { + h.label.trim().to_string() + }, + url, + }) + }) + .collect(); + + root["desktopHosts"] = serde_json::to_value(hosts).unwrap_or(serde_json::Value::Array(vec![])); + root["desktopDefaultHostId"] = match &config.default_host_id { + Some(id) if !id.trim().is_empty() => serde_json::Value::String(id.trim().to_string()), + _ => serde_json::Value::Null, + }; + + fs::write(&path, serde_json::to_string_pretty(&root)?)?; + Ok(()) +} + +#[tauri::command] +fn desktop_hosts_get() -> Result { + Ok(read_desktop_hosts_config_from_disk()) +} + +#[tauri::command] +fn desktop_hosts_set(config: DesktopHostsConfig) -> Result<(), String> { + write_desktop_hosts_config_to_disk(&config).map_err(|err| err.to_string()) +} + + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct HostProbeResult { + status: String, + latency_ms: u64, +} + +#[tauri::command] +async fn desktop_host_probe(url: String) -> Result { + let normalized = normalize_host_url(&url).ok_or_else(|| "Invalid URL".to_string())?; + let health = format!("{}/health", normalized.trim_end_matches('/')); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(2)) + .build() + .map_err(|err| err.to_string())?; + let started = std::time::Instant::now(); + match client.get(&health).send().await { + Ok(resp) => { + let status = resp.status(); + let latency_ms = started.elapsed().as_millis() as u64; + if status.is_success() { + Ok(HostProbeResult { + status: "ok".to_string(), + latency_ms, + }) + } else if status.as_u16() == 401 || status.as_u16() == 403 { + Ok(HostProbeResult { + status: "auth".to_string(), + latency_ms, + }) + } else { + Ok(HostProbeResult { + status: "unreachable".to_string(), + latency_ms, + }) + } + } + Err(_) => Ok(HostProbeResult { + status: "unreachable".to_string(), + latency_ms: started.elapsed().as_millis() as u64, + }), + } +} + +#[derive(Clone, Serialize)] +#[serde(tag = "event", content = "data")] +enum UpdateProgressEvent { + #[serde(rename_all = "camelCase")] + Started { + content_length: Option, + }, + #[serde(rename_all = "camelCase")] + Progress { + chunk_length: usize, + downloaded: u64, + total: Option, + }, + Finished, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DesktopUpdateInfo { + available: bool, + current_version: String, + version: Option, + body: Option, + date: Option, +} + +struct PendingUpdate(Mutex>); + +fn pick_unused_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + Ok(port) +} + +fn is_nonempty_string(value: &str) -> bool { + !value.trim().is_empty() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SidecarNotifyPayload { + title: Option, + body: Option, + tag: Option, + require_hidden: Option, +} + +fn maybe_show_sidecar_notification(app: &tauri::AppHandle, payload: SidecarNotifyPayload) { + let require_hidden = payload.require_hidden.unwrap_or(false); + if require_hidden { + let focused = app + .try_state::() + .map(|state| *state.focused.lock().expect("focus mutex")) + .unwrap_or(false); + if focused { + return; + } + } + + let title = payload + .title + .filter(|t| is_nonempty_string(t)) + .unwrap_or_else(|| "OpenChamber".to_string()); + let body = payload.body.filter(|b| is_nonempty_string(b)); + let _tag = payload.tag; + + use tauri_plugin_notification::NotificationExt; + + let mut builder = app.notification().builder().title(title); + if let Some(body) = body { + builder = builder.body(body); + } + + #[cfg(target_os = "macos")] + { + builder = builder.sound("Glass"); + } + let _ = builder.show(); +} + +async fn wait_for_health(url: &str) -> bool { + let client = match reqwest::Client::builder().no_proxy().build() { + Ok(c) => c, + Err(_) => return false, + }; + + let deadline = std::time::Instant::now() + HEALTH_TIMEOUT; + let health_url = format!("{}/health", url.trim_end_matches('/')); + + while std::time::Instant::now() < deadline { + if let Ok(resp) = client.get(&health_url).send().await { + if resp.status().is_success() { + return true; + } + } + tokio::time::sleep(HEALTH_POLL_INTERVAL).await; + } + + false +} + +fn kill_sidecar(app: tauri::AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + + let mut guard = state.child.lock().expect("sidecar mutex"); + if let Some(child) = guard.take() { + let _ = child.kill(); + } +} + +fn build_local_url(port: u16) -> String { + format!("http://127.0.0.1:{port}") +} + +async fn spawn_local_server(app: &tauri::AppHandle) -> Result { + let stored_port = read_desktop_local_port_from_disk(); + let mut candidates: Vec> = Vec::new(); + if let Some(port) = stored_port { + candidates.push(Some(port)); + } + candidates.push(Some(DEFAULT_DESKTOP_PORT)); + candidates.push(None); + + let dist_dir = resolve_web_dist_dir(app)?; + let no_proxy = "localhost,127.0.0.1"; + + // macOS app launch env often lacks user PATH entries. + let mut path_segments: Vec = Vec::new(); + let mut seen = std::collections::HashSet::::new(); + + let mut push_unique = |value: String| { + let trimmed = value.trim(); + if trimmed.is_empty() { + return; + } + if seen.insert(trimmed.to_string()) { + path_segments.push(trimmed.to_string()); + } + }; + + // Respect explicit binary overrides by adding their parent dir first. + for var in [ + "OPENCHAMBER_OPENCODE_PATH", + "OPENCHAMBER_OPENCODE_BIN", + "OPENCODE_PATH", + "OPENCODE_BINARY", + ] { + if let Ok(val) = env::var(var) { + let trimmed = val.trim(); + if trimmed.is_empty() { + continue; + } + let path = std::path::Path::new(trimmed); + if let Some(parent) = path.parent() { + push_unique(parent.to_string_lossy().to_string()); + } + } + } + + // Common locations. + push_unique("/opt/homebrew/bin".to_string()); + push_unique("/usr/local/bin".to_string()); + push_unique("/usr/bin".to_string()); + push_unique("/bin".to_string()); + push_unique("/usr/sbin".to_string()); + push_unique("/sbin".to_string()); + + if let Ok(home) = env::var("HOME") { + let home = home.trim(); + if !home.is_empty() { + // OpenCode installer default. + push_unique(format!("{home}/.opencode/bin")); + push_unique(format!("{home}/.local/bin")); + push_unique(format!("{home}/.bun/bin")); + push_unique(format!("{home}/.cargo/bin")); + push_unique(format!("{home}/bin")); + } + } + + if let Ok(existing) = env::var("PATH") { + for segment in existing.split(':') { + push_unique(segment.to_string()); + } + } + + let augmented_path = path_segments.join(":"); + + for candidate in candidates { + let port = match candidate { + Some(p) => p, + None => pick_unused_port()?, + }; + let url = build_local_url(port); + + let cmd = app + .shell() + .sidecar(SIDECAR_NAME) + .map_err(|err| anyhow!("Failed to resolve sidecar '{SIDECAR_NAME}': {err}"))? + .args(["--port", &port.to_string()]) + .env("OPENCHAMBER_HOST", "127.0.0.1") + .env("OPENCHAMBER_DIST_DIR", dist_dir.clone()) + .env("OPENCHAMBER_DESKTOP_NOTIFY", "true") + .env("PATH", augmented_path.clone()) + .env("NO_PROXY", no_proxy) + .env("no_proxy", no_proxy); + + let (rx, child) = match cmd.spawn() { + Ok(v) => v, + Err(err) => { + log::warn!("[sidecar] spawn failed on port {port}: {err}"); + continue; + } + }; + + let app_handle = app.clone(); + tauri::async_runtime::spawn(async move { + let mut rx = rx; + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Stdout(bytes) => { + let line = String::from_utf8_lossy(&bytes); + if let Some(rest) = line.strip_prefix(SIDECAR_NOTIFY_PREFIX) { + if let Ok(parsed) = + serde_json::from_str::(rest.trim()) + { + maybe_show_sidecar_notification(&app_handle, parsed); + } + } + } + CommandEvent::Error(error) => { + log::warn!("[sidecar] error: {error}"); + } + CommandEvent::Terminated(payload) => { + log::warn!( + "[sidecar] terminated code={:?} signal={:?}", + payload.code, + payload.signal + ); + break; + } + _ => {} + } + } + }); + + if let Some(state) = app.try_state::() { + *state.child.lock().expect("sidecar mutex") = Some(child); + *state.url.lock().expect("sidecar url mutex") = Some(url.clone()); + } + + if !wait_for_health(&url).await { + kill_sidecar(app.clone()); + continue; + } + + let _ = write_desktop_local_port_to_disk(port); + return Ok(url); + } + + Err(anyhow!("Sidecar health check failed")) +} + +fn resolve_web_dist_dir(app: &tauri::AppHandle) -> Result { + let candidates = ["web-dist", "resources/web-dist"]; + for candidate in candidates { + let path = app + .path() + .resolve(candidate, tauri::path::BaseDirectory::Resource) + .map_err(|err| anyhow!("Failed to resolve '{candidate}' resources: {err}"))?; + let index = path.join("index.html"); + if fs::metadata(&index).is_ok() { + return Ok(path); + } + } + + Err(anyhow!( + "Web assets missing in app resources (expected index.html under web-dist)" + )) +} + +fn normalize_server_url(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + + match url::Url::parse(trimmed) { + Ok(url) => { + if url.scheme() == "http" || url.scheme() == "https" { + Some(trimmed.trim_end_matches('/').to_string()) + } else { + None + } + } + Err(_) => None, + } +} + +#[derive(Deserialize)] +struct DesktopNotifyPayload { + title: Option, + body: Option, + tag: Option, +} + +#[tauri::command] +fn desktop_notify( + app: tauri::AppHandle, + payload: Option, +) -> Result { + let payload = payload.unwrap_or(DesktopNotifyPayload { + title: None, + body: None, + tag: None, + }); + + use tauri_plugin_notification::NotificationExt; + + let mut builder = app + .notification() + .builder() + .title(payload.title.unwrap_or_else(|| "OpenChamber".to_string())); + + if let Some(body) = payload.body { + if is_nonempty_string(&body) { + builder = builder.body(body); + } + } + + if let Some(tag) = payload.tag { + if is_nonempty_string(&tag) { + let _ = tag; + } + } + + #[cfg(target_os = "macos")] + { + builder = builder.sound("Glass"); + } + + builder.show().map(|_| true).map_err(|err| err.to_string()) +} + +#[tauri::command] +async fn desktop_check_for_updates( + app: tauri::AppHandle, + pending: tauri::State<'_, PendingUpdate>, +) -> Result { + let updater = app.updater().map_err(|err| err.to_string())?; + let update = updater.check().await.map_err(|err| err.to_string())?; + + let current_version = app.package_info().version.to_string(); + + let info = if let Some(update) = update { + *pending.0.lock().expect("pending update mutex") = Some(update.clone()); + DesktopUpdateInfo { + available: true, + current_version, + version: Some(update.version.clone()), + body: update.body.clone(), + date: update.date.map(|date| date.to_string()), + } + } else { + *pending.0.lock().expect("pending update mutex") = None; + DesktopUpdateInfo { + available: false, + current_version, + version: None, + body: None, + date: None, + } + }; + + Ok(info) +} + +#[tauri::command] +async fn desktop_download_and_install_update( + app: tauri::AppHandle, + pending: tauri::State<'_, PendingUpdate>, +) -> Result<(), String> { + let Some(update) = pending.0.lock().expect("pending update mutex").take() else { + return Err("No pending update".to_string()); + }; + + let mut downloaded: u64 = 0; + let mut total: Option = None; + let mut started = false; + + update + .download_and_install( + |chunk_length, content_length| { + if !started { + total = content_length; + let _ = app.emit( + "openchamber:update-progress", + UpdateProgressEvent::Started { content_length }, + ); + started = true; + } + + downloaded = downloaded.saturating_add(chunk_length as u64); + let _ = app.emit( + "openchamber:update-progress", + UpdateProgressEvent::Progress { + chunk_length, + downloaded, + total, + }, + ); + }, + || { + let _ = app.emit("openchamber:update-progress", UpdateProgressEvent::Finished); + }, + ) + .await + .map_err(|err| err.to_string())?; + + Ok(()) +} + +#[tauri::command] +fn desktop_restart(app: tauri::AppHandle) { + app.restart(); +} + +fn create_main_window(app: &tauri::AppHandle, url: &str, local_origin: &str) -> Result<()> { + let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?; + + let home = std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).unwrap_or_default(); + #[cfg(target_os = "macos")] + fn macos_major_version() -> Option { + fn cmd_stdout(cmd: &str, args: &[&str]) -> Option { + let output = Command::new(cmd).args(args).output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() + } + + // Use marketing version (sw_vers), but map legacy 10.x to minor (10.15 -> 15). + // This matches WebKit UA fallback logic in the UI. + if let Some(raw) = cmd_stdout("/usr/bin/sw_vers", &["-productVersion"]).or_else(|| cmd_stdout("sw_vers", &["-productVersion"])) { + let raw = raw.trim(); + let mut parts = raw.split('.'); + let major = parts.next().and_then(|v| v.parse::().ok())?; + let minor = parts.next().and_then(|v| v.parse::().ok()).unwrap_or(0); + return Some(if major == 10 { minor } else { major }); + } + + // Fallback: derive from Darwin major (kern.osrelease major). + let raw = cmd_stdout("/usr/sbin/sysctl", &["-n", "kern.osrelease"]) + .or_else(|| cmd_stdout("sysctl", &["-n", "kern.osrelease"])) + .or_else(|| cmd_stdout("/usr/bin/uname", &["-r"])) + .or_else(|| cmd_stdout("uname", &["-r"]))?; + let raw = raw.trim(); + let major = raw.split('.').next()?.parse::().ok()?; + if major >= 20 { + return Some(major - 9); + } + if major >= 15 { + return Some(major - 4); + } + Some(major) + } + + #[cfg(not(target_os = "macos"))] + fn macos_major_version() -> Option { + None + } + + let macos_major = macos_major_version().unwrap_or(0); + + let home_json = serde_json::to_string(&home).unwrap_or_else(|_| "\"\"".into()); + let local_json = serde_json::to_string(local_origin).unwrap_or_else(|_| "\"\"".into()); + + let mut init_script = format!( + "(function(){{try{{window.__OPENCHAMBER_HOME__={home_json};window.__OPENCHAMBER_MACOS_MAJOR__={macos_major};window.__OPENCHAMBER_LOCAL_ORIGIN__={local_json};}}catch(_e){{}}}})();" + ); + + // Cleanup: older builds injected a native-ish Instance switcher button into pages. + // Remove it if present so the UI-owned host switcher is the only one. + init_script.push_str("\ntry{var old=document.getElementById('__oc-instance-switcher');if(old)old.remove();}catch(_e){}"); + + if !cfg!(debug_assertions) { + init_script.push_str("\ntry{document.addEventListener('contextmenu',function(e){e.preventDefault();},true);}catch(_e){}"); + } + + if let Some(state) = app.try_state::() { + *state.script.lock().expect("desktop ui injection mutex") = Some(init_script.clone()); + } + + let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(parsed)) + .title("OpenChamber") + .inner_size(1280.0, 800.0) + .decorations(true) + .visible(false) + .initialization_script(&init_script) + ; + + #[cfg(target_os = "macos")] + { + builder = builder + .hidden_title(true) + .title_bar_style(tauri::TitleBarStyle::Overlay) + .traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition { x: 17.0, y: 26.0 })); + } + + let window = builder.build()?; + + let _ = window.show(); + let _ = window.set_focus(); + + Ok(()) +} + +fn main() { + let log_builder = tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .clear_targets() + .targets([ + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), + ]); + + let builder = tauri::Builder::default() + .manage(SidecarState::default()) + .manage(DesktopUiInjectionState::default()) + .manage(WindowFocusState::default()) + .manage(MenuRuntimeState::default()) + .manage(PendingUpdate(Mutex::new(None))) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_process::init()) .plugin(log_builder.build()) + .on_page_load(|window, _payload| { + if let Some(state) = window.app_handle().try_state::() { + if let Ok(guard) = state.script.lock() { + if let Some(script) = guard.as_ref() { + let _ = window.eval(script); + } + } + } + }) .menu(|app| { #[cfg(target_os = "macos")] { - return build_macos_menu(app); + build_macos_menu(app) } + #[cfg(not(target_os = "macos"))] { - return tauri::menu::Menu::default(app); + tauri::menu::Menu::default(app) } }) - .setup(|app| { - #[cfg(target_os = "macos")] - prevent_app_nap(); - - app.manage(TerminalState::new()); - - let stored_state = tauri::async_runtime::block_on(load_window_state()).unwrap_or(None); - let manager = WindowStateManager::new(stored_state.clone().unwrap_or_default()); - app.manage(manager.clone()); - - if let Some(window) = app.get_webview_window("main") { - #[cfg(target_os = "macos")] - { - // Apply layer optimizations for smoother scrolling - optimize_webview_layer(&window); - } - - if let Some(saved) = &stored_state { - let _ = window_state::apply_window_state(&window, saved); - } - - let _ = window.show(); - let _ = window.set_focus(); - } - - let runtime = DesktopRuntime::initialize_sync()?; - app.manage(runtime.clone()); - - let app_handle = app.app_handle().clone(); - let runtime_clone = runtime.clone(); - tauri::async_runtime::spawn(async move { - runtime_clone.start_opencode().await; - - if let Err(e) = - restore_bookmarks_on_startup(app_handle.state::().clone()).await - { - warn!("Failed to restore bookmarks on startup: {}", e); - } - - let _ = app_handle.emit("openchamber:runtime-ready", ()); - }); - - // Sidecar watchdog: restart on unexpected exit and notify UI - { - let app_handle = app.app_handle().clone(); - let runtime = runtime.clone(); - tauri::async_runtime::spawn(async move { - let mut backoff_ms: u64 = 1000; - loop { - if runtime.opencode_manager().is_shutting_down() { - break; - } - - let mut sleep_ms = backoff_ms; - - match runtime.opencode_manager().is_child_running().await { - Ok(true) => { - sleep_ms = 1000; - backoff_ms = 1000; - } - Ok(false) => { - let _ = app_handle.emit("server.instance.disposed", ()); - if runtime.opencode_manager().is_cli_available() { - if let Err(err) = - runtime.opencode_manager().ensure_running().await - { - warn!( - "[desktop:watchdog] Failed to restart OpenCode: {err}" - ); - } else { - backoff_ms = 1000; - } - } - } - Err(err) => { - warn!("[desktop:watchdog] Failed to check child status: {err}"); - } - } - - tokio::time::sleep(Duration::from_millis(sleep_ms)).await; - backoff_ms = (backoff_ms * 2).min(8000); - } - }); - } - - // Health and wake monitor: emit health and port updates to webview - { - let app_handle = app.app_handle().clone(); - let runtime = runtime.clone(); - tauri::async_runtime::spawn(async move { - #[derive(Clone, Serialize)] - struct HealthSnapshot { - ok: bool, - port: Option, - api_prefix: String, - cli_available: bool, - } - - let mut last_snapshot: Option = None; - let mut last_tick = Instant::now(); - - loop { - if runtime.opencode_manager().is_shutting_down() { - break; - } - - let now = Instant::now(); - let gap_ms = now.saturating_duration_since(last_tick).as_millis() as u64; - last_tick = now; - - let snapshot = HealthSnapshot { - ok: runtime.opencode_manager().is_ready(), - port: runtime.opencode_manager().current_port(), - api_prefix: runtime.opencode_manager().api_prefix(), - cli_available: opencode_manager::check_cli_exists(), - }; - - let changed = match &last_snapshot { - Some(prev) => { - prev.ok != snapshot.ok - || prev.port != snapshot.port - || prev.api_prefix != snapshot.api_prefix - || prev.cli_available != snapshot.cli_available - } - None => true, - }; - - if changed { - let _ = app_handle.emit("openchamber:health-changed", &snapshot); - last_snapshot = Some(snapshot.clone()); - } - - if gap_ms > 15000 { - let _ = app_handle.emit("openchamber:wake", ()); - } - - tokio::time::sleep(Duration::from_secs(5)).await; - } - }); - } - - spawn_assistant_notifications(app.app_handle().clone(), runtime.clone()); - spawn_session_activity_tracker(app.app_handle().clone(), runtime.clone()); - - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - desktop_server_info, - desktop_restart_opencode, - desktop_get_macos_version, - #[cfg(feature = "devtools")] - desktop_open_devtools, - load_settings, - save_settings, - restart_opencode, - list_directory, - search_files, - create_directory, - delete_path, - rename_path, - read_file, - read_file_binary, - write_file, - exec_commands, - request_directory_access, - start_accessing_directory, - stop_accessing_directory, - pick_directory, - restore_bookmarks_on_startup, - process_directory_selection, - check_is_git_repository, - get_git_status, - get_git_diff, - get_git_file_diff, - revert_git_file, - is_linked_worktree, - get_git_branches, - delete_git_branch, - delete_remote_branch, - list_git_worktrees, - add_git_worktree, - remove_git_worktree, - ensure_openchamber_ignored, - create_git_commit, - git_push, - git_pull, - git_fetch, - checkout_branch, - create_branch, - rename_branch, - get_git_log, - get_commit_files, - get_git_identities, - create_git_identity, - update_git_identity, - delete_git_identity, - get_current_git_identity, - has_local_identity, - get_global_git_identity, - get_remote_url, - set_git_identity, - discover_git_credentials, - generate_commit_message, - generate_pr_description, - create_terminal_session, - send_terminal_input, - resize_terminal, - close_terminal, - restart_terminal_session, - force_kill_terminal, - fetch_desktop_logs, - desktop_notify, - github_auth_status, - github_auth_start, - github_auth_complete, - github_auth_disconnect, - github_auth_activate, - github_me, - github_pr_status, - github_pr_create, - github_pr_merge, - github_pr_ready, - github_prs_list, - github_pr_context, - github_issues_list, - github_issue_get, - github_issue_comments, - ]) .on_menu_event(|app, event| { #[cfg(target_os = "macos")] { - let event_id = event.id().as_ref(); + let id = event.id().as_ref(); - // Check for updates - if event_id == MENU_ITEM_CHECK_FOR_UPDATES_ID { - let _ = app.emit(CHECK_FOR_UPDATES_EVENT, ()); + log::info!("[menu] click id={}", id); + + #[cfg(debug_assertions)] + { + let msg = serde_json::to_string(id).unwrap_or_else(|_| "\"(unserializable)\"".into()); + eval_in_main_window(app, &format!("console.log('[menu] id=', {});", msg)); + } + + if id == MENU_ITEM_CHECK_FOR_UPDATES_ID { + dispatch_check_for_updates(app); return; } - // External links - if event_id == MENU_ITEM_REPORT_BUG_ID { + if id == MENU_ITEM_REPORT_BUG_ID { use tauri_plugin_shell::ShellExt; #[allow(deprecated)] { @@ -932,7 +1220,7 @@ fn main() { return; } - if event_id == MENU_ITEM_REQUEST_FEATURE_ID { + if id == MENU_ITEM_REQUEST_FEATURE_ID { use tauri_plugin_shell::ShellExt; #[allow(deprecated)] { @@ -941,7 +1229,7 @@ fn main() { return; } - if event_id == MENU_ITEM_JOIN_DISCORD_ID { + if id == MENU_ITEM_JOIN_DISCORD_ID { use tauri_plugin_shell::ShellExt; #[allow(deprecated)] { @@ -950,2056 +1238,178 @@ fn main() { return; } - // App menu actions - if event_id == MENU_ITEM_ABOUT_ID { - let _ = app.emit("openchamber:menu-action", "about"); + if id == MENU_ITEM_ABOUT_ID { + dispatch_menu_action(app, "about"); + return; + } + if id == MENU_ITEM_SETTINGS_ID { + dispatch_menu_action(app, "settings"); + return; + } + if id == MENU_ITEM_COMMAND_PALETTE_ID { + dispatch_menu_action(app, "command-palette"); return; } - if event_id == MENU_ITEM_SETTINGS_ID { - let _ = app.emit("openchamber:menu-action", "settings"); + if id == MENU_ITEM_NEW_SESSION_ID { + dispatch_menu_action(app, "new-session"); + return; + } + if id == MENU_ITEM_WORKTREE_CREATOR_ID { + dispatch_menu_action(app, "new-worktree-session"); + return; + } + if id == MENU_ITEM_CHANGE_WORKSPACE_ID { + dispatch_menu_action(app, "change-workspace"); return; } - if event_id == MENU_ITEM_COMMAND_PALETTE_ID { - let _ = app.emit("openchamber:menu-action", "command-palette"); + if id == MENU_ITEM_OPEN_GIT_TAB_ID { + dispatch_menu_action(app, "open-git-tab"); + return; + } + if id == MENU_ITEM_OPEN_DIFF_TAB_ID { + dispatch_menu_action(app, "open-diff-tab"); return; } - // File menu actions - if event_id == MENU_ITEM_NEW_SESSION_ID { - let _ = app.emit("openchamber:menu-action", "new-session"); + if id == MENU_ITEM_OPEN_FILES_TAB_ID { + dispatch_menu_action(app, "open-files-tab"); + return; + } + if id == MENU_ITEM_OPEN_TERMINAL_TAB_ID { + dispatch_menu_action(app, "open-terminal-tab"); return; } - if event_id == MENU_ITEM_WORKTREE_CREATOR_ID { - let _ = app.emit("openchamber:menu-action", "worktree-creator"); + if id == MENU_ITEM_THEME_LIGHT_ID { + dispatch_menu_action(app, "theme-light"); + return; + } + if id == MENU_ITEM_THEME_DARK_ID { + dispatch_menu_action(app, "theme-dark"); + return; + } + if id == MENU_ITEM_THEME_SYSTEM_ID { + dispatch_menu_action(app, "theme-system"); return; } - if event_id == MENU_ITEM_CHANGE_WORKSPACE_ID { - let _ = app.emit("openchamber:menu-action", "change-workspace"); + if id == MENU_ITEM_TOGGLE_SIDEBAR_ID { + dispatch_menu_action(app, "toggle-sidebar"); + return; + } + if id == MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID { + dispatch_menu_action(app, "toggle-memory-debug"); return; } - // View menu actions - if event_id == MENU_ITEM_OPEN_GIT_TAB_ID { - let _ = app.emit("openchamber:menu-action", "open-git-tab"); + if id == MENU_ITEM_HELP_DIALOG_ID { + dispatch_menu_action(app, "help-dialog"); return; } - - if event_id == MENU_ITEM_OPEN_DIFF_TAB_ID { - let _ = app.emit("openchamber:menu-action", "open-diff-tab"); - return; - } - - if event_id == MENU_ITEM_OPEN_TERMINAL_TAB_ID { - let _ = app.emit("openchamber:menu-action", "open-terminal-tab"); - return; - } - - if event_id == MENU_ITEM_THEME_LIGHT_ID { - let _ = app.emit("openchamber:menu-action", "theme-light"); - return; - } - - if event_id == MENU_ITEM_THEME_DARK_ID { - let _ = app.emit("openchamber:menu-action", "theme-dark"); - return; - } - - if event_id == MENU_ITEM_THEME_SYSTEM_ID { - let _ = app.emit("openchamber:menu-action", "theme-system"); - return; - } - - if event_id == MENU_ITEM_TOGGLE_SIDEBAR_ID { - let _ = app.emit("openchamber:menu-action", "toggle-sidebar"); - return; - } - - if event_id == MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID { - let _ = app.emit("openchamber:menu-action", "toggle-memory-debug"); - return; - } - - // Help menu actions - if event_id == MENU_ITEM_HELP_DIALOG_ID { - let _ = app.emit("openchamber:menu-action", "help-dialog"); - return; - } - - if event_id == MENU_ITEM_DOWNLOAD_LOGS_ID { - let _ = app.emit("openchamber:menu-action", "download-logs"); - return; + if id == MENU_ITEM_DOWNLOAD_LOGS_ID { + dispatch_menu_action(app, "download-logs"); } } }) .on_window_event(|window, event| { - let window_state_manager = window.state::().inner().clone(); - - match event { - tauri::WindowEvent::Focused(true) => { - // Clear dock badge and underlying badge state when the window gains focus - let _ = window.set_badge_count(None); - let _ = window - .app_handle() - .emit("openchamber:clear-badge-sessions", ()); + if let tauri::WindowEvent::Focused(focused) = event { + let app = window.app_handle(); + if let Some(state) = app.try_state::() { + *state.focused.lock().expect("focus mutex") = *focused; } - tauri::WindowEvent::Moved(position) => { - let is_maximized = window.is_maximized().unwrap_or(false); - window_state_manager.update_position( - position.x as f64, - position.y as f64, - is_maximized, - ); - } - tauri::WindowEvent::Resized(size) => { - let is_maximized = window.is_maximized().unwrap_or(false); - window_state_manager.update_size( - size.width as f64, - size.height as f64, - is_maximized, - ); - } - tauri::WindowEvent::CloseRequested { api, .. } => { - api.prevent_close(); - let runtime = window.state::().inner().clone(); - let window_handle = window.clone(); - let manager_clone = window_state_manager.clone(); - tauri::async_runtime::spawn(async move { - if let Err(err) = persist_window_state(&window_handle, &manager_clone).await - { - warn!("Failed to persist window state: {}", err); - } - runtime.shutdown().await; - let _ = window_handle.app_handle().exit(0); - }); - } - _ => {} } }) + .invoke_handler(tauri::generate_handler![ + desktop_notify, + desktop_check_for_updates, + desktop_download_and_install_update, + desktop_restart, + desktop_set_auto_worktree_menu, + desktop_hosts_get, + desktop_hosts_set, + desktop_host_probe, + ]) + .setup(|app| { + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + // Always ensure local server is running for escape hatch. + let local_url = if cfg!(debug_assertions) { + let dev_url = "http://127.0.0.1:3001"; + if wait_for_health(dev_url).await { + dev_url.to_string() + } else { + match spawn_local_server(&handle).await { + Ok(local) => local, + Err(err) => { + log::error!("[desktop] failed to start local server: {err}"); + return; + } + } + } + } else { + match spawn_local_server(&handle).await { + Ok(local) => local, + Err(err) => { + log::error!("[desktop] failed to start local server: {err}"); + return; + } + } + }; + + // Ensure local URL is always available to desktop commands, + // even when we are using the Vite dev server (no sidecar child). + if let Some(state) = handle.try_state::() { + *state.url.lock().expect("sidecar url mutex") = Some(local_url.clone()); + } + + let local_origin = url::Url::parse(&local_url) + .ok() + .map(|u| u.origin().ascii_serialization()) + .unwrap_or_else(|| local_url.clone()); + + // Selected host: env override first, then desktop default host, else local. + let env_target = std::env::var("OPENCHAMBER_SERVER_URL") + .ok() + .and_then(|raw| normalize_server_url(&raw)); + + let mut initial_url = env_target.unwrap_or_else(|| local_url.clone()); + + if initial_url == local_url { + let cfg = read_desktop_hosts_config_from_disk(); + if let Some(default_id) = cfg.default_host_id { + if default_id == LOCAL_HOST_ID { + initial_url = local_url.clone(); + } else if let Some(host) = cfg.hosts.into_iter().find(|h| h.id == default_id) { + initial_url = host.url; + } + } + } + + if let Err(err) = create_main_window(&handle, &initial_url, &local_origin) { + log::error!("[desktop] failed to create window: {err}"); + } + }); + + Ok(()) + }) + ; + + let app = builder .build(tauri::generate_context!()) .expect("failed to build Tauri application"); - app.run(|_app_handle, _event| {}); -} - -fn spawn_http_server(port: u16, state: ServerState, shutdown_rx: broadcast::Receiver<()>) { - tauri::async_runtime::spawn(async move { - if let Err(error) = run_http_server(port, state, shutdown_rx).await { - error!("[desktop:http] server stopped: {error:?}"); + app.run(|app_handle, event| { + match event { + tauri::RunEvent::ExitRequested { .. } => { + // Best-effort cleanup; never block shutdown. + kill_sidecar(app_handle.clone()); + } + tauri::RunEvent::Exit => { + kill_sidecar(app_handle.clone()); + } + _ => {} } }); } - -async fn run_http_server( - port: u16, - state: ServerState, - mut shutdown_rx: broadcast::Receiver<()>, -) -> Result<()> { - let router = Router::new() - .route("/health", get(health_handler)) - .route( - "/api/openchamber/models-metadata", - get(models_metadata_handler), - ) - .route("/api/quota/providers", get(quota_providers_handler)) - .route("/api/quota/{providerId}", get(quota_provider_handler)) - .route("/api/opencode/directory", post(change_directory_handler)) - .route("/api", any(proxy_to_opencode)) - .route("/api/{*rest}", any(proxy_to_opencode)) - .with_state(state) - .layer(CorsLayer::permissive()); - - let addr = format!("127.0.0.1:{port}"); - let listener = TcpListener::bind(&addr).await?; - info!("[desktop:http] listening on http://{addr}"); - - axum::serve(listener, router) - .with_graceful_shutdown(async move { - let _ = shutdown_rx.recv().await; - }) - .await?; - - Ok(()) -} - -async fn health_handler(State(state): State) -> Json { - Json(HealthResponse { - status: "ok", - server_port: state.server_port, - opencode_port: state.opencode.current_port(), - api_prefix: state.opencode.api_prefix(), - is_opencode_ready: state.opencode.is_ready(), - cli_available: opencode_manager::check_cli_exists(), - }) -} - -async fn models_metadata_handler( - State(state): State, -) -> Result, StatusCode> { - let now = Instant::now(); - let cached_payload: Option = { - let cache = state.models_metadata_cache.lock().await; - if let (Some(payload), Some(fetched_at)) = (&cache.payload, cache.fetched_at) { - if now.duration_since(fetched_at) < MODELS_METADATA_CACHE_TTL { - return Ok(Json(payload.clone())); - } - } - cache.payload.clone() - }; - - let response = state - .client - .get(MODELS_DEV_API_URL) - .header(header::ACCEPT, "application/json") - .timeout(MODELS_METADATA_REQUEST_TIMEOUT) - .send() - .await - .map_err(|error| { - warn!("[desktop:http] Failed to fetch models metadata: {error}"); - StatusCode::BAD_GATEWAY - })?; - - if !response.status().is_success() { - warn!( - "[desktop:http] models.dev responded with status {}", - response.status() - ); - if let Some(payload) = cached_payload { - return Ok(Json(payload)); - } - return Err(StatusCode::BAD_GATEWAY); - } - - let payload = response.json::().await.map_err(|error| { - warn!("[desktop:http] Failed to parse models.dev payload: {error}"); - StatusCode::BAD_GATEWAY - })?; - - { - let mut cache = state.models_metadata_cache.lock().await; - cache.payload = Some(payload.clone()); - cache.fetched_at = Some(Instant::now()); - } - - Ok(Json(payload)) -} - -async fn quota_providers_handler(State(_state): State) -> Response { - match quota_providers::list_configured_quota_providers().await { - Ok(providers) => json_response( - StatusCode::OK, - QuotaProvidersResponse { providers }, - ), - Err(err) => { - error!("[desktop:quota] Failed to list quota providers: {}", err); - config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - } - } -} - -async fn quota_provider_handler( - State(state): State, - Path(provider_id): Path, -) -> Response { - let trimmed = provider_id.trim(); - if trimmed.is_empty() { - return config_error_response(StatusCode::BAD_REQUEST, "Provider ID is required"); - } - - match quota_providers::fetch_quota_for_provider(&state.client, trimmed).await { - Ok(result) => json_response(StatusCode::OK, result), - Err(err) => { - error!("[desktop:quota] Failed to fetch quota: {}", err); - config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - } - } -} - -#[derive(Deserialize)] -struct DirectoryChangeRequest { - path: String, -} - -#[derive(Serialize)] -struct DirectoryChangeResponse { - success: bool, - restarted: bool, - path: String, -} - -fn json_response(status: StatusCode, payload: T) -> Response { - (status, Json(payload)).into_response() -} - -fn config_error_response(status: StatusCode, message: impl Into) -> Response { - json_response( - status, - ConfigErrorResponse { - error: message.into(), - }, - ) -} - -async fn parse_request_payload(req: &mut Request) -> Result, Response> { - let body = std::mem::take(req.body_mut()); - let body_bytes = to_bytes(body, PROXY_BODY_LIMIT) - .await - .map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Invalid request body"))?; - - if body_bytes.is_empty() { - return Ok(HashMap::new()); - } - - serde_json::from_slice::>(&body_bytes) - .map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Malformed JSON payload")) -} - -async fn refresh_opencode_after_config_change( - state: &ServerState, - reason: &str, -) -> Result<(), Response> { - info!("[desktop:config] Restarting OpenCode after {}", reason); - state.opencode.restart().await.map_err(|err| { - config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to restart OpenCode: {}", err), - ) - })?; - Ok(()) -} - -fn extract_directory_from_request(req: &Request) -> Option { - if let Some(value) = req.headers().get("x-opencode-directory") { - if let Ok(text) = value.to_str() { - let trimmed = text.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - } - - let query = req.uri().query()?; - for pair in query.split('&') { - let mut parts = pair.splitn(2, '='); - let key = parts.next()?; - if key != "directory" { - continue; - } - let value = parts.next().unwrap_or(""); - if let Ok(decoded) = urlencoding::decode(value) { - let trimmed = decoded.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - } - - None -} - -async fn resolve_directory_candidate(candidate: &str) -> Result { - let mut resolved = expand_tilde_path(candidate); - if !resolved.is_absolute() { - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); - resolved = home.join(resolved); - } - - let metadata = fs::metadata(&resolved) - .await - .map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Directory not found"))?; - if !metadata.is_dir() { - return Err(config_error_response( - StatusCode::BAD_REQUEST, - "Specified path is not a directory", - )); - } - - if let Ok(canonicalized) = fs::canonicalize(&resolved).await { - resolved = canonicalized; - } - - Ok(resolved) -} - -async fn resolve_project_directory_from_settings( - settings: &SettingsStore, -) -> Result, Response> { - let raw = settings.load().await.map_err(|_| { - config_error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load settings") - })?; - - let active_id = raw - .get("activeProjectId") - .and_then(|value| value.as_str()) - .unwrap_or("") - .trim() - .to_string(); - - let projects = raw.get("projects").and_then(|value| value.as_array()); - if let Some(projects) = projects { - if let Some(entry) = projects.iter().find(|entry| { - entry - .get("id") - .and_then(|value| value.as_str()) - .map(|value| value.trim() == active_id) - .unwrap_or(false) - }) { - if let Some(path) = entry.get("path").and_then(|value| value.as_str()) { - return resolve_directory_candidate(path).await.map(Some); - } - } - - if let Some(entry) = projects.first() { - if let Some(path) = entry.get("path").and_then(|value| value.as_str()) { - return resolve_directory_candidate(path).await.map(Some); - } - } - } - - let legacy = raw - .get("lastDirectory") - .and_then(|value| value.as_str()) - .unwrap_or("") - .trim(); - if !legacy.is_empty() { - return resolve_directory_candidate(legacy).await.map(Some); - } - - Ok(None) -} - -async fn resolve_project_directory( - state: &ServerState, - directory: Option, -) -> Result { - if let Some(directory) = directory { - return resolve_directory_candidate(&directory).await; - } - - match resolve_project_directory_from_settings(state.settings.as_ref()).await? { - Some(path) => Ok(path), - None => Err(config_error_response( - StatusCode::BAD_REQUEST, - "Directory parameter or active project is required", - )), - } -} - -async fn handle_agent_route( - state: &ServerState, - method: Method, - mut req: Request, - name: String, -) -> Result { - // Get working directory for project-level agent detection - let working_directory = - match resolve_project_directory(state, extract_directory_from_request(&req)).await { - Ok(directory) => directory, - Err(response) => return Ok(response), - }; - - match method { - Method::GET => { - match opencode_config::get_agent_sources(&name, Some(&working_directory)).await { - Ok(sources) => { - let resolved_scope = if sources.md.exists { - sources.md.scope.clone() - } else { - sources.json.scope.clone() - }; - let scope = resolved_scope.map(|s| match s { - opencode_config::Scope::User => opencode_config::CommandScope::User, - opencode_config::Scope::Project => opencode_config::CommandScope::Project, - }); - Ok(json_response( - StatusCode::OK, - ConfigMetadataResponse { - name, - is_built_in: !sources.md.exists && !sources.json.exists, - scope, - sources, - }, - )) - } - Err(err) => { - error!("[desktop:config] Failed to read agent sources: {}", err); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to read agent configuration", - )) - } - } - } - Method::POST => { - let payload = match parse_request_payload(&mut req).await { - Ok(data) => data, - Err(resp) => return Ok(resp), - }; - - // Extract scope from payload if present - let scope = payload - .get("scope") - .and_then(|v| v.as_str()) - .and_then(|s| match s { - "project" => Some(opencode_config::AgentScope::Project), - "user" => Some(opencode_config::AgentScope::User), - _ => None, - }); - - match opencode_config::create_agent(&name, &payload, Some(&working_directory), scope) - .await - { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "agent creation").await - { - return Ok(resp); - } - - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Agent {} created successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) - } - Err(err) => { - error!("[desktop:config] Failed to create agent {}: {}", name, err); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )) - } - } - } - Method::PATCH => { - let payload = match parse_request_payload(&mut req).await { - Ok(data) => data, - Err(resp) => return Ok(resp), - }; - - match opencode_config::update_agent(&name, &payload, Some(&working_directory)).await { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "agent update").await - { - return Ok(resp); - } - - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Agent {} updated successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) - } - Err(err) => { - error!("[desktop:config] Failed to update agent {}: {}", name, err); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )) - } - } - } - Method::DELETE => { - match opencode_config::delete_agent(&name, Some(&working_directory)).await { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "agent deletion").await - { - return Ok(resp); - } - - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Agent {} deleted successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) - } - Err(err) => { - error!("[desktop:config] Failed to delete agent {}: {}", name, err); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )) - } - } - } - _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), - } -} - -/// Response type for skill metadata -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SkillMetadataResponse { - name: String, - exists: bool, - #[serde(skip_serializing_if = "Option::is_none")] - scope: Option, - #[serde(skip_serializing_if = "Option::is_none")] - source: Option, - sources: opencode_config::SkillConfigSources, -} - -/// Response type for skill list -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SkillListItem { - name: String, - path: String, - scope: opencode_config::Scope, - source: opencode_config::SkillSource, - sources: opencode_config::SkillConfigSources, -} - -/// Response type for skill file content -#[derive(Serialize)] -struct SkillFileResponse { - path: String, - content: String, -} - -async fn handle_skill_list_route( - state: &ServerState, - req: Request, -) -> Result { - let working_directory = - match resolve_project_directory(state, extract_directory_from_request(&req)).await { - Ok(directory) => directory, - Err(response) => return Ok(response), - }; - let discovered = opencode_config::discover_skills(Some(&working_directory)); - - let mut skills = Vec::new(); - for skill in discovered { - match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await { - Ok(sources) => { - skills.push(SkillListItem { - name: skill.name, - path: skill.path, - scope: skill.scope, - source: skill.source, - sources, - }); - } - Err(err) => { - error!( - "[desktop:config] Failed to get skill sources for {}: {}", - skill.name, err - ); - } - } - } - - Ok(json_response( - StatusCode::OK, - serde_json::json!({ "skills": skills }), - )) -} - -async fn handle_skill_route( - state: &ServerState, - method: Method, - mut req: Request, - name: String, - file_path: Option, -) -> Result { - let working_directory = - match resolve_project_directory(state, extract_directory_from_request(&req)).await { - Ok(directory) => directory, - Err(response) => return Ok(response), - }; - - // Handle file operations: /api/config/skills/:name/files/* - if let Some(ref fp) = file_path { - match method { - Method::GET => { - // Read supporting file - match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { - Ok(sources) => { - if !sources.md.exists { - return Ok(config_error_response( - StatusCode::NOT_FOUND, - "Skill not found", - )); - } - let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - match opencode_config::read_skill_supporting_file( - std::path::Path::new(&skill_dir), - fp, - ) - .await - { - Ok(content) => Ok(json_response( - StatusCode::OK, - SkillFileResponse { - path: fp.clone(), - content, - }, - )), - Err(_) => Ok(config_error_response( - StatusCode::NOT_FOUND, - "File not found", - )), - } - } - Err(err) => { - error!("[desktop:config] Failed to read skill sources: {}", err); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to read skill", - )) - } - } - } - Method::PUT => { - // Write supporting file - let payload = match parse_request_payload(&mut req).await { - Ok(data) => data, - Err(resp) => return Ok(resp), - }; - let content = payload - .get("content") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { - Ok(sources) => { - if !sources.md.exists { - return Ok(config_error_response( - StatusCode::NOT_FOUND, - "Skill not found", - )); - } - let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - match opencode_config::write_skill_supporting_file( - std::path::Path::new(&skill_dir), - fp, - content, - ) - .await - { - Ok(()) => Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: false, - message: format!("File {} saved successfully", fp), - reload_delay_ms: 0, - }, - )), - Err(err) => Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )), - } - } - Err(err) => Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )), - } - } - Method::DELETE => { - // Delete supporting file - match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { - Ok(sources) => { - if !sources.md.exists { - return Ok(config_error_response( - StatusCode::NOT_FOUND, - "Skill not found", - )); - } - let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - match opencode_config::delete_skill_supporting_file( - std::path::Path::new(&skill_dir), - fp, - ) - .await - { - Ok(()) => Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: false, - message: format!("File {} deleted successfully", fp), - reload_delay_ms: 0, - }, - )), - Err(err) => Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )), - } - } - Err(err) => Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )), - } - } - _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), - } - } else { - // Handle skill CRUD: /api/config/skills/:name - match method { - Method::GET => { - match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { - Ok(sources) => { - let scope = sources.md.scope.clone(); - let source = sources.md.source.clone(); - Ok(json_response( - StatusCode::OK, - SkillMetadataResponse { - name, - exists: sources.md.exists, - scope, - source, - sources, - }, - )) - } - Err(err) => { - error!("[desktop:config] Failed to read skill sources: {}", err); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to read skill configuration", - )) - } - } - } - Method::POST => { - let payload = match parse_request_payload(&mut req).await { - Ok(data) => data, - Err(resp) => return Ok(resp), - }; - - let scope = payload - .get("scope") - .and_then(|v| v.as_str()) - .and_then(|s| match s { - "project" => Some(opencode_config::SkillScope::Project), - "user" => Some(opencode_config::SkillScope::User), - _ => None, - }); - - match opencode_config::create_skill( - &name, - &payload, - Some(&working_directory), - scope, - ) - .await - { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "skill creation").await - { - return Ok(resp); - } - - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Skill {} created successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) - } - Err(err) => { - error!("[desktop:config] Failed to create skill {}: {}", name, err); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )) - } - } - } - Method::PATCH => { - let payload = match parse_request_payload(&mut req).await { - Ok(data) => data, - Err(resp) => return Ok(resp), - }; - - match opencode_config::update_skill(&name, &payload, Some(&working_directory)).await - { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "skill update").await - { - return Ok(resp); - } - - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Skill {} updated successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) - } - Err(err) => { - error!("[desktop:config] Failed to update skill {}: {}", name, err); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )) - } - } - } - Method::DELETE => { - match opencode_config::delete_skill(&name, Some(&working_directory)).await { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "skill deletion").await - { - return Ok(resp); - } - - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Skill {} deleted successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) - } - Err(err) => { - error!("[desktop:config] Failed to delete skill {}: {}", name, err); - let status = if err.to_string().contains("not found") { - StatusCode::NOT_FOUND - } else { - StatusCode::INTERNAL_SERVER_ERROR - }; - Ok(config_error_response(status, err.to_string())) - } - } - } - _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), - } - } -} - -async fn handle_command_route( - state: &ServerState, - method: Method, - mut req: Request, - name: String, -) -> Result { - // Get working directory for project-level command detection - let working_directory = - match resolve_project_directory(state, extract_directory_from_request(&req)).await { - Ok(directory) => directory, - Err(response) => return Ok(response), - }; - - match method { - Method::GET => { - match opencode_config::get_command_sources(&name, Some(&working_directory)).await { - Ok(sources) => { - let resolved_scope = if sources.md.exists { - sources.md.scope.clone() - } else { - sources.json.scope.clone() - }; - let scope = resolved_scope.map(|s| match s { - opencode_config::Scope::User => opencode_config::CommandScope::User, - opencode_config::Scope::Project => opencode_config::CommandScope::Project, - }); - Ok(json_response( - StatusCode::OK, - ConfigMetadataResponse { - name, - is_built_in: !sources.md.exists && !sources.json.exists, - scope, - sources, - }, - )) - } - Err(err) => { - error!("[desktop:config] Failed to read command sources: {}", err); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to read command configuration", - )) - } - } - } - Method::POST => { - let payload = match parse_request_payload(&mut req).await { - Ok(data) => data, - Err(resp) => return Ok(resp), - }; - - // Extract scope from payload if present - let scope = payload - .get("scope") - .and_then(|v| v.as_str()) - .and_then(|s| match s { - "project" => Some(opencode_config::CommandScope::Project), - "user" => Some(opencode_config::CommandScope::User), - _ => None, - }); - - match opencode_config::create_command(&name, &payload, Some(&working_directory), scope) - .await - { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "command creation").await - { - return Ok(resp); - } - - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Command {} created successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) - } - Err(err) => { - error!( - "[desktop:config] Failed to create command {}: {}", - name, err - ); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )) - } - } - } - Method::PATCH => { - let payload = match parse_request_payload(&mut req).await { - Ok(data) => data, - Err(resp) => return Ok(resp), - }; - - match opencode_config::update_command(&name, &payload, Some(&working_directory)).await { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "command update").await - { - return Ok(resp); - } - - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Command {} updated successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) - } - Err(err) => { - error!( - "[desktop:config] Failed to update command {}: {}", - name, err - ); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )) - } - } - } - Method::DELETE => { - match opencode_config::delete_command(&name, Some(&working_directory)).await { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "command deletion").await - { - return Ok(resp); - } - - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Command {} deleted successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) - } - Err(err) => { - error!( - "[desktop:config] Failed to delete command {}: {}", - name, err - ); - let status = if err.to_string().contains("not found") { - StatusCode::NOT_FOUND - } else { - StatusCode::INTERNAL_SERVER_ERROR - }; - Ok(config_error_response(status, err.to_string())) - } - } - } - _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), - } -} - -async fn handle_config_routes( - state: ServerState, - path: &str, - method: Method, - mut req: Request, -) -> Result { - if path == "/api/config/themes" && method == Method::GET { - let themes = read_custom_themes_from_disk().await; - return Ok(json_response( - StatusCode::OK, - serde_json::json!({ "themes": themes }), - )); - } - - if let Some(name) = path.strip_prefix("/api/config/agents/") { - let trimmed = name.trim(); - if trimmed.is_empty() { - return Ok(config_error_response( - StatusCode::BAD_REQUEST, - "Agent name is required", - )); - } - return handle_agent_route(&state, method, req, trimmed.to_string()).await; - } - - if let Some(name) = path.strip_prefix("/api/config/commands/") { - let trimmed = name.trim(); - if trimmed.is_empty() { - return Ok(config_error_response( - StatusCode::BAD_REQUEST, - "Command name is required", - )); - } - return handle_command_route(&state, method, req, trimmed.to_string()).await; - } - - // Skills catalog routes (must be checked before /api/config/skills/:name) - if path == "/api/config/skills/catalog" && method == Method::GET { - let refresh = req - .uri() - .query() - .map(|q| q.contains("refresh=true")) - .unwrap_or(false); - - let working_directory = - match resolve_project_directory(&state, extract_directory_from_request(&req)).await { - Ok(directory) => directory, - Err(response) => return Ok(response), - }; - let payload = skills_catalog::get_catalog(&working_directory, refresh).await; - return Ok(json_response(StatusCode::OK, payload)); - } - - if path == "/api/config/skills/scan" && method == Method::POST { - let payload_map = match parse_request_payload(&mut req).await { - Ok(data) => data, - Err(resp) => return Ok(resp), - }; - - let payload_value = serde_json::Value::Object(payload_map.into_iter().collect()); - let scan_request = - match serde_json::from_value::(payload_value) { - Ok(v) => v, - Err(_) => { - return Ok(json_response( - StatusCode::BAD_REQUEST, - skills_catalog::SkillsRepoScanResponse { - ok: false, - items: None, - error: Some(skills_catalog::SkillsRepoError { - kind: "invalidSource".to_string(), - message: "Malformed scan request".to_string(), - ssh_only: None, - identities: None, - conflicts: None, - }), - }, - )) - } - }; - - let response = skills_catalog::scan_repository(scan_request).await; - let status = if response.ok { - StatusCode::OK - } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") { - StatusCode::UNAUTHORIZED - } else { - StatusCode::BAD_REQUEST - }; - - return Ok(json_response(status, response)); - } - - if path == "/api/config/skills/install" && method == Method::POST { - let payload_map = match parse_request_payload(&mut req).await { - Ok(data) => data, - Err(resp) => return Ok(resp), - }; - - let payload_value = serde_json::Value::Object(payload_map.into_iter().collect()); - let install_request = - match serde_json::from_value::(payload_value) { - Ok(v) => v, - Err(_) => { - return Ok(json_response( - StatusCode::BAD_REQUEST, - skills_catalog::SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(skills_catalog::SkillsRepoError { - kind: "invalidSource".to_string(), - message: "Malformed install request".to_string(), - ssh_only: None, - identities: None, - conflicts: None, - }), - }, - )) - } - }; - - let working_directory = if install_request.scope == "project" { - match resolve_project_directory(&state, extract_directory_from_request(&req)).await { - Ok(directory) => directory, - Err(response) => return Ok(response), - } - } else { - dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) - }; - - let response = skills_catalog::install_skills(&working_directory, install_request).await; - let status = if response.ok { - StatusCode::OK - } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") { - StatusCode::UNAUTHORIZED - } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") { - StatusCode::CONFLICT - } else { - StatusCode::BAD_REQUEST - }; - - return Ok(json_response(status, response)); - } - - // Handle skill routes: /api/config/skills and /api/config/skills/:name - if path == "/api/config/skills" && method == Method::GET { - return handle_skill_list_route(&state, req).await; - } - - if let Some(rest) = path.strip_prefix("/api/config/skills/") { - // Check if it's a file operation: /api/config/skills/:name/files/* - if let Some(files_start) = rest.find("/files/") { - let name = &rest[..files_start]; - let file_path_encoded = &rest[files_start + 7..]; // Skip "/files/" - // Decode URL-encoded path (e.g., "docs%2Foptimization.md" -> "docs/optimization.md") - let file_path = urlencoding::decode(file_path_encoded) - .map(|s| s.into_owned()) - .unwrap_or_else(|_| file_path_encoded.to_string()); - if name.is_empty() { - return Ok(config_error_response( - StatusCode::BAD_REQUEST, - "Skill name is required", - )); - } - return handle_skill_route(&state, method, req, name.to_string(), Some(file_path)) - .await; - } - - let trimmed = rest.trim(); - if trimmed.is_empty() { - return Ok(config_error_response( - StatusCode::BAD_REQUEST, - "Skill name is required", - )); - } - return handle_skill_route(&state, method, req, trimmed.to_string(), None).await; - } - - if path == "/api/config/reload" && method == Method::POST { - if let Err(resp) = - refresh_opencode_after_config_change(&state, "manual configuration reload").await - { - return Ok(resp); - } - - return Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: "Configuration reloaded successfully. Refreshing interface...".to_string(), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )); - } - - // Handle provider source lookup: GET /api/provider/:providerId/source - if let Some(rest) = path.strip_prefix("/api/provider/") { - if let Some(provider_id) = rest.strip_suffix("/source") { - if method == Method::GET { - let trimmed = provider_id.trim(); - if trimmed.is_empty() { - return Ok(config_error_response( - StatusCode::BAD_REQUEST, - "Provider ID is required", - )); - } - - let requested_directory = extract_directory_from_request(&req); - let working_directory = if let Some(ref value) = requested_directory { - match resolve_project_directory(&state, Some(value.to_string())).await { - Ok(directory) => Some(directory), - Err(resp) => return Ok(resp), - } - } else { - resolve_project_directory(&state, None).await.ok() - }; - - match opencode_config::get_provider_sources(trimmed, working_directory.as_deref()) - .await - { - Ok(mut sources) => { - let auth = opencode_auth::get_provider_auth(trimmed).await; - sources.auth.exists = auth.ok().flatten().is_some(); - return Ok(json_response( - StatusCode::OK, - serde_json::json!({ - "providerId": trimmed, - "sources": sources - }), - )); - } - Err(err) => { - error!( - "[desktop:config] Failed to get provider sources {}: {}", - trimmed, err - ); - return Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )); - } - } - } - } - } - - // Handle provider auth removal: DELETE /api/provider/:providerId/auth - if let Some(rest) = path.strip_prefix("/api/provider/") { - if let Some(provider_id) = rest.strip_suffix("/auth") { - if method == Method::DELETE { - let trimmed = provider_id.trim(); - if trimmed.is_empty() { - return Ok(config_error_response( - StatusCode::BAD_REQUEST, - "Provider ID is required", - )); - } - - let scope = req - .uri() - .query() - .and_then(|query| { - query - .split('&') - .find(|pair| pair.starts_with("scope=")) - .and_then(|pair| pair.split('=').nth(1)) - }) - .unwrap_or("auth"); - - let requested_directory = extract_directory_from_request(&req); - let working_directory = if scope == "project" { - match resolve_project_directory(&state, requested_directory.clone()).await { - Ok(directory) => Some(directory), - Err(resp) => return Ok(resp), - } - } else if let Some(ref value) = requested_directory { - match resolve_project_directory(&state, Some(value.to_string())).await { - Ok(directory) => Some(directory), - Err(resp) => return Ok(resp), - } - } else { - resolve_project_directory(&state, None).await.ok() - }; - - let removal_result = if scope == "auth" { - opencode_auth::remove_provider_auth(trimmed).await - } else if scope == "user" { - opencode_config::remove_provider_config( - trimmed, - working_directory.as_deref(), - opencode_config::ProviderScope::User, - ) - .await - } else if scope == "project" { - opencode_config::remove_provider_config( - trimmed, - working_directory.as_deref(), - opencode_config::ProviderScope::Project, - ) - .await - } else if scope == "custom" { - opencode_config::remove_provider_config( - trimmed, - working_directory.as_deref(), - opencode_config::ProviderScope::Custom, - ) - .await - } else if scope == "all" { - let auth_removed = opencode_auth::remove_provider_auth(trimmed) - .await - .unwrap_or(false); - let user_removed = opencode_config::remove_provider_config( - trimmed, - working_directory.as_deref(), - opencode_config::ProviderScope::User, - ) - .await - .unwrap_or(false); - let project_removed = if let Some(ref directory) = working_directory { - opencode_config::remove_provider_config( - trimmed, - Some(directory), - opencode_config::ProviderScope::Project, - ) - .await - .unwrap_or(false) - } else { - false - }; - let custom_removed = opencode_config::remove_provider_config( - trimmed, - working_directory.as_deref(), - opencode_config::ProviderScope::Custom, - ) - .await - .unwrap_or(false); - - Ok(auth_removed || user_removed || project_removed || custom_removed) - } else { - return Ok(config_error_response( - StatusCode::BAD_REQUEST, - "Invalid scope", - )); - }; - - match removal_result { - Ok(removed) => { - if removed { - if let Err(resp) = refresh_opencode_after_config_change( - &state, - &format!("provider {} disconnected", trimmed), - ) - .await - { - return Ok(resp); - } - } - - return Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: removed, - message: if removed { - "Provider disconnected successfully".to_string() - } else { - "Provider was not connected".to_string() - }, - reload_delay_ms: if removed { CLIENT_RELOAD_DELAY_MS } else { 0 }, - }, - )); - } - Err(err) => { - error!( - "[desktop:config] Failed to disconnect provider {}: {}", - trimmed, err - ); - return Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )); - } - } - } - } - } - - Ok(StatusCode::NOT_FOUND.into_response()) -} - -async fn change_directory_handler( - State(state): State, - Json(payload): Json, -) -> Result, StatusCode> { - // Acquire lock to prevent concurrent directory changes - let _lock = state.directory_change_lock.lock().await; - - let requested_path = payload.path.trim(); - if requested_path.is_empty() { - warn!("[desktop:http] ERROR: Empty path provided"); - return Err(StatusCode::BAD_REQUEST); - } - - let mut resolved_path = expand_tilde_path(requested_path); - if !resolved_path.is_absolute() { - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); - resolved_path = home.join(resolved_path); - } - - // Validate directory exists and is accessible - match fs::metadata(&resolved_path).await { - Ok(metadata) => { - if !metadata.is_dir() { - warn!( - "[desktop:http] ERROR: Path is not a directory: {:?}", - resolved_path - ); - return Err(StatusCode::BAD_REQUEST); - } - } - Err(err) => { - warn!( - "[desktop:http] ERROR: Cannot access path: {:?} - {}", - resolved_path, err - ); - return Err(StatusCode::NOT_FOUND); - } - } - - if let Ok(canonicalized) = fs::canonicalize(&resolved_path).await { - resolved_path = canonicalized; - } - - let path_value = resolved_path.to_string_lossy().to_string(); - - state - .settings - .update(|mut settings| { - if !settings.is_object() { - settings = Value::Object(Default::default()); - } - - let mut projects = settings - .get("projects") - .and_then(|value| value.as_array()) - .cloned() - .unwrap_or_default(); - - let existing_index = projects.iter().position(|entry| { - entry - .get("path") - .and_then(|value| value.as_str()) - .map(|value| value == path_value) - .unwrap_or(false) - }); - - let active_project_id = if let Some(index) = existing_index { - projects[index] - .get("id") - .and_then(|value| value.as_str()) - .unwrap_or("") - .to_string() - } else { - let id = uuid::Uuid::new_v4().to_string(); - let project = serde_json::json!({ - "id": id, - "path": path_value, - "addedAt": chrono::Utc::now().timestamp_millis(), - "lastOpenedAt": chrono::Utc::now().timestamp_millis(), - }); - projects.push(project); - id - }; - - let map = settings.as_object_mut().unwrap(); - map.insert("projects".to_string(), Value::Array(projects)); - map.insert( - "activeProjectId".to_string(), - Value::String(active_project_id.clone()), - ); - map.insert( - "lastDirectory".to_string(), - Value::String(path_value.clone()), - ); - - settings - }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(DirectoryChangeResponse { - success: true, - restarted: false, - path: path_value, - })) -} - -async fn proxy_to_opencode( - State(state): State, - req: Request, -) -> Result { - let origin_path = req.uri().path().to_string(); - let method = req.method().clone(); - - // Check if this is a provider auth deletion request (DELETE /api/provider/:id/auth) - let is_provider_auth_delete = method == Method::DELETE - && origin_path.starts_with("/api/provider/") - && origin_path.ends_with("/auth") - && origin_path != "/api/provider/auth"; // Exclude GET /api/provider/auth - - let is_provider_source_get = method == Method::GET - && origin_path.starts_with("/api/provider/") - && origin_path.ends_with("/source"); - - let is_desktop_config_route = origin_path.starts_with("/api/config/agents/") - || origin_path.starts_with("/api/config/commands/") - || origin_path.starts_with("/api/config/skills") - || origin_path == "/api/config/themes" - || origin_path == "/api/config/reload" - || is_provider_auth_delete - || is_provider_source_get; - - if is_desktop_config_route { - return handle_config_routes(state, &origin_path, method, req).await; - } - - let port = state.opencode.current_port().ok_or_else(|| { - error!("[desktop:http] PROXY FAILED: OpenCode not running (no port)"); - StatusCode::SERVICE_UNAVAILABLE - })?; - - let query = req.uri().query(); - let rewritten_path = state.opencode.rewrite_path(&origin_path); - let mut target = format!("http://127.0.0.1:{port}{rewritten_path}"); - if let Some(q) = query { - target.push('?'); - target.push_str(q); - } - - let (parts, body) = req.into_parts(); - let method = parts.method.clone(); - let mut builder = state.client.request(method, &target); - - let mut headers = parts.headers; - headers.insert(header::HOST, format!("127.0.0.1:{port}").parse().unwrap()); - if headers - .get(header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .map(|val| val.contains("text/event-stream")) - .unwrap_or(false) - { - headers.insert(header::CONNECTION, "keep-alive".parse().unwrap()); - } - - for (key, value) in headers.iter() { - if key == &header::CONTENT_LENGTH { - continue; - } - builder = builder.header(key, value); - } - - let body_bytes = to_bytes(body, PROXY_BODY_LIMIT) - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; - - let response = if body_bytes.is_empty() { - builder.send().await.map_err(|_| StatusCode::BAD_GATEWAY)? - } else { - builder - .body(ReqwestBody::from(body_bytes)) - .send() - .await - .map_err(|_| StatusCode::BAD_GATEWAY)? - }; - - let status = response.status(); - let mut resp_builder = Response::builder().status(status); - for (key, value) in response.headers() { - if key.as_str().eq_ignore_ascii_case("connection") { - continue; - } - resp_builder = resp_builder.header(key, value); - } - - let stream = response.bytes_stream().map(|chunk| { - chunk - .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)) - .map(axum::body::Bytes::from) - }); - let body = Body::from_stream(stream); - resp_builder.body(body).map_err(|_| StatusCode::BAD_GATEWAY) -} - -#[derive(Clone)] -pub(crate) struct SettingsStore { - path: PathBuf, - guard: Arc>, -} - -impl SettingsStore { - pub(crate) fn new() -> Result { - // Use ~/.config/openchamber for consistency with Electron/web versions - let home = dirs::home_dir().ok_or_else(|| anyhow!("No home directory"))?; - let mut dir = home; - dir.push(".config"); - dir.push("openchamber"); - std::fs::create_dir_all(&dir).ok(); - dir.push("settings.json"); - Ok(Self { - path: dir, - guard: Arc::new(Mutex::new(())), - }) - } - - pub(crate) async fn load(&self) -> Result { - let _lock = self.guard.lock().await; - match fs::read(&self.path).await { - Ok(bytes) => { - let value = - serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())); - Ok(value) - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - Ok(Value::Object(Default::default())) - } - Err(err) => Err(err.into()), - } - } - - pub(crate) async fn update_with(&self, f: F) -> Result<(Value, R)> - where - F: FnOnce(Value) -> (Value, R), - { - let _lock = self.guard.lock().await; - - let current = match fs::read(&self.path).await { - Ok(bytes) => { - serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())) - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - Value::Object(Default::default()) - } - Err(err) => return Err(err.into()), - }; - - let current_snapshot = current.clone(); - let (next, result) = f(current); - - if next != current_snapshot { - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent).await.ok(); - } - let bytes = serde_json::to_vec_pretty(&next)?; - fs::write(&self.path, bytes).await?; - } - - Ok((next, result)) - } - - pub(crate) async fn update(&self, f: F) -> Result - where - F: FnOnce(Value) -> Value, - { - let (next, _) = self.update_with(|current| (f(current), ())).await?; - Ok(next) - } - - pub(crate) async fn last_directory(&self) -> Result> { - let settings = self.load().await?; - let candidate = settings - .get("lastDirectory") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(expand_tilde_path); - Ok(candidate) - } -} - -fn openchamber_user_config_root() -> Option { - let home = dirs::home_dir()?; - Some(home.join(".config").join("openchamber")) -} - -fn openchamber_themes_dir() -> Option { - openchamber_user_config_root().map(|root| root.join("themes")) -} - -fn value_non_empty_string(value: &Value) -> Option { - value - .as_str() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) -} - -fn get_nested<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { - let mut current = value; - for key in path { - current = current.get(*key)?; - } - Some(current) -} - -fn has_required_theme_fields(theme: &Value) -> bool { - let required_paths: [&[&str]; 46] = [ - &["metadata", "id"], - &["metadata", "name"], - &["metadata", "variant"], - &["colors", "primary", "base"], - &["colors", "primary", "foreground"], - &["colors", "surface", "background"], - &["colors", "surface", "foreground"], - &["colors", "surface", "muted"], - &["colors", "surface", "mutedForeground"], - &["colors", "surface", "elevated"], - &["colors", "surface", "elevatedForeground"], - &["colors", "surface", "subtle"], - &["colors", "interactive", "border"], - &["colors", "interactive", "selection"], - &["colors", "interactive", "selectionForeground"], - &["colors", "interactive", "focusRing"], - &["colors", "interactive", "hover"], - &["colors", "status", "error"], - &["colors", "status", "errorForeground"], - &["colors", "status", "errorBackground"], - &["colors", "status", "errorBorder"], - &["colors", "status", "warning"], - &["colors", "status", "warningForeground"], - &["colors", "status", "warningBackground"], - &["colors", "status", "warningBorder"], - &["colors", "status", "success"], - &["colors", "status", "successForeground"], - &["colors", "status", "successBackground"], - &["colors", "status", "successBorder"], - &["colors", "status", "info"], - &["colors", "status", "infoForeground"], - &["colors", "status", "infoBackground"], - &["colors", "status", "infoBorder"], - &["colors", "syntax", "base", "background"], - &["colors", "syntax", "base", "foreground"], - &["colors", "syntax", "base", "keyword"], - &["colors", "syntax", "base", "string"], - &["colors", "syntax", "base", "number"], - &["colors", "syntax", "base", "function"], - &["colors", "syntax", "base", "variable"], - &["colors", "syntax", "base", "type"], - &["colors", "syntax", "base", "comment"], - &["colors", "syntax", "base", "operator"], - &["colors", "syntax", "highlights", "diffAdded"], - &["colors", "syntax", "highlights", "diffRemoved"], - &["colors", "syntax", "highlights", "lineNumber"], - ]; - - for path in required_paths { - let Some(value) = get_nested(theme, path) else { - return false; - }; - if value_non_empty_string(value).is_none() { - return false; - } - } - - let variant = get_nested(theme, &["metadata", "variant"]) - .and_then(value_non_empty_string) - .unwrap_or_default(); - if variant != "light" && variant != "dark" { - return false; - } - - true -} - -fn normalize_theme_json(mut theme: Value) -> Option { - if !theme.is_object() { - return None; - } - - if !has_required_theme_fields(&theme) { - return None; - } - - let id = get_nested(&theme, &["metadata", "id"]).and_then(value_non_empty_string)?; - let name = get_nested(&theme, &["metadata", "name"]).and_then(value_non_empty_string)?; - let variant = get_nested(&theme, &["metadata", "variant"]).and_then(value_non_empty_string)?; - - // Ensure metadata exists and is an object. - let metadata = theme - .get_mut("metadata") - .and_then(|v| v.as_object_mut())?; - - metadata.insert("id".to_string(), Value::String(id.trim().to_string())); - metadata.insert("name".to_string(), Value::String(name.trim().to_string())); - metadata.insert("variant".to_string(), Value::String(variant)); - - if !metadata - .get("description") - .and_then(|v| v.as_str()) - .is_some() - { - metadata.insert("description".to_string(), Value::String("".to_string())); - } - - let version_ok = metadata - .get("version") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .is_some(); - if !version_ok { - metadata.insert("version".to_string(), Value::String("1.0.0".to_string())); - } - - if let Some(tags_value) = metadata.get_mut("tags") { - if let Some(tags) = tags_value.as_array_mut() { - tags.retain(|tag| tag.as_str().map(str::trim).filter(|s| !s.is_empty()).is_some()); - } else { - *tags_value = Value::Array(vec![]); - } - } else { - metadata.insert("tags".to_string(), Value::Array(vec![])); - } - - Some(theme) -} - -async fn read_custom_themes_from_disk() -> Vec { - let Some(dir) = openchamber_themes_dir() else { - return vec![]; - }; - - let mut results: Vec = vec![]; - let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); - - let mut entries = match fs::read_dir(&dir).await { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return vec![], - Err(err) => { - warn!("[desktop:themes] Failed to list themes dir {:?}: {}", dir, err); - return vec![]; - } - }; - - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - let is_json = path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("json")) - .unwrap_or(false); - if !is_json { - continue; - } - - let metadata = match entry.metadata().await { - Ok(v) => v, - Err(_) => continue, - }; - if !metadata.is_file() { - continue; - } - if metadata.len() > MAX_THEME_JSON_BYTES { - warn!( - "[desktop:themes] Skip {:?}: too large ({} bytes)", - path, - metadata.len() - ); - continue; - } - - let bytes = match fs::read(&path).await { - Ok(bytes) => bytes, - Err(err) => { - warn!("[desktop:themes] Failed to read {:?}: {}", path, err); - continue; - } - }; - - let parsed: Value = match serde_json::from_slice(&bytes) { - Ok(v) => v, - Err(err) => { - warn!("[desktop:themes] Invalid JSON {:?}: {}", path, err); - continue; - } - }; - - let normalized = match normalize_theme_json(parsed) { - Some(v) => v, - None => { - warn!("[desktop:themes] Invalid theme JSON {:?}", path); - continue; - } - }; - - let id = get_nested(&normalized, &["metadata", "id"]) - .and_then(value_non_empty_string) - .unwrap_or_default(); - if id.is_empty() || seen_ids.contains(&id) { - continue; - } - seen_ids.insert(id); - - results.push(normalized); - } - - results -} diff --git a/packages/desktop/src-tauri/src/opencode_auth.rs b/packages/desktop/src-tauri/src/opencode_auth.rs deleted file mode 100644 index c4eaa95b..00000000 --- a/packages/desktop/src-tauri/src/opencode_auth.rs +++ /dev/null @@ -1,109 +0,0 @@ -use anyhow::{anyhow, Result}; -use log::info; -use serde_json::Value; -use std::path::PathBuf; -use tokio::fs; - -/// Get OpenCode data directory path (~/.local/share/opencode) -fn get_data_dir() -> PathBuf { - dirs::home_dir() - .expect("Cannot determine home directory") - .join(".local") - .join("share") - .join("opencode") -} - -/// Get auth file path -fn get_auth_file() -> PathBuf { - get_data_dir().join("auth.json") -} - -/// Ensure data directory exists -async fn ensure_data_dir() -> Result<()> { - let data_dir = get_data_dir(); - fs::create_dir_all(&data_dir).await?; - Ok(()) -} - -/// Read auth.json file -pub async fn read_auth() -> Result { - let auth_file = get_auth_file(); - - if !auth_file.exists() { - return Ok(Value::Object(serde_json::Map::new())); - } - - let content = fs::read_to_string(&auth_file).await?; - let trimmed = content.trim(); - - if trimmed.is_empty() { - return Ok(Value::Object(serde_json::Map::new())); - } - - serde_json::from_str(trimmed).map_err(|e| anyhow!("Failed to parse auth file: {}", e)) -} - -/// Write auth.json file with backup -pub async fn write_auth(auth: &Value) -> Result<()> { - ensure_data_dir().await?; - - let auth_file = get_auth_file(); - - // Create backup before writing - if auth_file.exists() { - let file_name = auth_file - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| anyhow!("Invalid auth file name"))?; - - let backup_path = auth_file.with_file_name(format!("{file_name}.openchamber.backup")); - fs::copy(&auth_file, &backup_path).await?; - info!("Created auth backup: {}", backup_path.display()); - } - - let json_string = serde_json::to_string_pretty(auth)?; - fs::write(&auth_file, json_string).await?; - info!("Successfully wrote auth file"); - - Ok(()) -} - -/// Get provider auth entry from auth.json -pub async fn get_provider_auth(provider_id: &str) -> Result> { - if provider_id.is_empty() { - return Err(anyhow!("Provider ID is required")); - } - - let auth = read_auth().await?; - let auth_obj = auth - .as_object() - .ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))?; - Ok(auth_obj.get(provider_id).cloned()) -} - -/// Remove provider auth entry from auth.json -pub async fn remove_provider_auth(provider_id: &str) -> Result { - if provider_id.is_empty() { - return Err(anyhow!("Provider ID is required")); - } - - let mut auth = read_auth().await?; - - let auth_obj = auth - .as_object_mut() - .ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))?; - - if !auth_obj.contains_key(provider_id) { - info!( - "Provider {} not found in auth file, nothing to remove", - provider_id - ); - return Ok(false); - } - - auth_obj.remove(provider_id); - write_auth(&auth).await?; - info!("Removed provider auth: {}", provider_id); - - Ok(true) -} diff --git a/packages/desktop/src-tauri/src/opencode_config.rs b/packages/desktop/src-tauri/src/opencode_config.rs deleted file mode 100644 index 57111b53..00000000 --- a/packages/desktop/src-tauri/src/opencode_config.rs +++ /dev/null @@ -1,2383 +0,0 @@ -use anyhow::{anyhow, Result}; -use log::info; -use once_cell::sync::Lazy; -use regex::Regex; -use serde::Serialize; -use serde_json::{Map, Value}; -use std::collections::HashMap; -use std::env; -use std::path::{Path, PathBuf}; -use tokio::fs; - -static PROMPT_FILE_PATTERN: Lazy = - Lazy::new(|| Regex::new(r"(?i)^\{file:(.+)\}$").expect("valid regex")); - -/// Agent scope types -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum AgentScope { - User, - Project, -} - -/// Command scope types -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum CommandScope { - User, - Project, -} - -/// Generic scope enum for SourceInfo (agents and commands share same structure) -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Scope { - User, - Project, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProviderScope { - User, - Project, - Custom, -} - -impl From for Scope { - fn from(scope: AgentScope) -> Self { - match scope { - AgentScope::User => Scope::User, - AgentScope::Project => Scope::Project, - } - } -} - -impl From for Scope { - fn from(scope: CommandScope) -> Self { - match scope { - CommandScope::User => Scope::User, - CommandScope::Project => Scope::Project, - } - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SourceInfo { - pub exists: bool, - pub path: Option, - pub fields: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub scope: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MdLocationInfo { - pub exists: bool, - pub path: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ConfigSources { - pub md: SourceInfo, - pub json: SourceInfo, - #[serde(skip_serializing_if = "Option::is_none")] - pub project_md: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_md: Option, -} - -/// Get OpenCode config directory path -fn get_config_dir() -> PathBuf { - dirs::home_dir() - .expect("Cannot determine home directory") - .join(".config") - .join("opencode") -} - -/// Get agent directory path -fn get_agent_dir() -> PathBuf { - get_config_dir().join("agents") -} - -fn get_legacy_agent_dir() -> PathBuf { - get_config_dir().join("agent") -} - -/// Get user-level command directory path -fn get_command_dir() -> PathBuf { - get_config_dir().join("commands") -} - -fn get_legacy_command_dir() -> PathBuf { - get_config_dir().join("command") -} - -/// Get config file path -fn get_config_file() -> PathBuf { - get_config_dir().join("opencode.json") -} - -/// Get all possible project config paths in priority order -/// Priority: root > .opencode/, json > jsonc -fn get_project_config_candidates(working_directory: &Path) -> Vec { - vec![ - working_directory.join("opencode.json"), - working_directory.join("opencode.jsonc"), - working_directory.join(".opencode").join("opencode.json"), - working_directory.join(".opencode").join("opencode.jsonc"), - ] -} - -/// Find existing project config file or return default path for new config -fn get_project_config_file(working_directory: &Path) -> PathBuf { - let candidates = get_project_config_candidates(working_directory); - - // Return first existing config file - for candidate in &candidates { - if candidate.exists() { - return candidate.clone(); - } - } - - // Default to root opencode.json for new configs - candidates - .into_iter() - .next() - .unwrap_or_else(|| working_directory.join("opencode.json")) -} - -/// Get custom config file path from OPENCODE_CONFIG env var -fn get_custom_config_file() -> Option { - env::var("OPENCODE_CONFIG").ok().map(PathBuf::from) -} - -struct ConfigPaths { - user: PathBuf, - project: Option, - custom: Option, -} - -struct ConfigLayers { - user: Value, - project: Value, - custom: Value, - #[allow(dead_code)] - merged: Value, - paths: ConfigPaths, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProviderSources { - pub auth: ProviderSourceInfo, - pub user: ProviderSourceInfo, - pub project: ProviderSourceInfo, - pub custom: ProviderSourceInfo, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProviderSourceInfo { - pub exists: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, -} - -fn get_config_paths(working_directory: Option<&Path>) -> ConfigPaths { - ConfigPaths { - user: get_config_file(), - project: working_directory.map(get_project_config_file), - custom: get_custom_config_file(), - } -} - -fn merge_values(base: &Value, overlay: &Value) -> Value { - match (base, overlay) { - (Value::Object(base_map), Value::Object(overlay_map)) => { - let mut merged = base_map.clone(); - for (key, value) in overlay_map.iter() { - let base_value = merged.get(key).unwrap_or(&Value::Null).clone(); - let merged_value = merge_values(&base_value, value); - merged.insert(key.clone(), merged_value); - } - Value::Object(merged) - } - _ => overlay.clone(), - } -} - -async fn read_config_file(path: &Path) -> Result { - if !path.exists() { - return Ok(Value::Object(serde_json::Map::new())); - } - - let content = fs::read_to_string(path).await?; - let normalized = strip_json_comments(&content).trim().to_string(); - - if normalized.is_empty() { - return Ok(Value::Object(serde_json::Map::new())); - } - - serde_json::from_str(&normalized) - .or_else(|_| json5::from_str::(&normalized)) - .map_err(|e| anyhow!("Failed to parse config: {}", e)) -} - -async fn read_config_layers(working_directory: Option<&Path>) -> Result { - let paths = get_config_paths(working_directory); - let user = read_config_file(&paths.user).await?; - let project = if let Some(ref path) = paths.project { - read_config_file(path).await? - } else { - Value::Object(serde_json::Map::new()) - }; - let custom = if let Some(ref path) = paths.custom { - read_config_file(path).await? - } else { - Value::Object(serde_json::Map::new()) - }; - - let merged = merge_values(&merge_values(&user, &project), &custom); - - Ok(ConfigLayers { - user, - project, - custom, - merged, - paths, - }) -} - -struct JsonEntrySource { - exists: bool, - path: Option, - section: Option, -} - -fn get_json_entry_source( - layers: &ConfigLayers, - section_key: &str, - entry_name: &str, -) -> JsonEntrySource { - if let Some(ref custom_path) = layers.paths.custom { - if let Some(section) = layers.custom.get(section_key).and_then(|v| v.as_object()) { - if let Some(value) = section.get(entry_name) { - return JsonEntrySource { - exists: true, - path: Some(custom_path.clone()), - section: Some(value.clone()), - }; - } - } - } - - if let Some(ref project_path) = layers.paths.project { - if let Some(section) = layers.project.get(section_key).and_then(|v| v.as_object()) { - if let Some(value) = section.get(entry_name) { - return JsonEntrySource { - exists: true, - path: Some(project_path.clone()), - section: Some(value.clone()), - }; - } - } - } - - if let Some(section) = layers.user.get(section_key).and_then(|v| v.as_object()) { - if let Some(value) = section.get(entry_name) { - return JsonEntrySource { - exists: true, - path: Some(layers.paths.user.clone()), - section: Some(value.clone()), - }; - } - } - - JsonEntrySource { - exists: false, - path: None, - section: None, - } -} - -fn get_json_write_target(layers: &ConfigLayers, preferred_scope: Option) -> PathBuf { - if let Some(ref custom_path) = layers.paths.custom { - return custom_path.clone(); - } - - if preferred_scope == Some(Scope::Project) { - if let Some(ref project_path) = layers.paths.project { - return project_path.clone(); - } - } - - if let Some(ref project_path) = layers.paths.project { - return project_path.clone(); - } - - layers.paths.user.clone() -} - -fn get_default_json_path(layers: &ConfigLayers) -> PathBuf { - if let Some(ref custom_path) = layers.paths.custom { - return custom_path.clone(); - } - if let Some(ref project_path) = layers.paths.project { - return project_path.clone(); - } - layers.paths.user.clone() -} - -fn get_config_for_path<'a>(layers: &'a mut ConfigLayers, target_path: &Path) -> &'a mut Value { - if let Some(ref custom_path) = layers.paths.custom { - if custom_path == target_path { - return &mut layers.custom; - } - } - if let Some(ref project_path) = layers.paths.project { - if project_path == target_path { - return &mut layers.project; - } - } - &mut layers.user -} - -pub async fn get_provider_sources( - provider_id: &str, - working_directory: Option<&Path>, -) -> Result { - if provider_id.trim().is_empty() { - return Err(anyhow!("Provider ID is required")); - } - - let layers = read_config_layers(working_directory).await?; - - let custom_exists = layers - .custom - .get("provider") - .and_then(|v| v.as_object()) - .and_then(|p| p.get(provider_id)) - .is_some() - || layers - .custom - .get("providers") - .and_then(|v| v.as_object()) - .and_then(|p| p.get(provider_id)) - .is_some(); - let project_exists = layers - .project - .get("provider") - .and_then(|v| v.as_object()) - .and_then(|p| p.get(provider_id)) - .is_some() - || layers - .project - .get("providers") - .and_then(|v| v.as_object()) - .and_then(|p| p.get(provider_id)) - .is_some(); - let user_exists = layers - .user - .get("provider") - .and_then(|v| v.as_object()) - .and_then(|p| p.get(provider_id)) - .is_some() - || layers - .user - .get("providers") - .and_then(|v| v.as_object()) - .and_then(|p| p.get(provider_id)) - .is_some(); - - Ok(ProviderSources { - auth: ProviderSourceInfo { - exists: false, - path: None, - }, - user: ProviderSourceInfo { - exists: user_exists, - path: Some(layers.paths.user.to_string_lossy().to_string()), - }, - project: ProviderSourceInfo { - exists: project_exists, - path: layers - .paths - .project - .as_ref() - .map(|p| p.to_string_lossy().to_string()), - }, - custom: ProviderSourceInfo { - exists: custom_exists, - path: layers - .paths - .custom - .as_ref() - .map(|p| p.to_string_lossy().to_string()), - }, - }) -} - -pub async fn remove_provider_config( - provider_id: &str, - working_directory: Option<&Path>, - scope: ProviderScope, -) -> Result { - if provider_id.trim().is_empty() { - return Err(anyhow!("Provider ID is required")); - } - - let mut layers = read_config_layers(working_directory).await?; - let target_path = match scope { - ProviderScope::Project => layers - .paths - .project - .clone() - .ok_or_else(|| anyhow!("Project config path is not available"))?, - ProviderScope::Custom => layers - .paths - .custom - .clone() - .ok_or_else(|| anyhow!("Custom config path is not available"))?, - ProviderScope::User => layers.paths.user.clone(), - }; - - let config = get_config_for_path(&mut layers, &target_path); - let mut removed = false; - let mut remove_provider_key = false; - let mut remove_providers_key = false; - - if let Some(provider_section) = config.get_mut("provider").and_then(|v| v.as_object_mut()) { - if provider_section.remove(provider_id).is_some() { - removed = true; - if provider_section.is_empty() { - remove_provider_key = true; - } - } - } - - if let Some(provider_section) = config.get_mut("providers").and_then(|v| v.as_object_mut()) { - if provider_section.remove(provider_id).is_some() { - removed = true; - if provider_section.is_empty() { - remove_providers_key = true; - } - } - } - - if !removed { - return Ok(false); - } - - if remove_provider_key { - config.as_object_mut().map(|map| map.remove("provider")); - } - if remove_providers_key { - config.as_object_mut().map(|map| map.remove("providers")); - } - - write_config_at(config, &target_path).await?; - Ok(true) -} - -// ============== AGENT SCOPE HELPERS ============== - -/// Get project-level agent directory path -fn get_project_agent_dir(working_directory: &Path) -> PathBuf { - working_directory.join(".opencode").join("agents") -} - -fn get_legacy_project_agent_dir(working_directory: &Path) -> PathBuf { - working_directory.join(".opencode").join("agent") -} - -/// Get project-level agent path -fn get_project_agent_path(working_directory: &Path, agent_name: &str) -> PathBuf { - let plural_path = get_project_agent_dir(working_directory).join(format!("{}.md", agent_name)); - let legacy_path = - get_legacy_project_agent_dir(working_directory).join(format!("{}.md", agent_name)); - if legacy_path.exists() && !plural_path.exists() { - return legacy_path; - } - plural_path -} - -/// Get user-level agent path -fn get_user_agent_path(agent_name: &str) -> PathBuf { - let plural_path = get_agent_dir().join(format!("{}.md", agent_name)); - let legacy_path = get_legacy_agent_dir().join(format!("{}.md", agent_name)); - if legacy_path.exists() && !plural_path.exists() { - return legacy_path; - } - plural_path -} - -/// Ensure project agent directory exists -async fn ensure_project_agent_dir(working_directory: &Path) -> Result { - let project_agent_dir = get_project_agent_dir(working_directory); - fs::create_dir_all(&project_agent_dir).await?; - fs::create_dir_all(&get_legacy_project_agent_dir(working_directory)).await?; - Ok(project_agent_dir) -} - -/// Determine agent scope based on where the .md file exists -pub fn get_agent_scope( - agent_name: &str, - working_directory: Option<&Path>, -) -> (Option, Option) { - if let Some(wd) = working_directory { - let project_path = get_project_agent_path(wd, agent_name); - if project_path.exists() { - return (Some(AgentScope::Project), Some(project_path)); - } - } - - let user_path = get_user_agent_path(agent_name); - if user_path.exists() { - return (Some(AgentScope::User), Some(user_path)); - } - - (None, None) -} - -/// Get the path where an agent should be written based on scope -fn get_agent_write_path( - agent_name: &str, - working_directory: Option<&Path>, - requested_scope: Option, -) -> (AgentScope, PathBuf) { - // For updates: check existing location first (project takes precedence) - let (existing_scope, existing_path) = get_agent_scope(agent_name, working_directory); - if let Some(path) = existing_path { - return (existing_scope.unwrap(), path); - } - - // For new agents or built-in overrides: use requested scope or default to user - let scope = requested_scope.unwrap_or(AgentScope::User); - if scope == AgentScope::Project { - if let Some(wd) = working_directory { - return (AgentScope::Project, get_project_agent_path(wd, agent_name)); - } - } - - (AgentScope::User, get_user_agent_path(agent_name)) -} - -// ============== COMMAND SCOPE HELPERS ============== - -/// Get project-level command directory path -fn get_project_command_dir(working_directory: &Path) -> PathBuf { - working_directory.join(".opencode").join("commands") -} - -fn get_legacy_project_command_dir(working_directory: &Path) -> PathBuf { - working_directory.join(".opencode").join("command") -} - -/// Get project-level command path -fn get_project_command_path(working_directory: &Path, command_name: &str) -> PathBuf { - let plural_path = - get_project_command_dir(working_directory).join(format!("{}.md", command_name)); - let legacy_path = - get_legacy_project_command_dir(working_directory).join(format!("{}.md", command_name)); - if legacy_path.exists() && !plural_path.exists() { - return legacy_path; - } - plural_path -} - -/// Get user-level command path -fn get_user_command_path(command_name: &str) -> PathBuf { - let plural_path = get_command_dir().join(format!("{}.md", command_name)); - let legacy_path = get_legacy_command_dir().join(format!("{}.md", command_name)); - if legacy_path.exists() && !plural_path.exists() { - return legacy_path; - } - plural_path -} - -/// Ensure project command directory exists -async fn ensure_project_command_dir(working_directory: &Path) -> Result { - let project_command_dir = get_project_command_dir(working_directory); - fs::create_dir_all(&project_command_dir).await?; - fs::create_dir_all(&get_legacy_project_command_dir(working_directory)).await?; - Ok(project_command_dir) -} - -/// Determine command scope based on where the .md file exists -pub fn get_command_scope( - command_name: &str, - working_directory: Option<&Path>, -) -> (Option, Option) { - if let Some(wd) = working_directory { - let project_path = get_project_command_path(wd, command_name); - if project_path.exists() { - return (Some(CommandScope::Project), Some(project_path)); - } - } - - let user_path = get_user_command_path(command_name); - if user_path.exists() { - return (Some(CommandScope::User), Some(user_path)); - } - - (None, None) -} - -/// Get the path where a command should be written based on scope -fn get_command_write_path( - command_name: &str, - working_directory: Option<&Path>, - requested_scope: Option, -) -> (CommandScope, PathBuf) { - // For updates: check existing location first (project takes precedence) - let (existing_scope, existing_path) = get_command_scope(command_name, working_directory); - if let Some(path) = existing_path { - return (existing_scope.unwrap(), path); - } - - // For new commands or built-in overrides: use requested scope or default to user - let scope = requested_scope.unwrap_or(CommandScope::User); - if scope == CommandScope::Project { - if let Some(wd) = working_directory { - return ( - CommandScope::Project, - get_project_command_path(wd, command_name), - ); - } - } - - (CommandScope::User, get_user_command_path(command_name)) -} - -/// Ensure required directories exist -async fn ensure_dirs() -> Result<()> { - let config_dir = get_config_dir(); - let agent_dir = get_agent_dir(); - let command_dir = get_command_dir(); - - fs::create_dir_all(&config_dir).await?; - fs::create_dir_all(&agent_dir).await?; - fs::create_dir_all(&get_legacy_agent_dir()).await?; - fs::create_dir_all(&command_dir).await?; - fs::create_dir_all(&get_legacy_command_dir()).await?; - - Ok(()) -} - -/// Check if a value is a prompt file reference like {file:./prompts/agent.txt} -fn is_prompt_file_reference(value: &str) -> bool { - PROMPT_FILE_PATTERN.is_match(value.trim()) -} - -/// Resolve a prompt file reference to an absolute path -fn resolve_prompt_file_path(reference: &str) -> Option { - let trimmed = reference.trim(); - let captures = PROMPT_FILE_PATTERN.captures(trimmed)?; - let target = captures.get(1)?.as_str().trim(); - - if target.is_empty() { - return None; - } - - let path = if target.starts_with("./") { - get_config_dir().join(&target[2..]) - } else if Path::new(target).is_absolute() { - PathBuf::from(target) - } else { - get_config_dir().join(target) - }; - - Some(path) -} - -/// Write content to a prompt file -async fn write_prompt_file(file_path: &Path, content: &str) -> Result<()> { - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).await?; - } - fs::write(file_path, content).await?; - info!("Updated prompt file: {}", file_path.display()); - Ok(()) -} - -/// Strip JSON comments from content -fn strip_json_comments(content: &str) -> String { - let mut result = String::new(); - let mut in_string = false; - let mut escape_next = false; - let mut chars = content.chars().peekable(); - - while let Some(ch) = chars.next() { - if escape_next { - result.push(ch); - escape_next = false; - continue; - } - - if ch == '\\' && in_string { - result.push(ch); - escape_next = true; - continue; - } - - if ch == '"' { - in_string = !in_string; - result.push(ch); - continue; - } - - if !in_string { - if ch == '/' { - if let Some(&next_ch) = chars.peek() { - if next_ch == '/' { - // Line comment - skip until end of line - chars.next(); // consume the second '/' - while let Some(c) = chars.next() { - if c == '\n' { - result.push('\n'); - break; - } - } - continue; - } else if next_ch == '*' { - // Block comment - skip until */ - chars.next(); // consume the '*' - let mut prev = ' '; - while let Some(c) = chars.next() { - if prev == '*' && c == '/' { - break; - } - prev = c; - } - continue; - } - } - } - } - - result.push(ch); - } - - result -} - -/// Read merged opencode.json configuration files -#[allow(dead_code)] -pub async fn read_config(working_directory: Option<&Path>) -> Result { - Ok(read_config_layers(working_directory).await?.merged) -} - -/// Write opencode.json configuration file with backup -pub async fn write_config_at(config: &Value, config_file: &Path) -> Result<()> { - // Create/overwrite single backup before writing - if config_file.exists() { - let file_name = config_file - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| anyhow!("Invalid config file name"))?; - - let backup_path = config_file.with_file_name(format!("{file_name}.openchamber.backup")); - fs::copy(&config_file, &backup_path).await?; - info!("Created config backup: {}", backup_path.display()); - } - - let json_string = serde_json::to_string_pretty(config)?; - if let Some(parent) = config_file.parent() { - fs::create_dir_all(parent).await?; - } - fs::write(config_file, json_string).await?; - info!("Successfully wrote config file: {}", config_file.display()); - - Ok(()) -} - -/// Write user-level opencode.json configuration file -#[allow(dead_code)] -pub async fn write_config(config: &Value) -> Result<()> { - let config_file = get_config_file(); - write_config_at(config, &config_file).await -} - -/// Markdown file data -#[derive(Debug)] -struct MdData { - frontmatter: HashMap, - body: String, -} - -/// Parse markdown file with YAML frontmatter -async fn parse_md_file(file_path: &Path) -> Result { - let content = fs::read_to_string(file_path).await?; - - // Match YAML frontmatter: ---\n...\n---\n - let re = Regex::new(r"(?s)^---\r?\n(.*?)\r?\n---\r?\n(.*)$").expect("valid regex"); - - if let Some(captures) = re.captures(&content) { - let yaml_str = captures.get(1).map(|m| m.as_str()).unwrap_or(""); - let body = captures.get(2).map(|m| m.as_str()).unwrap_or("").trim(); - - let frontmatter: HashMap = - serde_yaml::from_str(yaml_str).unwrap_or_default(); - - Ok(MdData { - frontmatter, - body: body.to_string(), - }) - } else { - // No frontmatter, treat entire content as body - Ok(MdData { - frontmatter: HashMap::new(), - body: content.trim().to_string(), - }) - } -} - -/// Write markdown file with YAML frontmatter -async fn write_md_file( - file_path: &Path, - frontmatter: &HashMap, - body: &str, -) -> Result<()> { - // Filter out null values - OpenCode expects keys to be omitted rather than set to null - let cleaned_frontmatter: HashMap = frontmatter - .iter() - .filter(|(_, v)| !v.is_null()) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - let yaml_str = serde_yaml::to_string(&cleaned_frontmatter)?; - let content = format!("---\n{}---\n\n{}", yaml_str, body); - - fs::write(file_path, content).await?; - info!("Successfully wrote markdown file: {}", file_path.display()); - - Ok(()) -} - -/// Get information about where agent configuration is stored -pub async fn get_agent_sources( - agent_name: &str, - working_directory: Option<&Path>, -) -> Result { - ensure_dirs().await?; - - // Check project level first (takes precedence) - let project_path = working_directory.map(|wd| get_project_agent_path(wd, agent_name)); - let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false); - - // Then check user level - let user_path = get_user_agent_path(agent_name); - let user_exists = user_path.exists(); - - // Determine which md file to use (project takes precedence) - let (md_path, md_exists, md_scope) = if project_exists { - (project_path.clone(), true, Some(Scope::Project)) - } else if user_exists { - (Some(user_path.clone()), true, Some(Scope::User)) - } else { - (None, false, None) - }; - - let mut md_fields = Vec::new(); - if md_exists { - if let Some(ref path) = md_path { - let md_data = parse_md_file(path).await?; - md_fields.extend(md_data.frontmatter.keys().cloned()); - if !md_data.body.trim().is_empty() { - md_fields.push("prompt".to_string()); - } - } - } - - let layers = read_config_layers(working_directory).await?; - let json_source = get_json_entry_source(&layers, "agent", agent_name); - let json_section = json_source.section.as_ref(); - - let json_fields = json_section - .and_then(|value| value.as_object()) - .map(|obj| obj.keys().cloned().collect::>()) - .unwrap_or_default(); - - let json_path_buf = json_source - .path - .unwrap_or_else(|| get_default_json_path(&layers)); - let json_path = json_path_buf.display().to_string(); - let json_scope = if layers.paths.project.as_ref() == Some(&json_path_buf) { - Some(Scope::Project) - } else { - Some(Scope::User) - }; - - let sources = ConfigSources { - md: SourceInfo { - exists: md_exists, - path: md_path.map(|p| p.display().to_string()), - fields: md_fields, - scope: md_scope, - }, - json: SourceInfo { - exists: json_source.exists, - path: Some(json_path), - fields: json_fields, - scope: if json_source.exists { json_scope } else { None }, - }, - project_md: Some(MdLocationInfo { - exists: project_exists, - path: project_path.map(|p| p.display().to_string()), - }), - user_md: Some(MdLocationInfo { - exists: user_exists, - path: Some(user_path.display().to_string()), - }), - }; - - Ok(sources) -} - -/// Create new agent as .md file -pub async fn create_agent( - agent_name: &str, - config: &HashMap, - working_directory: Option<&Path>, - scope: Option, -) -> Result<()> { - ensure_dirs().await?; - - // Check if agent already exists at either level - if let Some(wd) = working_directory { - let project_path = get_project_agent_path(wd, agent_name); - if project_path.exists() { - return Err(anyhow!( - "Agent {} already exists as project-level .md file", - agent_name - )); - } - } - - let user_path = get_user_agent_path(agent_name); - if user_path.exists() { - return Err(anyhow!( - "Agent {} already exists as user-level .md file", - agent_name - )); - } - - let layers = read_config_layers(working_directory).await?; - let json_source = get_json_entry_source(&layers, "agent", agent_name); - if json_source.exists { - return Err(anyhow!( - "Agent {} already exists in opencode.json", - agent_name - )); - } - - // Determine target path based on requested scope - let (target_scope, target_path) = if scope == Some(AgentScope::Project) { - if let Some(wd) = working_directory { - ensure_project_agent_dir(wd).await?; - (AgentScope::Project, get_project_agent_path(wd, agent_name)) - } else { - (AgentScope::User, user_path) - } - } else { - (AgentScope::User, user_path) - }; - - // Extract prompt and scope from config - scope is only used for path determination, not written to file - let mut frontmatter = config.clone(); - let prompt = frontmatter - .remove("prompt") - .and_then(|v| v.as_str().map(|s| s.to_string())) - .unwrap_or_default(); - frontmatter.remove("scope"); // Remove scope - it's not a valid agent field - - // Write .md file - write_md_file(&target_path, &frontmatter, &prompt).await?; - info!( - "Created new agent: {} (scope: {:?}, path: {})", - agent_name, - target_scope, - target_path.display() - ); - - Ok(()) -} - -/// Update existing agent using field-level logic -pub async fn update_agent( - agent_name: &str, - updates: &HashMap, - working_directory: Option<&Path>, -) -> Result<()> { - ensure_dirs().await?; - - // Determine correct path: project level takes precedence - let (scope, md_path) = get_agent_write_path(agent_name, working_directory, None); - let md_exists = md_path.exists(); - - // Check if agent exists in opencode.json across all config layers - let mut layers = read_config_layers(working_directory).await?; - let json_source = get_json_entry_source(&layers, "agent", agent_name); - let mut existing_agent = json_source - .section - .as_ref() - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_else(Map::new); - let had_json_fields = !existing_agent.is_empty(); - - let preferred_scope = if working_directory.is_some() { - Some(Scope::Project) - } else { - Some(Scope::User) - }; - let json_target_path = if json_source.exists { - json_source - .path - .clone() - .unwrap_or_else(|| get_json_write_target(&layers, preferred_scope)) - } else { - get_json_write_target(&layers, preferred_scope) - }; - let config = get_config_for_path(&mut layers, &json_target_path); - - // Determine if we should create a new md file: - // Only for built-in agents (no md file AND no json config) - let is_builtin_override = !md_exists && !had_json_fields; - - let target_path = if !md_exists && is_builtin_override { - // Built-in agent override - create at user level - get_user_agent_path(agent_name) - } else { - md_path.clone() - }; - - let mut md_data = if md_exists { - Some(parse_md_file(&md_path).await?) - } else if is_builtin_override { - // Only create new md data for built-in overrides - Some(MdData { - frontmatter: HashMap::new(), - body: String::new(), - }) - } else { - None - }; - - // Only create new md if it's a built-in override - let creating_new_md = is_builtin_override; - - let mut md_modified = false; - let mut json_modified = false; - - for (field, value) in updates.iter() { - // Handle explicit removals (null payload) for scalar/frontmatter/JSON fields - if value.is_null() { - if md_exists { - if let Some(ref mut data) = md_data { - if data.frontmatter.remove(field).is_some() { - md_modified = true; - } - } - } - if existing_agent.remove(field).is_some() { - json_modified = true; - } - continue; - } - - // Special handling for prompt field - if field == "prompt" { - let normalized_value = value.as_str().unwrap_or("").to_string(); - - if md_exists || creating_new_md { - if let Some(ref mut data) = md_data { - data.body = normalized_value.clone(); - md_modified = true; - } - continue; - } else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str()) { - if is_prompt_file_reference(prompt_ref) { - if let Some(prompt_file_path) = resolve_prompt_file_path(prompt_ref) { - write_prompt_file(&prompt_file_path, &normalized_value).await?; - } else { - return Err(anyhow!( - "Invalid prompt file reference for agent {}", - agent_name - )); - } - continue; - } - } - - // For JSON-only agents, store prompt inline in JSON - existing_agent.insert("prompt".to_string(), Value::String(normalized_value)); - json_modified = true; - continue; - } - - // Check where field is currently defined - let in_md = md_data - .as_ref() - .map(|data| data.frontmatter.contains_key(field)) - .unwrap_or(false); - let in_json = existing_agent.contains_key(field); - - // JSON takes precedence over md, so update JSON first if field exists there - if in_json { - // Update in opencode.json (takes precedence) - existing_agent.insert(field.clone(), value.clone()); - json_modified = true; - } else if in_md || creating_new_md { - // Update in .md frontmatter - if let Some(ref mut data) = md_data { - data.frontmatter.insert(field.clone(), value.clone()); - md_modified = true; - } - } else { - // New field - add to the appropriate location based on agent source - if (md_exists || creating_new_md) && md_data.is_some() { - if let Some(ref mut data) = md_data { - data.frontmatter.insert(field.clone(), value.clone()); - md_modified = true; - } - } else { - // JSON-only agent or has JSON fields - add to JSON - existing_agent.insert(field.clone(), value.clone()); - json_modified = true; - } - } - } - - // Write changes - if md_modified { - if let Some(data) = md_data { - write_md_file(&target_path, &data.frontmatter, &data.body).await?; - } - } - - if json_modified { - // Avoid creating a new JSON section for agents that already live exclusively in .md - if md_exists && !had_json_fields { - json_modified = false; - } - } - - if json_modified { - if !config.is_object() { - *config = Value::Object(Map::new()); - } - - let config_obj = config.as_object_mut().unwrap(); - let agents_entry = config_obj - .entry("agent".to_string()) - .or_insert_with(|| Value::Object(Map::new())); - - if !agents_entry.is_object() { - *agents_entry = Value::Object(Map::new()); - } - - let agents_obj = agents_entry.as_object_mut().unwrap(); - agents_obj.insert(agent_name.to_string(), Value::Object(existing_agent)); - - write_config_at(config, &json_target_path).await?; - } - - info!( - "Updated agent: {} (scope: {:?}, md: {}, json: {})", - agent_name, scope, md_modified, json_modified - ); - - Ok(()) -} - -/// Delete agent configuration -pub async fn delete_agent(agent_name: &str, working_directory: Option<&Path>) -> Result<()> { - let mut deleted = false; - - // 1. Check project level first (takes precedence) - if let Some(wd) = working_directory { - let project_path = get_project_agent_path(wd, agent_name); - if project_path.exists() { - fs::remove_file(&project_path).await?; - info!( - "Deleted project-level agent .md file: {}", - project_path.display() - ); - deleted = true; - } - } - - // 2. Check user level - let user_path = get_user_agent_path(agent_name); - if user_path.exists() { - fs::remove_file(&user_path).await?; - info!("Deleted user-level agent .md file: {}", user_path.display()); - deleted = true; - } - - // 3. Remove section from opencode.json if exists (highest precedence entry only) - let mut layers = read_config_layers(working_directory).await?; - let json_source = get_json_entry_source(&layers, "agent", agent_name); - if json_source.exists { - if let Some(json_path) = json_source.path.clone() { - let config = get_config_for_path(&mut layers, &json_path); - if let Some(agents) = config.get_mut("agent").and_then(|v| v.as_object_mut()) { - if agents.remove(agent_name).is_some() { - write_config_at(config, &json_path).await?; - info!("Removed agent from opencode.json: {}", agent_name); - deleted = true; - } - } - } - } - - // 4. If nothing was deleted (built-in agent), disable it in highest-precedence config - if !deleted { - let preferred_scope = if working_directory.is_some() { - Some(Scope::Project) - } else { - Some(Scope::User) - }; - let json_path = get_json_write_target(&layers, preferred_scope); - let config = get_config_for_path(&mut layers, &json_path); - if !config.is_object() { - *config = Value::Object(serde_json::Map::new()); - } - let config_obj = config.as_object_mut().unwrap(); - if !config_obj.contains_key("agent") { - config_obj.insert("agent".to_string(), Value::Object(serde_json::Map::new())); - } - let agents = config_obj.get_mut("agent").unwrap(); - if !agents.is_object() { - *agents = Value::Object(serde_json::Map::new()); - } - let mut disable_obj = serde_json::Map::new(); - disable_obj.insert("disable".to_string(), Value::Bool(true)); - agents - .as_object_mut() - .unwrap() - .insert(agent_name.to_string(), Value::Object(disable_obj)); - write_config_at(config, &json_path).await?; - info!("Disabled built-in agent: {}", agent_name); - } - - Ok(()) -} - -/// Get information about where command configuration is stored -pub async fn get_command_sources( - command_name: &str, - working_directory: Option<&Path>, -) -> Result { - ensure_dirs().await?; - - // Check project level first (takes precedence) - let project_path = working_directory.map(|wd| get_project_command_path(wd, command_name)); - let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false); - - // Then check user level - let user_path = get_user_command_path(command_name); - let user_exists = user_path.exists(); - - // Determine which md file to use (project takes precedence) - let (md_path, md_exists, md_scope) = if project_exists { - (project_path.clone(), true, Some(Scope::Project)) - } else if user_exists { - (Some(user_path.clone()), true, Some(Scope::User)) - } else { - (None, false, None) - }; - - let mut md_fields = Vec::new(); - if md_exists { - if let Some(ref path) = md_path { - let md_data = parse_md_file(path).await?; - md_fields.extend(md_data.frontmatter.keys().cloned()); - if !md_data.body.trim().is_empty() { - md_fields.push("template".to_string()); - } - } - } - - let layers = read_config_layers(working_directory).await?; - let json_source = get_json_entry_source(&layers, "command", command_name); - let json_section = json_source.section.as_ref(); - - let json_fields = json_section - .and_then(|value| value.as_object()) - .map(|obj| obj.keys().cloned().collect::>()) - .unwrap_or_default(); - - let json_path_buf = json_source - .path - .unwrap_or_else(|| get_default_json_path(&layers)); - let json_path = json_path_buf.display().to_string(); - let json_scope = if layers.paths.project.as_ref() == Some(&json_path_buf) { - Some(Scope::Project) - } else { - Some(Scope::User) - }; - - let sources = ConfigSources { - md: SourceInfo { - exists: md_exists, - path: md_path.map(|p| p.display().to_string()), - fields: md_fields, - scope: md_scope, - }, - json: SourceInfo { - exists: json_source.exists, - path: Some(json_path), - fields: json_fields, - scope: if json_source.exists { json_scope } else { None }, - }, - project_md: Some(MdLocationInfo { - exists: project_exists, - path: project_path.map(|p| p.display().to_string()), - }), - user_md: Some(MdLocationInfo { - exists: user_exists, - path: Some(user_path.display().to_string()), - }), - }; - - Ok(sources) -} - -/// Create new command as .md file -pub async fn create_command( - command_name: &str, - config: &HashMap, - working_directory: Option<&Path>, - scope: Option, -) -> Result<()> { - ensure_dirs().await?; - - // Check if command already exists at either level - if let Some(wd) = working_directory { - let project_path = get_project_command_path(wd, command_name); - if project_path.exists() { - return Err(anyhow!( - "Command {} already exists as project-level .md file", - command_name - )); - } - } - - let user_path = get_user_command_path(command_name); - if user_path.exists() { - return Err(anyhow!( - "Command {} already exists as user-level .md file", - command_name - )); - } - - let layers = read_config_layers(working_directory).await?; - let json_source = get_json_entry_source(&layers, "command", command_name); - if json_source.exists { - return Err(anyhow!( - "Command {} already exists in opencode.json", - command_name - )); - } - - // Determine target path based on requested scope - let (target_scope, target_path) = if scope == Some(CommandScope::Project) { - if let Some(wd) = working_directory { - ensure_project_command_dir(wd).await?; - ( - CommandScope::Project, - get_project_command_path(wd, command_name), - ) - } else { - (CommandScope::User, user_path) - } - } else { - (CommandScope::User, user_path) - }; - - // Extract template and scope from config - scope is only used for path determination, not written to file - let mut frontmatter = config.clone(); - let template = frontmatter - .remove("template") - .and_then(|v| v.as_str().map(|s| s.to_string())) - .unwrap_or_default(); - frontmatter.remove("scope"); // Remove scope - it's not a valid command field - - // Write .md file - write_md_file(&target_path, &frontmatter, &template).await?; - info!( - "Created new command: {} (scope: {:?}, path: {})", - command_name, - target_scope, - target_path.display() - ); - - Ok(()) -} - -/// Update existing command using field-level logic -pub async fn update_command( - command_name: &str, - updates: &HashMap, - working_directory: Option<&Path>, -) -> Result<()> { - ensure_dirs().await?; - - // Determine correct path: project level takes precedence - let (scope, md_path) = get_command_write_path(command_name, working_directory, None); - let md_exists = md_path.exists(); - - let mut layers = read_config_layers(working_directory).await?; - let json_source = get_json_entry_source(&layers, "command", command_name); - let mut existing_command = json_source - .section - .as_ref() - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_else(Map::new); - let had_json_fields = !existing_command.is_empty(); - - let preferred_scope = if working_directory.is_some() { - Some(Scope::Project) - } else { - Some(Scope::User) - }; - let json_target_path = if json_source.exists { - json_source - .path - .clone() - .unwrap_or_else(|| get_json_write_target(&layers, preferred_scope)) - } else { - get_json_write_target(&layers, preferred_scope) - }; - let config = get_config_for_path(&mut layers, &json_target_path); - - // Only create a new md file for built-in overrides (no md + no json) - let is_builtin_override = !md_exists && !had_json_fields; - - let target_path = if !md_exists && is_builtin_override { - // Built-in command override - create at user level - get_user_command_path(command_name) - } else { - md_path.clone() - }; - - let mut md_data = if md_exists { - Some(parse_md_file(&md_path).await?) - } else if is_builtin_override { - Some(MdData { - frontmatter: HashMap::new(), - body: String::new(), - }) - } else { - None - }; - - let creating_new_md = is_builtin_override; - - let mut md_modified = false; - let mut json_modified = false; - - for (field, value) in updates.iter() { - // Handle explicit removals (null payload) for scalar/frontmatter/JSON fields - if value.is_null() { - if md_exists { - if let Some(ref mut data) = md_data { - if data.frontmatter.remove(field).is_some() { - md_modified = true; - } - } - } - if existing_command.remove(field).is_some() { - json_modified = true; - } - continue; - } - - // Special handling for template field - if field == "template" { - let normalized_value = value.as_str().unwrap_or("").to_string(); - - if md_exists || creating_new_md { - if let Some(ref mut data) = md_data { - data.body = normalized_value.clone(); - md_modified = true; - } - continue; - } else if let Some(template_ref) = - existing_command.get("template").and_then(|v| v.as_str()) - { - if is_prompt_file_reference(template_ref) { - if let Some(template_file_path) = resolve_prompt_file_path(template_ref) { - write_prompt_file(&template_file_path, &normalized_value).await?; - } else { - return Err(anyhow!( - "Invalid template file reference for command {}", - command_name - )); - } - continue; - } - } - - // For JSON-only commands, store template inline in JSON - existing_command.insert("template".to_string(), Value::String(normalized_value)); - json_modified = true; - continue; - } - - // Check where field is currently defined - let in_md = md_data - .as_ref() - .map(|data| data.frontmatter.contains_key(field)) - .unwrap_or(false); - let in_json = existing_command.contains_key(field); - - // JSON takes precedence over md, so update JSON first if field exists there - if in_json { - // Update in opencode.json while preserving existing fields - existing_command.insert(field.clone(), value.clone()); - json_modified = true; - } else if in_md || creating_new_md { - // Update in .md frontmatter - if let Some(ref mut data) = md_data { - data.frontmatter.insert(field.clone(), value.clone()); - md_modified = true; - } - } else { - // New field - add to the appropriate location based on command source - if (md_exists || creating_new_md) && md_data.is_some() { - if let Some(ref mut data) = md_data { - data.frontmatter.insert(field.clone(), value.clone()); - md_modified = true; - } - } else { - // JSON-only command or built-in - add to JSON - existing_command.insert(field.clone(), value.clone()); - json_modified = true; - } - } - } - - // Write changes - if md_modified { - if let Some(data) = md_data { - write_md_file(&target_path, &data.frontmatter, &data.body).await?; - } - } - - if json_modified { - // Avoid creating a new JSON section for commands that already live exclusively in .md - if md_exists && !had_json_fields { - json_modified = false; - } - } - - if json_modified { - if !config.is_object() { - *config = Value::Object(Map::new()); - } - - let config_obj = config.as_object_mut().unwrap(); - let commands_entry = config_obj - .entry("command".to_string()) - .or_insert_with(|| Value::Object(Map::new())); - - if !commands_entry.is_object() { - *commands_entry = Value::Object(Map::new()); - } - - let commands_obj = commands_entry.as_object_mut().unwrap(); - commands_obj.insert(command_name.to_string(), Value::Object(existing_command)); - - write_config_at(config, &json_target_path).await?; - } - - info!( - "Updated command: {} (scope: {:?}, md: {}, json: {})", - command_name, scope, md_modified, json_modified - ); - - Ok(()) -} - -/// Delete command configuration -pub async fn delete_command(command_name: &str, working_directory: Option<&Path>) -> Result<()> { - let mut deleted = false; - - // 1. Check project level first (takes precedence) - if let Some(wd) = working_directory { - let project_path = get_project_command_path(wd, command_name); - if project_path.exists() { - fs::remove_file(&project_path).await?; - info!( - "Deleted project-level command .md file: {}", - project_path.display() - ); - deleted = true; - } - } - - // 2. Check user level - let user_path = get_user_command_path(command_name); - if user_path.exists() { - fs::remove_file(&user_path).await?; - info!( - "Deleted user-level command .md file: {}", - user_path.display() - ); - deleted = true; - } - - // 3. Remove section from opencode.json if exists (highest precedence entry only) - let mut layers = read_config_layers(working_directory).await?; - let json_source = get_json_entry_source(&layers, "command", command_name); - if json_source.exists { - if let Some(json_path) = json_source.path.clone() { - let config = get_config_for_path(&mut layers, &json_path); - if let Some(commands) = config.get_mut("command").and_then(|v| v.as_object_mut()) { - if commands.remove(command_name).is_some() { - write_config_at(config, &json_path).await?; - info!("Removed command from opencode.json: {}", command_name); - deleted = true; - } - } - } - } - - // 4. If nothing was deleted, throw error - if !deleted { - return Err(anyhow!("Command \"{}\" not found", command_name)); - } - - Ok(()) -} - -// ============== SKILL SCOPE TYPES ============== - -/// Skill scope types -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum SkillScope { - User, - Project, -} - -impl From for Scope { - fn from(scope: SkillScope) -> Self { - match scope { - SkillScope::User => Scope::User, - SkillScope::Project => Scope::Project, - } - } -} - -/// Skill source type (opencode vs claude-compat) -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum SkillSource { - Opencode, - Claude, -} - -/// Supporting file info -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SupportingFile { - pub name: String, - pub path: String, - pub full_path: String, -} - -/// Skill-specific source info with supporting files -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillSourceInfo { - pub exists: bool, - pub path: Option, - pub dir: Option, - pub fields: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub scope: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, - pub supporting_files: Vec, - // Actual content values - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub instructions: Option, -} - -/// Skill config sources -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillConfigSources { - pub md: SkillSourceInfo, - #[serde(skip_serializing_if = "Option::is_none")] - pub project_md: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub claude_md: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_md: Option, -} - -/// Discovered skill info -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DiscoveredSkill { - pub name: String, - pub path: String, - pub scope: Scope, - pub source: SkillSource, -} - -// ============== SKILL SCOPE HELPERS ============== - -/// Get user-level skill directory path -fn get_skill_dir() -> PathBuf { - get_config_dir().join("skills") -} - -fn get_legacy_skill_dir() -> PathBuf { - get_config_dir().join("skill") -} - -/// Get user-level skill directory for a specific skill -fn get_user_skill_dir(skill_name: &str) -> PathBuf { - let plural_path = get_skill_dir().join(skill_name); - let legacy_path = get_legacy_skill_dir().join(skill_name); - if legacy_path.exists() && !plural_path.exists() { - return legacy_path; - } - plural_path -} - -/// Get user-level skill SKILL.md path -fn get_user_skill_path(skill_name: &str) -> PathBuf { - let plural_path = get_skill_dir().join(skill_name).join("SKILL.md"); - let legacy_path = get_legacy_skill_dir().join(skill_name).join("SKILL.md"); - if legacy_path.exists() && !plural_path.exists() { - return legacy_path; - } - plural_path -} - -/// Get project-level skill directory (.opencode/skills/) -fn get_project_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf { - let plural_path = working_directory - .join(".opencode") - .join("skills") - .join(skill_name); - let legacy_path = working_directory - .join(".opencode") - .join("skill") - .join(skill_name); - if legacy_path.exists() && !plural_path.exists() { - return legacy_path; - } - plural_path -} - -/// Get project-level skill SKILL.md path -fn get_project_skill_path(working_directory: &Path, skill_name: &str) -> PathBuf { - let plural_path = working_directory - .join(".opencode") - .join("skills") - .join(skill_name) - .join("SKILL.md"); - let legacy_path = working_directory - .join(".opencode") - .join("skill") - .join(skill_name) - .join("SKILL.md"); - if legacy_path.exists() && !plural_path.exists() { - return legacy_path; - } - plural_path -} - -/// Get Claude-compatible skill directory (.claude/skills/) -fn get_claude_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf { - working_directory - .join(".claude") - .join("skills") - .join(skill_name) -} - -/// Get Claude-compatible skill SKILL.md path -fn get_claude_skill_path(working_directory: &Path, skill_name: &str) -> PathBuf { - get_claude_skill_dir(working_directory, skill_name).join("SKILL.md") -} - -/// Ensure skill directories exist -async fn ensure_skill_dirs() -> Result<()> { - let skill_dir = get_skill_dir(); - fs::create_dir_all(&skill_dir).await?; - fs::create_dir_all(&get_legacy_skill_dir()).await?; - Ok(()) -} - -/// Ensure project skill directory exists -async fn ensure_project_skill_dir(working_directory: &Path, skill_name: &str) -> Result { - let project_skill_dir = get_project_skill_dir(working_directory, skill_name); - fs::create_dir_all(&project_skill_dir).await?; - let legacy_project_skill_dir = working_directory - .join(".opencode") - .join("skill") - .join(skill_name); - fs::create_dir_all(&legacy_project_skill_dir).await?; - Ok(project_skill_dir) -} - -/// Determine skill scope based on where the SKILL.md file exists -pub fn get_skill_scope( - skill_name: &str, - working_directory: Option<&Path>, -) -> (Option, Option, Option) { - if let Some(wd) = working_directory { - // Check .opencode/skills first - let project_path = get_project_skill_path(wd, skill_name); - if project_path.exists() { - return ( - Some(SkillScope::Project), - Some(project_path), - Some(SkillSource::Opencode), - ); - } - - // Check .claude/skills (claude-compat) - let claude_path = get_claude_skill_path(wd, skill_name); - if claude_path.exists() { - return ( - Some(SkillScope::Project), - Some(claude_path), - Some(SkillSource::Claude), - ); - } - } - - let user_path = get_user_skill_path(skill_name); - if user_path.exists() { - return ( - Some(SkillScope::User), - Some(user_path), - Some(SkillSource::Opencode), - ); - } - - (None, None, None) -} - -/// List supporting files in a skill directory (excluding SKILL.md) -fn list_supporting_files(skill_dir: &Path) -> Vec { - let mut files = Vec::new(); - - fn walk_dir(dir: &Path, relative_base: &Path, files: &mut Vec) { - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - let file_name = entry.file_name().to_string_lossy().to_string(); - - if path.is_dir() { - walk_dir(&path, relative_base, files); - } else if file_name != "SKILL.md" { - let relative_path = path - .strip_prefix(relative_base) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| file_name.clone()); - - files.push(SupportingFile { - name: file_name, - path: relative_path, - full_path: path.display().to_string(), - }); - } - } - } - } - - walk_dir(skill_dir, skill_dir, &mut files); - files -} - -/// Discover all skills from all sources -pub fn discover_skills(working_directory: Option<&Path>) -> Vec { - let mut skills: std::collections::HashMap = - std::collections::HashMap::new(); - - // Helper to add skill if not already found - let mut add_skill = |name: String, path: PathBuf, scope: Scope, source: SkillSource| { - if !skills.contains_key(&name) { - skills.insert( - name.clone(), - DiscoveredSkill { - name, - path: path.display().to_string(), - scope, - source, - }, - ); - } - }; - - // 1. Project level .opencode/skills/ (highest priority) - if let Some(wd) = working_directory { - let project_skill_dir = wd.join(".opencode").join("skills"); - if project_skill_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&project_skill_dir) { - for entry in entries.flatten() { - if entry.path().is_dir() { - let skill_name = entry.file_name().to_string_lossy().to_string(); - let skill_md = entry.path().join("SKILL.md"); - if skill_md.exists() { - add_skill(skill_name, skill_md, Scope::Project, SkillSource::Opencode); - } - } - } - } - } - - let legacy_project_skill_dir = wd.join(".opencode").join("skill"); - if legacy_project_skill_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&legacy_project_skill_dir) { - for entry in entries.flatten() { - if entry.path().is_dir() { - let skill_name = entry.file_name().to_string_lossy().to_string(); - let skill_md = entry.path().join("SKILL.md"); - if skill_md.exists() { - add_skill(skill_name, skill_md, Scope::Project, SkillSource::Opencode); - } - } - } - } - } - - // 2. Claude-compatible .claude/skills/ - let claude_skill_dir = wd.join(".claude").join("skills"); - if claude_skill_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&claude_skill_dir) { - for entry in entries.flatten() { - if entry.path().is_dir() { - let skill_name = entry.file_name().to_string_lossy().to_string(); - let skill_md = entry.path().join("SKILL.md"); - if skill_md.exists() { - add_skill(skill_name, skill_md, Scope::Project, SkillSource::Claude); - } - } - } - } - } - } - - // 3. User level ~/.config/opencode/skills/ - let user_skill_dir = get_skill_dir(); - if user_skill_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&user_skill_dir) { - for entry in entries.flatten() { - if entry.path().is_dir() { - let skill_name = entry.file_name().to_string_lossy().to_string(); - let skill_md = entry.path().join("SKILL.md"); - if skill_md.exists() { - add_skill(skill_name, skill_md, Scope::User, SkillSource::Opencode); - } - } - } - } - } - - let legacy_user_skill_dir = get_legacy_skill_dir(); - if legacy_user_skill_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&legacy_user_skill_dir) { - for entry in entries.flatten() { - if entry.path().is_dir() { - let skill_name = entry.file_name().to_string_lossy().to_string(); - let skill_md = entry.path().join("SKILL.md"); - if skill_md.exists() { - add_skill(skill_name, skill_md, Scope::User, SkillSource::Opencode); - } - } - } - } - } - - skills.into_values().collect() -} - -/// Get information about where skill configuration is stored -pub async fn get_skill_sources( - skill_name: &str, - working_directory: Option<&Path>, -) -> Result { - ensure_skill_dirs().await?; - - // Check all possible locations - let project_path = working_directory.map(|wd| get_project_skill_path(wd, skill_name)); - let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false); - let project_dir = project_exists - .then(|| working_directory.map(|wd| get_project_skill_dir(wd, skill_name))) - .flatten(); - - let claude_path = working_directory.map(|wd| get_claude_skill_path(wd, skill_name)); - let claude_exists = claude_path.as_ref().map(|p| p.exists()).unwrap_or(false); - let claude_dir = claude_exists - .then(|| working_directory.map(|wd| get_claude_skill_dir(wd, skill_name))) - .flatten(); - - let user_path = get_user_skill_path(skill_name); - let user_exists = user_path.exists(); - let user_dir = if user_exists { - Some(get_user_skill_dir(skill_name)) - } else { - None - }; - - // Determine which md file to use (priority: project > claude > user) - let (md_path, md_exists, md_scope, md_source, md_dir) = if project_exists { - ( - project_path.clone(), - true, - Some(Scope::Project), - Some(SkillSource::Opencode), - project_dir.clone(), - ) - } else if claude_exists { - ( - claude_path.clone(), - true, - Some(Scope::Project), - Some(SkillSource::Claude), - claude_dir.clone(), - ) - } else if user_exists { - ( - Some(user_path.clone()), - true, - Some(Scope::User), - Some(SkillSource::Opencode), - user_dir.clone(), - ) - } else { - (None, false, None, None, None) - }; - - let mut md_fields = Vec::new(); - let mut supporting_files = Vec::new(); - let mut md_name: Option = None; - let mut md_description: Option = None; - let mut md_instructions: Option = None; - - if md_exists { - if let Some(ref path) = md_path { - let md_data = parse_md_file(path).await?; - md_fields.extend(md_data.frontmatter.keys().cloned()); - - // Extract actual content values - md_name = md_data - .frontmatter - .get("name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - md_description = md_data - .frontmatter - .get("description") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - if !md_data.body.trim().is_empty() { - md_fields.push("instructions".to_string()); - md_instructions = Some(md_data.body.clone()); - } - } - if let Some(ref dir) = md_dir { - supporting_files = list_supporting_files(dir); - } - } - - Ok(SkillConfigSources { - md: SkillSourceInfo { - exists: md_exists, - path: md_path.map(|p| p.display().to_string()), - dir: md_dir.map(|d| d.display().to_string()), - fields: md_fields, - scope: md_scope, - source: md_source, - supporting_files, - name: md_name, - description: md_description, - instructions: md_instructions, - }, - project_md: Some(MdLocationInfo { - exists: project_exists, - path: project_path.map(|p| p.display().to_string()), - }), - claude_md: Some(MdLocationInfo { - exists: claude_exists, - path: claude_path.map(|p| p.display().to_string()), - }), - user_md: Some(MdLocationInfo { - exists: user_exists, - path: Some(user_path.display().to_string()), - }), - }) -} - -/// Read a supporting file content -pub async fn read_skill_supporting_file(skill_dir: &Path, relative_path: &str) -> Result { - let full_path = skill_dir.join(relative_path); - if !full_path.exists() { - return Err(anyhow!("File not found: {}", relative_path)); - } - let content = fs::read_to_string(&full_path).await?; - Ok(content) -} - -/// Write a supporting file -pub async fn write_skill_supporting_file( - skill_dir: &Path, - relative_path: &str, - content: &str, -) -> Result<()> { - let full_path = skill_dir.join(relative_path); - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent).await?; - } - fs::write(&full_path, content).await?; - info!("Wrote supporting file: {}", full_path.display()); - Ok(()) -} - -/// Delete a supporting file -pub async fn delete_skill_supporting_file(skill_dir: &Path, relative_path: &str) -> Result<()> { - let full_path = skill_dir.join(relative_path); - if full_path.exists() { - fs::remove_file(&full_path).await?; - info!("Deleted supporting file: {}", full_path.display()); - - // Clean up empty parent directories - let mut parent = full_path.parent(); - while let Some(p) = parent { - if p == skill_dir { - break; - } - if let Ok(mut entries) = std::fs::read_dir(p) { - if entries.next().is_none() { - let _ = std::fs::remove_dir(p); - parent = p.parent(); - } else { - break; - } - } else { - break; - } - } - } - Ok(()) -} - -/// Validate skill name (lowercase alphanumeric with hyphens, 1-64 chars) -fn validate_skill_name(skill_name: &str) -> Result<()> { - let re = Regex::new(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$").expect("valid regex"); - if !re.is_match(skill_name) || skill_name.len() > 64 { - return Err(anyhow!( - "Invalid skill name \"{}\". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.", - skill_name - )); - } - Ok(()) -} - -/// Create new skill -pub async fn create_skill( - skill_name: &str, - config: &HashMap, - working_directory: Option<&Path>, - scope: Option, -) -> Result<()> { - ensure_skill_dirs().await?; - validate_skill_name(skill_name)?; - - // Check if skill already exists - let (_existing_scope, existing_path, _) = get_skill_scope(skill_name, working_directory); - if existing_path.is_some() { - return Err(anyhow!("Skill {} already exists", skill_name)); - } - - // Determine target directory - let (target_scope, target_dir) = if scope == Some(SkillScope::Project) { - if let Some(wd) = working_directory { - let dir = ensure_project_skill_dir(wd, skill_name).await?; - (SkillScope::Project, dir) - } else { - let dir = get_user_skill_dir(skill_name); - fs::create_dir_all(&dir).await?; - (SkillScope::User, dir) - } - } else { - let dir = get_user_skill_dir(skill_name); - fs::create_dir_all(&dir).await?; - (SkillScope::User, dir) - }; - - let target_path = target_dir.join("SKILL.md"); - - // Extract fields - let mut frontmatter = config.clone(); - let instructions = frontmatter - .remove("instructions") - .and_then(|v| v.as_str().map(|s| s.to_string())) - .unwrap_or_default(); - frontmatter.remove("scope"); - frontmatter.remove("supportingFiles"); - - // Ensure required fields - if !frontmatter.contains_key("name") { - frontmatter.insert("name".to_string(), Value::String(skill_name.to_string())); - } - if !frontmatter.contains_key("description") { - return Err(anyhow!("Skill description is required")); - } - - write_md_file(&target_path, &frontmatter, &instructions).await?; - - // Write supporting files if provided - if let Some(supporting_files) = config.get("supportingFiles").and_then(|v| v.as_array()) { - for file in supporting_files { - if let (Some(path), Some(content)) = ( - file.get("path").and_then(|v| v.as_str()), - file.get("content").and_then(|v| v.as_str()), - ) { - write_skill_supporting_file(&target_dir, path, content).await?; - } - } - } - - info!( - "Created new skill: {} (scope: {:?}, path: {})", - skill_name, - target_scope, - target_path.display() - ); - Ok(()) -} - -/// Update existing skill -pub async fn update_skill( - skill_name: &str, - updates: &HashMap, - working_directory: Option<&Path>, -) -> Result<()> { - let (_, existing_path, _) = get_skill_scope(skill_name, working_directory); - let md_path = existing_path.ok_or_else(|| anyhow!("Skill \"{}\" not found", skill_name))?; - let md_dir = md_path - .parent() - .ok_or_else(|| anyhow!("Invalid skill path"))?; - - let mut md_data = parse_md_file(&md_path).await?; - let mut md_modified = false; - - for (field, value) in updates.iter() { - if field == "scope" { - continue; - } - - if field == "instructions" { - let normalized = value.as_str().unwrap_or("").to_string(); - md_data.body = normalized; - md_modified = true; - continue; - } - - if field == "supportingFiles" { - if let Some(files) = value.as_array() { - for file in files { - if let Some(true) = file.get("delete").and_then(|v| v.as_bool()) { - if let Some(path) = file.get("path").and_then(|v| v.as_str()) { - delete_skill_supporting_file(md_dir, path).await?; - } - } else if let (Some(path), Some(content)) = ( - file.get("path").and_then(|v| v.as_str()), - file.get("content").and_then(|v| v.as_str()), - ) { - write_skill_supporting_file(md_dir, path, content).await?; - } - } - } - continue; - } - - md_data.frontmatter.insert(field.clone(), value.clone()); - md_modified = true; - } - - if md_modified { - write_md_file(&md_path, &md_data.frontmatter, &md_data.body).await?; - } - - info!( - "Updated skill: {} (path: {})", - skill_name, - md_path.display() - ); - Ok(()) -} - -/// Delete skill -pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) -> Result<()> { - let mut deleted = false; - - // Check and delete from all locations - if let Some(wd) = working_directory { - // Project level .opencode/skill/ - let project_dir = get_project_skill_dir(wd, skill_name); - if project_dir.exists() { - fs::remove_dir_all(&project_dir).await?; - info!( - "Deleted project-level skill directory: {}", - project_dir.display() - ); - deleted = true; - } - - // Claude-compat .claude/skills/ - let claude_dir = get_claude_skill_dir(wd, skill_name); - if claude_dir.exists() { - fs::remove_dir_all(&claude_dir).await?; - info!( - "Deleted claude-compat skill directory: {}", - claude_dir.display() - ); - deleted = true; - } - } - - // User level - let user_dir = get_user_skill_dir(skill_name); - if user_dir.exists() { - fs::remove_dir_all(&user_dir).await?; - info!("Deleted user-level skill directory: {}", user_dir.display()); - deleted = true; - } - - let legacy_user_dir = get_legacy_skill_dir().join(skill_name); - if legacy_user_dir.exists() { - fs::remove_dir_all(&legacy_user_dir).await?; - info!( - "Deleted legacy user-level skill directory: {}", - legacy_user_dir.display() - ); - deleted = true; - } - - if !deleted { - return Err(anyhow!("Skill \"{}\" not found", skill_name)); - } - - Ok(()) -} diff --git a/packages/desktop/src-tauri/src/opencode_manager.rs b/packages/desktop/src-tauri/src/opencode_manager.rs deleted file mode 100644 index 43b8241b..00000000 --- a/packages/desktop/src-tauri/src/opencode_manager.rs +++ /dev/null @@ -1,751 +0,0 @@ -use anyhow::{anyhow, Result}; -use log::{debug, info, warn}; -use once_cell::sync::Lazy; -use parking_lot::RwLock; -use regex::Regex; -use reqwest::Client; -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, - time::Duration, -}; -use tokio::{ - io::{AsyncBufReadExt, BufReader}, - process::{Child, Command}, - sync::Mutex, - time::timeout, -}; - -static URL_REGEX: Lazy = Lazy::new(|| { - Regex::new(r#"https?://[^:\s]+:(?P\d+)(?P/[^\s"']*)?"#).expect("valid regex") -}); - -const FIRST_SIGNAL_TIMEOUT_MS: u64 = 750; -const READY_CHECK_TIMEOUT_MS: u64 = 20000; -const READY_CHECK_INTERVAL_MS: u64 = 400; - -#[derive(Clone)] -pub struct OpenCodeManager { - binary: Option, - args: Vec, - env: HashMap, - working_dir: Arc>, - desired_port: u16, - child: Arc>>, - port: Arc>>, - api_prefix: Arc>, - is_ready: Arc, - shutting_down: Arc, - http_client: Client, -} - -fn normalize_api_prefix(prefix: &str) -> String { - let trimmed = prefix.trim(); - if trimmed.is_empty() || trimmed == "/" { - return String::new(); - } - - let mut normalized = trimmed.trim_end_matches('/').to_string(); - if !normalized.starts_with('/') { - normalized.insert(0, '/'); - } - normalized -} - -impl OpenCodeManager { - pub fn new_with_directory(_initial_dir: Option) -> Self { - let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT") - .ok() - .and_then(|raw| raw.parse::().ok()) - .unwrap_or(0); - - let binary = resolve_opencode_binary(); - - if let Some(ref bin) = binary { - if !Path::new(bin).is_absolute() { - info!("[desktop:opencode] using PATH-resolved binary: {}", bin); - } else { - info!("[desktop:opencode] using binary: {}", bin); - } - } else { - warn!("[desktop:opencode] OpenCode CLI not found - app will run in limited mode"); - } - - let mut args = vec![ - "serve".to_string(), - "--port".to_string(), - desired_port.to_string(), - ]; - if let Ok(config) = std::env::var("OPENCHAMBER_OPENCODE_CONFIG") { - if !config.is_empty() { - args.push("--config".to_string()); - args.push(config); - } - } - - let env = build_augmented_env(); - let working_dir = dirs::home_dir() - .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); - - info!( - "[desktop:opencode] Initial working directory: {:?}", - working_dir - ); - - Self { - binary, - args, - env, - working_dir: Arc::new(RwLock::new(working_dir)), - desired_port, - child: Arc::new(Mutex::new(None)), - port: Arc::new(RwLock::new(None)), - api_prefix: Arc::new(RwLock::new(String::new())), - is_ready: Arc::new(AtomicBool::new(false)), - shutting_down: Arc::new(AtomicBool::new(false)), - http_client: Client::builder() - .timeout(Duration::from_secs(2)) - .build() - .unwrap(), - } - } - - pub fn is_cli_available(&self) -> bool { - self.binary.is_some() - } - - pub async fn ensure_running(&self) -> Result<()> { - if self.binary.is_none() { - return Err(anyhow!("OpenCode CLI is not available")); - } - - let mut guard = self.child.lock().await; - if let Some(child) = guard.as_mut() { - if child.try_wait()?.is_none() && self.is_ready.load(Ordering::SeqCst) { - return Ok(()); - } - } - - self.is_ready.store(false, Ordering::SeqCst); - let child = self.spawn_process().await?; - *guard = Some(child); - drop(guard); - - // Wait for port detection from logs - if self.desired_port == 0 { - self.wait_for_port_detection().await?; - } - - // Detect API prefix early so proxy can forward correctly - let _ = self.detect_api_prefix().await; - - // Wait for OpenCode to become ready by polling endpoints - self.wait_for_ready().await?; - - self.is_ready.store(true, Ordering::SeqCst); - if let Some(port) = self.current_port() { - info!("[desktop:opencode] ready on port {port}"); - } - Ok(()) - } - - pub async fn restart(&self) -> Result<()> { - info!("[desktop:opencode] restarting..."); - self.is_ready.store(false, Ordering::SeqCst); - - self.graceful_stop().await?; - - // Brief delay to let OS release resources - tokio::time::sleep(Duration::from_millis(250)).await; - - // Reset state - if self.desired_port == 0 { - *self.port.write() = None; - } - *self.api_prefix.write() = String::new(); - - self.ensure_running().await - } - - pub async fn shutdown(&self) -> Result<()> { - self.shutting_down.store(true, Ordering::SeqCst); - self.is_ready.store(false, Ordering::SeqCst); - self.graceful_stop().await - } - - #[allow(dead_code)] - pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> { - *self.working_dir.write() = new_dir; - Ok(()) - } - - #[allow(dead_code)] - pub fn get_working_directory(&self) -> PathBuf { - self.working_dir.read().clone() - } - - async fn detect_api_prefix(&self) -> Result<()> { - let Some(port) = self.current_port() else { - return Err(anyhow!("Cannot detect API prefix without port")); - }; - - // Try no prefix first, then /api (compatibility). - let candidates = ["", "/api"]; - for candidate in candidates { - let base = if candidate.is_empty() { - format!("http://127.0.0.1:{port}") - } else { - format!("http://127.0.0.1:{port}{candidate}") - }; - - let url = format!("{base}/config"); - match self.http_client.get(&url).send().await { - Ok(resp) if resp.status().is_success() => { - // Validate it's actually JSON config, not HTML - if let Ok(text) = resp.text().await { - if text.trim().starts_with('{') || text.trim().starts_with('[') { - info!("[desktop:opencode] Detected API prefix: {:?}", candidate); - *self.api_prefix.write() = normalize_api_prefix(candidate); - return Ok(()); - } - } - } - _ => continue, - } - } - - info!("[desktop:opencode] No API prefix detected, using empty prefix"); - *self.api_prefix.write() = String::new(); - Ok(()) - } - - pub fn current_port(&self) -> Option { - *self.port.read() - } - - pub fn api_prefix(&self) -> String { - self.api_prefix.read().clone() - } - - pub fn is_ready(&self) -> bool { - self.is_ready.load(Ordering::SeqCst) - } - - pub fn is_shutting_down(&self) -> bool { - self.shutting_down.load(Ordering::SeqCst) - } - - pub async fn is_child_running(&self) -> Result { - let mut guard = self.child.lock().await; - if let Some(child) = guard.as_mut() { - match child.try_wait()? { - None => return Ok(true), - Some(_status) => { - *guard = None; - self.is_ready.store(false, Ordering::SeqCst); - return Ok(false); - } - } - } - Ok(false) - } - - pub fn rewrite_path(&self, incoming_path: &str) -> String { - // Strip /api prefix to get OpenCode path - let result = incoming_path - .strip_prefix("/api") - .map(|rest| if rest.is_empty() { "/" } else { rest }) - .unwrap_or(incoming_path) - .to_string(); - - debug!( - "[opencode_manager] rewrite_path: '{}' -> '{}'", - incoming_path, result - ); - result - } - - async fn spawn_process(&self) -> Result { - let binary = self - .binary - .as_ref() - .ok_or_else(|| anyhow!("Cannot spawn process: OpenCode CLI is not available"))?; - - info!("[desktop:opencode] launching {} {:?}", binary, self.args); - - let working_dir = self.working_dir.read().clone(); - let mut cmd = Command::new(binary); - cmd.args(&self.args) - .current_dir(&working_dir) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(false); - - for (key, value) in &self.env { - cmd.env(key, value); - } - - let mut child = cmd.spawn().map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - anyhow!( - "OpenCode binary '{}' not found. Set OPENCODE_BINARY or ensure it's in PATH.", - binary - ) - } else { - anyhow!("Failed to spawn OpenCode: {}", e) - } - })?; - - // Set port immediately if pre-configured - if self.desired_port > 0 { - *self.port.write() = Some(self.desired_port); - } - - // Wait for first signal (stdout/stderr) within 750ms to confirm startup - let first_signal_received = Arc::new(AtomicBool::new(false)); - - if let Some(stdout) = child.stdout.take() { - let signal_flag = first_signal_received.clone(); - self.spawn_output_reader(stdout, "stdout", move || { - signal_flag.store(true, Ordering::SeqCst); - }); - } - - if let Some(stderr) = child.stderr.take() { - let signal_flag = first_signal_received.clone(); - self.spawn_output_reader(stderr, "stderr", move || { - signal_flag.store(true, Ordering::SeqCst); - }); - } - - // Wait for first signal or timeout - let start = std::time::Instant::now(); - while start.elapsed() < Duration::from_millis(FIRST_SIGNAL_TIMEOUT_MS) { - if first_signal_received.load(Ordering::SeqCst) { - break; - } - if let Ok(Some(_)) = child.try_wait() { - return Err(anyhow!("OpenCode process exited immediately after spawn")); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - - Ok(child) - } - - fn spawn_output_reader( - &self, - stream: impl tokio::io::AsyncRead + Unpin + Send + 'static, - label: &'static str, - on_first_line: F, - ) where - F: FnOnce() + Send + 'static, - { - let manager = self.clone(); - let first_line_flag = Arc::new(Mutex::new(Some(on_first_line))); - - tauri::async_runtime::spawn(async move { - let reader = BufReader::new(stream); - let mut lines = reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - // Trigger first signal callback - if let Some(callback) = first_line_flag.lock().await.take() { - callback(); - } - - debug!("[opencode:{label}] {line}"); - manager.ingest_output_line(&line); - } - }); - } - - fn ingest_output_line(&self, line: &str) { - if let Some(captures) = URL_REGEX.captures(line) { - if let Some(port_match) = captures - .name("port") - .and_then(|m| m.as_str().parse::().ok()) - { - *self.port.write() = Some(port_match); - } - - if let Some(path_match) = captures.name("path") { - let value = path_match.as_str(); - if !value.is_empty() && value != "/" { - *self.api_prefix.write() = value.to_string(); - } - } - } - } - - async fn wait_for_port_detection(&self) -> Result<()> { - let start = std::time::Instant::now(); - let timeout_duration = Duration::from_secs(15); - - while start.elapsed() < timeout_duration { - if self.current_port().is_some() { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - - Err(anyhow!("OpenCode did not report port within 15 seconds")) - } - - async fn wait_for_ready(&self) -> Result<()> { - let Some(port) = self.current_port() else { - return Err(anyhow!("Cannot check readiness without port")); - }; - - let deadline = tokio::time::Instant::now() + Duration::from_millis(READY_CHECK_TIMEOUT_MS); - let mut last_error: Option = None; - - while tokio::time::Instant::now() < deadline { - let api_prefix = self.api_prefix(); - - // Try /config, /agent endpoints - match self.check_endpoints(port, &api_prefix).await { - Ok(()) => { - return Ok(()); - } - Err(e) => { - last_error = Some(e.to_string()); - } - } - - tokio::time::sleep(Duration::from_millis(READY_CHECK_INTERVAL_MS)).await; - } - - Err(anyhow!( - "OpenCode not ready after {}ms: {}", - READY_CHECK_TIMEOUT_MS, - last_error.unwrap_or_else(|| "no error details".to_string()) - )) - } - - async fn check_endpoints(&self, port: u16, prefix: &str) -> Result<()> { - let base_url = format!("http://127.0.0.1:{port}{prefix}"); - - let config_url = format!("{base_url}/config"); - let agent_url = format!("{base_url}/agent"); - - let (config_resp, agent_resp) = tokio::join!( - self.http_client.get(&config_url).send(), - self.http_client.get(&agent_url).send() - ); - - let config_resp = config_resp?; - if !config_resp.status().is_success() { - return Err(anyhow!("/config returned {}", config_resp.status())); - } - - let agent_resp = agent_resp?; - if !agent_resp.status().is_success() { - return Err(anyhow!("/agent returned {}", agent_resp.status())); - } - - Ok(()) - } - - async fn graceful_stop(&self) -> Result<()> { - let port_to_kill = self.current_port(); - - let mut guard = self.child.lock().await; - let Some(mut child) = guard.take() else { - // No child, but still kill by port in case of orphaned processes - drop(guard); - kill_process_on_port(port_to_kill); - return Ok(()); - }; - - if child.try_wait()?.is_some() { - // Already exited, but still clean up by port - drop(guard); - kill_process_on_port(port_to_kill); - return Ok(()); - } - - // SIGTERM - #[cfg(unix)] - { - use nix::{ - sys::signal::{kill, Signal}, - unistd::Pid, - }; - if let Some(id) = child.id() { - let _ = kill(Pid::from_raw(id as i32), Signal::SIGTERM); - info!("[desktop:opencode] sent SIGTERM"); - } - } - #[cfg(windows)] - { - let _ = child.kill().await; - } - - // Wait 3 seconds for graceful exit - match timeout(Duration::from_secs(3), child.wait()).await { - Ok(_) => { - info!("[desktop:opencode] exited gracefully"); - drop(guard); - kill_process_on_port(port_to_kill); - return Ok(()); - } - Err(_) => { - warn!("[desktop:opencode] did not exit after SIGTERM, sending SIGKILL"); - } - } - - // SIGKILL - let _ = child.kill().await; - - match timeout(Duration::from_secs(2), child.wait()).await { - Ok(_) => { - info!("[desktop:opencode] exited after SIGKILL"); - } - Err(_) => { - warn!("[desktop:opencode] unresponsive after SIGKILL, continuing anyway"); - } - } - - drop(guard); - kill_process_on_port(port_to_kill); - - Ok(()) - } -} - -fn kill_process_on_port(port: Option) { - let Some(port) = port else { return }; - - // Kill any process listening on our port to clean up orphaned children. - // The opencode CLI is a Node wrapper that spawns the actual binary as a child. - // Killing the wrapper doesn't kill the child, so we kill by port. - #[cfg(unix)] - { - use std::process::Command; - // First get PIDs, then kill them separately to avoid xargs issues - if let Ok(output) = Command::new("lsof") - .args(["-ti", &format!(":{}", port)]) - .output() - { - let pids = String::from_utf8_lossy(&output.stdout); - for pid in pids.split_whitespace() { - if let Ok(pid_num) = pid.trim().parse::() { - // Don't kill our own process - if pid_num != std::process::id() as i32 { - let _ = Command::new("kill") - .args(["-9", &pid_num.to_string()]) - .output(); - } - } - } - } - } -} - -/// Check if CLI binary exists (can be called dynamically for polling) -pub fn check_cli_exists() -> bool { - if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() { - return false; - } - resolve_opencode_binary().is_some() -} - -fn resolve_opencode_binary() -> Option { - if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() { - return None; - } - - if let Ok(value) = std::env::var("OPENCODE_BINARY") { - if !value.is_empty() && Path::new(&value).exists() { - info!( - "[desktop:opencode] using binary from OPENCODE_BINARY env: {}", - value - ); - return Some(value); - } - } - - let shell_env = detect_shell_env(); - - if let Some(ref binary) = shell_env.opencode_binary { - if Path::new(binary).exists() { - info!( - "[desktop:opencode] using binary from shell OPENCODE_BINARY: {}", - binary - ); - return Some(binary.clone()); - } - } - - if let Some(ref login_path) = shell_env.path { - for dir in login_path.split(':') { - let candidate = format!("{}/opencode", dir); - if Path::new(&candidate).exists() { - info!("[desktop:opencode] found binary in PATH: {}", candidate); - return Some(candidate); - } - } - } - - if let Some(home) = dirs::home_dir() { - let fallback = home.join(".opencode/bin/opencode"); - if fallback.exists() { - info!( - "[desktop:opencode] found binary in fallback location: {:?}", - fallback - ); - return Some(fallback.to_string_lossy().to_string()); - } - } - - warn!("[desktop:opencode] opencode binary not found"); - None -} - -fn build_augmented_env() -> HashMap { - let mut env: HashMap = std::env::vars().collect(); - if let Ok(login_path) = detect_login_shell_path() { - let current = env.get("PATH").cloned().unwrap_or_default(); - env.insert("PATH".to_string(), merge_paths(&login_path, ¤t)); - } - env -} - -fn merge_paths(login_path: &str, current: &str) -> String { - let mut segments = Vec::new(); - let mut seen = std::collections::HashSet::new(); - - for part in login_path.split(':').chain(current.split(':')) { - if part.is_empty() || seen.contains(part) { - continue; - } - seen.insert(part.to_string()); - segments.push(part); - } - - segments.join(":") -} - -#[derive(Default)] -struct ShellEnv { - path: Option, - opencode_binary: Option, -} - -#[cfg(target_os = "macos")] -fn get_user_shell() -> Option { - use std::process::Command; - - let username = - dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?; - - let output = Command::new("dscl") - .args([".", "-read", &format!("/Users/{}", username), "UserShell"]) - .output() - .ok()?; - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.split(':').nth(1).map(|s| s.trim().to_string()) - } else { - None - } -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn get_user_shell() -> Option { - std::env::var("SHELL").ok() -} - -#[cfg(not(unix))] -fn get_user_shell() -> Option { - None -} - -fn build_shell_env_command(shell: &str) -> Vec { - let shell_name = std::path::Path::new(shell) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("sh"); - - match shell_name { - "nu" | "nushell" => vec![ - "-l".to_string(), - "-i".to_string(), - "-c".to_string(), - "echo $\"__PATH__=($env.PATH | str join (char esep))\"; echo $\"__OPENCODE_BINARY__=($env.OPENCODE_BINARY? | default '')\"".to_string(), - ], - "bash" => vec![ - "-lic".to_string(), - "source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"; echo \"__OPENCODE_BINARY__=$OPENCODE_BINARY\"".to_string(), - ], - _ => vec![ - "-lic".to_string(), - "echo \"__PATH__=$PATH\"; echo \"__OPENCODE_BINARY__=$OPENCODE_BINARY\"".to_string(), - ], - } -} - -fn detect_shell_env() -> ShellEnv { - #[cfg(not(unix))] - { - ShellEnv::default() - } - #[cfg(unix)] - { - use std::process::Command; - - let shell = get_user_shell().unwrap_or_else(|| "/bin/zsh".into()); - info!("[desktop:opencode] detected user shell: {}", shell); - let args = build_shell_env_command(&shell); - info!("[desktop:opencode] shell args: {:?}", args); - - let output = match Command::new(&shell).args(&args).output() { - Ok(o) => o, - Err(e) => { - warn!("[desktop:opencode] failed to run shell {}: {}", shell, e); - return ShellEnv::default(); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - warn!( - "[desktop:opencode] shell env detection failed for {}, stderr: {}", - shell, stderr - ); - return ShellEnv::default(); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - info!("[desktop:opencode] shell stdout length: {}", stdout.len()); - let mut env = ShellEnv::default(); - - for line in stdout.lines() { - if let Some(path) = line.strip_prefix("__PATH__=") { - if !path.is_empty() { - env.path = Some(path.to_string()); - } - } else if let Some(binary) = line.strip_prefix("__OPENCODE_BINARY__=") { - if !binary.is_empty() { - env.opencode_binary = Some(binary.to_string()); - } - } - } - - info!( - "[desktop:opencode] parsed path exists: {}", - env.path.is_some() - ); - env - } -} - -fn detect_login_shell_path() -> Result { - detect_shell_env() - .path - .ok_or_else(|| anyhow!("shell PATH detection failed")) -} diff --git a/packages/desktop/src-tauri/src/path_utils.rs b/packages/desktop/src-tauri/src/path_utils.rs deleted file mode 100644 index 13ea7f22..00000000 --- a/packages/desktop/src-tauri/src/path_utils.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::path::PathBuf; - -pub fn expand_tilde_path(value: &str) -> PathBuf { - let trimmed = value.trim(); - if trimmed.is_empty() { - return PathBuf::from(trimmed); - } - - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); - - if trimmed == "~" { - return home; - } - - if trimmed.starts_with("~/") || trimmed.starts_with("~\\") { - return home.join(&trimmed[2..]); - } - - PathBuf::from(trimmed) -} diff --git a/packages/desktop/src-tauri/src/quota_providers.rs b/packages/desktop/src-tauri/src/quota_providers.rs deleted file mode 100644 index a6118fb1..00000000 --- a/packages/desktop/src-tauri/src/quota_providers.rs +++ /dev/null @@ -1,930 +0,0 @@ -use anyhow::{anyhow, Result}; -use chrono::{DateTime, Local, TimeZone}; -use log::warn; -use reqwest::Client; -use serde::Serialize; -use serde_json::Value; -use std::{ - collections::{HashMap, HashSet}, - path::PathBuf, - time::Duration, -}; - -use crate::opencode_auth; - -const OPENCODE_CONFIG_DIR: &str = ".config/opencode"; -const OPENCODE_DATA_DIR: &str = ".local/share/opencode"; - -const GOOGLE_CLIENT_ID: &str = - "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; -const GOOGLE_CLIENT_SECRET: &str = "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"; -const DEFAULT_PROJECT_ID: &str = "rising-fact-p41fc"; -const GOOGLE_WINDOW_SECONDS: i64 = 5 * 60 * 60; - -const GOOGLE_ENDPOINTS: [&str; 3] = [ - "https://daily-cloudcode-pa.sandbox.googleapis.com", - "https://autopush-cloudcode-pa.sandbox.googleapis.com", - "https://cloudcode-pa.googleapis.com", -]; - -const GOOGLE_USER_AGENT: &str = "antigravity/1.11.5 windows/amd64"; -const GOOGLE_API_CLIENT: &str = "google-cloud-sdk vscode_cloudshelleditor/0.1"; -const GOOGLE_CLIENT_METADATA: &str = - "{\"ideType\":\"IDE_UNSPECIFIED\",\"platform\":\"PLATFORM_UNSPECIFIED\",\"pluginType\":\"GEMINI\"}"; - -#[derive(Clone, Debug, Default)] -struct AuthEntry { - token: Option, - access: Option, - refresh: Option, - expires: Option, - key: Option, -} - -#[derive(Clone, Debug, Default)] -struct GoogleAuth { - access_token: Option, - refresh_token: Option, - expires: Option, - project_id: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProviderResult { - provider_id: String, - provider_name: String, - ok: bool, - configured: bool, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - usage: Option, - fetched_at: i64, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ProviderUsage { - windows: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - models: Option>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct UsageWindow { - used_percent: Option, - remaining_percent: Option, - window_seconds: Option, - reset_after_seconds: Option, - reset_at: Option, - reset_at_formatted: Option, - reset_after_formatted: Option, -} - -fn get_home_dir() -> PathBuf { - dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) -} - -fn opencode_config_dir() -> PathBuf { - get_home_dir().join(OPENCODE_CONFIG_DIR) -} - -fn opencode_data_dir() -> PathBuf { - get_home_dir().join(OPENCODE_DATA_DIR) -} - -fn antigravity_accounts_paths() -> [PathBuf; 2] { - [ - opencode_config_dir().join("antigravity-accounts.json"), - opencode_data_dir().join("antigravity-accounts.json"), - ] -} - -async fn read_json_file(path: &PathBuf) -> Option { - if !path.exists() { - return None; - } - let raw = tokio::fs::read_to_string(path).await.ok()?; - let trimmed = raw.trim(); - if trimmed.is_empty() { - return None; - } - serde_json::from_str(trimmed).map_err(|err| { - warn!("Failed to read JSON file {}: {}", path.display(), err); - err - }).ok() -} - -fn get_auth_entry<'a>(auth: &'a serde_json::Map, aliases: &[&str]) -> Option<&'a Value> { - for alias in aliases { - if let Some(value) = auth.get(*alias) { - return Some(value); - } - } - None -} - -fn normalize_auth_entry(value: Option<&Value>) -> Option { - let value = value?; - match value { - Value::String(token) => Some(AuthEntry { - token: Some(token.clone()), - ..AuthEntry::default() - }), - Value::Object(map) => { - let token = map.get("token").and_then(|v| v.as_str()).map(|s| s.to_string()); - let access = map.get("access").and_then(|v| v.as_str()).map(|s| s.to_string()); - let refresh = map.get("refresh").and_then(|v| v.as_str()).map(|s| s.to_string()); - let key = map.get("key").and_then(|v| v.as_str()).map(|s| s.to_string()); - let expires = map - .get("expires") - .and_then(|v| v.as_i64()) - .or_else(|| map.get("expires").and_then(|v| v.as_f64()).map(|v| v.round() as i64)); - - Some(AuthEntry { - token, - access, - refresh, - expires, - key, - }) - } - _ => None, - } -} - -fn format_reset_time(timestamp_ms: i64) -> Option { - let reset_dt = Local.timestamp_millis_opt(timestamp_ms).single()?; - let now = Local::now(); - let is_today = reset_dt.date_naive() == now.date_naive(); - - if is_today { - // Same day: show time only (e.g., "9:56 PM") - Some(reset_dt.format("%-I:%M %p").to_string()) - } else { - // Different day: show date + weekday + time (e.g., "Feb 2, Sun 9:56 PM") - Some(reset_dt.format("%b %-d, %a %-I:%M %p").to_string()) - } -} - -fn calculate_reset_after_seconds(reset_at: Option) -> Option { - let reset_at = reset_at?; - let now_ms = chrono::Utc::now().timestamp_millis(); - let delta = (reset_at - now_ms) / 1000; - Some(delta.max(0)) -} - -fn to_usage_window(used_percent: Option, window_seconds: Option, reset_at: Option) -> UsageWindow { - let remaining_percent = used_percent.map(|value| (100.0 - value).max(0.0)); - let reset_after_seconds = calculate_reset_after_seconds(reset_at); - let reset_formatted = reset_at.and_then(format_reset_time); - - UsageWindow { - used_percent, - remaining_percent, - window_seconds, - reset_after_seconds, - reset_at, - reset_at_formatted: reset_formatted.clone(), - reset_after_formatted: reset_formatted, - } -} - -fn build_result( - provider_id: &str, - provider_name: &str, - ok: bool, - configured: bool, - usage: Option, - error: Option, -) -> ProviderResult { - ProviderResult { - provider_id: provider_id.to_string(), - provider_name: provider_name.to_string(), - ok, - configured, - error, - usage, - fetched_at: chrono::Utc::now().timestamp_millis(), - } -} - -async fn load_auth_map() -> Result> { - let auth = opencode_auth::read_auth().await?; - auth.as_object() - .cloned() - .ok_or_else(|| anyhow!("Auth file is not a valid JSON object")) -} - -async fn has_antigravity_accounts() -> bool { - for path in antigravity_accounts_paths() { - if let Some(data) = read_json_file(&path).await { - if data - .get("accounts") - .and_then(|value| value.as_array()) - .is_some_and(|accounts| !accounts.is_empty()) - { - return true; - } - } - } - false -} - -pub async fn list_configured_quota_providers() -> Result> { - let auth = load_auth_map().await?; - let mut configured: HashSet = HashSet::new(); - - let openai_auth = normalize_auth_entry(get_auth_entry(&auth, &["openai", "codex", "chatgpt"])); - if let Some(entry) = openai_auth { - if entry.access.is_some() || entry.token.is_some() { - configured.insert("openai".to_string()); - } - } - - let google_auth = normalize_auth_entry(get_auth_entry(&auth, &["google", "antigravity"])); - if let Some(entry) = google_auth { - if entry.access.is_some() || entry.token.is_some() || entry.refresh.is_some() { - configured.insert("google".to_string()); - } - } - - let zai_auth = - normalize_auth_entry(get_auth_entry(&auth, &["zai-coding-plan", "zai", "z.ai"])); - if let Some(entry) = zai_auth { - if entry.key.is_some() || entry.token.is_some() { - configured.insert("zai-coding-plan".to_string()); - } - } - - let github_copilot_auth = - normalize_auth_entry(get_auth_entry(&auth, &["github-copilot"])); - if let Some(entry) = github_copilot_auth { - if entry.access.is_some() || entry.token.is_some() { - configured.insert("github-copilot".to_string()); - } - } - - if has_antigravity_accounts().await { - configured.insert("google".to_string()); - } - - Ok(configured.into_iter().collect()) -} - -fn parse_number(value: Option<&Value>) -> Option { - let value = value?; - value.as_f64().or_else(|| value.as_i64().map(|v| v as f64)) -} - -async fn fetch_openai_quota(client: &Client) -> Result { - let auth = load_auth_map().await?; - let entry = normalize_auth_entry(get_auth_entry(&auth, &["openai", "codex", "chatgpt"])); - let access_token = entry - .as_ref() - .and_then(|entry| entry.access.clone().or(entry.token.clone())); - - let Some(access_token) = access_token else { - return Ok(build_result( - "openai", - "OpenAI", - false, - false, - None, - Some("Not configured".to_string()), - )); - }; - - let response = client - .get("https://chatgpt.com/backend-api/wham/usage") - .bearer_auth(access_token) - .header("Content-Type", "application/json") - .send() - .await; - - let response = match response { - Ok(resp) => resp, - Err(err) => { - return Ok(build_result( - "openai", - "OpenAI", - false, - true, - None, - Some(err.to_string()), - )) - } - }; - - if !response.status().is_success() { - return Ok(build_result( - "openai", - "OpenAI", - false, - true, - None, - Some(format!("API error: {}", response.status().as_u16())), - )); - } - - let payload: Value = match response.json().await { - Ok(value) => value, - Err(err) => { - return Ok(build_result( - "openai", - "OpenAI", - false, - true, - None, - Some(err.to_string()), - )) - } - }; - - let primary = payload - .get("rate_limit") - .and_then(|value| value.get("primary_window")); - let secondary = payload - .get("rate_limit") - .and_then(|value| value.get("secondary_window")); - - let mut windows: HashMap = HashMap::new(); - - if let Some(primary) = primary { - let used_percent = parse_number(primary.get("used_percent")); - let window_seconds = primary - .get("limit_window_seconds") - .and_then(|value| value.as_i64()); - let reset_at = primary - .get("reset_at") - .and_then(|value| value.as_i64()) - .map(|value| value * 1000); - windows.insert( - "5h".to_string(), - to_usage_window(used_percent, window_seconds, reset_at), - ); - } - - if let Some(secondary) = secondary { - let used_percent = parse_number(secondary.get("used_percent")); - let window_seconds = secondary - .get("limit_window_seconds") - .and_then(|value| value.as_i64()); - let reset_at = secondary - .get("reset_at") - .and_then(|value| value.as_i64()) - .map(|value| value * 1000); - windows.insert( - "weekly".to_string(), - to_usage_window(used_percent, window_seconds, reset_at), - ); - } - - Ok(build_result( - "openai", - "OpenAI", - true, - true, - Some(ProviderUsage { - windows, - models: None, - }), - None, - )) -} - -async fn resolve_google_auth() -> Result> { - let auth = load_auth_map().await?; - let entry = normalize_auth_entry(get_auth_entry(&auth, &["google", "antigravity"])); - - if let Some(entry) = entry { - let mut refresh = entry.refresh.clone(); - let mut project_id = None; - if let Some(value) = entry.refresh.clone() { - if let Some((first, second)) = value.split_once('|') { - refresh = Some(first.to_string()); - project_id = Some(second.to_string()); - } - } - return Ok(Some(GoogleAuth { - access_token: entry.access.or(entry.token), - refresh_token: refresh, - expires: entry.expires, - project_id, - })); - } - - for path in antigravity_accounts_paths() { - let data = match read_json_file(&path).await { - Some(data) => data, - None => continue, - }; - let accounts = data.get("accounts").and_then(|value| value.as_array()); - if let Some(accounts) = accounts { - if accounts.is_empty() { - continue; - } - let index = data - .get("activeIndex") - .and_then(|value| value.as_i64()) - .unwrap_or(0) - .max(0) as usize; - let account = accounts.get(index).or_else(|| accounts.first()); - if let Some(account) = account { - let refresh_token = account - .get("refreshToken") - .and_then(|value| value.as_str()) - .map(|value| value.to_string()); - if refresh_token.is_none() { - continue; - } - let project_id = account - .get("projectId") - .and_then(|value| value.as_str()) - .or_else(|| { - account - .get("managedProjectId") - .and_then(|value| value.as_str()) - }) - .map(|value| value.to_string()); - - return Ok(Some(GoogleAuth { - access_token: None, - refresh_token, - expires: None, - project_id, - })); - } - } - } - - Ok(None) -} - -async fn refresh_google_access_token(client: &Client, refresh_token: &str) -> Result> { - let body = format!( - "client_id={}&client_secret={}&refresh_token={}&grant_type=refresh_token", - urlencoding::encode(GOOGLE_CLIENT_ID), - urlencoding::encode(GOOGLE_CLIENT_SECRET), - urlencoding::encode(refresh_token) - ); - - let response = client - .post("https://oauth2.googleapis.com/token") - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await; - - let response = match response { - Ok(resp) => resp, - Err(err) => { - warn!("Failed to refresh Google token: {}", err); - return Ok(None); - } - }; - - if !response.status().is_success() { - return Ok(None); - } - - let payload: Value = response.json().await.unwrap_or(Value::Null); - Ok(payload - .get("access_token") - .and_then(|value| value.as_str()) - .map(|value| value.to_string())) -} - -async fn fetch_google_models(client: &Client, access_token: &str, project_id: Option<&str>) -> Option { - let body = if let Some(project_id) = project_id { - serde_json::json!({ "project": project_id }) - } else { - serde_json::json!({}) - }; - - for endpoint in GOOGLE_ENDPOINTS { - let response = client - .post(format!("{}/v1internal:fetchAvailableModels", endpoint)) - .header("Authorization", format!("Bearer {}", access_token)) - .header("Content-Type", "application/json") - .header("User-Agent", GOOGLE_USER_AGENT) - .header("X-Goog-Api-Client", GOOGLE_API_CLIENT) - .header("Client-Metadata", GOOGLE_CLIENT_METADATA) - .json(&body) - .timeout(Duration::from_secs(15)) - .send() - .await; - - let response = match response { - Ok(resp) => resp, - Err(_) => continue, - }; - - if response.status().is_success() { - if let Ok(payload) = response.json::().await { - return Some(payload); - } - } - } - - None -} - -fn parse_reset_time(value: Option<&Value>) -> Option { - let value = value?; - if let Some(num) = value.as_i64() { - if num > 0 { - return Some(num); - } - } - if let Some(text) = value.as_str() { - if let Ok(parsed) = DateTime::parse_from_rfc3339(text) { - return Some(parsed.timestamp_millis()); - } - } - None -} - -async fn fetch_google_quota(client: &Client) -> Result { - let auth = resolve_google_auth().await?; - let Some(auth) = auth else { - return Ok(build_result( - "google", - "Google", - false, - false, - None, - Some("Not configured".to_string()), - )); - }; - - let now = chrono::Utc::now().timestamp_millis(); - let mut access_token = auth.access_token; - if access_token.is_none() - || auth - .expires - .is_some_and(|expires| expires <= now) - { - let Some(refresh_token) = auth.refresh_token.as_ref() else { - return Ok(build_result( - "google", - "Google", - false, - true, - None, - Some("Missing refresh token".to_string()), - )); - }; - access_token = refresh_google_access_token(client, refresh_token).await?; - } - - let Some(access_token) = access_token else { - return Ok(build_result( - "google", - "Google", - false, - true, - None, - Some("Failed to refresh OAuth token".to_string()), - )); - }; - - let project_id = auth.project_id.unwrap_or_else(|| DEFAULT_PROJECT_ID.to_string()); - let payload = fetch_google_models(client, &access_token, Some(project_id.as_str())).await; - let Some(payload) = payload else { - return Ok(build_result( - "google", - "Google", - false, - true, - None, - Some("Failed to fetch models".to_string()), - )); - }; - - let mut models: HashMap = HashMap::new(); - if let Some(model_map) = payload.get("models").and_then(|value| value.as_object()) { - for (model_name, model_data) in model_map { - let remaining_fraction = parse_number(model_data.get("quotaInfo").and_then(|v| v.get("remainingFraction"))); - let remaining_percent = remaining_fraction.map(|value| (value * 100.0).round()); - let used_percent = remaining_percent.map(|value| (100.0 - value).max(0.0)); - let reset_at = parse_reset_time(model_data.get("quotaInfo").and_then(|v| v.get("resetTime"))); - - let mut windows = HashMap::new(); - windows.insert( - "5h".to_string(), - to_usage_window(used_percent, Some(GOOGLE_WINDOW_SECONDS), reset_at), - ); - models.insert( - model_name.to_string(), - ProviderUsage { - windows, - models: None, - }, - ); - } - } - - Ok(build_result( - "google", - "Google", - true, - true, - Some(ProviderUsage { - windows: HashMap::new(), - models: if models.is_empty() { None } else { Some(models) }, - }), - None, - )) -} - -fn normalize_timestamp(value: Option<&Value>) -> Option { - let value = value?; - if let Some(num) = value.as_i64() { - if num < 1_000_000_000_000 { - return Some(num * 1000); - } - return Some(num); - } - None -} - -fn resolve_window_seconds(limit: &Value) -> Option { - let number = limit.get("number").and_then(|value| value.as_i64())?; - let unit = limit.get("unit").and_then(|value| value.as_i64())?; - let unit_seconds = match unit { - 3 => Some(3600), - _ => None, - }?; - Some(unit_seconds * number) -} - -fn resolve_window_label(window_seconds: Option) -> String { - let Some(window_seconds) = window_seconds else { - return "tokens".to_string(); - }; - if window_seconds % 86400 == 0 { - let days = window_seconds / 86400; - if days == 7 { - return "weekly".to_string(); - } - return format!("{}d", days); - } - if window_seconds % 3600 == 0 { - return format!("{}h", window_seconds / 3600); - } - format!("{}s", window_seconds) -} - -async fn fetch_zai_quota(client: &Client) -> Result { - let auth = load_auth_map().await?; - let entry = normalize_auth_entry(get_auth_entry(&auth, &["zai-coding-plan", "zai", "z.ai"])); - let api_key = entry - .as_ref() - .and_then(|entry| entry.key.clone().or(entry.token.clone())); - - let Some(api_key) = api_key else { - return Ok(build_result( - "zai-coding-plan", - "z.ai", - false, - false, - None, - Some("Not configured".to_string()), - )); - }; - - let response = client - .get("https://api.z.ai/api/monitor/usage/quota/limit") - .bearer_auth(api_key) - .header("Content-Type", "application/json") - .send() - .await; - - let response = match response { - Ok(resp) => resp, - Err(err) => { - return Ok(build_result( - "zai-coding-plan", - "z.ai", - false, - true, - None, - Some(err.to_string()), - )) - } - }; - - if !response.status().is_success() { - return Ok(build_result( - "zai-coding-plan", - "z.ai", - false, - true, - None, - Some(format!("API error: {}", response.status().as_u16())), - )); - } - - let payload: Value = match response.json().await { - Ok(value) => value, - Err(err) => { - return Ok(build_result( - "zai-coding-plan", - "z.ai", - false, - true, - None, - Some(err.to_string()), - )) - } - }; - - let limits = payload - .get("data") - .and_then(|value| value.get("limits")) - .and_then(|value| value.as_array()) - .cloned() - .unwrap_or_default(); - let tokens_limit = limits - .iter() - .find(|limit| limit.get("type").and_then(|value| value.as_str()) == Some("TOKENS_LIMIT")); - - let mut windows = HashMap::new(); - if let Some(limit) = tokens_limit { - let window_seconds = resolve_window_seconds(limit); - let window_label = resolve_window_label(window_seconds); - let reset_at = normalize_timestamp(limit.get("nextResetTime")); - let used_percent = parse_number(limit.get("percentage")); - - windows.insert( - window_label, - to_usage_window(used_percent, window_seconds, reset_at), - ); - } - - Ok(build_result( - "zai-coding-plan", - "z.ai", - true, - true, - Some(ProviderUsage { - windows, - models: None, - }), - None, - )) -} - -async fn fetch_github_copilot_quota(client: &Client) -> Result { - let auth = load_auth_map().await?; - let entry = normalize_auth_entry(get_auth_entry(&auth, &["github-copilot"])); - let access_token = entry - .as_ref() - .and_then(|entry| entry.access.clone().or(entry.token.clone())); - - let Some(access_token) = access_token else { - return Ok(build_result( - "github-copilot", - "GitHub Copilot", - false, - false, - None, - Some("Not configured".to_string()), - )); - }; - - let response = client - .get("https://api.github.com/copilot_internal/user") - .bearer_auth(access_token) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "OpenChamber") - .send() - .await; - - let response = match response { - Ok(resp) => resp, - Err(err) => { - return Ok(build_result( - "github-copilot", - "GitHub Copilot", - false, - true, - None, - Some(err.to_string()), - )) - } - }; - - if !response.status().is_success() { - return Ok(build_result( - "github-copilot", - "GitHub Copilot", - false, - true, - None, - Some(format!("API error: {}", response.status().as_u16())), - )); - } - - let payload: Value = match response.json().await { - Ok(value) => value, - Err(err) => { - return Ok(build_result( - "github-copilot", - "GitHub Copilot", - false, - true, - None, - Some(err.to_string()), - )) - } - }; - - // Parse reset date - let mut reset_at: Option = None; - let reset_date_utc = payload - .get("quota_reset_date_utc") - .and_then(|v| v.as_str()); - let reset_date = payload - .get("quota_reset_date") - .and_then(|v| v.as_str()); - - if let Some(date_str) = reset_date_utc { - if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) { - reset_at = Some(dt.timestamp_millis()); - } - } else if let Some(date_str) = reset_date { - // Use the date as UTC midnight - let full_date = format!("{}T00:00:00Z", date_str); - if let Ok(dt) = DateTime::parse_from_rfc3339(&full_date) { - reset_at = Some(dt.timestamp_millis()); - } - } - - let mut windows: HashMap = HashMap::new(); - - // Get premium_interactions snapshot - if let Some(snapshots) = payload.get("quota_snapshots") { - if let Some(premium) = snapshots.get("premium_interactions") { - let mut used_percent: Option = None; - - let unlimited = premium - .get("unlimited") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if !unlimited { - if let Some(percent_remaining) = premium.get("percent_remaining").and_then(|v| v.as_f64()) { - used_percent = Some(100.0 - percent_remaining); - } else if let Some(entitlement) = premium.get("entitlement").and_then(|v| v.as_f64()) { - if entitlement > 0.0 { - let remaining = premium - .get("remaining") - .and_then(|v| v.as_f64()) - .or_else(|| premium.get("quota_remaining").and_then(|v| v.as_f64())); - - if let Some(rem) = remaining { - used_percent = Some(((entitlement - rem) / entitlement) * 100.0); - } - } - } - } - - windows.insert( - "premium_interactions".to_string(), - to_usage_window(used_percent, None, reset_at), - ); - } - } - - Ok(build_result( - "github-copilot", - "GitHub Copilot", - true, - true, - Some(ProviderUsage { - windows, - models: None, - }), - None, - )) -} - -pub async fn fetch_quota_for_provider(client: &Client, provider_id: &str) -> Result { - match provider_id { - "openai" => fetch_openai_quota(client).await, - "google" => fetch_google_quota(client).await, - "zai-coding-plan" => fetch_zai_quota(client).await, - "github-copilot" => fetch_github_copilot_quota(client).await, - _ => Ok(build_result( - provider_id, - provider_id, - false, - false, - None, - Some("Unsupported provider".to_string()), - )), - } -} diff --git a/packages/desktop/src-tauri/src/session_activity.rs b/packages/desktop/src-tauri/src/session_activity.rs deleted file mode 100644 index e319509b..00000000 --- a/packages/desktop/src-tauri/src/session_activity.rs +++ /dev/null @@ -1,527 +0,0 @@ -use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; - -use anyhow::Result; -use futures_util::TryStreamExt; -use log::{debug, info, warn}; -use reqwest::Client; -use serde::Deserialize; -use serde_json::Value; -use tauri::{AppHandle, Emitter}; -use tokio::sync::Mutex; -use tokio_util::io::StreamReader; - -use crate::path_utils::expand_tilde_path; -use crate::DesktopRuntime; - -#[derive(Deserialize)] -struct EventEnvelope { - #[serde(rename = "type")] - event_type: String, - #[serde(default)] - properties: Value, -} - -#[derive(Deserialize)] -struct MultiplexedEventEnvelope { - #[serde(default)] - directory: Option, - payload: EventEnvelope, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum ActivityPhase { - Idle, - Busy, - Cooldown, -} - -#[derive(Clone, Debug)] -enum SseScope { - Global, - Directory(std::path::PathBuf), -} - -pub fn spawn_session_activity_tracker( - app: AppHandle, - runtime: DesktopRuntime, -) -> tauri::async_runtime::JoinHandle<()> { - tauri::async_runtime::spawn(async move { - let client = Client::builder() - .timeout(Duration::from_secs(24 * 60 * 60)) - .tcp_keepalive(Some(Duration::from_secs(30))) - .build() - .expect("failed to build reqwest client"); - - let mut shutdown_rx = runtime.subscribe_shutdown(); - let phases = Arc::new(Mutex::new(HashMap::::new())); - let cooldowns = Arc::new(Mutex::new(HashMap::< - String, - tauri::async_runtime::JoinHandle<()>, - >::new())); - - loop { - tokio::select! { - _ = shutdown_rx.recv() => { - info!("[desktop:activity] Shutdown received, stopping SSE listener"); - break; - } - _ = async { - // Reset stale phases to idle before connecting so UI doesn't stay stuck on "working" after wake. - reset_and_emit_all_phases(&app, phases.clone(), cooldowns.clone()).await; - - if let Err(err) = run_once(&app, &runtime, &client, phases.clone(), cooldowns.clone()).await { - warn!("[desktop:activity] SSE loop error: {err:?}"); - } - tokio::time::sleep(Duration::from_secs(2)).await; - } => {} - } - } - }) -} - -async fn run_once( - app: &AppHandle, - runtime: &DesktopRuntime, - client: &Client, - phases: Arc>>, - cooldowns: Arc>>>, -) -> Result<()> { - let opencode = runtime.opencode_manager(); - - let port = match opencode.current_port() { - Some(port) => port, - None => { - warn!("[desktop:activity] OpenCode port unavailable; will retry"); - tokio::time::sleep(Duration::from_secs(2)).await; - return Ok(()); - } - }; - - let prefix = opencode.api_prefix(); - let base = format!("http://127.0.0.1:{port}{prefix}"); - let (response, scope) = connect_activity_sse(runtime, client, &base).await?; - - use tokio::io::AsyncBufReadExt; - - let stream = response - .bytes_stream() - .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)); - let mut reader = StreamReader::new(stream); - let mut buf = Vec::new(); - let mut data_lines: Vec = Vec::new(); - - loop { - buf.clear(); - let bytes_read = match tokio::time::timeout( - Duration::from_secs(2), - reader.read_until(b'\n', &mut buf), - ) - .await - { - Ok(Ok(n)) => n, - Ok(Err(err)) => { - warn!("[desktop:activity] Read error in SSE stream: {err:?}"); - return Err(err.into()); - } - Err(_) => { - // No data received recently; if we are connected to a directory-scoped stream and the working directory - // has changed, reconnect so activity tracking follows the new directory. - if let SseScope::Directory(connected_dir) = &scope { - if let Some(current_dir) = - resolve_project_directory_from_settings(runtime).await - { - if current_dir != *connected_dir { - debug!( - "[desktop:activity] Project directory changed; reconnecting activity SSE (from {:?} to {:?})", - connected_dir, current_dir - ); - return Ok(()); - } - } - } - continue; - } - }; - if bytes_read == 0 { - break; - } - - let line = match std::str::from_utf8(&buf) { - Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(), - Err(err) => { - warn!("[desktop:activity] Non-UTF8 SSE chunk: {err}"); - continue; - } - }; - - if line.is_empty() { - if data_lines.is_empty() { - continue; - } - let raw = data_lines.join("\n"); - data_lines.clear(); - - match parse_event_envelope(&raw) { - Ok((event, _directory)) => { - handle_event(app, event, phases.clone(), cooldowns.clone()).await - } - Err(err) => warn!("[desktop:activity] Failed to parse SSE data: {err}; raw={raw}"), - }; - continue; - } - - if let Some(rest) = line.strip_prefix("data:") { - data_lines.push(rest.trim_start().to_string()); - } - } - - Ok(()) -} - -fn parse_event_envelope(raw: &str) -> Result<(EventEnvelope, Option)> { - if let Ok(event) = serde_json::from_str::(raw) { - return Ok((event, None)); - } - - let multiplexed = serde_json::from_str::(raw)?; - Ok((multiplexed.payload, multiplexed.directory)) -} - -async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option { - let settings = runtime.settings().load().await.ok()?; - - if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) { - if let Some(projects) = settings.get("projects").and_then(Value::as_array) { - if let Some(path) = projects.iter().find_map(|entry| { - let id = entry.get("id").and_then(Value::as_str)?; - if id != active_id { - return None; - } - entry.get("path").and_then(Value::as_str) - }) { - return Some(expand_tilde_path(path)); - } - } - } - - settings - .get("lastDirectory") - .and_then(Value::as_str) - .map(expand_tilde_path) -} - -async fn connect_activity_sse( - runtime: &DesktopRuntime, - client: &Client, - base: &str, -) -> Result<(reqwest::Response, SseScope)> { - let global_url = format!("{base}/global/event"); - match try_connect_sse(client, &global_url, "[desktop:activity]").await { - Ok(response) => { - debug!("[desktop:activity] Using SSE endpoint: {global_url}"); - return Ok((response, SseScope::Global)); - } - Err(err) => { - debug!( - "[desktop:activity] SSE endpoint unavailable: {global_url} ({err:?}); falling back" - ); - } - } - - let event_url = format!("{base}/event"); - match try_connect_sse(client, &event_url, "[desktop:activity]").await { - Ok(response) => { - debug!("[desktop:activity] Using SSE endpoint: {event_url}"); - return Ok((response, SseScope::Global)); - } - Err(err) => { - debug!( - "[desktop:activity] SSE endpoint unavailable: {event_url} ({err:?}); falling back" - ); - } - } - - let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else { - anyhow::bail!("No project directory available for SSE fallback"); - }; - let directory = working_dir.to_string_lossy().to_string(); - let mut parsed = reqwest::Url::parse(&event_url)?; - parsed - .query_pairs_mut() - .append_pair("directory", &directory); - let directory_url = parsed.to_string(); - - let response = try_connect_sse(client, &directory_url, "[desktop:activity]").await?; - debug!("[desktop:activity] Using directory-scoped SSE endpoint: {directory_url}"); - Ok((response, SseScope::Directory(working_dir))) -} - -async fn try_connect_sse( - client: &Client, - url: &str, - log_prefix: &str, -) -> Result { - debug!("{log_prefix} Connecting SSE: {url}"); - - let response = client - .get(url) - .header("accept", "text/event-stream") - .header("accept-encoding", "identity") - .send() - .await?; - - debug!( - "{log_prefix} SSE response status={} headers={:?}", - response.status(), - response.headers() - ); - - if !response.status().is_success() { - anyhow::bail!("SSE connect failed with status {}", response.status()); - } - - Ok(response) -} - -async fn handle_event( - app: &AppHandle, - event: EventEnvelope, - phases: Arc>>, - cooldowns: Arc>>>, -) { - match event.event_type.as_str() { - "session.status" => { - let session_id = event - .properties - .get("sessionID") - .and_then(Value::as_str) - .map(|s| s.to_string()); - let status = event - .properties - .get("status") - .and_then(|s| s.get("type")) - .and_then(Value::as_str); - - if let (Some(id), Some(status_type)) = (session_id, status) { - let phase = if status_type == "busy" || status_type == "retry" { - ActivityPhase::Busy - } else { - ActivityPhase::Idle - }; - set_phase(app, &id, phase, phases.clone(), cooldowns.clone()).await; - } - } - "session.idle" => { - let session_id = event - .properties - .get("sessionID") - .and_then(Value::as_str) - .map(|s| s.to_string()); - if let Some(id) = session_id { - set_phase( - app, - &id, - ActivityPhase::Idle, - phases.clone(), - cooldowns.clone(), - ) - .await; - } - } - "message.updated" => { - if let Some(info) = event.properties.get("info") { - let role = info.get("role").and_then(Value::as_str).unwrap_or_default(); - if role != "assistant" { - return; - } - - let finish = info.get("finish").and_then(Value::as_str); - if finish != Some("stop") { - return; - } - - let session_id = info - .get("sessionID") - .and_then(Value::as_str) - .map(|s| s.to_string()); - - if let Some(id) = session_id { - enter_cooldown_if_busy(app, &id, phases.clone(), cooldowns.clone()).await; - } - } - } - "message.part.updated" => { - let Some(info) = event.properties.get("info") else { - return; - }; - - let role = info.get("role").and_then(Value::as_str).unwrap_or_default(); - if role != "assistant" { - return; - } - - let session_id = info - .get("sessionID") - .and_then(Value::as_str) - .map(|s| s.to_string()); - - let Some(id) = session_id else { - return; - }; - - // Mark session busy when we see assistant parts streaming (covers cases where session.status is missing). - if is_streaming_assistant_part(&event.properties) { - set_phase( - app, - &id, - ActivityPhase::Busy, - phases.clone(), - cooldowns.clone(), - ) - .await; - } - - // Derive cooldown from info.finish === 'stop' when present. - if has_finish_stop(info) { - enter_cooldown_if_busy(app, &id, phases.clone(), cooldowns.clone()).await; - } - } - _ => {} - } -} - -fn is_streaming_assistant_part(properties: &Value) -> bool { - let Some(part) = properties.get("part") else { - return false; - }; - let part_type = part.get("type").and_then(Value::as_str).unwrap_or_default(); - matches!( - part_type, - "step-start" | "text" | "tool" | "reasoning" | "file" | "patch" - ) -} - -fn has_finish_stop(info: &Value) -> bool { - info.get("finish").and_then(Value::as_str) == Some("stop") -} - -async fn enter_cooldown_if_busy( - app: &AppHandle, - session_id: &str, - phases: Arc>>, - cooldowns: Arc>>>, -) { - let current = { phases.lock().await.get(session_id).cloned() }; - if !matches!(current, Some(ActivityPhase::Busy)) { - return; - } - - set_phase( - app, - session_id, - ActivityPhase::Cooldown, - phases.clone(), - cooldowns.clone(), - ) - .await; - - let app_clone = app.clone(); - let phases_clone = phases.clone(); - let cooldowns_clone = cooldowns.clone(); - let id_clone = session_id.to_string(); - let handle = tauri::async_runtime::spawn(async move { - tokio::time::sleep(Duration::from_secs(2)).await; - let current = { phases_clone.lock().await.get(&id_clone).cloned() }; - if matches!(current, Some(ActivityPhase::Cooldown)) { - set_phase( - &app_clone, - &id_clone, - ActivityPhase::Idle, - phases_clone, - cooldowns_clone, - ) - .await; - } - }); - - let mut cd = cooldowns.lock().await; - if let Some(prev) = cd.remove(session_id) { - prev.abort(); - } - cd.insert(session_id.to_string(), handle); -} - -async fn set_phase( - app: &AppHandle, - session_id: &str, - phase: ActivityPhase, - phases: Arc>>, - cooldowns: Arc>>>, -) { - { - let mut map = phases.lock().await; - let current = map.get(session_id); - if current == Some(&phase) { - return; - } - map.insert(session_id.to_string(), phase.clone()); - - // Cancel cooldown timer when leaving cooldown - if !matches!(phase, ActivityPhase::Cooldown) { - if let Some(handle) = cooldowns.lock().await.remove(session_id) { - handle.abort(); - } - } - } - - // Emit to webview so UI stays in sync - let payload = serde_json::json!({ - "sessionId": session_id, - "phase": match phase { - ActivityPhase::Idle => "idle", - ActivityPhase::Busy => "busy", - ActivityPhase::Cooldown => "cooldown", - } - }); - - let _ = app.emit("openchamber:session-activity", payload); -} - -async fn reset_and_emit_all_phases( - app: &AppHandle, - phases: Arc>>, - cooldowns: Arc>>>, -) { - // Cancel any cooldown timers and set all phases to idle to avoid stale "busy" after wake. - { - let mut cd = cooldowns.lock().await; - for handle in cd.values() { - handle.abort(); - } - cd.clear(); - } - - let snapshot = { - let mut guard = phases.lock().await; - for value in guard.values_mut() { - *value = ActivityPhase::Idle; - } - guard.clone() - }; - - if snapshot.is_empty() { - return; - } - - for (session_id, phase) in snapshot { - let payload = serde_json::json!({ - "sessionId": session_id, - "phase": match phase { - ActivityPhase::Idle => "idle", - ActivityPhase::Busy => "busy", - ActivityPhase::Cooldown => "cooldown", - } - }); - let _ = app.emit("openchamber:session-activity", payload); - } -} diff --git a/packages/desktop/src-tauri/src/skills_catalog.rs b/packages/desktop/src-tauri/src/skills_catalog.rs deleted file mode 100644 index 54f1ded0..00000000 --- a/packages/desktop/src-tauri/src/skills_catalog.rs +++ /dev/null @@ -1,2041 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use once_cell::sync::Lazy; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; -use tokio::process::Command; -use tokio::sync::Mutex; -use uuid::Uuid; - -use crate::opencode_config; - -static SKILL_NAME_RE: Lazy = - Lazy::new(|| Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").expect("valid skill name regex")); - -static AUTH_ERROR_RE: Lazy = Lazy::new(|| { - Regex::new(r"(?i)(permission denied|publickey|could not read from remote repository|authentication failed)") - .expect("valid auth error regex") -}); - -const CACHE_TTL: Duration = Duration::from_secs(30 * 60); - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillsCatalogSource { - pub id: String, - pub label: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_subpath: Option, - - #[serde(skip_serializing)] - pub git_identity_id: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillsCatalogInstalledBadge { - pub is_installed: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub scope: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ClawdHubSkillMetadata { - pub slug: String, - pub version: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub display_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub owner: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub downloads: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stars: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillsCatalogItem { - pub source_id: String, - pub repo_source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub repo_subpath: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub git_identity_id: Option, - pub skill_dir: String, - pub skill_name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub frontmatter_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - pub installable: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub warnings: Option>, - pub installed: SkillsCatalogInstalledBadge, - #[serde(skip_serializing_if = "Option::is_none")] - pub clawdhub: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillsCatalogResponse { - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub sources: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub items_by_source: Option>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillsRepoScanResponse { - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub items: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillsInstallResponse { - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub installed: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub skipped: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct InstalledSkill { - pub skill_name: String, - pub scope: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkippedSkill { - pub skill_name: String, - pub reason: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillConflict { - pub skill_name: String, - pub scope: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct IdentitySummary { - pub id: String, - pub name: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillsRepoError { - pub kind: String, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub ssh_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub identities: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub conflicts: Option>, -} - -#[derive(Debug, Clone)] -struct RepoParsed { - normalized_repo: String, - clone_https: String, - clone_ssh: String, - effective_subpath: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct GitIdentityWrapper { - profiles: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct GitIdentityProfile { - id: String, - name: String, - #[serde(default)] - ssh_key: Option, -} - -fn identities_storage_path() -> Result { - let mut path = dirs::home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?; - path.push(".config"); - path.push("openchamber"); - path.push("git-identities.json"); - Ok(path) -} - -fn list_identities() -> Vec { - let Ok(path) = identities_storage_path() else { - return vec![]; - }; - - let Ok(content) = std::fs::read_to_string(path) else { - return vec![]; - }; - - let Ok(wrapper) = serde_json::from_str::(&content) else { - return vec![]; - }; - - wrapper - .profiles - .into_iter() - .map(|p| IdentitySummary { - id: p.id, - name: p.name, - }) - .collect() -} - -fn resolve_identity_ssh_key(identity_id: Option<&str>) -> Option { - let id = identity_id?.trim(); - if id.is_empty() { - return None; - } - - let path = identities_storage_path().ok()?; - let content = std::fs::read_to_string(path).ok()?; - let wrapper = serde_json::from_str::(&content).ok()?; - - wrapper - .profiles - .into_iter() - .find(|p| p.id == id) - .and_then(|p| p.ssh_key) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) -} - -fn parse_repo_source(source: &str, subpath: Option<&str>) -> Result { - let raw = source.trim(); - if raw.is_empty() { - return Err(anyhow!("Repository source is required")); - } - - let explicit_subpath = subpath - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - - // SSH URL - let ssh_re = Regex::new(r"^git@github\.com:([^/\s]+)/([^\s#]+)$").unwrap(); - if let Some(caps) = ssh_re.captures(raw) { - let owner = caps.get(1).unwrap().as_str(); - let repo = caps.get(2).unwrap().as_str().trim_end_matches(".git"); - return Ok(RepoParsed { - normalized_repo: format!("{}/{}", owner, repo), - clone_https: format!("https://github.com/{}/{}.git", owner, repo), - clone_ssh: format!("git@github.com:{}/{}.git", owner, repo), - effective_subpath: explicit_subpath, - }); - } - - // HTTPS URL - let https_re = Regex::new(r"^https?://github\.com/([^/\s]+)/([^\s#]+)$").unwrap(); - if let Some(caps) = https_re.captures(raw) { - let owner = caps.get(1).unwrap().as_str(); - let repo = caps.get(2).unwrap().as_str().trim_end_matches(".git"); - return Ok(RepoParsed { - normalized_repo: format!("{}/{}", owner, repo), - clone_https: format!("https://github.com/{}/{}.git", owner, repo), - clone_ssh: format!("git@github.com:{}/{}.git", owner, repo), - effective_subpath: explicit_subpath, - }); - } - - // Shorthand owner/repo[/subpath] - let shorthand_re = Regex::new(r"^([^/\s]+)/([^/\s]+)(?:/(.+))?$").unwrap(); - if let Some(caps) = shorthand_re.captures(raw) { - let owner = caps.get(1).unwrap().as_str(); - let repo = caps.get(2).unwrap().as_str().trim_end_matches(".git"); - let shorthand_subpath = caps - .get(3) - .map(|m| m.as_str().trim().to_string()) - .filter(|s| !s.is_empty()); - - return Ok(RepoParsed { - normalized_repo: format!("{}/{}", owner, repo), - clone_https: format!("https://github.com/{}/{}.git", owner, repo), - clone_ssh: format!("git@github.com:{}/{}.git", owner, repo), - effective_subpath: explicit_subpath.or(shorthand_subpath), - }); - } - - Err(anyhow!("Unsupported repository source format")) -} - -fn validate_skill_name(name: &str) -> bool { - if name.len() < 1 || name.len() > 64 { - return false; - } - SKILL_NAME_RE.is_match(name) -} - -fn parse_skill_md_frontmatter(contents: &str) -> (Option, Option, Vec) { - // Expect: - // --- - // yaml - // --- - // body... - let mut warnings = vec![]; - if !contents.starts_with("---") { - warnings.push("Invalid SKILL.md: missing YAML frontmatter delimiter".to_string()); - return (None, None, warnings); - } - - let parts: Vec<&str> = contents.splitn(3, "---").collect(); - if parts.len() < 3 { - warnings.push("Invalid SKILL.md: missing YAML frontmatter delimiter".to_string()); - return (None, None, warnings); - } - - let yaml_text = parts[1]; - let parsed: serde_yaml::Value = match serde_yaml::from_str(yaml_text) { - Ok(v) => v, - Err(_) => { - warnings.push("Invalid SKILL.md: failed to parse YAML frontmatter".to_string()); - return (None, None, warnings); - } - }; - - let name = parsed - .get("name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let description = parsed - .get("description") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - (name, description, warnings) -} - -async fn run_git( - args: &[String], - cwd: &Path, - ssh_key: Option<&str>, - timeout: Duration, -) -> Result<(String, String)> { - let mut cmd = Command::new("git"); - - if let Some(key) = ssh_key { - let key = key.trim(); - if !key.is_empty() { - let ssh_command = format!( - "ssh -i {} -o BatchMode=yes -o StrictHostKeyChecking=accept-new", - key - ); - cmd.arg("-c") - .arg(format!("core.sshCommand={}", ssh_command)); - } - } - - cmd.args(args) - .current_dir(cwd) - .stdin(std::process::Stdio::null()) - .kill_on_drop(true) - .env("GIT_TERMINAL_PROMPT", "0") - .env("GCM_INTERACTIVE", "Never") - .env("LC_ALL", "C"); - - let output = tokio::time::timeout(timeout, cmd.output()) - .await - .map_err(|_| anyhow!("Git command timed out"))? - .context("Failed to execute git command")?; - - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - - if !output.status.success() { - let combined = format!("{}\n{}", stderr, stdout); - return Err(anyhow!(combined.trim().to_string())); - } - - Ok((stdout, stderr)) -} - -fn auth_required_error(message: &str) -> SkillsRepoError { - SkillsRepoError { - kind: "authRequired".to_string(), - message: message.to_string(), - ssh_only: Some(true), - identities: Some(list_identities()), - conflicts: None, - } -} - -fn simple_error(kind: &str, message: &str) -> SkillsRepoError { - SkillsRepoError { - kind: kind.to_string(), - message: message.to_string(), - ssh_only: None, - identities: None, - conflicts: None, - } -} - -fn conflicts_error(conflicts: Vec) -> SkillsRepoError { - SkillsRepoError { - kind: "conflicts".to_string(), - message: "Some skills already exist in the selected scope".to_string(), - ssh_only: None, - identities: None, - conflicts: Some(conflicts), - } -} - -async fn clone_repo(clone_url: &str, target_dir: &Path, ssh_key: Option<&str>) -> Result<()> { - let preferred = vec![ - "clone".to_string(), - "--depth".to_string(), - "1".to_string(), - "--filter=blob:none".to_string(), - "--no-checkout".to_string(), - clone_url.to_string(), - target_dir.display().to_string(), - ]; - - let fallback = vec![ - "clone".to_string(), - "--depth".to_string(), - "1".to_string(), - "--no-checkout".to_string(), - clone_url.to_string(), - target_dir.display().to_string(), - ]; - - let cwd = std::env::temp_dir(); - - if run_git(&preferred, &cwd, ssh_key, Duration::from_secs(60)) - .await - .is_ok() - { - return Ok(()); - } - - run_git(&fallback, &cwd, ssh_key, Duration::from_secs(60)).await?; - Ok(()) -} - -async fn safe_rm(dir: &Path) { - let _ = tokio::fs::remove_dir_all(dir).await; -} - -async fn scan_repo_items( - source: &str, - subpath: Option<&str>, - default_subpath: Option<&str>, - ssh_key: Option<&str>, -) -> Result<( - String, - Option, - Vec<( - String, - String, - Option, - Option, - Vec, - bool, - )>, -)> { - let parsed = parse_repo_source(source, subpath)?; - let effective_subpath = parsed - .effective_subpath - .clone() - .or_else(|| default_subpath.map(|s| s.to_string())) - .filter(|s| !s.trim().is_empty()); - - let clone_url = if ssh_key.is_some() { - parsed.clone_ssh.clone() - } else { - parsed.clone_https.clone() - }; - - let temp_base = std::env::temp_dir().join(format!( - "openchamber-desktop-skills-scan-{}", - Uuid::new_v4() - )); - - // Clone into temp_base (directory must not exist for git clone target) - let _ = tokio::fs::remove_dir_all(&temp_base).await; - - let clone_res = clone_repo(&clone_url, &temp_base, ssh_key).await; - if let Err(err) = clone_res { - let msg = err.to_string(); - if AUTH_ERROR_RE.is_match(&msg) { - return Err(anyhow!("AUTH_REQUIRED")); - } - return Err(anyhow!(msg)); - } - - // Fast path: sparse checkout only SKILL.md files, then read them from disk. - // This avoids spawning `git show` per skill. - let patterns: Vec = if let Some(ref sp) = effective_subpath { - vec![format!("{}/SKILL.md", sp), format!("{}/**/SKILL.md", sp)] - } else { - vec!["SKILL.md".to_string(), "**/SKILL.md".to_string()] - }; - - let sparse_init = run_git( - &vec![ - "-C".to_string(), - temp_base.display().to_string(), - "sparse-checkout".to_string(), - "init".to_string(), - "--no-cone".to_string(), - ], - &std::env::temp_dir(), - ssh_key, - Duration::from_secs(15), - ) - .await; - - let mut skill_md_paths: Vec = vec![]; - - if sparse_init.is_ok() { - let mut set_args = vec![ - "-C".to_string(), - temp_base.display().to_string(), - "sparse-checkout".to_string(), - "set".to_string(), - ]; - set_args.extend(patterns.clone()); - - let sparse_set = run_git( - &set_args, - &std::env::temp_dir(), - ssh_key, - Duration::from_secs(30), - ) - .await; - if sparse_set.is_ok() { - let checkout = run_git( - &vec![ - "-C".to_string(), - temp_base.display().to_string(), - "checkout".to_string(), - "--force".to_string(), - "HEAD".to_string(), - ], - &std::env::temp_dir(), - ssh_key, - Duration::from_secs(60), - ) - .await; - - if checkout.is_ok() { - let ls_files = run_git( - &vec![ - "-C".to_string(), - temp_base.display().to_string(), - "ls-files".to_string(), - ], - &std::env::temp_dir(), - ssh_key, - Duration::from_secs(15), - ) - .await; - - if let Ok((out, _)) = ls_files { - skill_md_paths = out - .lines() - .map(|l| l.trim().to_string()) - .filter(|l| !l.is_empty()) - .filter(|p| p.ends_with("/SKILL.md") || p == "SKILL.md") - .collect(); - } - } - } - } - - // Fallback: use ls-tree to find SKILL.md paths. - if skill_md_paths.is_empty() { - let mut list_args = vec![ - "-C".to_string(), - temp_base.display().to_string(), - "ls-tree".to_string(), - "-r".to_string(), - "--name-only".to_string(), - "HEAD".to_string(), - ]; - - if let Some(ref sp) = effective_subpath { - list_args.push("--".to_string()); - list_args.push(sp.clone()); - } - - let list_out = run_git( - &list_args, - &std::env::temp_dir(), - ssh_key, - Duration::from_secs(30), - ) - .await; - let stdout = match list_out { - Ok((out, _)) => out, - Err(_) => { - safe_rm(&temp_base).await; - return Ok((parsed.normalized_repo, effective_subpath, vec![])); - } - }; - - skill_md_paths = stdout - .lines() - .map(|l| l.trim().to_string()) - .filter(|l| !l.is_empty()) - .filter(|p| p.ends_with("/SKILL.md") || p == "SKILL.md") - .collect(); - } - - let mut skill_dirs: Vec = skill_md_paths - .into_iter() - .filter(|p| p != "SKILL.md") - .map(|p| { - let dir = Path::new(&p) - .parent() - .map(|d| d.to_string_lossy().to_string()) - .unwrap_or_else(|| "".to_string()); - dir.replace('\\', "/") - }) - .collect(); - - skill_dirs.sort(); - skill_dirs.dedup(); - - let mut items = vec![]; - - for skill_dir in skill_dirs { - let skill_name = skill_dir - .split('/') - .filter(|s| !s.is_empty()) - .last() - .unwrap_or("") - .to_string(); - - if skill_name.is_empty() { - continue; - } - - let mut warnings = vec![]; - - let skill_md_repo_path = if skill_dir.is_empty() { - "SKILL.md".to_string() - } else { - format!("{}/SKILL.md", skill_dir) - }; - - let skill_md_fs_path = repo_path_to_fs(&temp_base, &skill_md_repo_path); - let contents = match tokio::fs::read_to_string(&skill_md_fs_path).await { - Ok(text) => text, - Err(_) => { - // Fallback to git show if the file is not present in working tree. - let show_args = vec![ - "-C".to_string(), - temp_base.display().to_string(), - "show".to_string(), - format!("HEAD:{}", skill_md_repo_path), - ]; - - match run_git( - &show_args, - &std::env::temp_dir(), - ssh_key, - Duration::from_secs(15), - ) - .await - { - Ok((out, _)) => out, - Err(_) => { - warnings.push("Failed to read SKILL.md".to_string()); - String::new() - } - } - } - }; - - let (frontmatter_name, description, mut fm_warnings) = - parse_skill_md_frontmatter(&contents); - warnings.append(&mut fm_warnings); - - let installable = validate_skill_name(&skill_name); - if !installable { - warnings.push("Skill directory name is not a valid OpenCode skill name".to_string()); - } - - items.push(( - source.to_string(), - skill_dir, - frontmatter_name, - description, - warnings, - installable, - )); - } - - safe_rm(&temp_base).await; - - Ok((parsed.normalized_repo, effective_subpath, items)) -} - -#[derive(Debug, Clone)] -struct CacheEntry { - created_at: Instant, - items: Vec, -} - -static CATALOG_CACHE: Lazy>> = - Lazy::new(|| Mutex::new(HashMap::new())); - -fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<&str>) -> String { - format!( - "{}::{}::{}", - normalized_repo, - subpath.unwrap_or(""), - identity_id.unwrap_or("") - ) -} - -// ============== ClawdHub API ============== - -const CLAWDHUB_API_BASE: &str = "https://clawdhub.com/api/v1"; -const CLAWDHUB_PAGE_LIMIT: usize = 25; - -fn is_clawdhub_source(source: &str) -> bool { - source.starts_with("clawdhub:") -} - -#[derive(Debug, Deserialize)] -struct ClawdHubSkillOwner { - handle: Option, -} - -#[derive(Debug, Deserialize)] -struct ClawdHubSkillStats { - downloads: Option, - stars: Option, -} - -#[derive(Debug, Deserialize)] -struct ClawdHubSkillTags { - latest: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ClawdHubSkillVersion { - version: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ClawdHubSkillListItem { - slug: String, - display_name: Option, - summary: Option, - tags: Option, - latest_version: Option, - stats: Option, - owner: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ClawdHubSkillsResponse { - items: Vec, - next_cursor: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ClawdHubSkillInfoResponse { - skill: Option, - latest_version: Option, -} - -#[derive(Debug, Deserialize)] -struct ClawdHubSkillInfoSkill { - tags: Option, -} - -async fn scan_clawdhub() -> Result> { - let client = reqwest::Client::builder() - .user_agent("OpenChamber-Desktop/1.0") - .timeout(Duration::from_secs(30)) - .build()?; - - let mut all_items = Vec::new(); - let mut cursor: Option = None; - let max_pages = 20; - - for page in 0..max_pages { - let url = match &cursor { - Some(c) => format!( - "{}{}?cursor={}&limit={}", - CLAWDHUB_API_BASE, - "/skills", - urlencoding::encode(c), - CLAWDHUB_PAGE_LIMIT - ), - None => format!("{}/skills?limit={}", CLAWDHUB_API_BASE, CLAWDHUB_PAGE_LIMIT), - }; - - let mut response: Option = None; - let max_attempts = 10; - - for attempt in 0..max_attempts { - let resp = client.get(&url).send().await?; - if resp.status().is_success() { - response = Some(resp); - break; - } - - let should_retry = (resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS - || resp.status().is_server_error()) - && attempt + 1 < max_attempts; - - if should_retry { - tokio::time::sleep(Duration::from_millis(50 * (attempt + 1) as u64)).await; - continue; - } - - if page > 0 && !all_items.is_empty() { - break; - } - return Err(anyhow!("ClawdHub API error: {}", resp.status())); - } - - let Some(response) = response else { - break; - }; - - let data: ClawdHubSkillsResponse = match response.json().await { - Ok(parsed) => parsed, - Err(err) => { - if page > 0 && !all_items.is_empty() { - break; - } - return Err(err.into()); - } - }; - - for item in data.items { - let latest_version = item - .tags - .as_ref() - .and_then(|t| t.latest.clone()) - .or_else(|| item.latest_version.as_ref().and_then(|v| v.version.clone())) - .unwrap_or_else(|| "1.0.0".to_string()); - - all_items.push(SkillsCatalogItem { - source_id: "clawdhub".to_string(), - repo_source: "clawdhub:registry".to_string(), - repo_subpath: None, - git_identity_id: None, - skill_dir: item.slug.clone(), - skill_name: item.slug.clone(), - frontmatter_name: item.display_name.clone(), - description: item.summary, - installable: true, - warnings: None, - installed: SkillsCatalogInstalledBadge { - is_installed: false, - scope: None, - }, - clawdhub: Some(ClawdHubSkillMetadata { - slug: item.slug, - version: latest_version, - display_name: item.display_name, - owner: item.owner.and_then(|o| o.handle), - downloads: item.stats.as_ref().and_then(|s| s.downloads), - stars: item.stats.as_ref().and_then(|s| s.stars), - }), - }); - } - - match data.next_cursor { - Some(c) => cursor = Some(c), - None => break, - } - - // Rate limiting - tokio::time::sleep(Duration::from_millis(100)).await; - } - - // Sort by downloads (most popular first) - all_items.sort_by(|a, b| { - let a_downloads = a.clawdhub.as_ref().and_then(|c| c.downloads).unwrap_or(0); - let b_downloads = b.clawdhub.as_ref().and_then(|c| c.downloads).unwrap_or(0); - b_downloads.cmp(&a_downloads) - }); - - Ok(all_items) -} - -async fn download_clawdhub_skill(slug: &str, version: &str) -> Result> { - let client = reqwest::Client::builder() - .user_agent("OpenChamber-Desktop/1.0") - .timeout(Duration::from_secs(60)) - .build()?; - - let url = format!( - "{}/download?slug={}&version={}", - CLAWDHUB_API_BASE, - urlencoding::encode(slug), - urlencoding::encode(version) - ); - - let response = client.get(&url).send().await?; - if !response.status().is_success() { - return Err(anyhow!("ClawdHub download error: {}", response.status())); - } - - Ok(response.bytes().await?.to_vec()) -} - -async fn fetch_clawdhub_skill_info(slug: &str) -> Result { - let client = reqwest::Client::builder() - .user_agent("OpenChamber-Desktop/1.0") - .timeout(Duration::from_secs(15)) - .build()?; - - let url = format!("{}/skills/{}", CLAWDHUB_API_BASE, urlencoding::encode(slug)); - let response = client.get(&url).send().await?; - - if !response.status().is_success() { - return Err(anyhow!("ClawdHub skill info error: {}", response.status())); - } - - Ok(response.json().await?) -} - -fn load_custom_catalog_sources() -> Vec { - let settings_path = dirs::home_dir().map(|mut home| { - home.push(".config"); - home.push("openchamber"); - home.push("settings.json"); - home - }); - - let Some(path) = settings_path else { - return vec![]; - }; - - let Ok(content) = std::fs::read_to_string(path) else { - return vec![]; - }; - - let Ok(value) = serde_json::from_str::(&content) else { - return vec![]; - }; - - let Some(arr) = value.get("skillCatalogs").and_then(|v| v.as_array()) else { - return vec![]; - }; - - let mut result = vec![]; - let mut seen = std::collections::HashSet::new(); - - for entry in arr { - let Some(obj) = entry.as_object() else { - continue; - }; - - let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim(); - let label = obj - .get("label") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - let source = obj - .get("source") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - let subpath = obj - .get("subpath") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - let git_identity_id = obj - .get("gitIdentityId") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - - if id.is_empty() || label.is_empty() || source.is_empty() { - continue; - } - - if seen.contains(id) { - continue; - } - seen.insert(id.to_string()); - - result.push(SkillsCatalogSource { - id: id.to_string(), - label: label.to_string(), - description: Some(source.to_string()), - source: source.to_string(), - default_subpath: if subpath.is_empty() { - None - } else { - Some(subpath.to_string()) - }, - git_identity_id: if git_identity_id.is_empty() { - None - } else { - Some(git_identity_id.to_string()) - }, - }); - } - - result -} - -pub async fn get_curated_sources() -> Vec { - let mut sources = vec![ - SkillsCatalogSource { - id: "anthropic".to_string(), - label: "Anthropic".to_string(), - description: Some("Anthropic's public skills repository".to_string()), - source: "anthropics/skills".to_string(), - default_subpath: Some("skills".to_string()), - git_identity_id: None, - }, - SkillsCatalogSource { - id: "clawdhub".to_string(), - label: "ClawdHub".to_string(), - description: Some("Community skill registry with vector search".to_string()), - source: "clawdhub:registry".to_string(), - default_subpath: None, - git_identity_id: None, - }, - ]; - - sources.extend(load_custom_catalog_sources()); - sources -} - -pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatalogResponse { - let sources = get_curated_sources().await; - - let discovered = opencode_config::discover_skills(Some(working_directory)); - let installed_by_name: HashMap = discovered - .into_iter() - .map(|s| (s.name.clone(), s)) - .collect(); - - let mut items_by_source: HashMap> = HashMap::new(); - - for src in &sources { - // Handle ClawdHub sources separately (API-based, not git-based) - if is_clawdhub_source(&src.source) { - let key = "clawdhub:registry".to_string(); - - let maybe_cached = if refresh { - None - } else { - let cache = CATALOG_CACHE.lock().await; - cache.get(&key).cloned() - }; - - let cached_items = maybe_cached.and_then(|entry| { - if entry.created_at.elapsed() < CACHE_TTL { - Some(entry.items) - } else { - None - } - }); - - let scanned_items = if let Some(items) = cached_items { - items - } else { - let items = match scan_clawdhub().await { - Ok(items) => items, - Err(_) => { - items_by_source.insert(src.id.clone(), vec![]); - continue; - } - }; - - let mut cache = CATALOG_CACHE.lock().await; - cache.insert( - key, - CacheEntry { - created_at: Instant::now(), - items: items.clone(), - }, - ); - - items - }; - - // Update installed badges - let enriched: Vec = scanned_items - .into_iter() - .map(|mut item| { - let installed = installed_by_name.get(&item.skill_name); - item.installed = SkillsCatalogInstalledBadge { - is_installed: installed.is_some(), - scope: installed.map(|s| match s.scope { - opencode_config::Scope::User => "user".to_string(), - opencode_config::Scope::Project => "project".to_string(), - }), - }; - item - }) - .collect(); - - items_by_source.insert(src.id.clone(), enriched); - continue; - } - - // Handle GitHub sources (git clone based) - let parsed = match parse_repo_source(&src.source, None) { - Ok(p) => p, - Err(_) => { - items_by_source.insert(src.id.clone(), vec![]); - continue; - } - }; - - let effective_subpath = src - .default_subpath - .as_deref() - .or(parsed.effective_subpath.as_deref()) - .unwrap_or(""); - - let key = cache_key( - &parsed.normalized_repo, - Some(effective_subpath), - src.git_identity_id.as_deref(), - ); - - let maybe_cached = if refresh { - None - } else { - let cache = CATALOG_CACHE.lock().await; - cache.get(&key).cloned() - }; - - let cached_items = maybe_cached.and_then(|entry| { - if entry.created_at.elapsed() < CACHE_TTL { - Some(entry.items) - } else { - None - } - }); - - let scanned_items = if let Some(items) = cached_items { - items - } else { - let ssh_key = resolve_identity_ssh_key(src.git_identity_id.as_deref()); - let scan = scan_repo_items( - &src.source, - None, - src.default_subpath.as_deref(), - ssh_key.as_deref(), - ) - .await; - - let (_, _, raw_items) = match scan { - Ok(v) => v, - Err(_) => { - items_by_source.insert(src.id.clone(), vec![]); - continue; - } - }; - - let mut items: Vec = vec![]; - for (repo_source, skill_dir, fm_name, desc, warnings, installable) in raw_items { - let skill_name = skill_dir - .split('/') - .filter(|s| !s.is_empty()) - .last() - .unwrap_or("") - .to_string(); - - let installed = installed_by_name.get(&skill_name); - - items.push(SkillsCatalogItem { - source_id: src.id.clone(), - repo_source, - repo_subpath: src.default_subpath.clone(), - git_identity_id: src.git_identity_id.clone(), - skill_dir, - skill_name, - frontmatter_name: fm_name, - description: desc, - installable, - warnings: if warnings.is_empty() { - None - } else { - Some(warnings) - }, - installed: SkillsCatalogInstalledBadge { - is_installed: installed.is_some(), - scope: installed.map(|s| match s.scope { - opencode_config::Scope::User => "user".to_string(), - opencode_config::Scope::Project => "project".to_string(), - }), - }, - clawdhub: None, - }); - } - - items.sort_by(|a, b| a.skill_name.cmp(&b.skill_name)); - - let mut cache = CATALOG_CACHE.lock().await; - cache.insert( - key, - CacheEntry { - created_at: Instant::now(), - items: items.clone(), - }, - ); - - items - }; - - // Update installed badges at request time (cache may be stale for installs) - let mut enriched = vec![]; - for mut item in scanned_items { - let installed = installed_by_name.get(&item.skill_name); - item.installed = SkillsCatalogInstalledBadge { - is_installed: installed.is_some(), - scope: installed.map(|s| match s.scope { - opencode_config::Scope::User => "user".to_string(), - opencode_config::Scope::Project => "project".to_string(), - }), - }; - enriched.push(item); - } - - items_by_source.insert(src.id.clone(), enriched); - } - - SkillsCatalogResponse { - ok: true, - sources: Some(sources), - items_by_source: Some(items_by_source), - error: None, - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillsScanRequest { - pub source: String, - pub subpath: Option, - pub git_identity_id: Option, -} - -pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse { - let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref()); - - match scan_repo_items( - &req.source, - req.subpath.as_deref(), - None, - ssh_key.as_deref(), - ) - .await - { - Ok((_normalized, effective_subpath, raw_items)) => { - let mut items = vec![]; - for (repo_source, skill_dir, fm_name, desc, warnings, installable) in raw_items { - let skill_name = skill_dir - .split('/') - .filter(|s| !s.is_empty()) - .last() - .unwrap_or("") - .to_string(); - - items.push(SkillsCatalogItem { - source_id: "manual".to_string(), - repo_source, - repo_subpath: effective_subpath.clone(), - git_identity_id: req.git_identity_id.clone(), - skill_dir, - skill_name, - frontmatter_name: fm_name, - description: desc, - installable, - warnings: if warnings.is_empty() { - None - } else { - Some(warnings) - }, - installed: SkillsCatalogInstalledBadge { - is_installed: false, - scope: None, - }, - clawdhub: None, - }); - } - items.sort_by(|a, b| a.skill_name.cmp(&b.skill_name)); - - SkillsRepoScanResponse { - ok: true, - items: Some(items), - error: None, - } - } - Err(err) => { - if err.to_string().contains("AUTH_REQUIRED") { - return SkillsRepoScanResponse { - ok: false, - items: None, - error: Some(auth_required_error( - "Authentication required to access this repository", - )), - }; - } - - SkillsRepoScanResponse { - ok: false, - items: None, - error: Some(simple_error("networkError", &err.to_string())), - } - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClawdHubInstallMeta { - pub slug: String, - pub version: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillsInstallSelection { - pub skill_dir: String, - pub clawdhub: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillsInstallRequest { - pub source: String, - pub subpath: Option, - pub git_identity_id: Option, - pub scope: String, - pub selections: Vec, - pub conflict_policy: Option, - pub conflict_decisions: Option>, -} - -fn user_skill_dir() -> Result { - Ok(dirs::home_dir() - .ok_or_else(|| anyhow!("Could not find home directory"))? - .join(".config") - .join("opencode") - .join("skills")) -} - -fn legacy_user_skill_dir() -> Result { - Ok(dirs::home_dir() - .ok_or_else(|| anyhow!("Could not find home directory"))? - .join(".config") - .join("opencode") - .join("skill")) -} - -fn target_skill_dir(scope: &str, working_directory: &Path, skill_name: &str) -> Result { - if scope == "user" { - let preferred = user_skill_dir()?.join(skill_name); - let legacy = legacy_user_skill_dir()?.join(skill_name); - if legacy.exists() && !preferred.exists() { - return Ok(legacy); - } - return Ok(preferred); - } - - if scope == "project" { - let preferred = working_directory - .join(".opencode") - .join("skills") - .join(skill_name); - let legacy = working_directory - .join(".opencode") - .join("skill") - .join(skill_name); - if legacy.exists() && !preferred.exists() { - return Ok(legacy); - } - return Ok(preferred); - } - - Err(anyhow!("Invalid scope")) -} - -fn repo_path_to_fs(base: &Path, repo_rel_posix: &str) -> PathBuf { - let mut current = base.to_path_buf(); - for part in repo_rel_posix.split('/') { - let trimmed = part.trim(); - if trimmed.is_empty() { - continue; - } - current.push(trimmed); - } - current -} - -async fn copy_dir_no_symlinks(src: &Path, dst: &Path) -> Result<()> { - let src_real = tokio::fs::canonicalize(src).await?; - - tokio::fs::create_dir_all(dst).await?; - - let mut stack: Vec<(PathBuf, PathBuf)> = vec![(src.to_path_buf(), dst.to_path_buf())]; - - while let Some((current_src, current_dst)) = stack.pop() { - tokio::fs::create_dir_all(¤t_dst).await?; - - let current_src_real = tokio::fs::canonicalize(¤t_src).await?; - if !current_src_real.starts_with(&src_real) { - return Err(anyhow!("Invalid source path traversal detected")); - } - - let mut dir = tokio::fs::read_dir(¤t_src).await?; - while let Some(entry) = dir.next_entry().await? { - let next_src = entry.path(); - let next_dst = current_dst.join(entry.file_name()); - - let meta = tokio::fs::symlink_metadata(&next_src).await?; - if meta.file_type().is_symlink() { - return Err(anyhow!("Symlinks are not supported in skills")); - } - - if meta.is_dir() { - stack.push((next_src, next_dst)); - continue; - } - - if meta.is_file() { - if let Some(parent) = next_dst.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::copy(&next_src, &next_dst).await?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = meta.permissions().mode() & 0o777; - let mut perms = tokio::fs::metadata(&next_dst).await?.permissions(); - perms.set_mode(mode); - let _ = tokio::fs::set_permissions(&next_dst, perms).await; - } - } - } - } - - Ok(()) -} - -async fn install_skills_from_clawdhub( - working_directory: &Path, - req: &SkillsInstallRequest, -) -> SkillsInstallResponse { - let mut installed = vec![]; - let mut skipped = vec![]; - - let _user_dir = match user_skill_dir() { - Ok(d) => d, - Err(e) => { - return SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(simple_error("unknown", &e.to_string())), - }; - } - }; - - // Check for conflicts first - let mut conflicts = vec![]; - for sel in &req.selections { - let slug = sel - .clawdhub - .as_ref() - .map(|c| c.slug.as_str()) - .unwrap_or(&sel.skill_dir); - if !validate_skill_name(slug) { - continue; - } - - let target = match target_skill_dir(&req.scope, working_directory, slug) { - Ok(p) => p, - Err(_) => continue, - }; - - if target.exists() { - let decision = req - .conflict_decisions - .as_ref() - .and_then(|m| m.get(slug)) - .map(|s| s.as_str()); - - let auto = req.conflict_policy.as_deref().unwrap_or("prompt"); - - if decision.is_none() && auto != "skipAll" && auto != "overwriteAll" { - conflicts.push(SkillConflict { - skill_name: slug.to_string(), - scope: req.scope.clone(), - }); - } - } - } - - if !conflicts.is_empty() { - return SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(conflicts_error(conflicts)), - }; - } - - for sel in &req.selections { - let slug = sel - .clawdhub - .as_ref() - .map(|c| c.slug.as_str()) - .unwrap_or(&sel.skill_dir); - let mut version = sel - .clawdhub - .as_ref() - .map(|c| c.version.as_str()) - .unwrap_or("latest") - .to_string(); - - if !validate_skill_name(slug) { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: "Invalid skill name".to_string(), - }); - continue; - } - - // Resolve 'latest' version - if version == "latest" { - if let Ok(info) = fetch_clawdhub_skill_info(slug).await { - if let Some(latest) = info - .skill - .and_then(|s| s.tags) - .and_then(|t| t.latest) - .or_else(|| info.latest_version.and_then(|v| v.version)) - { - version = latest; - } - } - - if version == "latest" { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: "Unable to resolve latest version".to_string(), - }); - continue; - } - } - - let target_dir = match target_skill_dir(&req.scope, working_directory, slug) { - Ok(p) => p, - Err(e) => { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: e.to_string(), - }); - continue; - } - }; - - let exists = target_dir.exists(); - let mut decision = req - .conflict_decisions - .as_ref() - .and_then(|m| m.get(slug)) - .cloned(); - - let auto = req.conflict_policy.as_deref().unwrap_or("prompt"); - - if decision.is_none() { - if exists && auto == "skipAll" { - decision = Some("skip".to_string()); - } - if exists && auto == "overwriteAll" { - decision = Some("overwrite".to_string()); - } - if !exists { - decision = Some("overwrite".to_string()); - } - } - - if exists && decision.as_deref() == Some("skip") { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: "Already installed (skipped)".to_string(), - }); - continue; - } - - if exists && decision.as_deref() == Some("overwrite") { - let _ = tokio::fs::remove_dir_all(&target_dir).await; - } - - // Download and extract - match download_clawdhub_skill(slug, &version).await { - Ok(zip_data) => { - let temp_dir = - std::env::temp_dir().join(format!("clawdhub-{}-{}", slug, Uuid::new_v4())); - let _ = tokio::fs::remove_dir_all(&temp_dir).await; - - // Extract ZIP using the zip crate - let cursor = std::io::Cursor::new(&zip_data); - let mut archive = match zip::ZipArchive::new(cursor) { - Ok(a) => a, - Err(e) => { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: format!("Failed to open ZIP: {}", e), - }); - continue; - } - }; - - if let Err(e) = std::fs::create_dir_all(&temp_dir) { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: format!("Failed to create temp dir: {}", e), - }); - continue; - } - - let mut extract_ok = true; - for i in 0..archive.len() { - let mut file = match archive.by_index(i) { - Ok(f) => f, - Err(e) => { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: format!("Failed to read ZIP entry: {}", e), - }); - extract_ok = false; - break; - } - }; - - let outpath = temp_dir.join(file.name()); - - if file.name().ends_with('/') { - let _ = std::fs::create_dir_all(&outpath); - } else { - if let Some(p) = outpath.parent() { - let _ = std::fs::create_dir_all(p); - } - let mut outfile = match std::fs::File::create(&outpath) { - Ok(f) => f, - Err(e) => { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: format!("Failed to create file: {}", e), - }); - extract_ok = false; - break; - } - }; - if let Err(e) = std::io::copy(&mut file, &mut outfile) { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: format!("Failed to write file: {}", e), - }); - extract_ok = false; - break; - } - } - } - - if !extract_ok { - let _ = std::fs::remove_dir_all(&temp_dir); - continue; - } - - // Verify SKILL.md exists - let skill_md_path = temp_dir.join("SKILL.md"); - if !skill_md_path.exists() { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: "SKILL.md not found in downloaded package".to_string(), - }); - let _ = std::fs::remove_dir_all(&temp_dir); - continue; - } - - // Move to target directory - if let Some(parent) = target_dir.parent() { - let _ = tokio::fs::create_dir_all(parent).await; - } - - if let Err(e) = tokio::fs::rename(&temp_dir, &target_dir).await { - // If rename fails (cross-device), try copy - if let Err(e2) = copy_dir_no_symlinks(&temp_dir, &target_dir).await { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: format!("Failed to move files: {} / {}", e, e2), - }); - let _ = tokio::fs::remove_dir_all(&temp_dir).await; - continue; - } - let _ = tokio::fs::remove_dir_all(&temp_dir).await; - } - - installed.push(InstalledSkill { - skill_name: slug.to_string(), - scope: req.scope.clone(), - }); - } - Err(e) => { - skipped.push(SkippedSkill { - skill_name: slug.to_string(), - reason: format!("Failed to download: {}", e), - }); - } - } - } - - SkillsInstallResponse { - ok: true, - installed: Some(installed), - skipped: Some(skipped), - error: None, - } -} - -pub async fn install_skills( - working_directory: &Path, - req: SkillsInstallRequest, -) -> SkillsInstallResponse { - // Handle ClawdHub sources separately - if is_clawdhub_source(&req.source) { - return install_skills_from_clawdhub(working_directory, &req).await; - } - - let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref()); - - let selections: Vec = req - .selections - .into_iter() - .map(|s| s.skill_dir.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - - if selections.is_empty() { - return SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(simple_error( - "invalidSource", - "No skills selected for installation", - )), - }; - } - - // Compute conflicts in target scope only. - let mut conflicts = vec![]; - for skill_dir in &selections { - let skill_name = skill_dir - .split('/') - .filter(|s| !s.is_empty()) - .last() - .unwrap_or("") - .to_string(); - - if !validate_skill_name(&skill_name) { - continue; - } - - let target = match target_skill_dir(&req.scope, working_directory, &skill_name) { - Ok(p) => p, - Err(_) => { - return SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(simple_error("invalidSource", "Invalid scope")), - }; - } - }; - - if target.exists() { - let decision = req - .conflict_decisions - .as_ref() - .and_then(|m| m.get(&skill_name)) - .map(|s| s.as_str()); - - let auto = req.conflict_policy.as_deref().unwrap_or("prompt"); - - if decision.is_none() && auto != "skipAll" && auto != "overwriteAll" { - conflicts.push(SkillConflict { - skill_name, - scope: req.scope.clone(), - }); - } - } - } - - if !conflicts.is_empty() { - return SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(conflicts_error(conflicts)), - }; - } - - // Clone - let parsed = match parse_repo_source(&req.source, req.subpath.as_deref()) { - Ok(p) => p, - Err(err) => { - return SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(simple_error("invalidSource", &err.to_string())), - }; - } - }; - - let clone_url = if ssh_key.is_some() { - parsed.clone_ssh.clone() - } else { - parsed.clone_https.clone() - }; - - let temp_base = std::env::temp_dir().join(format!( - "openchamber-desktop-skills-install-{}", - Uuid::new_v4() - )); - let _ = tokio::fs::remove_dir_all(&temp_base).await; - - let clone_res = clone_repo(&clone_url, &temp_base, ssh_key.as_deref()).await; - if let Err(err) = clone_res { - let msg = err.to_string(); - if AUTH_ERROR_RE.is_match(&msg) { - return SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(auth_required_error( - "Authentication required to access this repository", - )), - }; - } - - return SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(simple_error("networkError", &msg)), - }; - } - - // sparse-checkout selected dirs - let init_args = vec![ - "-C".to_string(), - temp_base.display().to_string(), - "sparse-checkout".to_string(), - "init".to_string(), - "--cone".to_string(), - ]; - let _ = run_git( - &init_args, - &std::env::temp_dir(), - ssh_key.as_deref(), - Duration::from_secs(15), - ) - .await; - - let mut set_args = vec![ - "-C".to_string(), - temp_base.display().to_string(), - "sparse-checkout".to_string(), - "set".to_string(), - ]; - for dir in &selections { - set_args.push(dir.clone()); - } - - if let Err(err) = run_git( - &set_args, - &std::env::temp_dir(), - ssh_key.as_deref(), - Duration::from_secs(30), - ) - .await - { - safe_rm(&temp_base).await; - return SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(simple_error("unknown", &err.to_string())), - }; - } - - let checkout_args = vec![ - "-C".to_string(), - temp_base.display().to_string(), - "checkout".to_string(), - "--force".to_string(), - "HEAD".to_string(), - ]; - - if let Err(err) = run_git( - &checkout_args, - &std::env::temp_dir(), - ssh_key.as_deref(), - Duration::from_secs(60), - ) - .await - { - safe_rm(&temp_base).await; - return SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(simple_error("unknown", &err.to_string())), - }; - } - - let mut installed = vec![]; - let mut skipped = vec![]; - - for skill_dir in selections { - let skill_name = skill_dir - .split('/') - .filter(|s| !s.is_empty()) - .last() - .unwrap_or("") - .to_string(); - - if !validate_skill_name(&skill_name) { - skipped.push(SkippedSkill { - skill_name, - reason: "Invalid skill name (directory basename)".to_string(), - }); - continue; - } - - let src_dir = repo_path_to_fs(&temp_base, &skill_dir); - let skill_md = src_dir.join("SKILL.md"); - if !skill_md.exists() { - skipped.push(SkippedSkill { - skill_name, - reason: "SKILL.md not found in selected directory".to_string(), - }); - continue; - } - - let target_dir = match target_skill_dir(&req.scope, working_directory, &skill_name) { - Ok(p) => p, - Err(err) => { - skipped.push(SkippedSkill { - skill_name, - reason: err.to_string(), - }); - continue; - } - }; - - let exists = target_dir.exists(); - - let mut decision: Option = req - .conflict_decisions - .as_ref() - .and_then(|m| m.get(&skill_name)) - .cloned(); - - let auto = req - .conflict_policy - .as_deref() - .unwrap_or("prompt") - .to_string(); - - if decision.is_none() { - if exists && auto == "skipAll" { - decision = Some("skip".to_string()); - } - if exists && auto == "overwriteAll" { - decision = Some("overwrite".to_string()); - } - if !exists { - decision = Some("overwrite".to_string()); - } - } - - if exists && decision.as_deref() == Some("skip") { - skipped.push(SkippedSkill { - skill_name, - reason: "Already installed (skipped)".to_string(), - }); - continue; - } - - if exists && decision.as_deref() == Some("overwrite") { - let _ = tokio::fs::remove_dir_all(&target_dir).await; - } - - if let Some(parent) = target_dir.parent() { - let _ = tokio::fs::create_dir_all(parent).await; - } - - if let Err(err) = copy_dir_no_symlinks(&src_dir, &target_dir).await { - let _ = tokio::fs::remove_dir_all(&target_dir).await; - skipped.push(SkippedSkill { - skill_name, - reason: err.to_string(), - }); - continue; - } - - installed.push(InstalledSkill { - skill_name, - scope: req.scope.clone(), - }); - } - - safe_rm(&temp_base).await; - - SkillsInstallResponse { - ok: true, - installed: Some(installed), - skipped: Some(skipped), - error: None, - } -} diff --git a/packages/desktop/src-tauri/src/window_state.rs b/packages/desktop/src-tauri/src/window_state.rs deleted file mode 100644 index 5bd81745..00000000 --- a/packages/desktop/src-tauri/src/window_state.rs +++ /dev/null @@ -1,167 +0,0 @@ -use anyhow::{anyhow, Result}; -use serde::{Deserialize, Serialize}; -use std::{ - path::PathBuf, - sync::{Arc, Mutex}, -}; -use tauri::{LogicalPosition, LogicalSize, WebviewWindow, Window}; -use tokio::fs as async_fs; - -const WINDOW_STATE_FILE: &str = "window-state.json"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WindowState { - pub width: f64, - pub height: f64, - pub x: f64, - pub y: f64, - pub is_maximized: bool, -} - -impl Default for WindowState { - fn default() -> Self { - Self { - width: 1280.0, - height: 800.0, - x: 0.0, - y: 0.0, - is_maximized: false, - } - } -} - -#[derive(Serialize, Deserialize)] -struct WindowStateFile { - #[serde(rename = "windowState")] - pub window_state: WindowState, -} - -#[derive(Clone)] -pub struct WindowStateManager { - inner: Arc>, -} - -impl WindowStateManager { - pub fn new(initial: WindowState) -> Self { - Self { - inner: Arc::new(Mutex::new(initial)), - } - } - - pub fn snapshot(&self) -> WindowState { - self.inner.lock().expect("window state poisoned").clone() - } - - pub fn update_position(&self, x: f64, y: f64, is_maximized: bool) { - if is_maximized { - return; - } - if let Ok(mut state) = self.inner.lock() { - if !state.is_maximized { - state.x = x; - state.y = y; - } - } - } - - pub fn update_size(&self, width: f64, height: f64, is_maximized: bool) { - if let Ok(mut state) = self.inner.lock() { - if !is_maximized { - state.width = width; - state.height = height; - } - state.is_maximized = is_maximized; - } - } -} - -fn state_file_path() -> Result { - let mut path = dirs::home_dir().ok_or_else(|| anyhow!("No home directory"))?; - path.push(".config"); - path.push("openchamber"); - path.push(WINDOW_STATE_FILE); - Ok(path) -} - -pub async fn load_window_state() -> Result> { - let path = state_file_path()?; - match async_fs::read(&path).await { - Ok(bytes) => { - let file: WindowStateFile = serde_json::from_slice(&bytes)?; - Ok(Some(file.window_state)) - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err.into()), - } -} - -pub async fn save_window_state(state: &WindowState) -> Result<()> { - let path = state_file_path()?; - if let Some(parent) = path.parent() { - async_fs::create_dir_all(parent).await?; - } - let payload = WindowStateFile { - window_state: state.clone(), - }; - let data = serde_json::to_vec_pretty(&payload)?; - async_fs::write(&path, data).await?; - Ok(()) -} - -pub fn apply_window_state(window: &WebviewWindow, state: &WindowState) -> Result<()> { - let mut normalized = state.clone(); - clamp_to_visible_region(window, &mut normalized); - - if normalized.width > 0.0 && normalized.height > 0.0 { - let _ = window.set_size(LogicalSize::new(normalized.width, normalized.height)); - } - let _ = window.set_position(LogicalPosition::new(normalized.x, normalized.y)); - if state.is_maximized { - let _ = window.maximize(); - } else { - let _ = window.unmaximize(); - } - Ok(()) -} - -pub async fn persist_window_state(window: &Window, manager: &WindowStateManager) -> Result<()> { - let mut snapshot = manager.snapshot(); - let is_maximized = window.is_maximized().unwrap_or(snapshot.is_maximized); - snapshot.is_maximized = is_maximized; - - if !is_maximized { - let scale_factor = window.scale_factor().unwrap_or(1.0); - if let Ok(size) = window.outer_size() { - let logical: LogicalSize = size.to_logical(scale_factor); - snapshot.width = logical.width.max(200.0); - snapshot.height = logical.height.max(200.0); - } - if let Ok(position) = window.outer_position() { - let logical: LogicalPosition = position.to_logical(scale_factor); - snapshot.x = logical.x; - snapshot.y = logical.y; - } - } - - save_window_state(&snapshot).await -} - -fn clamp_to_visible_region(window: &WebviewWindow, state: &mut WindowState) { - let monitor = match window.current_monitor() { - Ok(Some(monitor)) => monitor, - _ => return, - }; - let scale_factor = monitor.scale_factor(); - let monitor_size: LogicalSize = monitor.size().to_logical(scale_factor); - let monitor_position: LogicalPosition = monitor.position().to_logical(scale_factor); - - state.width = state.width.clamp(400.0, monitor_size.width); - state.height = state.height.clamp(300.0, monitor_size.height); - - let max_x = monitor_position.x + (monitor_size.width - state.width).max(0.0); - let max_y = monitor_position.y + (monitor_size.height - state.height).max(0.0); - - state.x = state.x.clamp(monitor_position.x, max_x); - state.y = state.y.clamp(monitor_position.y, max_y); -} diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index efc30536..5aa76a6f 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -4,15 +4,16 @@ "version": "1.6.3", "identifier": "ai.opencode.openchamber", "build": { - "beforeDevCommand": "bun run dev", - "beforeBuildCommand": "bun run build", - "devUrl": "http://127.0.0.1:1421", - "frontendDist": "../dist" + "beforeDevCommand": "node ./scripts/dev-web-server.mjs", + "beforeBuildCommand": "bun run build:sidecar", + "devUrl": "http://127.0.0.1:3001", + "frontendDist": "../noop-dist" }, "app": { "windows": [ { "label": "main", + "create": false, "title": "OpenChamber", "transparent": false, "width": 1280, @@ -33,10 +34,13 @@ "security": { "csp": null }, + "withGlobalTauri": true, "macOSPrivateApi": true }, "bundle": { "active": true, + "externalBin": ["sidecars/openchamber-server"], + "resources": ["resources/web-dist/**/*"], "icon": [ "icons/icon.icns", "icons/icon.png" diff --git a/packages/desktop/src/api/diagnostics.ts b/packages/desktop/src/api/diagnostics.ts deleted file mode 100644 index 0d2afd24..00000000 --- a/packages/desktop/src/api/diagnostics.ts +++ /dev/null @@ -1,32 +0,0 @@ - -import type { DiagnosticsAPI } from '@openchamber/ui/lib/api/types'; - -type LogResponse = { - fileName?: string; - content?: string; -}; - -const normalizePayload = (payload: LogResponse): { fileName: string; content: string } => ({ - fileName: typeof payload.fileName === 'string' && payload.fileName.trim().length > 0 ? payload.fileName : 'openchamber.log', - content: typeof payload.content === 'string' ? payload.content : '', -}); - -export const createDesktopDiagnosticsAPI = (): DiagnosticsAPI => ({ - async downloadLogs() { - try { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - const result = await safeInvoke('fetch_desktop_logs', {}, { - timeout: 10000, - onCancel: () => { - console.warn('[DiagnosticsAPI] Fetch desktop logs operation timed out'); - } - }); - return normalizePayload(result ?? {}); - } catch (error) { - if (error instanceof Error) { - throw error; - } - throw new Error('Failed to download desktop logs'); - } - }, -}); diff --git a/packages/desktop/src/api/files.ts b/packages/desktop/src/api/files.ts deleted file mode 100644 index 4e9ae497..00000000 --- a/packages/desktop/src/api/files.ts +++ /dev/null @@ -1,280 +0,0 @@ - -import { safeInvoke } from '../lib/tauriCallbackManager'; -import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI, ListDirectoryOptions } from '@openchamber/ui/lib/api/types'; - -type ReadFileBinaryResponse = { - dataUrl: string; - path: string; -}; - -type ListDirectoryResponse = DirectoryListResult & { - path?: string; - entries: Array< - DirectoryListResult['entries'][number] & { - isFile?: boolean; - isSymbolicLink?: boolean; - } - >; -}; - -type SearchFilesResponse = { - root: string; - count: number; - files: Array<{ - name: string; - path: string; - relativePath: string; - extension?: string; - }>; -}; - -const normalizePath = (path: string): string => path.replace(/\\/g, '/'); - -const normalizeDirectoryPayload = (result: ListDirectoryResponse): DirectoryListResult => ({ - directory: normalizePath(result.directory || result.path || ''), - entries: Array.isArray(result.entries) - ? result.entries.map((entry) => ({ - name: entry.name || '', - path: normalizePath(entry.path || ''), - isDirectory: entry.isDirectory ?? false, - size: entry.size ?? 0, - modified: (entry as { modified?: string }).modified ?? new Date().toISOString(), - })) - : [], -}); - -export const createDesktopFilesAPI = (): FilesAPI => ({ - async listDirectory(path: string, options?: ListDirectoryOptions): Promise { - try { - const result = await safeInvoke('list_directory', { - path: normalizePath(path), - // NOTE: pass both casings; Tauri arg casing differs across commands - respectGitignore: options?.respectGitignore ?? false, - respect_gitignore: options?.respectGitignore ?? false, - }, { - timeout: 10000, - onCancel: () => { - console.warn('[FilesAPI] List directory operation timed out'); - } - }); - - return normalizeDirectoryPayload(result); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(message || 'Failed to list directory'); - } - }, - - async search(payload: FileSearchQuery): Promise { - try { - const normalizedDirectory = - typeof payload.directory === 'string' && payload.directory.length > 0 - ? normalizePath(payload.directory) - : undefined; - - const result = await safeInvoke('search_files', { - directory: normalizedDirectory, - query: payload.query, - // NOTE: pass both casings; Tauri arg casing differs across commands - maxResults: payload.maxResults || 100, - includeHidden: payload.includeHidden ?? false, - respectGitignore: payload.respectGitignore ?? true, - max_results: payload.maxResults || 100, - include_hidden: payload.includeHidden ?? false, - respect_gitignore: payload.respectGitignore ?? true, - }, { - timeout: 15000, - onCancel: () => { - console.warn('[FilesAPI] Search files operation timed out'); - } - }); - - if (!result || !Array.isArray(result.files)) { - return []; - } - - return result.files.map((file) => ({ - path: normalizePath(file.path), - preview: file.relativePath ? [normalizePath(file.relativePath)] : undefined, - })); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(message || 'Failed to search files'); - } - }, - - async createDirectory(path: string): Promise<{ success: boolean; path: string }> { - try { - const normalizedPath = normalizePath(path); - const result = await safeInvoke<{ success: boolean; path: string }>('create_directory', { - path: normalizedPath - }, { - timeout: 5000, - onCancel: () => { - console.warn('[FilesAPI] Create directory operation timed out'); - } - }); - - return { - success: Boolean(result?.success), - path: result?.path ? normalizePath(result.path) : normalizedPath, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(message || 'Failed to create directory'); - } - }, - - async readFile(path: string): Promise<{ content: string; path: string }> { - try { - const normalizedPath = normalizePath(path); - const result = await safeInvoke<{ content: string; path: string }>('read_file', { - path: normalizedPath - }, { - timeout: 10000, - onCancel: () => { - console.warn('[FilesAPI] Read file operation timed out'); - } - }); - - return { - content: result?.content ?? '', - path: result?.path ? normalizePath(result.path) : normalizedPath, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(message || 'Failed to read file'); - } - }, - - async readFileBinary(path: string): Promise { - try { - const normalizedPath = normalizePath(path); - const result = await safeInvoke('read_file_binary', { - path: normalizedPath - }, { - timeout: 15000, - onCancel: () => { - console.warn('[FilesAPI] Read binary file operation timed out'); - } - }); - - return { - dataUrl: result?.dataUrl ?? '', - path: result?.path ? normalizePath(result.path) : normalizedPath, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(message || 'Failed to read file'); - } - }, - - async writeFile(path: string, content: string): Promise<{ success: boolean; path: string }> { - try { - const normalizedPath = normalizePath(path); - const result = await safeInvoke<{ success: boolean; path: string }>('write_file', { - path: normalizedPath, - content - }, { - timeout: 10000, - onCancel: () => { - console.warn('[FilesAPI] Write file operation timed out'); - } - }); - - return { - success: Boolean(result?.success), - path: result?.path ? normalizePath(result.path) : normalizedPath, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(message || 'Failed to write file'); - } - }, - - async delete(path: string): Promise<{ success: boolean }> { - try { - const normalizedPath = normalizePath(path); - const result = await safeInvoke<{ success: boolean }>('delete_path', { - path: normalizedPath, - }, { - timeout: 10000, - onCancel: () => { - console.warn('[FilesAPI] Delete operation timed out'); - } - }); - - return { - success: Boolean(result?.success), - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(message || 'Failed to delete path'); - } - }, - - async rename(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }> { - try { - const result = await safeInvoke<{ success: boolean; path: string }>('rename_path', { - oldPath: normalizePath(oldPath), - newPath: normalizePath(newPath), - }, { - timeout: 10000, - onCancel: () => { - console.warn('[FilesAPI] Rename operation timed out'); - } - }); - - return { - success: Boolean(result?.success), - path: result?.path ? normalizePath(result.path) : normalizePath(newPath), - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(message || 'Failed to rename path'); - } - }, - - async execCommands(commands: string[], cwd: string): Promise<{ - success: boolean; - results: Array<{ - command: string; - success: boolean; - exitCode?: number; - stdout?: string; - stderr?: string; - error?: string; - }>; - }> { - try { - const normalizedCwd = normalizePath(cwd); - const result = await safeInvoke<{ - success: boolean; - results: Array<{ - command: string; - success: boolean; - exitCode?: number; - stdout?: string; - stderr?: string; - error?: string; - }>; - }>('exec_commands', { - commands, - cwd: normalizedCwd - }, { - timeout: 120000, // 2 minutes for command execution - onCancel: () => { - console.warn('[FilesAPI] Exec commands operation timed out'); - } - }); - - return { - success: Boolean(result?.success), - results: result?.results ?? [], - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(message || 'Failed to execute commands'); - } - }, -}); diff --git a/packages/desktop/src/api/git.ts b/packages/desktop/src/api/git.ts deleted file mode 100644 index 9747240d..00000000 --- a/packages/desktop/src/api/git.ts +++ /dev/null @@ -1,286 +0,0 @@ - -import { safeInvoke } from '../lib/tauriCallbackManager'; -import type { - GitAPI, - GitStatus, - GitDiffResponse, - GetGitDiffOptions, - GitFileDiffResponse, - GitBranch, - GitDeleteBranchPayload, - GitDeleteRemoteBranchPayload, - GeneratedCommitMessage, - GeneratedPullRequestDescription, - GitWorktreeInfo, - GitAddWorktreePayload, - GitRemoveWorktreePayload, - CreateGitCommitOptions, - GitCommitResult, - GitPushResult, - GitPullResult, - GitLogOptions, - GitLogResponse, - GitCommitFilesResponse, - GitIdentitySummary, - GitIdentityProfile, - DiscoveredGitCredential -} from '@openchamber/ui/lib/api/types'; - -async function safeGitInvoke(command: string, args?: Record): Promise { - try { - return await safeInvoke(command, args, { - timeout: 120000, - onCancel: () => { - console.warn(`[GitAPI] Git operation ${command} did not complete within 120s; it may still be running.`); - } - }); - } catch (error) { - const message = typeof error === 'string' ? error : (error as Error).message || 'Unknown error'; - throw new Error(message); - } -} - -export const createDesktopGitAPI = (): GitAPI => ({ - async checkIsGitRepository(directory: string): Promise { - return safeGitInvoke('check_is_git_repository', { directory }); - }, - - async getGitStatus(directory: string): Promise { - return safeGitInvoke('get_git_status', { directory }); - }, - - async getGitDiff(directory: string, options: GetGitDiffOptions): Promise { - const diff = await safeGitInvoke('get_git_diff', { - directory, - pathStr: options.path, - staged: options.staged, - contextLines: options.contextLines - }); - return { diff }; - }, - - async getGitFileDiff(directory: string, options: { path: string }): Promise { - const [original, modified] = await safeGitInvoke<[string, string]>('get_git_file_diff', { - directory, - pathStr: options.path, - }); - return { - original: original ?? '', - modified: modified ?? '', - path: options.path, - }; - }, - - async revertGitFile(directory: string, filePath: string): Promise { - return safeGitInvoke('revert_git_file', { directory, filePath }); - }, - - async isLinkedWorktree(directory: string): Promise { - return safeGitInvoke('is_linked_worktree', { directory }); - }, - - async getGitBranches(directory: string): Promise { - return safeGitInvoke('get_git_branches', { directory }); - }, - - async deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> { - await safeGitInvoke('delete_git_branch', { - directory, - branch: payload.branch, - force: payload.force - }); - return { success: true }; - }, - - async deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> { - await safeGitInvoke('delete_remote_branch', { - directory, - branch: payload.branch, - remote: payload.remote - }); - return { success: true }; - }, - - async generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }> { - const response = await safeGitInvoke<{ message: GeneratedCommitMessage }>('generate_commit_message', { - directory, - files - }); - return response; - }, - - async generatePullRequestDescription( - directory: string, - payload: { base: string; head: string; context?: string } - ): Promise { - const params: { directory: string; base: string; head: string; context?: string } = { - directory, - base: payload.base, - head: payload.head, - }; - if (payload.context?.trim()) { - params.context = payload.context.trim(); - } - return safeGitInvoke('generate_pr_description', params); - }, - - async listGitWorktrees(directory: string): Promise { - return safeGitInvoke('list_git_worktrees', { directory }); - }, - - async addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> { - await safeGitInvoke('add_git_worktree', { - directory, - pathStr: payload.path, - branch: payload.branch, - createBranch: payload.createBranch, - startPoint: payload.startPoint, - }); - return { success: true, path: payload.path, branch: payload.branch }; - }, - - async removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }> { - await safeGitInvoke('remove_git_worktree', { - directory, - pathStr: payload.path, - force: payload.force - }); - return { success: true }; - }, - - async ensureOpenChamberIgnored(directory: string): Promise { - // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. - return safeGitInvoke('ensure_openchamber_ignored', { directory }); - }, - - async createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise { - return safeGitInvoke('create_git_commit', { - directory, - message, - addAll: options?.addAll, - files: options?.files - }); - }, - - async gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record }): Promise { - return safeGitInvoke('git_push', { - directory, - remote: options?.remote, - branch: options?.branch, - options: options?.options - }); - }, - - async gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise { - return safeGitInvoke('git_pull', { - directory, - remote: options?.remote, - branch: options?.branch - }); - }, - - async gitFetch(directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }> { - await safeGitInvoke('git_fetch', { - directory, - remote: options?.remote - }); - return { success: true }; - }, - - async checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> { - await safeGitInvoke('checkout_branch', { directory, branch }); - return { success: true, branch }; - }, - - async createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }> { - await safeGitInvoke('create_branch', { - directory, - name, - startPoint - }); - return { success: true, branch: name }; - }, - - async renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }> { - await safeGitInvoke('rename_branch', { - directory, - oldName, - newName - }); - return { success: true, branch: newName }; - }, - - async getGitLog(directory: string, options?: GitLogOptions): Promise { - return safeGitInvoke('get_git_log', { - directory, - maxCount: options?.maxCount, - from: options?.from, - to: options?.to, - file: options?.file - }); - }, - - async getCommitFiles(directory: string, hash: string): Promise { - return safeGitInvoke('get_commit_files', { - directory, - hash - }); - }, - - async getCurrentGitIdentity(directory: string): Promise { - try { - return await safeGitInvoke('get_current_git_identity', { directory }); - } catch { - return null; - } - }, - - async hasLocalIdentity(directory: string): Promise { - try { - return await safeGitInvoke('has_local_identity', { directory }); - } catch { - return false; - } - }, - - async setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> { - const profile = await safeGitInvoke('set_git_identity', { directory, profileId }); - return { success: true, profile }; - }, - - async getGitIdentities(): Promise { - return safeGitInvoke('get_git_identities'); - }, - - async createGitIdentity(profile: GitIdentityProfile): Promise { - return safeGitInvoke('create_git_identity', { profile }); - }, - - async updateGitIdentity(id: string, updates: GitIdentityProfile): Promise { - return safeGitInvoke('update_git_identity', { id, updates }); - }, - - async deleteGitIdentity(id: string): Promise { - return safeGitInvoke('delete_git_identity', { id }); - }, - - async discoverGitCredentials(): Promise { - return safeGitInvoke('discover_git_credentials'); - }, - - async getGlobalGitIdentity(): Promise { - try { - return await safeGitInvoke('get_global_git_identity'); - } catch { - return null; - } - }, - - async getRemoteUrl(directory: string, remote?: string): Promise { - try { - return await safeGitInvoke('get_remote_url', { directory, remote }); - } catch { - return null; - } - }, -}); diff --git a/packages/desktop/src/api/github.ts b/packages/desktop/src/api/github.ts deleted file mode 100644 index 93c3c857..00000000 --- a/packages/desktop/src/api/github.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { - GitHubAPI, - GitHubAuthStatus, - GitHubIssueCommentsResult, - GitHubIssueGetResult, - GitHubIssuesListResult, - GitHubPullRequestContextResult, - GitHubPullRequestsListResult, - GitHubPullRequest, - GitHubPullRequestCreateInput, - GitHubPullRequestMergeInput, - GitHubPullRequestMergeResult, - GitHubPullRequestReadyInput, - GitHubPullRequestReadyResult, - GitHubPullRequestStatus, - GitHubDeviceFlowComplete, - GitHubDeviceFlowStart, - GitHubUserSummary, -} from '@openchamber/ui/lib/api/types'; - -export const createDesktopGitHubAPI = (): GitHubAPI => ({ - async authStatus(): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_auth_status', {}, { timeout: 8000 }); - }, - - async authStart(): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_auth_start', {}, { timeout: 8000 }); - }, - - async authComplete(deviceCode: string): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_auth_complete', { deviceCode }, { timeout: 12000 }); - }, - - async authDisconnect(): Promise<{ removed: boolean }> { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - const result = await safeInvoke<{ removed: boolean }>('github_auth_disconnect', {}, { timeout: 8000 }); - return { removed: Boolean(result?.removed) }; - }, - - async authActivate(accountId: string): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_auth_activate', { accountId }, { timeout: 8000 }); - }, - - async me(): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_me', {}, { timeout: 8000 }); - }, - - async prStatus(directory: string, branch: string): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_pr_status', { directory, branch }, { timeout: 12000 }); - }, - - async prCreate(payload: GitHubPullRequestCreateInput): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_pr_create', payload, { timeout: 20000 }); - }, - - async prMerge(payload: GitHubPullRequestMergeInput): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_pr_merge', payload, { timeout: 20000 }); - }, - - async prReady(payload: GitHubPullRequestReadyInput): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_pr_ready', payload, { timeout: 20000 }); - }, - - async issuesList(directory: string, options?: { page?: number }): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_issues_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 }); - }, - - async issueGet(directory: string, number: number): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_issue_get', { directory, number }, { timeout: 20000 }); - }, - - async issueComments(directory: string, number: number): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_issue_comments', { directory, number }, { timeout: 20000 }); - }, - - async prsList(directory: string, options?: { page?: number }): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_prs_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 }); - }, - - async prContext( - directory: string, - number: number, - options?: { includeDiff?: boolean; includeCheckDetails?: boolean } - ): Promise { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke( - 'github_pr_context', - { directory, number, includeDiff: Boolean(options?.includeDiff), includeCheckDetails: Boolean(options?.includeCheckDetails) }, - { timeout: 30000 } - ); - }, -}); diff --git a/packages/desktop/src/api/index.ts b/packages/desktop/src/api/index.ts deleted file mode 100644 index f102c4a2..00000000 --- a/packages/desktop/src/api/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { RuntimeAPIs, TerminalHandlers } from '@openchamber/ui/lib/api/types'; -import { createDesktopTerminalAPI } from './terminal'; -import { createDesktopGitAPI } from './git'; -import { createDesktopFilesAPI } from './files'; -import { createDesktopSettingsAPI } from './settings'; -import { createDesktopPermissionsAPI } from './permissions'; -import { createDesktopDiagnosticsAPI } from './diagnostics'; -import { createDesktopNotificationsAPI } from './notifications'; -import { createDesktopToolsAPI } from './tools'; -import { createDesktopGitHubAPI } from './github'; - -const activeTerminalConnections = new Set(); - -export const createDesktopAPIs = (): RuntimeAPIs & { cleanup?: () => void } => { - const terminalAPI = createDesktopTerminalAPI(); - const originalConnect = terminalAPI.connect.bind(terminalAPI); - - const wrappedTerminalAPI = { - ...terminalAPI, - connect: (sessionId: string, handlers: TerminalHandlers) => { - activeTerminalConnections.add(sessionId); - const connection = originalConnect(sessionId, handlers); - - const originalClose = connection.close; - return { - ...connection, - close: () => { - activeTerminalConnections.delete(sessionId); - originalClose(); - }, - }; - }, - }; - - return { - runtime: { platform: 'desktop', isDesktop: true, isVSCode: false, label: 'tauri-bootstrap' }, - terminal: wrappedTerminalAPI, - git: createDesktopGitAPI(), - files: createDesktopFilesAPI(), - settings: createDesktopSettingsAPI(), - permissions: createDesktopPermissionsAPI(), - notifications: createDesktopNotificationsAPI(), - github: createDesktopGitHubAPI(), - diagnostics: createDesktopDiagnosticsAPI(), - tools: createDesktopToolsAPI(), - cleanup: () => { - console.info('[DesktopAPIs] Performing cleanup...'); - - const activeConnections = Array.from(activeTerminalConnections); - activeConnections.forEach(sessionId => { - console.info(`[DesktopAPIs] Closing terminal session: ${sessionId}`); - - activeTerminalConnections.delete(sessionId); - }); - - console.info(`[DesktopAPIs] Cleanup completed, closed ${activeConnections.length} terminal connections`); - }, - }; -}; diff --git a/packages/desktop/src/api/notifications.ts b/packages/desktop/src/api/notifications.ts deleted file mode 100644 index 71f6f0b6..00000000 --- a/packages/desktop/src/api/notifications.ts +++ /dev/null @@ -1,58 +0,0 @@ - -import type { NotificationsAPI, NotificationPayload } from '@openchamber/ui/lib/api/types'; -import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification'; -import { safeInvoke } from '../lib/tauriCallbackManager'; - -export const requestInitialNotificationPermission = async (): Promise => { - try { - if (typeof window !== 'undefined' && 'Notification' in window) { - const permission = await Notification.requestPermission(); - if (permission !== 'granted') { - console.warn('[notifications] Notification permission not granted'); - } - } - } catch (error) { - console.error('[notifications] Failed to request permission:', error); - } -}; - -export const createDesktopNotificationsAPI = (): NotificationsAPI => ({ - async notifyAgentCompletion(payload?: NotificationPayload): Promise { - try { - let granted = await isPermissionGranted(); - if (!granted) { - const permission = await requestPermission(); - granted = permission === 'granted'; - } - - if (!granted) { - console.warn('[notifications] Cannot send notification: Permission denied'); - return false; - } - - await safeInvoke( - 'desktop_notify', - { payload }, - { - timeout: 5000, - onCancel: () => { - console.warn('[NotificationsAPI] Notify operation timed out'); - }, - }, - ); - return true; - } catch (error) { - console.error('[notifications] Failed to send notification:', error); - return false; - } - }, - - async canNotify(): Promise { - try { - return await isPermissionGranted(); - } catch (error) { - console.warn('[notifications] Failed to check notification permission:', error); - return false; - } - } -}); diff --git a/packages/desktop/src/api/permissions.ts b/packages/desktop/src/api/permissions.ts deleted file mode 100644 index 32e874c7..00000000 --- a/packages/desktop/src/api/permissions.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { DirectoryPermissionRequest, DirectoryPermissionResult, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types'; - -export const createDesktopPermissionsAPI = (): PermissionsAPI => ({ - async requestDirectoryAccess(request: DirectoryPermissionRequest): Promise { - try { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - const result = await safeInvoke('request_directory_access', { request }, { - timeout: 30000, - onCancel: () => { - console.warn('[PermissionsAPI] Request directory access operation timed out'); - } - }); - return result; - } catch (error) { - console.error('[desktop] Error requesting directory access:', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }, - async startAccessingDirectory(path: string): Promise { - try { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - const result = await safeInvoke('start_accessing_directory', { path }, { - timeout: 10000, - onCancel: () => { - console.warn('[PermissionsAPI] Start accessing directory operation timed out'); - } - }); - return result; - } catch (error) { - console.error('[desktop] Error starting directory access:', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }, - async stopAccessingDirectory(path: string): Promise { - try { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - const result = await safeInvoke('stop_accessing_directory', { path }, { - timeout: 5000, - onCancel: () => { - console.warn('[PermissionsAPI] Stop accessing directory operation timed out'); - } - }); - return result; - } catch (error) { - console.error('[desktop] Error stopping directory access:', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }, -}); diff --git a/packages/desktop/src/api/settings.ts b/packages/desktop/src/api/settings.ts deleted file mode 100644 index 69049d2d..00000000 --- a/packages/desktop/src/api/settings.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types'; - -const sanitizePayload = (data: unknown): SettingsPayload => { - if (!data || typeof data !== 'object') { - return {}; - } - return data as SettingsPayload; -}; - -export const createDesktopSettingsAPI = (): SettingsAPI => ({ - async load(): Promise { - try { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - const result = await safeInvoke<{ settings: unknown; source: 'desktop' | 'web' }>('load_settings', {}, { - timeout: 5000, - onCancel: () => { - console.warn('[SettingsAPI] Load settings operation timed out'); - } - }); - return { - settings: sanitizePayload(result.settings), - source: result.source, - }; - } catch (error) { - throw new Error(`Failed to load settings: ${error instanceof Error ? error.message : String(error)}`); - } - }, - - async save(changes: Partial): Promise { - try { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - const result = await safeInvoke('save_settings', { changes }, { - timeout: 5000, - onCancel: () => { - console.warn('[SettingsAPI] Save settings operation timed out'); - } - }); - return sanitizePayload(result); - } catch (error) { - throw new Error(`Failed to save settings: ${error instanceof Error ? error.message : String(error)}`); - } - }, - - async restartOpenCode(): Promise<{ restarted: boolean }> { - try { - const { safeInvoke } = await import('../lib/tauriCallbackManager'); - const result = await safeInvoke<{ restarted: boolean }>('restart_opencode', {}, { - timeout: 10000, - onCancel: () => { - console.warn('[SettingsAPI] Restart OpenCode operation timed out'); - } - }); - return { restarted: result.restarted }; - } catch (error) { - throw new Error(`Failed to restart OpenCode: ${error instanceof Error ? error.message : String(error)}`); - } - }, -}); diff --git a/packages/desktop/src/api/terminal.ts b/packages/desktop/src/api/terminal.ts deleted file mode 100644 index 176f2b73..00000000 --- a/packages/desktop/src/api/terminal.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { safeInvoke, safeListen } from '../lib/tauriCallbackManager'; -import type { - TerminalAPI, - TerminalHandlers, - CreateTerminalOptions, - ResizeTerminalPayload, - TerminalSession, - TerminalStreamEvent -} from '@openchamber/ui/lib/api/types'; - -async function safeTerminalInvoke(command: string, args?: Record): Promise { - try { - return await safeInvoke(command, args, { - timeout: 10000, - onCancel: () => { - console.warn(`[TerminalAPI] Command ${command} timed out`); - } - }); - } catch (error) { - const message = typeof error === 'string' ? error : (error as Error).message || 'Unknown error'; - throw new Error(message); - } -} - -export const createDesktopTerminalAPI = (): TerminalAPI => ({ - async createSession(options: CreateTerminalOptions): Promise { - const cols = options.cols ?? 80; - const rows = options.rows ?? 24; - - const res = await safeTerminalInvoke<{ session_id: string }>('create_terminal_session', { - payload: { - cols, - rows, - cwd: options.cwd - } - }); - - return { - sessionId: res.session_id, - cols, - rows - }; - }, - - connect(sessionId: string, handlers: TerminalHandlers) { - let unlistenFn: (() => void) | undefined; - let cancelled = false; - let isConnected = false; - - const stopListening = () => { - if (unlistenFn) { - unlistenFn(); - unlistenFn = undefined; - isConnected = false; - } - }; - - const startListening = async () => { - try { - const unlisten = await safeListen( - `terminal://${sessionId}`, - (event) => { - if (cancelled) { - return; - } - - handlers.onEvent(event.payload); - - if (event.payload?.type === 'exit') { - stopListening(); - } - }, - { - // Terminal streams are long-lived; never auto-expire this listener. - timeout: 0, - } - ); - - if (cancelled) { - unlisten(); - return; - } - - unlistenFn = unlisten; - isConnected = true; - handlers.onEvent({ type: 'connected' }); - } catch (err) { - console.error('Failed to listen to terminal events:', err); - if (!cancelled) { - handlers.onError?.(err instanceof Error ? err : new Error(String(err))); - } - } - }; - - startListening(); - - return { - close: () => { - cancelled = true; - stopListening(); - }, - isConnected: () => isConnected, - }; - }, - - async sendInput(sessionId: string, input: string): Promise { - await safeTerminalInvoke('send_terminal_input', { - - sessionId, - session_id: sessionId, - data: input, - }); - }, - - async resize(payload: ResizeTerminalPayload): Promise { - await safeTerminalInvoke('resize_terminal', { - sessionId: payload.sessionId, - session_id: payload.sessionId, - cols: payload.cols, - rows: payload.rows, - }); - }, - - async close(sessionId: string): Promise { - await safeTerminalInvoke('close_terminal', { - sessionId, - session_id: sessionId, - }); - }, - - async restartSession( - currentSessionId: string, - options: CreateTerminalOptions - ): Promise { - const cols = options.cols ?? 80; - const rows = options.rows ?? 24; - - const res = await safeTerminalInvoke<{ session_id: string }>( - 'restart_terminal_session', - { - payload: { - session_id: currentSessionId, - cols, - rows, - cwd: options.cwd ?? '', - }, - } - ); - - return { - sessionId: res.session_id, - cols, - rows, - }; - }, - - async forceKill(options: { - sessionId?: string; - cwd?: string; - }): Promise { - await safeTerminalInvoke('force_kill_terminal', { - payload: { - session_id: options.sessionId ?? null, - cwd: options.cwd ?? null, - }, - }); - }, -}); diff --git a/packages/desktop/src/api/tools.ts b/packages/desktop/src/api/tools.ts deleted file mode 100644 index 97f373a6..00000000 --- a/packages/desktop/src/api/tools.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ToolsAPI } from '@openchamber/ui/lib/api/types'; - -export const createDesktopToolsAPI = (): ToolsAPI => ({ - async getAvailableTools(): Promise { - - const response = await fetch('/api/experimental/tool/ids'); - - if (!response.ok) { - throw new Error(`Tools API returned ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - - if (!Array.isArray(data)) { - throw new Error('Tools API returned invalid data format'); - } - - return data - .filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid') - .sort(); - }, -}); diff --git a/packages/desktop/src/api/updater.ts b/packages/desktop/src/api/updater.ts deleted file mode 100644 index efe2a0d1..00000000 --- a/packages/desktop/src/api/updater.ts +++ /dev/null @@ -1,145 +0,0 @@ -export interface UpdateInfo { - available: boolean; - version?: string; - currentVersion: string; - body?: string; - date?: string; -} - -export interface UpdateProgress { - downloaded: number; - total?: number; -} - -interface Update { - version: string; - body?: string; - date?: string; - downloadAndInstall: ( - onEvent?: (event: DownloadEvent) => void - ) => Promise; -} - -type DownloadEvent = - | { event: 'Started'; data: { contentLength?: number } } - | { event: 'Progress'; data: { chunkLength: number } } - | { event: 'Finished' }; - -let cachedUpdate: Update | null = null; - -export async function checkForUpdates(): Promise { - try { - const { check } = await import('@tauri-apps/plugin-updater'); - const [update, currentVersion] = await Promise.all([ - check(), - getCurrentVersion(), - ]); - cachedUpdate = update; - - if (!update) { - return { - available: false, - currentVersion, - }; - } - - const changelogNotes = await fetchChangelogNotes(currentVersion, update.version); - - return { - available: true, - version: update.version, - currentVersion, - body: changelogNotes ?? update.body ?? undefined, - date: update.date ?? undefined, - }; - } catch (error) { - console.error('[updater] Failed to check for updates:', error); - return { - available: false, - currentVersion: await getCurrentVersion(), - }; - } -} - -export async function downloadUpdate( - onProgress?: (progress: UpdateProgress) => void -): Promise { - let update = cachedUpdate; - if (!update) { - const { check } = await import('@tauri-apps/plugin-updater'); - const checked = await check(); - if (!checked) { - throw new Error('No update available'); - } - update = checked; - cachedUpdate = checked; - } - - let downloaded = 0; - let total: number | undefined; - - await update.downloadAndInstall((event: DownloadEvent) => { - switch (event.event) { - case 'Started': - total = event.data.contentLength; - onProgress?.({ downloaded: 0, total }); - break; - case 'Progress': - downloaded += event.data.chunkLength; - onProgress?.({ downloaded, total }); - break; - case 'Finished': - onProgress?.({ downloaded: total ?? downloaded, total }); - break; - } - }); -} - -export async function restartToUpdate(): Promise { - const { relaunch } = await import('@tauri-apps/plugin-process'); - await relaunch(); -} - -async function getCurrentVersion(): Promise { - try { - const { getVersion } = await import('@tauri-apps/api/app'); - return await getVersion(); - } catch { - return 'unknown'; - } -} - -async function fetchChangelogNotes(fromVersion: string, toVersion: string): Promise { - try { - const response = await fetch( - 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md' - ); - if (!response.ok) return undefined; - - const changelog = await response.text(); - const sections = changelog.split(/^## /m).slice(1); - - const fromNum = parseVersion(fromVersion); - const toNum = parseVersion(toVersion); - - const relevantSections = sections.filter((section) => { - const match = section.match(/^\[(\d+\.\d+\.\d+)\]/); - if (!match) return false; - const ver = parseVersion(match[1]); - return ver > fromNum && ver <= toNum; - }); - - if (relevantSections.length === 0) return undefined; - - return relevantSections - .map((s) => '## ' + s.trim()) - .join('\n\n'); - } catch { - return undefined; - } -} - -function parseVersion(version: string): number { - const parts = version.split('.').map(Number); - return (parts[0] || 0) * 10000 + (parts[1] || 0) * 100 + (parts[2] || 0); -} diff --git a/packages/desktop/src/lib/bridge.ts b/packages/desktop/src/lib/bridge.ts deleted file mode 100644 index 56cb8231..00000000 --- a/packages/desktop/src/lib/bridge.ts +++ /dev/null @@ -1,157 +0,0 @@ - -import { safeInvoke, cleanupAllTauriCallbacks } from './tauriCallbackManager'; - -type ServerInfo = { - server_port: number; - opencode_port?: number | null; - api_prefix?: string | null; - cli_available?: boolean; -}; - -declare global { - interface Window { - __OPENCHAMBER_DESKTOP_SERVER__?: { - origin: string; - opencodePort: number | null; - apiPrefix: string; - cliAvailable: boolean; - }; - } -} - -let bridgePromise: Promise | null = null; - -export function initializeDesktopBridge(): Promise { - if (!bridgePromise) { - bridgePromise = setupBridge(); - } - return bridgePromise; -} - -async function setupBridge(): Promise { - try { - const info = await safeInvoke('desktop_server_info', {}, { - timeout: 10000, - onCancel: () => { - console.warn('[Bridge] Server info request timed out'); - } - }); - const origin = `http://127.0.0.1:${info.server_port}`; - - window.__OPENCHAMBER_DESKTOP_SERVER__ = { - origin, - opencodePort: info.opencode_port ?? null, - apiPrefix: info.api_prefix ?? '', - cliAvailable: info.cli_available ?? false, - }; - - patchFetch(origin); - patchEventSource(origin); - - const cleanupDevtools = registerDevtoolsShortcut(); - - if (typeof window !== 'undefined') { - (window as { __openchamberCleanup?: () => void }).__openchamberCleanup = () => { - cleanupDevtools(); - }; - } - } catch (error) { - console.error('[bridge] Failed to initialize bridge:', error); - - if (typeof window !== 'undefined' && (window as { __openchamberCleanup?: () => void }).__openchamberCleanup) { - try { - (window as { __openchamberCleanup?: () => void }).__openchamberCleanup?.(); - } catch (cleanupError) { - console.warn('[bridge] Cleanup during failed initialization failed:', cleanupError); - } - delete (window as { __openchamberCleanup?: () => void }).__openchamberCleanup; - } - - cleanupAllTauriCallbacks(); - - throw error; - } -} - -function patchFetch(origin: string) { - const originalFetch = window.fetch.bind(window); - - const rewrite = (value: string): string => { - if (value.startsWith('http://') || value.startsWith('https://')) { - return value; - } - if (value.startsWith('//')) { - return `http:${value}`; - } - if (value.startsWith('/')) { - return `${origin}${value}`; - } - return value; - }; - - window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { - if (typeof input === 'string') { - return originalFetch(rewrite(input), init); - } - - if (input instanceof Request) { - const rewritten = rewrite(input.url); - if (rewritten === input.url) { - return originalFetch(input, init); - } - const cloned = new Request(rewritten, input); - return originalFetch(cloned, init); - } - - if (input instanceof URL) { - return originalFetch(rewrite(input.toString()), init); - } - - return originalFetch(input, init); - }; -} - -function patchEventSource(origin: string) { - if (typeof window.EventSource === 'undefined') { - return; - } - - const OriginalEventSource = window.EventSource; - - class DesktopEventSource extends OriginalEventSource { - constructor(url: string | URL, eventSourceInit?: EventSourceInit) { - const normalized = typeof url === 'string' ? url : url.toString(); - super(normalized.startsWith('/') ? `${origin}${normalized}` : normalized, eventSourceInit); - } - } - - Object.defineProperty(DesktopEventSource, 'name', { value: 'DesktopEventSource' }); - Object.setPrototypeOf(DesktopEventSource.prototype, OriginalEventSource.prototype); - Object.setPrototypeOf(DesktopEventSource, OriginalEventSource); - - window.EventSource = DesktopEventSource as unknown as typeof EventSource; -} - -function registerDevtoolsShortcut() { - const handler = (event: KeyboardEvent) => { - const key = event.key?.toLowerCase(); - if ((event.metaKey || event.ctrlKey) && event.altKey && key === 'i') { - event.preventDefault(); - - const devtoolsPromise = safeInvoke('desktop_open_devtools', {}, { - timeout: 2000, - onCancel: () => { - console.warn('[Bridge] Devtools invocation timed out'); - } - }); - devtoolsPromise.catch(() => { - - }); - } - }; - window.addEventListener('keydown', handler); - - return () => { - window.removeEventListener('keydown', handler); - }; -} diff --git a/packages/desktop/src/lib/tauriCallbackManager.ts b/packages/desktop/src/lib/tauriCallbackManager.ts deleted file mode 100644 index 4b6d00e2..00000000 --- a/packages/desktop/src/lib/tauriCallbackManager.ts +++ /dev/null @@ -1,320 +0,0 @@ - - -import { invoke } from '@tauri-apps/api/core'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; - -interface PendingCallback { - id: string; - timestamp: number; - type: 'invoke' | 'listen'; - cleanup?: () => void; - timeout?: NodeJS.Timeout; - timeoutMs?: number; -} - -interface CallbackManagerConfig { - maxCallbackAge?: number; - cleanupInterval?: number; - invokeTimeout?: number; - listenTimeout?: number; -} - -class TauriCallbackManager { - private callbacks = new Map(); - private isShuttingDown = false; - private cleanupTimer?: NodeJS.Timeout; - private config: Required; - private windowUnloadHandler?: () => void; - - constructor(config: CallbackManagerConfig = {}) { - this.config = { - maxCallbackAge: 30000, - cleanupInterval: 5000, - invokeTimeout: 10000, - listenTimeout: 30000, - ...config, - }; - - this.setupWindowUnloadHandler(); - this.startCleanupTimer(); - } - - register(callback: Omit): string { - if (this.isShuttingDown) { - console.warn('[TauriCallbackManager] Attempted to register callback during shutdown'); - return callback.id; - } - - const fullCallback: PendingCallback = { - ...callback, - timestamp: Date.now(), - }; - - this.callbacks.set(callback.id, fullCallback); - - const timeoutMs = - typeof fullCallback.timeoutMs === 'number' - ? fullCallback.timeoutMs - : callback.type === 'listen' - ? this.config.listenTimeout - : 0; - - if (timeoutMs > 0) { - const timeout = setTimeout(() => { - this.cleanupCallback(callback.id, 'timeout'); - }, timeoutMs); - fullCallback.timeout = timeout; - } - - return callback.id; - } - - unregister(callbackId: string): void { - const callback = this.callbacks.get(callbackId); - if (!callback) { - return; - } - - if (callback.timeout) { - clearTimeout(callback.timeout); - } - - if (callback.cleanup) { - try { - callback.cleanup(); - } catch (error) { - console.warn('[TauriCallbackManager] Cleanup function failed:', error); - } - } - - this.callbacks.delete(callbackId); - } - - private cleanupCallback(callbackId: string, reason: 'timeout' | 'shutdown' | 'expired'): void { - const callback = this.callbacks.get(callbackId); - if (!callback) { - return; - } - - if (reason === 'expired') { - console.warn(`[TauriCallbackManager] Callback ${callbackId} expired and was cleaned up`); - } - - this.unregister(callbackId); - } - - cleanupAll(): void { - this.isShuttingDown = true; - - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - this.cleanupTimer = undefined; - } - - const callbackIds = Array.from(this.callbacks.keys()); - callbackIds.forEach(id => this.cleanupCallback(id, 'shutdown')); - - this.callbacks.clear(); - } - - private startCleanupTimer(): void { - this.cleanupTimer = setInterval(() => { - if (this.isShuttingDown) { - return; - } - - const now = Date.now(); - const expiredCallbacks: string[] = []; - - this.callbacks.forEach((callback, id) => { - if (callback.type !== 'invoke') { - return; - } - const age = now - callback.timestamp; - if (age > this.config.maxCallbackAge) { - expiredCallbacks.push(id); - } - }); - - expiredCallbacks.forEach(id => this.cleanupCallback(id, 'expired')); - }, this.config.cleanupInterval); - } - - private setupWindowUnloadHandler(): void { - if (typeof window === 'undefined') { - return; - } - - this.windowUnloadHandler = () => { - console.info('[TauriCallbackManager] Window unloading, cleaning up callbacks...'); - this.cleanupAll(); - }; - - window.addEventListener('beforeunload', this.windowUnloadHandler); - window.addEventListener('pagehide', this.windowUnloadHandler); - } - - removeWindowHandlers(): void { - if (this.windowUnloadHandler && typeof window !== 'undefined') { - window.removeEventListener('beforeunload', this.windowUnloadHandler); - window.removeEventListener('pagehide', this.windowUnloadHandler); - this.windowUnloadHandler = undefined; - } - } - - getStats(): { total: number; invoke: number; listen: number } { - const stats = { total: 0, invoke: 0, listen: 0 }; - - this.callbacks.forEach(callback => { - stats.total++; - stats[callback.type]++; - }); - - return stats; - } -} - -let globalCallbackManager: TauriCallbackManager | null = null; - -export function getTauriCallbackManager(config?: CallbackManagerConfig): TauriCallbackManager { - if (!globalCallbackManager) { - globalCallbackManager = new TauriCallbackManager(config); - } - return globalCallbackManager; -} - -export async function safeInvoke( - command: string, - args?: Record, - options?: { - timeout?: number; - onCancel?: () => void; - } -): Promise { - const manager = getTauriCallbackManager(); - const callbackId = `invoke:${command}:${Date.now()}:${Math.random().toString(36).slice(2)}`; - - let timeoutHandle: NodeJS.Timeout | undefined; - let settled = false; - - manager.register({ - id: callbackId, - type: 'invoke', - }); - - const clearAndUnregister = () => { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - timeoutHandle = undefined; - } - manager.unregister(callbackId); - }; - - if (!options?.timeout || options.timeout <= 0) { - try { - const result = await invoke(command, args); - clearAndUnregister(); - return result; - } catch (error) { - clearAndUnregister(); - throw error; - } - } - - return new Promise((resolve, reject) => { - timeoutHandle = setTimeout(() => { - if (settled) { - return; - } - settled = true; - - console.warn(`[safeInvoke] Command ${command} timed out after ${options.timeout}ms`); - try { - options.onCancel?.(); - } catch (error) { - console.warn('[safeInvoke] onCancel handler threw:', error); - } - - clearAndUnregister(); - reject(new Error(`Command ${command} timed out after ${options.timeout}ms`)); - }, options.timeout); - - invoke(command, args) - .then((result) => { - if (settled) { - - return; - } - settled = true; - clearAndUnregister(); - resolve(result); - }) - .catch((error) => { - if (settled) { - return; - } - settled = true; - clearAndUnregister(); - reject(error); - }); - }); -} - -export async function safeListen( - event: string, - handler: (event: { payload: T }) => void, - options?: { - timeout?: number; - onCancel?: () => void; - } -): Promise { - const manager = getTauriCallbackManager(); - const callbackId = `listen:${event}:${Date.now()}:${Math.random().toString(36).slice(2)}`; - - try { - - manager.register({ - id: callbackId, - type: 'listen', - cleanup: options?.onCancel, - timeoutMs: options?.timeout, - }); - - const unlisten = await listen(event, (event) => { - - const currentManager = getTauriCallbackManager(); - if (currentManager.getStats().total === 0) { - return; - } - - try { - handler(event); - } catch (error) { - console.error(`[safeListen] Handler error for event ${event}:`, error); - } - }); - - const enhancedUnlisten = () => { - try { - unlisten(); - } catch (error) { - console.warn(`[safeListen] Failed to unlisten from ${event}:`, error); - } - manager.unregister(callbackId); - }; - - return enhancedUnlisten; - } catch (error) { - - manager.unregister(callbackId); - throw error; - } -} - -export function cleanupAllTauriCallbacks(): void { - if (globalCallbackManager) { - globalCallbackManager.cleanupAll(); - globalCallbackManager.removeWindowHandlers(); - globalCallbackManager = null; - } -} diff --git a/packages/desktop/src/main.tsx b/packages/desktop/src/main.tsx deleted file mode 100644 index 82e67106..00000000 --- a/packages/desktop/src/main.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import { createDesktopAPIs } from './api'; -import { requestInitialNotificationPermission } from './api/notifications'; -import { checkForUpdates, downloadUpdate, restartToUpdate, type UpdateInfo, type UpdateProgress } from './api/updater'; -import { initializeDesktopBridge } from './lib/bridge'; - -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; -import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; -import type { DesktopApi, DesktopSettings } from '@openchamber/ui/lib/desktop'; -import '@openchamber/ui/index.css'; -import '@openchamber/ui/styles/fonts'; - -if (!(window as typeof globalThis & { process?: unknown }).process) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as typeof globalThis & { process?: any }).process = { - env: {}, - platform: 'darwin', - version: 'v20.0.0', - versions: {}, - cwd: () => '/', - nextTick: (fn: () => void) => Promise.resolve().then(() => fn()), - }; -} - -if (import.meta.env.PROD) { - document.addEventListener('keydown', (e) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'r') { - e.preventDefault(); - } - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'r') { - e.preventDefault(); - } - }); - - document.addEventListener('contextmenu', (e) => { - e.preventDefault(); - }); -} - -declare global { - interface Window { - __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs; - __OPENCHAMBER_HOME__?: string; - opencodeDesktop?: DesktopApi; - } -} - -const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates'; -const MENU_ACTION_EVENT = 'openchamber:menu-action'; - -const cleanupFunctions: Array<() => void | Promise> = []; - -try { - await initializeDesktopBridge(); - - const activityUnlisten = await listen('openchamber:session-activity', (event) => { - window.dispatchEvent(new CustomEvent('openchamber:session-activity', { detail: event.payload })); - }); - cleanupFunctions.push(() => activityUnlisten()); - - const updateCheckUnlisten = await listen(CHECK_FOR_UPDATES_EVENT, () => { - window.dispatchEvent(new CustomEvent(CHECK_FOR_UPDATES_EVENT)); - }); - cleanupFunctions.push(() => updateCheckUnlisten()); - - const menuActionUnlisten = await listen(MENU_ACTION_EVENT, (event) => { - window.dispatchEvent(new CustomEvent(MENU_ACTION_EVENT, { detail: event.payload })); - }); - cleanupFunctions.push(() => menuActionUnlisten()); - - requestInitialNotificationPermission().catch(err => { - console.error('[main] Failed to request notification permission:', err); - }); - - window.__OPENCHAMBER_RUNTIME_APIS__ = createDesktopAPIs(); - - cleanupFunctions.push(() => { - console.info('[main] Cleaning up runtime APIs'); - - if (window.__OPENCHAMBER_RUNTIME_APIS__) { - /* cleanup placeholder */ - } - }); - -} catch (error) { - console.error('[main] FATAL: Failed to initialize desktop runtime:', error); - - for (const cleanup of cleanupFunctions) { - try { - const result = cleanup(); - if (result instanceof Promise) { - await result; - } - } catch (cleanupError) { - console.warn('[main] Cleanup function failed during error handling:', cleanupError); - } - } - - document.body.innerHTML = ` -
-

Desktop Runtime Initialization Failed

-
-${error instanceof Error ? error.stack : String(error)}
-      
-

Press Cmd+Option+I to open DevTools for more details

-
- `; - throw error; -} - -let homeDirectory: string | undefined; -try { - const { homeDir } = await import('@tauri-apps/api/path'); - homeDirectory = await homeDir(); -} catch { - homeDirectory = undefined; -} - -if (homeDirectory) { - window.__OPENCHAMBER_HOME__ = homeDirectory; -} - -window.opencodeDesktop = { - homeDirectory, - macosMajorVersion: null as number | null, - async getServerInfo() { - try { - const info = await invoke('desktop_server_info'); - return { - webPort: info.server_port, - openCodePort: info.opencode_port ?? null, - host: '127.0.0.1', - ready: info.opencode_port !== null, - cliAvailable: info.cli_available ?? false, - }; - } catch { - const server = window.__OPENCHAMBER_DESKTOP_SERVER__; - return { - webPort: server?.origin ? parseInt(server.origin.split(':')[2] || '0', 10) : null, - openCodePort: server?.opencodePort ?? null, - host: '127.0.0.1', - ready: false, - cliAvailable: server?.cliAvailable ?? false, - }; - } - }, - async getSettings(): Promise { - const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings'); - return result.settings; - }, - async updateSettings(changes: Partial): Promise { - const result = await invoke('save_settings', { changes }); - return result; - }, - async restartOpenCode() { - try { - await invoke('restart_opencode'); - return { success: true }; - } catch (error) { - console.error('[desktop] Error restarting OpenCode:', error); - return { success: false }; - } - }, - async shutdown() { - return { success: false }; - }, - async getHomeDirectory() { - return { success: true, path: homeDirectory || null }; - }, - async openExternal(url: string) { - try { - await open(url); - return { success: true }; - } catch (error) { - console.error('[desktop] Error opening external link:', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }, - markRendererReady() { - - }, - async requestDirectoryAccess(directoryPath?: string) { - try { - const normalized = typeof directoryPath === 'string' ? directoryPath.trim() : ''; - - // When the UI already picked a path (typed / directory tree), skip native dialog. - if (normalized.length > 0) { - const result = await invoke<{ - success: boolean; - path?: string; - projectId?: string; - error?: string; - }>('process_directory_selection', { - path: normalized, - }); - - return result; - } - - const { open } = await import('@tauri-apps/plugin-dialog'); - const selected = await open({ - directory: true, - multiple: false, - title: 'Select Working Directory', - }); - - if (!selected || typeof selected !== 'string') { - return { success: false, error: 'Directory selection cancelled' }; - } - - const result = await invoke<{ - success: boolean; - path?: string; - projectId?: string; - error?: string; - }>('process_directory_selection', { - path: selected, - }); - - return result; - } catch (error) { - console.error('[desktop] Error requesting directory access:', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }, - async startAccessingDirectory(directoryPath: string) { - try { - const result = await invoke<{ success: boolean; error?: string }>('start_accessing_directory', { path: directoryPath }); - return result; - } catch (error) { - console.error('[desktop] Error starting directory access:', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }, - - async stopAccessingDirectory(directoryPath: string) { - try { - const result = await invoke<{ success: boolean; error?: string }>('stop_accessing_directory', { path: directoryPath }); - return result; - } catch (error) { - console.error('[desktop] Error stopping directory access:', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }, - async notifyAssistantCompletion(payload) { - try { - const { createDesktopNotificationsAPI } = await import('./api/notifications'); - const result = await createDesktopNotificationsAPI().notifyAgentCompletion(payload); - return { success: result }; - } catch (error) { - console.error('[desktop] Error sending notification:', error); - return { success: false }; - } - }, - async checkForUpdates(): Promise { - return checkForUpdates(); - }, - async downloadUpdate(onProgress?: (progress: UpdateProgress) => void): Promise { - return downloadUpdate(onProgress); - }, - async restartToUpdate(): Promise { - return restartToUpdate(); - } -}; - -// Fetch macOS version from Rust -try { - const macosVersion = await invoke('desktop_get_macos_version'); - window.opencodeDesktop.macosMajorVersion = macosVersion > 0 ? macosVersion : null; - console.info('[main] macOS version:', macosVersion); -} catch (err) { - console.warn('[main] Failed to get macOS version:', err); - window.opencodeDesktop.macosMajorVersion = null; -} - -console.info('[main] window.opencodeDesktop assigned'); - -if (typeof window !== 'undefined') { - const handleBeforeUnload = () => { - console.info('[main] App is unloading, performing cleanup...'); - - cleanupFunctions.forEach((cleanup) => { - try { - const result = cleanup(); - if (result instanceof Promise) { - - result.catch(cleanupError => { - console.warn('[main] Cleanup function failed during unload:', cleanupError); - }); - } - } catch (cleanupError) { - console.warn('[main] Cleanup function failed during unload:', cleanupError); - } - }); - - console.info('[main] Cleanup initiated'); - }; - - window.addEventListener('beforeunload', handleBeforeUnload); - - window.addEventListener('pagehide', handleBeforeUnload); - - cleanupFunctions.push(() => { - window.removeEventListener('beforeunload', handleBeforeUnload); - window.removeEventListener('pagehide', handleBeforeUnload); - }); -} - -interface ServerInfo { - server_port: number; - opencode_port: number | null; - api_prefix: string; - cli_available: boolean; - has_last_directory: boolean; -} - -// Check if we need to prompt for directory selection first -const promptForDirectoryIfNeeded = async (): Promise => { - try { - const info = await invoke('desktop_server_info'); - // If CLI available but no saved directory, prompt user - if (info.cli_available && !info.has_last_directory) { - console.info('[main] No saved directory - prompting user'); - const { open } = await import('@tauri-apps/plugin-dialog'); - const selected = await open({ - directory: true, - multiple: false, - title: 'Select a project folder to get started' - }); - - if (selected && typeof selected === 'string') { - await invoke('process_directory_selection', { path: selected }); - await invoke('restart_opencode'); - } - } - } catch (error) { - console.error('[main] Directory selection failed:', error); - } -}; - -// Check if directory selection is needed, then wait for opencode -await promptForDirectoryIfNeeded(); - -// Wait for opencode to be ready (or timeout if no CLI) -const waitForOpencode = async (): Promise => { - const maxAttempts = 50; - for (let i = 0; i < maxAttempts; i++) { - const info = await invoke('desktop_server_info'); - // Ready if opencode running, or no CLI (will show onboarding) - if (!info.cli_available || info.opencode_port !== null) { - return; - } - await new Promise(r => setTimeout(r, 200)); - } -}; -await waitForOpencode(); - -try { - await import('@openchamber/ui/main'); -} catch (error) { - console.error('[main] FATAL: Failed to load UI module:', error); - document.body.innerHTML = ` -
-

UI Module Load Failed

-
-${error instanceof Error ? error.stack : String(error)}
-      
-

Check DevTools console for details

-
- `; - throw error; -} diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json deleted file mode 100644 index 994cb50c..00000000 --- a/packages/desktop/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "moduleDetection": "force", - "verbatimModuleSyntax": true, - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "baseUrl": ".", - "types": ["vite/client"], - "paths": { - "@/*": ["../ui/src/*"], - "@desktop/*": ["./src/*"], - "@openchamber/ui/*": ["../ui/src/*"], - "@openchamber/desktop/*": ["./src/*"] - } - }, - "include": ["src", "../ui/src", "../ui/src/types/**/*"] -} diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts deleted file mode 100644 index 0a801158..00000000 --- a/packages/desktop/vite.config.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { readFileSync } from 'node:fs'; -import { themeStoragePlugin } from '../../vite-theme-plugin'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8')); - -export default defineConfig({ - root: path.resolve(__dirname, '.'), - plugins: [react(), themeStoragePlugin()], - resolve: { - alias: [ - { find: '@opencode-ai/sdk/v2', replacement: path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/v2/client.js') }, - { find: '@openchamber/ui', replacement: path.resolve(__dirname, '../ui/src') }, - { find: '@desktop', replacement: path.resolve(__dirname, './src') }, - { find: '@', replacement: path.resolve(__dirname, '../ui/src') }, - ], - }, - worker: { - format: 'es', - }, - define: { - 'process.env': {}, - 'process.platform': JSON.stringify('darwin'), - 'process.version': JSON.stringify('v20.0.0'), - 'process.versions': JSON.stringify({}), - global: 'globalThis', - __APP_VERSION__: JSON.stringify(packageJson.version), - }, - optimizeDeps: { - include: ['@opencode-ai/sdk/v2'], - exclude: [ - '@tauri-apps/plugin-dialog', - '@tauri-apps/api/core', - '@tauri-apps/api/path', - ], - }, - server: { - host: '127.0.0.1', - port: 1421, - strictPort: true, - hmr: { - protocol: 'ws', - host: '127.0.0.1', - port: 1421, - }, - }, - build: { - outDir: path.resolve(__dirname, 'dist'), - emptyOutDir: true, - chunkSizeWarningLimit: 1200, - rollupOptions: { - output: { - manualChunks(id) { - if (!id.includes('node_modules')) return undefined; - - const match = id.split('node_modules/')[1]; - if (!match) return undefined; - - const segments = match.split('/'); - const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0]; - - if (packageName === 'react' || packageName === 'react-dom') return 'vendor-react'; - if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand'; - - if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk'; - if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown'; - if (packageName.startsWith('@radix-ui')) return 'vendor-radix'; - if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax'; - if (packageName.startsWith('@tauri-apps')) return 'vendor-tauri'; - - const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-'); - return `vendor-${sanitized}`; - }, - }, - }, - }, -}); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 9fd8cbce..6e87be15 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -16,6 +16,8 @@ import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { GitPollingProvider } from '@/hooks/useGitPolling'; import { useConfigStore } from '@/stores/useConfigStore'; import { hasModifier } from '@/lib/utils'; +import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop'; +import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { opencodeClient } from '@/lib/opencode/client'; @@ -25,8 +27,6 @@ import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { AboutDialog } from '@/components/ui/AboutDialog'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen'; -import { isCliAvailable } from '@/lib/desktop'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import type { RuntimeAPIs } from '@/lib/api/types'; @@ -53,17 +53,12 @@ function App({ apis }: AppProps) { const [showMemoryDebug, setShowMemoryDebug] = React.useState(false); const { uiFont, monoFont } = useFontPreferences(); const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); - const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => apis.runtime.isDesktop); const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode); - const [cliAvailable, setCliAvailable] = React.useState(() => { - if (!apis.runtime.isDesktop) return true; - return isCliAvailable(); - }); + const [showCliOnboarding, setShowCliOnboarding] = React.useState(false); React.useEffect(() => { - setIsDesktopRuntime(apis.runtime.isDesktop); setIsVSCodeRuntime(apis.runtime.isVSCode); - }, [apis.runtime.isDesktop, apis.runtime.isVSCode]); + }, [apis.runtime.isVSCode]); React.useEffect(() => { registerRuntimeAPIs(apis); @@ -175,6 +170,19 @@ function App({ apis }: AppProps) { useMenuActions(handleToggleMemoryDebug); + const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); + React.useEffect(() => { + if (!isTauriShell()) { + return; + } + const tauri = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record) => Promise } } }).__TAURI__; + if (typeof tauri?.core?.invoke !== 'function') { + return; + } + + void tauri.core.invoke('desktop_set_auto_worktree_menu', { enabled: settingsAutoCreateWorktree }); + }, [settingsAutoCreateWorktree]); + useSessionStatusBootstrap(); @@ -199,15 +207,42 @@ function App({ apis }: AppProps) { } }, [error, clearError]); + React.useEffect(() => { + if (!isDesktopShell() || !isDesktopLocalOriginActive()) { + return; + } + + let cancelled = false; + const run = async () => { + try { + const res = await fetch('/health', { method: 'GET' }); + if (!res.ok) return; + const data = (await res.json().catch(() => null)) as null | { openCodeRunning?: unknown; lastOpenCodeError?: unknown }; + if (!data || cancelled) return; + const openCodeRunning = data.openCodeRunning === true; + const err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : ''; + const cliMissing = !openCodeRunning && /ENOENT|spawn\s+opencode|opencode(\.exe)?\s+not\s+found|not\s+found/i.test(err); + setShowCliOnboarding(cliMissing); + } catch { + // ignore + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, []); + const handleCliAvailable = React.useCallback(() => { - setCliAvailable(true); + setShowCliOnboarding(false); window.location.reload(); }, []); - if (isDesktopRuntime && !cliAvailable) { + if (showCliOnboarding) { return ( -
+
diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 99f6b3bf..0f87beeb 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -2,9 +2,10 @@ import React from 'react'; import { RiLockLine, RiLockUnlockLine, RiLoader4Line } from '@remixicon/react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop'; +import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence'; import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence'; +import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher'; const STATUS_CHECK_ENDPOINT = '/auth/session'; @@ -119,9 +120,9 @@ const clearTokenFromUrl = () => { }; export const SessionAuthGate: React.FC = ({ children }) => { - const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []); const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []); - const skipAuth = desktopRuntime || vscodeRuntime; + const skipAuth = vscodeRuntime; + const showHostSwitcher = React.useMemo(() => isDesktopShell() && !vscodeRuntime, [vscodeRuntime]); const [state, setState] = React.useState(() => (skipAuth ? 'authenticated' : 'pending')); const [password, setPassword] = React.useState(''); const [isSubmitting, setIsSubmitting] = React.useState(false); @@ -349,6 +350,15 @@ export const SessionAuthGate: React.FC = ({ children }) =>

)} + + {showHostSwitcher && ( +
+ +

+ Use Local if remote is unreachable. +

+
+ )}
); diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 68e9d332..d46fb4cb 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -30,7 +30,7 @@ export const ChatContainer: React.FC = () => { isSyncing, messageStreamStates, trimToViewportWindow, - sessionActivityPhase, + sessionStatus, newSessionDraft, } = useSessionStore(); @@ -161,8 +161,8 @@ export const ChatContainer: React.FC = () => { try { await loadMessages(currentSessionId); } finally { - const currentPhase = sessionActivityPhase?.get(currentSessionId) ?? 'idle'; - const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown'; + const statusType = sessionStatus?.get(currentSessionId)?.type ?? 'idle'; + const isActivePhase = statusType === 'busy' || statusType === 'retry'; // When pinned and active, scroll is already maintained automatically const shouldSkipScroll = isActivePhase && isPinned; @@ -179,7 +179,7 @@ export const ChatContainer: React.FC = () => { }; void load(); - }, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionActivityPhase]); + }, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionStatus]); if (!currentSessionId && !draftOpen) { return ( @@ -266,14 +266,7 @@ export const ChatContainer: React.FC = () => { diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 7de52353..5a7bfbec 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -232,6 +232,9 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const canAbort = working.isWorking; + // Keep a ref to handleSubmit so callbacks don't depend on it. + const handleSubmitRef = React.useRef<(e?: React.FormEvent) => Promise>(async () => {}); + // Add message to queue instead of sending const handleQueueMessage = React.useCallback(() => { if (!hasContent || !currentSessionId) return; @@ -448,23 +451,21 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }; + handleSubmitRef.current = handleSubmit; + // Primary action for send button - respects queue mode setting const handlePrimaryAction = React.useCallback(() => { const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle'; if (queueModeEnabled && canQueue) { handleQueueMessage(); } else { - void handleSubmit(); + void handleSubmitRef.current(); } - }, [hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage, handleSubmit]); - - // Keep a ref to handleSubmit for auto-send effect - const handleSubmitRef = React.useRef(handleSubmit); - handleSubmitRef.current = handleSubmit; + }, [hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]); // Auto-send queued messages when session becomes idle (but not after abort) React.useEffect(() => { - const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'cooldown'; + const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'retry'; const isNowIdle = sessionPhase === 'idle'; // Check if session was recently aborted (within last 2 seconds) @@ -1483,8 +1484,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo isWaitingForPermission={working.isWaitingForPermission} wasAborted={working.wasAborted} abortActive={working.abortActive} - completionId={working.lastCompletionId} - isComplete={working.isComplete} showAbortStatus={showAbortStatus} /> diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 24c1f13d..a2f05062 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -38,7 +38,8 @@ import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { useIsDesktopRuntime, useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; +import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; +import { isDesktopShell } from '@/lib/desktop'; import { getAgentColor } from '@/lib/agentColors'; import { useDeviceInfo } from '@/lib/device'; import { getEditModeColors } from '@/lib/permissions/editModeColors'; @@ -346,7 +347,7 @@ export const ModelControls: React.FC = ({ const { favoriteModelsList, recentModelsList } = useModelLists(); const { isMobile } = useDeviceInfo(); - const isDesktopRuntime = useIsDesktopRuntime(); + const isDesktop = React.useMemo(() => isDesktopShell(), []); const isVSCodeRuntime = useIsVSCodeRuntime(); // Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns const isCompact = isMobile; @@ -2405,7 +2406,7 @@ export const ModelControls: React.FC = ({ 'model-controls__variant-label', controlTextSize, 'font-medium min-w-0 truncate', - isDesktopRuntime ? 'max-w-[180px]' : undefined, + isDesktop ? 'max-w-[180px]' : undefined, colorClass, )} > @@ -2473,7 +2474,7 @@ export const ModelControls: React.FC = ({ 'model-controls__agent-label', controlTextSize, 'font-medium min-w-0 truncate', - isDesktopRuntime ? 'max-w-[220px]' : undefined + isDesktop ? 'max-w-[220px]' : undefined )} style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined} > diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 8a44123a..3012ea75 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -53,8 +53,6 @@ interface StatusRowProps { isWaitingForPermission?: boolean; wasAborted?: boolean; abortActive?: boolean; - completionId?: string | null; - isComplete?: boolean; // Abort state (for mobile/vscode) showAbort?: boolean; onAbort?: () => void; @@ -69,8 +67,6 @@ export const StatusRow: React.FC = ({ isWaitingForPermission, wasAborted, abortActive, - completionId, - isComplete, showAbort, onAbort, showAbortStatus, @@ -124,17 +120,16 @@ export const StatusRow: React.FC = ({ const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending"); // Original logic from ChatInput const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive); - - // Track if placeholder is showing result (done/aborted) to keep StatusRow mounted - const [placeholderShowingResult, setPlaceholderShowingResult] = React.useState(false); - + // Keep StatusRow rendered while: // - isWorking (active session) - // - isComplete (showing "Done" result) - // - wasAborted (showing "Aborted" result) - // - placeholderShowingResult (placeholder still displaying result) - // - hasActiveTodos or showAbortStatus - const hasContent = isWorking || isComplete || wasAborted || placeholderShowingResult || hasActiveTodos || showAbortStatus; + // - wasAborted / showAbortStatus + // - hasActiveTodos + const hasContent = + isWorking || + Boolean(wasAborted) || + Boolean(showAbortStatus) || + hasActiveTodos; // Close popover when clicking outside const popoverRef = React.useRef(null); @@ -212,13 +207,10 @@ export const StatusRow: React.FC = ({ ) : shouldRenderPlaceholder ? ( ) : null} diff --git a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx index 6fc99e77..90cc0a92 100644 --- a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx +++ b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx @@ -1,254 +1,132 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import React from 'react'; import { Text } from '@/components/ui/text'; interface WorkingPlaceholderProps { - statusText: string | null; - isGenericStatus?: boolean; - isWaitingForPermission?: boolean; - wasAborted?: boolean; - completionId?: string | null; - isComplete?: boolean; - onResultVisibilityChange?: (isShowingResult: boolean) => void; + isWorking: boolean; + statusText: string | null; + isGenericStatus?: boolean; + isWaitingForPermission?: boolean; } -const STATUS_DISPLAY_TIME = 1500; // Minimum time to show each status -const DONE_DISPLAY_TIME = 2000; // Time to show Done/Aborted status - -type PlaceholderState = 'idle' | 'showing' | 'done' | 'aborted'; +const STATUS_DISPLAY_TIME_MS = 1200; export function WorkingPlaceholder({ + isWorking, + statusText, + isGenericStatus, + isWaitingForPermission, +}: WorkingPlaceholderProps) { + const [displayedText, setDisplayedText] = React.useState(null); + const [displayedPermission, setDisplayedPermission] = React.useState(false); + + const statusShownAtRef = React.useRef(0); + const queuedStatusRef = React.useRef<{ text: string; permission: boolean } | null>(null); + const processQueueTimerRef = React.useRef | null>(null); + + const clearTimers = React.useCallback(() => { + if (processQueueTimerRef.current) { + clearTimeout(processQueueTimerRef.current); + processQueueTimerRef.current = null; + } + }, []); + + const showStatus = React.useCallback((text: string, permission: boolean) => { + clearTimers(); + queuedStatusRef.current = null; + setDisplayedText(text); + setDisplayedPermission(permission); + statusShownAtRef.current = Date.now(); + }, [clearTimers]); + + const scheduleQueueProcess = React.useCallback(() => { + if (processQueueTimerRef.current) return; + const elapsed = Date.now() - statusShownAtRef.current; + const remaining = Math.max(0, STATUS_DISPLAY_TIME_MS - elapsed); + processQueueTimerRef.current = setTimeout(() => { + processQueueTimerRef.current = null; + + const queued = queuedStatusRef.current; + if (queued) { + showStatus(queued.text, queued.permission); + } + }, remaining); + }, [showStatus]); + + React.useEffect(() => { + if (!isWorking) { + clearTimers(); + queuedStatusRef.current = null; + setDisplayedText(null); + setDisplayedPermission(false); + return; + } + + const incomingText = isWaitingForPermission ? 'waiting for permission' : statusText; + const incomingPermission = Boolean(isWaitingForPermission); + const incomingGeneric = Boolean(isGenericStatus) && !incomingPermission; + + if (!incomingText) { + return; + } + + if (!displayedText) { + showStatus(incomingText, incomingPermission); + return; + } + + if (incomingText === displayedText && incomingPermission === displayedPermission) { + return; + } + + // Ignore generic churn. + if (incomingGeneric) { + return; + } + + const elapsed = Date.now() - statusShownAtRef.current; + if (elapsed >= STATUS_DISPLAY_TIME_MS) { + showStatus(incomingText, incomingPermission); + return; + } + + queuedStatusRef.current = { text: incomingText, permission: incomingPermission }; + scheduleQueueProcess(); + }, [ + isWorking, statusText, isGenericStatus, isWaitingForPermission, - wasAborted, - completionId, - isComplete, - onResultVisibilityChange, -}: WorkingPlaceholderProps) { - // Internal state machine - const [state, setState] = useState('idle'); - const [displayedText, setDisplayedText] = useState(null); - const [displayedPermission, setDisplayedPermission] = useState(false); + displayedText, + displayedPermission, + clearTimers, + showStatus, + scheduleQueueProcess, + ]); - // Refs for timing - const statusShownAtRef = useRef(0); - const queuedStatusRef = useRef<{ text: string; permission: boolean } | null>(null); - const processQueueTimerRef = useRef | null>(null); - const doneTimerRef = useRef | null>(null); - const lastCompletionIdRef = useRef(null); - // Track if we've ever shown activity in this turn - const hasShownActivityRef = useRef(false); - // Track the previous isComplete value to detect edges - const prevIsCompleteRef = useRef(false); - const prevWasAbortedRef = useRef(false); + React.useEffect(() => () => clearTimers(), [clearTimers]); - // Clear all timers - const clearTimers = useCallback(() => { - if (processQueueTimerRef.current) { - clearTimeout(processQueueTimerRef.current); - processQueueTimerRef.current = null; - } - if (doneTimerRef.current) { - clearTimeout(doneTimerRef.current); - doneTimerRef.current = null; - } - }, []); + if (!isWorking || !displayedText) { + return null; + } - // Show a status immediately - const showStatus = useCallback((text: string, permission: boolean) => { - clearTimers(); - queuedStatusRef.current = null; - setDisplayedText(text); - setDisplayedPermission(permission); - setState('showing'); - statusShownAtRef.current = Date.now(); - hasShownActivityRef.current = true; - }, [clearTimers]); + const label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1); + const displayText = `${label}...`; - // Schedule processing of queued status - const scheduleQueueProcess = useCallback(() => { - if (processQueueTimerRef.current) return; // Already scheduled - - const elapsed = Date.now() - statusShownAtRef.current; - const remaining = Math.max(0, STATUS_DISPLAY_TIME - elapsed); - - processQueueTimerRef.current = setTimeout(() => { - processQueueTimerRef.current = null; - const queued = queuedStatusRef.current; - if (queued) { - showStatus(queued.text, queued.permission); - } - // If nothing queued, keep showing current status - }, remaining); - }, [showStatus]); - - // Show done/aborted result - const showResult = useCallback((result: 'done' | 'aborted') => { - clearTimers(); - queuedStatusRef.current = null; - - // Only show result if we had activity - if (!hasShownActivityRef.current) { - setState('idle'); - setDisplayedText(null); - onResultVisibilityChange?.(false); - return; - } - - // Skip duplicate completion for same completionId - if (result === 'done' && completionId && lastCompletionIdRef.current === completionId) { - setState('idle'); - setDisplayedText(null); - hasShownActivityRef.current = false; - onResultVisibilityChange?.(false); - return; - } - - if (result === 'done' && completionId) { - lastCompletionIdRef.current = completionId; - } - - setState(result); - setDisplayedText(null); - onResultVisibilityChange?.(true); - - // Auto-hide after DONE_DISPLAY_TIME - doneTimerRef.current = setTimeout(() => { - doneTimerRef.current = null; - setState('idle'); - hasShownActivityRef.current = false; - onResultVisibilityChange?.(false); - }, DONE_DISPLAY_TIME); - }, [clearTimers, completionId, onResultVisibilityChange]); - - // Main effect: handle prop changes - useEffect(() => { - // Detect abort edge (false -> true) - if (wasAborted && !prevWasAbortedRef.current) { - prevWasAbortedRef.current = true; - showResult('aborted'); - return; - } - prevWasAbortedRef.current = !!wasAborted; - - // Detect completion edge (false -> true) - if (isComplete && !prevIsCompleteRef.current) { - prevIsCompleteRef.current = true; - showResult('done'); - return; - } - // Reset edge detection when isComplete goes back to false - if (!isComplete && prevIsCompleteRef.current) { - prevIsCompleteRef.current = false; - } - - // If we're showing done/aborted, don't process new status - if (state === 'done' || state === 'aborted') { - return; - } - - // Handle new status text - if (statusText) { - const now = Date.now(); - const elapsed = now - statusShownAtRef.current; - - if (state === 'idle' || !displayedText) { - // Not showing anything - show immediately (generic OK at turn start) - showStatus(statusText, !!isWaitingForPermission); - } else if (statusText !== displayedText || !!isWaitingForPermission !== displayedPermission) { - // Already showing something - ignore generic statuses - if (isGenericStatus) { - return; - } - - // Different specific status - if (elapsed >= STATUS_DISPLAY_TIME) { - // Minimum time passed - show immediately - showStatus(statusText, !!isWaitingForPermission); - } else { - // Queue the latest (overwrites previous queued) - queuedStatusRef.current = { text: statusText, permission: !!isWaitingForPermission }; - scheduleQueueProcess(); - } - } - // Same status - keep showing - } - // IMPORTANT: When statusText becomes null, we do NOT clear the display - // Only done/abort signals clear the display - - }, [statusText, isWaitingForPermission, wasAborted, isComplete, state, displayedText, displayedPermission, showStatus, showResult, scheduleQueueProcess, isGenericStatus]); - - // Cleanup on unmount - useEffect(() => { - return () => clearTimers(); - }, [clearTimers]); - - // Handle tab visibility changes - useEffect(() => { - const handleVisibilityChange = () => { - if (typeof document === 'undefined') return; - if (document.visibilityState !== 'visible') return; - - // If showing done/aborted when user returns, hide it - if (state === 'done' || state === 'aborted') { - clearTimers(); - setState('idle'); - setDisplayedText(null); - hasShownActivityRef.current = false; - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => document.removeEventListener('visibilitychange', handleVisibilityChange); - }, [state, clearTimers]); - - // Render nothing if idle with no text - if (state === 'idle' && !displayedText) { - return null; - } - - // Determine what to show - let label: string; - let showEllipsis = true; - - if (state === 'done') { - label = 'Done'; - showEllipsis = false; - } else if (state === 'aborted') { - label = 'Aborted'; - showEllipsis = false; - } else if (displayedText) { - label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1); - } else { - label = 'Working'; - } - - const displayText = showEllipsis ? `${label}...` : label; - const isVisible = state !== 'idle'; - - return ( -
- - {state === 'done' ? ( - - Done - - ) : state === 'aborted' ? ( - - Aborted - - ) : ( - - {displayText} - - )} - -
- ); + return ( +
+ + + {displayText} + + +
+ ); } diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx new file mode 100644 index 00000000..f92a9095 --- /dev/null +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -0,0 +1,704 @@ +import * as React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + RiCheckLine, + RiCloudOffLine, + RiEarthLine, + RiLoader4Line, + RiMore2Line, + RiPencilLine, + RiRefreshLine, + RiServerLine, + RiShieldKeyholeLine, + RiStarFill, + RiStarLine, + RiDeleteBinLine, +} from '@remixicon/react'; +import { cn } from '@/lib/utils'; +import { isTauriShell, isDesktopShell } from '@/lib/desktop'; +import { + desktopHostProbe, + desktopHostsGet, + desktopHostsSet, + type DesktopHost, + type HostProbeResult, +} from '@/lib/desktopHosts'; + +const LOCAL_HOST_ID = 'local'; + +type HostStatus = { + status: HostProbeResult['status']; + latencyMs: number; +}; + +const normalizeHostUrl = (raw: string): string | null => { + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + const url = new URL(trimmed); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + return url.origin; + } catch { + // Tauri/WebKit edge: accept origin without trailing slash. + try { + const url = new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + return url.origin; + } catch { + return null; + } + } +}; + +const toNavigationUrl = (origin: string): string => { + const trimmed = origin.trim(); + if (!trimmed) return trimmed; + return trimmed.endsWith('/') ? trimmed : `${trimmed}/`; +}; + +const getLocalOrigin = (): string => { + if (typeof window === 'undefined') return ''; + return window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin; +}; + +const makeId = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `host-${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + +const statusDotClass = (status: HostProbeResult['status'] | null): string => { + if (status === 'ok') return 'bg-status-success'; + if (status === 'auth') return 'bg-status-warning'; + if (status === 'unreachable') return 'bg-status-error'; + return 'bg-muted-foreground/40'; +}; + +const statusLabel = (status: HostProbeResult['status'] | null): string => { + if (status === 'ok') return 'Connected'; + if (status === 'auth') return 'Auth required'; + if (status === 'unreachable') return 'Unreachable'; + return 'Unknown'; +}; + +const statusIcon = (status: HostProbeResult['status'] | null) => { + if (status === 'ok') return ; + if (status === 'auth') return ; + if (status === 'unreachable') return ; + return ; +}; + +const buildLocalHost = (): DesktopHost => ({ + id: LOCAL_HOST_ID, + label: 'Local', + url: getLocalOrigin(), +}); + +const resolveCurrentHost = (hosts: DesktopHost[]) => { + const currentOrigin = typeof window === 'undefined' ? '' : window.location.origin; + const localOrigin = getLocalOrigin(); + const normalizedCurrent = normalizeHostUrl(currentOrigin) || currentOrigin; + const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin; + + if (normalizedCurrent && normalizedLocal && normalizedCurrent === normalizedLocal) { + return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal }; + } + + const match = hosts.find((h) => { + const normalized = normalizeHostUrl(h.url); + return normalized && normalized === normalizedCurrent; + }); + + if (match) { + return { id: match.id, label: match.label, url: normalizeHostUrl(match.url) || match.url }; + } + + return { + id: 'custom', + label: normalizedCurrent || 'Instance', + url: normalizedCurrent, + }; +}; + +type DesktopHostSwitcherDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwitcherDialogProps) { + const [configHosts, setConfigHosts] = React.useState([]); + const [defaultHostId, setDefaultHostId] = React.useState(null); + const [statusById, setStatusById] = React.useState>({}); + const [isLoading, setIsLoading] = React.useState(false); + const [isProbing, setIsProbing] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + const [error, setError] = React.useState(''); + + const [editingId, setEditingId] = React.useState(null); + const [editLabel, setEditLabel] = React.useState(''); + const [editUrl, setEditUrl] = React.useState(''); + + const [newLabel, setNewLabel] = React.useState(''); + const [newUrl, setNewUrl] = React.useState(''); + + const allHosts = React.useMemo(() => { + const local = buildLocalHost(); + const normalizedRemote = configHosts.map((h) => ({ + ...h, + url: normalizeHostUrl(h.url) || h.url, + })); + return [local, ...normalizedRemote]; + }, [configHosts]); + + const current = React.useMemo(() => resolveCurrentHost(allHosts), [allHosts]); + const currentDefaultLabel = React.useMemo(() => { + const id = defaultHostId || LOCAL_HOST_ID; + return allHosts.find((h) => h.id === id)?.label || 'Local'; + }, [allHosts, defaultHostId]); + + const persist = React.useCallback(async (nextHosts: DesktopHost[], nextDefaultHostId: string | null) => { + if (!isTauriShell()) return; + setIsSaving(true); + setError(''); + try { + // Persist only remote hosts; Local is derived. + const remote = nextHosts.filter((h) => h.id !== LOCAL_HOST_ID); + await desktopHostsSet({ hosts: remote, defaultHostId: nextDefaultHostId }); + setConfigHosts(remote); + setDefaultHostId(nextDefaultHostId); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setIsSaving(false); + } + }, []); + + const refresh = React.useCallback(async () => { + if (!isTauriShell()) return; + setIsLoading(true); + setError(''); + try { + const cfg = await desktopHostsGet(); + setConfigHosts(cfg.hosts || []); + setDefaultHostId(cfg.defaultHostId ?? null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load'); + setConfigHosts([]); + setDefaultHostId(null); + } finally { + setIsLoading(false); + } + }, []); + + const probeAll = React.useCallback(async (hosts: DesktopHost[]) => { + if (!isTauriShell()) return; + setIsProbing(true); + try { + const results = await Promise.all( + hosts.map(async (h) => { + const url = normalizeHostUrl(h.url); + if (!url) { + return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const; + } + const res = await desktopHostProbe(url).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const; + }) + ); + const next: Record = {}; + for (const [id, val] of results) { + next[id] = val; + } + setStatusById(next); + } finally { + setIsProbing(false); + } + }, []); + + React.useEffect(() => { + if (!open) { + setEditingId(null); + setEditLabel(''); + setEditUrl(''); + setNewLabel(''); + setNewUrl(''); + setError(''); + return; + } + void refresh(); + }, [open, refresh]); + + React.useEffect(() => { + if (!open) return; + void probeAll(allHosts); + }, [open, allHosts, probeAll]); + + const handleSwitch = React.useCallback((host: DesktopHost) => { + const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || ''); + if (!origin) return; + const target = toNavigationUrl(origin); + + try { + window.location.assign(target); + } catch { + window.location.href = target; + } + }, []); + + const beginEdit = React.useCallback((host: DesktopHost) => { + setEditingId(host.id); + setEditLabel(host.label); + setEditUrl(host.url); + setError(''); + }, []); + + const cancelEdit = React.useCallback(() => { + setEditingId(null); + setEditLabel(''); + setEditUrl(''); + }, []); + + const commitEdit = React.useCallback(async () => { + if (!editingId) return; + if (editingId === LOCAL_HOST_ID) { + cancelEdit(); + return; + } + + const url = normalizeHostUrl(editUrl); + if (!url) { + setError('Invalid URL (must be http/https)'); + return; + } + + const label = (editLabel || url).trim(); + const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h)); + await persist(nextHosts, defaultHostId); + cancelEdit(); + }, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist]); + + const addHost = React.useCallback(async () => { + const url = normalizeHostUrl(newUrl); + if (!url) { + setError('Invalid URL (must be http/https)'); + return; + } + const label = (newLabel || url).trim(); + const id = makeId(); + + const nextHosts = [{ id, label, url }, ...configHosts]; + await persist(nextHosts, defaultHostId); + setNewLabel(''); + setNewUrl(''); + }, [configHosts, defaultHostId, newLabel, newUrl, persist]); + + const deleteHost = React.useCallback(async (id: string) => { + if (id === LOCAL_HOST_ID) return; + const nextHosts = configHosts.filter((h) => h.id !== id); + const nextDefault = defaultHostId === id ? LOCAL_HOST_ID : defaultHostId; + await persist(nextHosts, nextDefault); + }, [configHosts, defaultHostId, persist]); + + const setDefault = React.useCallback(async (id: string) => { + const next = id === LOCAL_HOST_ID ? LOCAL_HOST_ID : id; + await persist(configHosts, next); + }, [configHosts, persist]); + + if (!isDesktopShell()) { + return null; + } + + const tauriAvailable = isTauriShell(); + + return ( + + + + + + Instance + + + Switch between Local and remote OpenChamber servers + + + +
+
+ Current: + {current.label} + Current default: + {currentDefaultLabel} +
+
+ +
+
+ + {!tauriAvailable && ( +
+
+ Instance switcher is limited on this page. Use Local to recover. +
+
+ )} + +
+
+ {isLoading ? ( +
Loading…
+ ) : ( + allHosts.map((host) => { + const isLocal = host.id === LOCAL_HOST_ID; + const isActive = host.id === current.id; + const isDefault = (defaultHostId || LOCAL_HOST_ID) === host.id; + const status = statusById[host.id] || null; + const isEditing = editingId === host.id; + const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url); + + return ( +
+ + +
+ + + + + + {isDefault ? 'Default' : 'Set as default'} + + + + {!isLocal && ( + + + + + + { + e.stopPropagation(); + beginEdit(host); + }} + disabled={isSaving} + > + + Edit + + { + e.stopPropagation(); + void deleteHost(host.id); + }} + className="text-destructive focus:text-destructive" + disabled={isSaving} + > + + Delete + + + + )} + + {isLocal && ( + +
+ ); + }) + )} +
+
+ + {tauriAvailable && editingId && editingId !== LOCAL_HOST_ID && ( +
+
+
Edit instance
+
+ + +
+
+
+ setEditLabel(e.target.value)} + placeholder="Label" + disabled={isSaving} + /> + setEditUrl(e.target.value)} + placeholder="https://host:port" + disabled={isSaving} + /> +
+
+ )} + +
+
+
Add instance
+ +
+
+ setNewLabel(e.target.value)} + placeholder="Label (optional)" + disabled={!tauriAvailable || isSaving} + /> + setNewUrl(e.target.value)} + placeholder="https://host:port" + disabled={!tauriAvailable || isSaving} + /> +
+
+ + {error && ( +
{error}
+ )} + +
+ ); +} + +type DesktopHostSwitcherButtonProps = { + headerIconButtonClass: string; +}; + +export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHostSwitcherButtonProps) { + const [open, setOpen] = React.useState(false); + const [label, setLabel] = React.useState('Local'); + const [status, setStatus] = React.useState(null); + + React.useEffect(() => { + if (!isTauriShell()) return; + + let cancelled = false; + const run = async () => { + try { + const cfg = await desktopHostsGet(); + const local = buildLocalHost(); + const all = [local, ...(cfg.hosts || [])]; + const current = resolveCurrentHost(all); + if (cancelled) return; + setLabel(current.label || 'Instance'); + const normalized = normalizeHostUrl(current.url); + if (!normalized) { + setStatus(null); + return; + } + const res = await desktopHostProbe(normalized).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + if (cancelled) return; + setStatus(res.status); + } catch { + if (!cancelled) { + setLabel('Instance'); + setStatus(null); + } + } + }; + + void run(); + const interval = window.setInterval(() => { + void run(); + }, 10_000); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, []); + + if (!isDesktopShell()) { + return null; + } + + const isCurrentlyLocal = (() => { + try { + const current = normalizeHostUrl(window.location.origin); + const local = normalizeHostUrl(getLocalOrigin()); + return Boolean(current && local && current === local); + } catch { + return false; + } + })(); + + // Fallback label when Tauri IPC is temporarily unavailable. + const fallbackLabel = (() => { + try { + const host = typeof window !== 'undefined' ? window.location.hostname : ''; + return host ? host : 'Instance'; + } catch { + return 'Instance'; + } + })(); + + const effectiveLabel = isCurrentlyLocal + ? 'Local' + : label === 'Local' + ? fallbackLabel + : label; + + return ( + <> + + + + + +

Instance

+
+
+ + + ); +} + +export function DesktopHostSwitcherInline() { + const [open, setOpen] = React.useState(false); + + if (!isDesktopShell()) { + return null; + } + + return ( + <> + + + + ); +} diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index b2e4df6d..5564727b 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -34,6 +34,8 @@ import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; import { updateDesktopSettings } from '@/lib/persistence'; import type { UsageWindow } from '@/types'; import type { GitHubAuthStatus } from '@/lib/api/types'; +import { DesktopHostSwitcherButton } from '@/components/desktop/DesktopHostSwitcher'; +import { isDesktopShell } from '@/lib/desktop'; const formatTime = (timestamp: number | null) => { if (!timestamp) return '-'; @@ -124,7 +126,7 @@ export const Header: React.FC = () => { if (typeof window === 'undefined') { return false; } - return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined'; + return isDesktopShell(); }); const isMacPlatform = React.useMemo(() => { @@ -138,11 +140,12 @@ export const Header: React.FC = () => { if (typeof window === 'undefined') { return null; } - // Use Tauri-provided version if available (accurate), otherwise fall back to UA parsing - const desktopApi = (window as typeof window & { opencodeDesktop?: { macosMajorVersion?: number | null } }).opencodeDesktop; - if (desktopApi?.macosMajorVersion != null) { - return desktopApi.macosMajorVersion; + + const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__; + if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) { + return injected; } + // Fallback: WebKit reports "Mac OS X 10_15_7" format where 10 is legacy prefix if (typeof navigator === 'undefined') { return null; @@ -163,8 +166,7 @@ export const Header: React.FC = () => { if (typeof window === 'undefined') { return; } - const detected = typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined'; - setIsDesktopApp(detected); + setIsDesktopApp(isDesktopShell()); }, []); const currentModel = getCurrentModel(); @@ -625,6 +627,9 @@ export const Header: React.FC = () => {
+ {isDesktopApp && ( + + )} +
{showHint && ( diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx index f7e50b64..0b7c1f4a 100644 --- a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx @@ -116,27 +116,13 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => const { setSidebarOpen } = useUIStore(); const { isMobile } = useDeviceInfo(); - const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { - if (typeof window === 'undefined') return false; - return typeof window.opencodeDesktop !== 'undefined'; - }); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - React.useEffect(() => { - if (typeof window === 'undefined') return; - setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); - }, []); - React.useEffect(() => { loadAgents(); }, [loadAgents]); - const bgClass = isDesktopRuntime - ? 'bg-transparent' - : isVSCode - ? 'bg-background' - : 'bg-sidebar'; + const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; const handleCreateNew = () => { // Generate unique name diff --git a/packages/ui/src/components/sections/commands/CommandsSidebar.tsx b/packages/ui/src/components/sections/commands/CommandsSidebar.tsx index a3c1ff5c..e0cca1f0 100644 --- a/packages/ui/src/components/sections/commands/CommandsSidebar.tsx +++ b/packages/ui/src/components/sections/commands/CommandsSidebar.tsx @@ -46,27 +46,13 @@ export const CommandsSidebar: React.FC = ({ onItemSelect } const { setSidebarOpen } = useUIStore(); const { isMobile } = useDeviceInfo(); - const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { - if (typeof window === 'undefined') return false; - return typeof window.opencodeDesktop !== 'undefined'; - }); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - React.useEffect(() => { - if (typeof window === 'undefined') return; - setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); - }, []); - React.useEffect(() => { loadCommands(); }, [loadCommands]); - const bgClass = isDesktopRuntime - ? 'bg-transparent' - : isVSCode - ? 'bg-background' - : 'bg-sidebar'; + const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; const handleCreateNew = () => { // Generate unique name diff --git a/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx b/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx index 13a0434e..abc1d4ab 100644 --- a/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx +++ b/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx @@ -67,18 +67,8 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt const { setSidebarOpen } = useUIStore(); const { isMobile } = useDeviceInfo(); - const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { - if (typeof window === 'undefined') return false; - return typeof window.opencodeDesktop !== 'undefined'; - }); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - React.useEffect(() => { - if (typeof window === 'undefined') return; - setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); - }, []); - const unimportedCredentials = getUnimportedCredentials(); React.useEffect(() => { @@ -98,11 +88,7 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt } }; - const bgClass = isDesktopRuntime - ? 'bg-transparent' - : isVSCode - ? 'bg-background' - : 'bg-sidebar'; + const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; const handleCreateProfile = () => { setSelectedProfile('new'); diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx index b34a679e..6f76a324 100644 --- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx @@ -6,7 +6,7 @@ import { AgentSelector } from '@/components/sections/commands/AgentSelector'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; import { updateDesktopSettings } from '@/lib/persistence'; -import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { useConfigStore } from '@/stores/useConfigStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getModifierLabel } from '@/lib/utils'; @@ -67,11 +67,8 @@ export const DefaultsSettings: React.FC = () => { try { let data: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string } | null = null; - // 1. Desktop runtime (Tauri) - if (isDesktopRuntime()) { - data = await getDesktopSettings(); - } else { - // 2. Runtime settings API (VSCode) + // 1. Runtime settings API (VSCode) + if (!data) { const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; if (runtimeSettings) { try { @@ -85,19 +82,19 @@ export const DefaultsSettings: React.FC = () => { }; } } catch { - // Fall through to fetch + // fall through } } + } - // 3. Fetch API (Web) - if (!data) { - const response = await fetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (response.ok) { - data = await response.json(); - } + // 2. Fetch API (Web/server) + if (!data) { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (response.ok) { + data = await response.json(); } } @@ -153,12 +150,12 @@ export const DefaultsSettings: React.FC = () => { defaultVariant: '', }); - if (!isDesktopRuntime()) { - const response = await fetch('/api/config/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ defaultModel: newValue }), - }); + { + const response = await fetch('/api/config/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ defaultModel: newValue }), + }); if (!response.ok) { console.warn('Failed to save default model to server:', response.status, response.statusText); } diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index 70abb456..ad4ec130 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -41,13 +41,12 @@ export const GitHubSettings: React.FC = () => { return; } - const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise } }).opencodeDesktop; - if (desktop?.openExternal) { + type TauriShell = { shell?: { open?: (url: string) => Promise } }; + const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; + if (tauri?.shell?.open) { try { - const result = await desktop.openExternal(url); - if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) { - return; - } + await tauri.shell.open(url); + return; } catch { // fall through } diff --git a/packages/ui/src/components/sections/openchamber/GitSettings.tsx b/packages/ui/src/components/sections/openchamber/GitSettings.tsx index 6f8bb6a1..5f2ee812 100644 --- a/packages/ui/src/components/sections/openchamber/GitSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitSettings.tsx @@ -3,7 +3,6 @@ import { RiInformationLine } from '@remixicon/react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Checkbox } from '@/components/ui/checkbox'; import { updateDesktopSettings } from '@/lib/persistence'; -import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop'; import { useConfigStore } from '@/stores/useConfigStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; @@ -22,11 +21,8 @@ export const GitSettings: React.FC = () => { try { let data: { gitmojiEnabled?: boolean } | null = null; - // 1. Desktop runtime (Tauri) - if (isDesktopRuntime()) { - data = await getDesktopSettings(); - } else { - // 2. Runtime settings API (VSCode) + // 1. Runtime settings API (VSCode) + if (!data) { const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; if (runtimeSettings) { try { @@ -40,19 +36,19 @@ export const GitSettings: React.FC = () => { }; } } catch { - // Fall through to fetch + // fall through } } + } - // 3. Fetch API (Web) - if (!data) { - const response = await fetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (response.ok) { - data = await response.json(); - } + // 2. Fetch API (Web/server) + if (!data) { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (response.ok) { + data = await response.json(); } } diff --git a/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx b/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx index 9e38b624..de81deaf 100644 --- a/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx @@ -5,7 +5,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { useDeviceInfo } from '@/lib/device'; import { useUIStore } from '@/stores/useUIStore'; import { updateDesktopSettings } from '@/lib/persistence'; -import { getDesktopSettings, isDesktopRuntime } from '@/lib/desktop'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { DEFAULT_MEMORY_LIMITS, DEFAULT_ACTIVE_SESSION_WINDOW } from '@/stores/types/sessionTypes'; @@ -34,11 +33,8 @@ export const MemoryLimitsSettings: React.FC = () => { try { let data: { memoryLimitHistorical?: number; memoryLimitViewport?: number; memoryLimitActiveSession?: number } | null = null; - // 1. Desktop runtime (Tauri) - if (isDesktopRuntime()) { - data = await getDesktopSettings(); - } else { - // 2. Runtime settings API (VSCode) + // 1. Runtime settings API (VSCode) + if (!data) { const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; if (runtimeSettings) { try { @@ -52,19 +48,19 @@ export const MemoryLimitsSettings: React.FC = () => { }; } } catch { - // Fall through to fetch + // fall through } } + } - // 3. Fetch API (Web) - if (!data) { - const response = await fetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (response.ok) { - data = await response.json(); - } + // 2. Fetch API (Web/server) + if (!data) { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (response.ok) { + data = await response.json(); } } @@ -91,17 +87,6 @@ export const MemoryLimitsSettings: React.FC = () => { const persistSetting = React.useCallback(async (key: string, value: number) => { try { await updateDesktopSettings({ [key]: value }); - - if (!isDesktopRuntime()) { - const response = await fetch('/api/config/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ [key]: value }), - }); - if (!response.ok) { - console.warn(`Failed to save ${key} to server:`, response.status, response.statusText); - } - } } catch (error) { console.warn(`Failed to save ${key}:`, error); } diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx index 3815384f..2db97524 100644 --- a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useUIStore } from '@/stores/useUIStore'; -import { isWebRuntime } from '@/lib/desktop'; +import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { Switch } from '@/components/ui/switch'; import { toast } from '@/components/ui'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; @@ -8,7 +8,9 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { GridLoader } from '@/components/ui/grid-loader'; export const NotificationSettings: React.FC = () => { - const isWeb = isWebRuntime(); + const isDesktop = React.useMemo(() => isDesktopShell(), []); + const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); + const isBrowser = !isDesktop && !isVSCode; const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled); const setNativeNotificationsEnabled = useUIStore(state => state.setNativeNotificationsEnabled); const notificationMode = useUIStore(state => state.notificationMode); @@ -22,7 +24,7 @@ export const NotificationSettings: React.FC = () => { const [pushBusy, setPushBusy] = React.useState(false); React.useEffect(() => { - if (!isWeb) { + if (!isBrowser) { setPushSupported(false); setPushSubscribed(false); return; @@ -58,10 +60,16 @@ export const NotificationSettings: React.FC = () => { }; void refresh(); - }, [isWeb]); + }, [isBrowser]); const handleToggleChange = async (checked: boolean) => { - if (!isWeb) { + if (isDesktop) { + setNativeNotificationsEnabled(checked); + return; + } + + if (!isBrowser) { + setNativeNotificationsEnabled(checked); return; } if (checked && typeof Notification !== 'undefined' && Notification.permission === 'default') { @@ -86,7 +94,7 @@ export const NotificationSettings: React.FC = () => { } }; - const canShowNotifications = isWeb && typeof Notification !== 'undefined' && Notification.permission === 'granted'; + const canShowNotifications = isDesktop || (isBrowser && typeof Notification !== 'undefined' && Notification.permission === 'granted'); const base64UrlToUint8Array = (base64Url: string): Uint8Array => { const padding = '='.repeat((4 - (base64Url.length % 4)) % 4); @@ -365,92 +373,93 @@ export const NotificationSettings: React.FC = () => { return (
- {/* General Notification Settings */} -
+

- Notification Preferences + When to notify

- Configure how and when you receive notifications. + Customize when notifications show up.

- Notify for subtasks + Enable notifications

- When off, no notifications for child sessions created during multi-run. + Turns notifications on or off.

setNotifyOnSubtasks(checked)} + checked={nativeNotificationsEnabled && canShowNotifications} + onCheckedChange={handleToggleChange} className="data-[state=checked]:bg-status-info" />
- {isWeb && ( - <> - {/* Foreground Notifications */} -
-

- Foreground Notifications -

-

- Uses the browser Notification API while OpenChamber is open. + {isBrowser && ( +

+ Your browser may ask for permission the first time. +

+ )} + + {nativeNotificationsEnabled && canShowNotifications && ( +
+
+ + Include subagent results + +

+ Also notify for child sessions started by the main one.

+ setNotifyOnSubtasks(checked)} + className="data-[state=checked]:bg-status-info" + /> +
+ )} -
+ {nativeNotificationsEnabled && canShowNotifications && ( +
+
- Enable foreground notifications + Notify while app is focused - +

+ When off, only notify when you are not looking at OpenChamber. +

+ setNotificationMode(checked ? 'always' : 'hidden-only')} + className="data-[state=checked]:bg-status-info" + /> +
+ )} - {nativeNotificationsEnabled && canShowNotifications && ( -
-
- - Notify even when visible - -

- When off, only notifies when the tab is hidden or the window is not focused. -

-
- setNotificationMode(checked ? 'always' : 'hidden-only')} - className="data-[state=checked]:bg-status-info" - /> -
- )} - + {isBrowser && ( + <> {notificationPermission === 'denied' && (

- Notification permission denied. Enable notifications in your browser settings. + Notification permission denied. Enable it in your browser settings.

)} {notificationPermission === 'granted' && !nativeNotificationsEnabled && (

- Permission granted, but foreground notifications are disabled. + Permission granted, but notifications are disabled.

)} - {/* Background Notifications */}

- Background Notifications (Push) + Background (Push)

- Uses push notifications; works when OpenChamber is closed. + Get notified even if this page is closed.

@@ -460,7 +469,7 @@ export const NotificationSettings: React.FC = () => {

) : (

- Desktop Chrome/Edge and Android support push in the browser. iOS requires an installed PWA. + Desktop Chrome/Edge and Android support push. iOS requires an installed PWA.

)} @@ -468,10 +477,10 @@ export const NotificationSettings: React.FC = () => {
- Enable background notifications + Enable push notifications

- Opens chat with /?session=<id> deep link. + Clicking a notification opens the relevant session.

@@ -499,6 +508,17 @@ export const NotificationSettings: React.FC = () => { )} )} + + {isVSCode && ( +
+

+ Delivery +

+

+ VS Code runtime handles notifications separately. +

+
+ )}
); }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx index 42ded42d..6bfa8914 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx @@ -62,19 +62,9 @@ export const OpenChamberSidebar: React.FC = ({ const { isMobile } = useDeviceInfo(); const showAbout = isMobile && isWebRuntime(); - const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { - if (typeof window === 'undefined') return false; - return typeof window.opencodeDesktop !== 'undefined'; - }); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const isWeb = React.useMemo(() => isWebRuntime(), []); - React.useEffect(() => { - if (typeof window === 'undefined') return; - setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); - }, []); - const visibleSections = React.useMemo(() => { return OPENCHAMBER_SECTION_GROUPS.filter((group) => { if (group.webOnly && !isWeb) return false; @@ -86,11 +76,7 @@ export const OpenChamberSidebar: React.FC = ({ // Desktop app: transparent for blur effect // VS Code: bg-background (same as page content) // Web/mobile: bg-sidebar - const bgClass = isDesktopRuntime - ? 'bg-transparent' - : isVSCode - ? 'bg-background' - : 'bg-sidebar'; + const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; return (
diff --git a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx index 73fdbbca..be85bc41 100644 --- a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx @@ -20,23 +20,9 @@ export const ProvidersSidebar: React.FC = ({ onItemSelect const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); const { isMobile } = useDeviceInfo(); - const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { - if (typeof window === 'undefined') return false; - return typeof window.opencodeDesktop !== 'undefined'; - }); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - React.useEffect(() => { - if (typeof window === 'undefined') return; - setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); - }, []); - - const bgClass = isDesktopRuntime - ? 'bg-transparent' - : isVSCode - ? 'bg-background' - : 'bg-sidebar'; + const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; return (
diff --git a/packages/ui/src/components/sections/shared/SettingsSidebarLayout.tsx b/packages/ui/src/components/sections/shared/SettingsSidebarLayout.tsx index 5e5247af..7b7df652 100644 --- a/packages/ui/src/components/sections/shared/SettingsSidebarLayout.tsx +++ b/packages/ui/src/components/sections/shared/SettingsSidebarLayout.tsx @@ -33,26 +33,12 @@ export const SettingsSidebarLayout: React.FC = ({ children, className, }) => { - const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { - if (typeof window === 'undefined') return false; - return typeof window.opencodeDesktop !== 'undefined'; - }); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - React.useEffect(() => { - if (typeof window === 'undefined') return; - setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); - }, []); - // Desktop app: transparent for blur effect // VS Code: bg-background (same as page content) // Web/mobile: bg-sidebar - const bgClass = isDesktopRuntime - ? 'bg-transparent' - : isVSCode - ? 'bg-background' - : 'bg-sidebar'; + const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; return (
= ({ onItemSelect }) => const { setSidebarOpen } = useUIStore(); const { isMobile } = useDeviceInfo(); - const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { - if (typeof window === 'undefined') return false; - return typeof window.opencodeDesktop !== 'undefined'; - }); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - React.useEffect(() => { - if (typeof window === 'undefined') return; - setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); - }, []); - React.useEffect(() => { loadSkills(); }, [loadSkills]); - const bgClass = isDesktopRuntime - ? 'bg-transparent' - : isVSCode - ? 'bg-background' - : 'bg-sidebar'; + const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; const handleCreateNew = () => { // Generate unique name diff --git a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx index 00e57d26..6bbecb60 100644 --- a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx @@ -21,7 +21,7 @@ import { import { RiGitRepositoryLine } from '@remixicon/react'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; @@ -50,10 +50,6 @@ type IdentityOption = { id: string; name: string }; const loadSettings = async (): Promise => { try { - if (isDesktopRuntime()) { - return await getDesktopSettings(); - } - const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; if (runtimeSettings) { const result = await runtimeSettings.load(); diff --git a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx index a825b56e..76fb934c 100644 --- a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx +++ b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx @@ -17,7 +17,6 @@ import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; import type { SkillsCatalogItem } from '@/lib/api/types'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { getDesktopSettings, isDesktopRuntime } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop'; @@ -33,10 +32,6 @@ interface SkillsCatalogPageProps { const loadSettings = async (): Promise => { try { - if (isDesktopRuntime()) { - return await getDesktopSettings(); - } - const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; if (runtimeSettings) { const result = await runtimeSettings.load(); diff --git a/packages/ui/src/components/sections/usage/UsageSidebar.tsx b/packages/ui/src/components/sections/usage/UsageSidebar.tsx index 00ad2203..dbca2bab 100644 --- a/packages/ui/src/components/sections/usage/UsageSidebar.tsx +++ b/packages/ui/src/components/sections/usage/UsageSidebar.tsx @@ -43,18 +43,8 @@ export const UsageSidebar: React.FC = ({ onItemSelect }) => { const loadUsageSettings = useQuotaStore((state) => state.loadSettings); const { isMobile } = useDeviceInfo(); - const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { - if (typeof window === 'undefined') return false; - return typeof window.opencodeDesktop !== 'undefined'; - }); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - React.useEffect(() => { - if (typeof window === 'undefined') return; - setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); - }, []); - React.useEffect(() => { void loadUsageSettings(); }, [loadUsageSettings]); @@ -89,14 +79,7 @@ export const UsageSidebar: React.FC = ({ onItemSelect }) => { void persistUsageSettings({ usageDisplayMode: value }); }, [persistUsageSettings, setUsageDisplayMode]); - - - - const bgClass = isDesktopRuntime - ? 'bg-transparent' - : isVSCode - ? 'bg-background' - : 'bg-sidebar'; + const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; return (
diff --git a/packages/ui/src/components/session/DirectoryTree.tsx b/packages/ui/src/components/session/DirectoryTree.tsx index e95eb212..8ef4ecfb 100644 --- a/packages/ui/src/components/session/DirectoryTree.tsx +++ b/packages/ui/src/components/session/DirectoryTree.tsx @@ -12,7 +12,6 @@ import { RiAddLine, RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLin import { cn, formatPathForDisplay } from '@/lib/utils'; import { opencodeClient } from '@/lib/opencode/client'; import { useDeviceInfo } from '@/lib/device'; -import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop'; import type { DesktopSettings } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; @@ -54,7 +53,6 @@ export const DirectoryTree: React.FC = ({ isRootReady, alwaysShowActions = false, }) => { - const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []); const { isMobile } = useDeviceInfo(); const [directories, setDirectories] = React.useState([]); const [expandedPaths, setExpandedPaths] = React.useState>(new Set()); @@ -243,22 +241,17 @@ export const DirectoryTree: React.FC = ({ } }; - const loadPinnedDirectories = async () => { - try { - let pinned: string[] = []; + const loadPinnedDirectories = async () => { + try { + let pinned: string[] = []; - if (desktopRuntime) { - const settings = await getDesktopSettings(); - pinned = Array.isArray(settings?.pinnedDirectories) ? settings.pinnedDirectories : []; - } else { - const response = await fetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (response.ok) { - const data = await response.json(); - pinned = Array.isArray(data?.pinnedDirectories) ? data.pinnedDirectories : []; - } + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (response.ok) { + const data = await response.json(); + pinned = Array.isArray(data?.pinnedDirectories) ? data.pinnedDirectories : []; } if (cancelled) { @@ -287,7 +280,7 @@ export const DirectoryTree: React.FC = ({ cancelled = true; window.removeEventListener('openchamber:settings-synced', handleSettingsSynced); }; - }, [desktopRuntime, stripTrailingSlashes]); + }, [stripTrailingSlashes]); const isInitialPinnedSync = React.useRef(true); diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index c786506c..0f890e86 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -24,7 +24,7 @@ import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; -import { isDesktopRuntime } from '@/lib/desktop'; +import { isTauriShell } from '@/lib/desktop'; import { useDeviceInfo } from '@/lib/device'; import { sessionEvents } from '@/lib/sessionEvents'; @@ -136,7 +136,7 @@ export const SessionDialogs: React.FC = () => { setHasShownInitialDirectoryPrompt(true); - if (isDesktopRuntime()) { + if (isTauriShell()) { requestAccess('') .then(async (result) => { if (!result.success || !result.path) { diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 03c2516f..c22d8339 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { toast } from '@/components/ui'; +import { isDesktopShell, isTauriShell } from '@/lib/desktop'; import { DndContext, DragOverlay, @@ -64,6 +65,7 @@ import { checkIsGitRepository } from '@/lib/gitApi'; import { getSafeStorage } from '@/stores/utils/safeStorage'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { updateDesktopSettings } from '@/lib/persistence'; import { BranchPickerDialog } from './BranchPickerDialog'; import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog'; import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog'; @@ -135,7 +137,7 @@ interface SortableProjectItemProps { isActiveProject: boolean; isRepo: boolean; isHovered: boolean; - isDesktopRuntime: boolean; + isDesktopShell: boolean; isStuck: boolean; hideDirectoryControls: boolean; mobileVariant: boolean; @@ -161,7 +163,7 @@ const SortableProjectItem: React.FC = ({ isActiveProject, isRepo, isHovered, - isDesktopRuntime, + isDesktopShell, isStuck, hideDirectoryControls, mobileVariant, @@ -190,7 +192,7 @@ const SortableProjectItem: React.FC = ({ return (
{/* Sentinel for sticky detection */} - {isDesktopRuntime && ( + {isDesktopShell && (
= ({
= ({ const [openMenuSessionId, setOpenMenuSessionId] = React.useState(null); const projectHeaderSentinelRefs = React.useRef>(new Map()); const ignoreIntersectionUntil = React.useRef(0); + const persistCollapsedProjectsTimer = React.useRef(null); + const pendingCollapsedProjects = React.useRef | null>(null); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); @@ -455,22 +459,64 @@ export const SessionSidebar: React.FC = ({ const shareSession = useSessionStore((state) => state.shareSession); const unshareSession = useSessionStore((state) => state.unshareSession); const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState); - const sessionActivityPhase = useSessionStore((state) => state.sessionActivityPhase); + const sessionStatus = useSessionStore((state) => state.sessionStatus); const permissions = useSessionStore((state) => state.permissions); const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata); const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); - const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { - if (typeof window === 'undefined') { - return false; - } - return typeof window.opencodeDesktop !== 'undefined'; - }); + const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []); + const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); + const flushCollapsedProjectsPersist = React.useCallback(() => { + if (isVSCode) { + return; + } + const collapsed = pendingCollapsedProjects.current; + pendingCollapsedProjects.current = null; + persistCollapsedProjectsTimer.current = null; + if (!collapsed) { + return; + } + + const { projects } = useProjectsStore.getState(); + const updatedProjects = projects.map((project) => ({ + ...project, + sidebarCollapsed: collapsed.has(project.id), + })); + void updateDesktopSettings({ projects: updatedProjects }).catch(() => {}); + }, [isVSCode]); + + const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set) => { + if (typeof window === 'undefined') { + return; + } + if (isVSCode) { + return; + } + + pendingCollapsedProjects.current = collapsed; + if (persistCollapsedProjectsTimer.current !== null) { + window.clearTimeout(persistCollapsedProjectsTimer.current); + } + persistCollapsedProjectsTimer.current = window.setTimeout(() => { + flushCollapsedProjectsPersist(); + }, 700); + }, [flushCollapsedProjectsPersist, isVSCode]); + + React.useEffect(() => { + return () => { + if (typeof window !== 'undefined' && persistCollapsedProjectsTimer.current !== null) { + window.clearTimeout(persistCollapsedProjectsTimer.current); + } + persistCollapsedProjectsTimer.current = null; + pendingCollapsedProjects.current = null; + }; + }, []); + React.useEffect(() => { try { const storedParents = safeStorage.getItem(SESSION_EXPANDED_STORAGE_KEY); @@ -490,13 +536,6 @@ export const SessionSidebar: React.FC = ({ } catch { /* ignored */ } }, [safeStorage]); - React.useEffect(() => { - if (typeof window === 'undefined') { - return; - } - setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); - }, []); - const sortedSessions = React.useMemo(() => { return [...sessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)); }, [sessions]); @@ -863,31 +902,32 @@ export const SessionSidebar: React.FC = ({ ); const handleOpenDirectoryDialog = React.useCallback(() => { - if (isDesktopRuntime && window.opencodeDesktop?.requestDirectoryAccess) { - window.opencodeDesktop - .requestDirectoryAccess('') - .then((result) => { - if (result.success && result.path) { - const added = addProject(result.path, { id: result.projectId }); - if (!added) { - toast.error('Failed to add project', { - description: 'Please select a valid directory.', - }); - } - } else if (result.error && result.error !== 'Directory selection cancelled') { - toast.error('Failed to select directory', { - description: result.error, + if (!tauriIpcAvailable) { + sessionEvents.requestDirectoryDialog(); + return; + } + + import('@/lib/desktop') + .then(({ requestDirectoryAccess }) => requestDirectoryAccess('')) + .then((result) => { + if (result.success && result.path) { + const added = addProject(result.path, { id: result.projectId }); + if (!added) { + toast.error('Failed to add project', { + description: 'Please select a valid directory.', }); } - }) - .catch((error) => { - console.error('Desktop: Error selecting directory:', error); - toast.error('Failed to select directory'); - }); - } else { - sessionEvents.requestDirectoryDialog(); - } - }, [addProject, isDesktopRuntime]); + } else if (result.error && result.error !== 'Directory selection cancelled') { + toast.error('Failed to select directory', { + description: result.error, + }); + } + }) + .catch((error) => { + console.error('Desktop: Error selecting directory:', error); + toast.error('Failed to select directory'); + }); + }, [addProject, tauriIpcAvailable]); const toggleParent = React.useCallback((sessionId: string) => { setExpandedParents((prev) => { @@ -1019,9 +1059,14 @@ export const SessionSidebar: React.FC = ({ try { safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next))); } catch { /* ignored */ } + + // Persist collapse state to server settings (web + desktop local/remote). + if (!isVSCode) { + scheduleCollapsedProjectsPersist(next); + } return next; }); - }, [safeStorage]); + }, [isVSCode, safeStorage, scheduleCollapsedProjectsPersist]); const normalizedProjects = React.useMemo(() => { return projects @@ -1081,7 +1126,7 @@ export const SessionSidebar: React.FC = ({ // Track when project sticky headers become "stuck" React.useEffect(() => { - if (!isDesktopRuntime) return; + if (!isDesktopShellRuntime) return; const observer = new IntersectionObserver( (entries) => { @@ -1108,7 +1153,7 @@ export const SessionSidebar: React.FC = ({ }); return () => observer.disconnect(); - }, [isDesktopRuntime, projectSections]); + }, [isDesktopShellRuntime, projectSections]); const renderSessionNode = React.useCallback( (node: SessionNode, depth = 0, groupDirectory?: string | null, projectId?: string | null): React.ReactNode => { @@ -1202,8 +1247,8 @@ export const SessionSidebar: React.FC = ({ ); } - const phase = sessionActivityPhase?.get(session.id) ?? 'idle'; - const isStreaming = phase === 'busy' || phase === 'cooldown'; + const statusType = sessionStatus?.get(session.id)?.type ?? 'idle'; + const isStreaming = statusType === 'busy' || statusType === 'retry'; const pendingPermissionCount = permissions.get(session.id)?.length ?? 0; const streamingIndicator = (() => { @@ -1427,7 +1472,7 @@ export const SessionSidebar: React.FC = ({ [ directoryStatus, sessionMemoryState, - sessionActivityPhase, + sessionStatus, permissions, currentSessionId, expandedParents, @@ -1571,7 +1616,7 @@ export const SessionSidebar: React.FC = ({ onClick={handleOpenDirectoryDialog} className={cn( 'inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50', - !isDesktopRuntime && 'bg-sidebar/60 hover:bg-sidebar', + !isDesktopShellRuntime && 'bg-sidebar/60 hover:bg-sidebar', )} aria-label="Add project" title="Add project" @@ -1650,7 +1695,7 @@ export const SessionSidebar: React.FC = ({ isActiveProject={isActiveProject} isRepo={Boolean(isRepo)} isHovered={isHovered} - isDesktopRuntime={isDesktopRuntime} + isDesktopShell={isDesktopShellRuntime} isStuck={stuckProjectHeaders.has(projectKey)} hideDirectoryControls={hideDirectoryControls} mobileVariant={mobileVariant} diff --git a/packages/ui/src/components/ui/AboutDialog.tsx b/packages/ui/src/components/ui/AboutDialog.tsx index 4f6c136a..bfdd78bd 100644 --- a/packages/ui/src/components/ui/AboutDialog.tsx +++ b/packages/ui/src/components/ui/AboutDialog.tsx @@ -47,7 +47,7 @@ export const AboutDialog: React.FC = ({ React.useEffect(() => { if (!open) return; - const isDesktop = typeof window !== 'undefined' && !!window.opencodeDesktop; + const isDesktop = typeof window !== 'undefined' && Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__); if (isDesktop) { const fetchVersion = async () => { diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index 2c3c5fda..1f7939f1 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -4,7 +4,6 @@ import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { RiCloseLine, RiDatabase2Line, RiDeleteBinLine, RiPulseLine } from '@remixicon/react'; -import { useDesktopServerInfo } from '@/hooks/useDesktopServerInfo'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; interface MemoryDebugPanelProps { @@ -20,7 +19,6 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) = trimToViewportWindow, evictLeastRecentlyUsed } = useSessionStore(); - const desktopInfo = useDesktopServerInfo(4000); const totalMessages = React.useMemo(() => { let total = 0; @@ -81,25 +79,7 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) =
- {desktopInfo && ( -
-
-
Desktop Host
-
- {desktopInfo.host ?? 'unknown'} -
-
-
-
OpenCode Port
-
- {desktopInfo.openCodePort ?? 'n/a'} - - {desktopInfo.ready ? 'ready' : 'starting'} - -
-
-
- )} + {null} {}
diff --git a/packages/ui/src/components/ui/OpenCodeStatusDialog.tsx b/packages/ui/src/components/ui/OpenCodeStatusDialog.tsx new file mode 100644 index 00000000..a3577200 --- /dev/null +++ b/packages/ui/src/components/ui/OpenCodeStatusDialog.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { toast } from '@/components/ui'; +import { useUIStore } from '@/stores/useUIStore'; + +export const OpenCodeStatusDialog: React.FC = () => { + const { + isOpenCodeStatusDialogOpen, + setOpenCodeStatusDialogOpen, + openCodeStatusText, + } = useUIStore(); + + const handleCopy = React.useCallback(() => { + if (!openCodeStatusText) { + return; + } + + void navigator.clipboard + .writeText(openCodeStatusText) + .then(() => { + toast.success('Copied', { description: 'OpenCode status copied to clipboard.' }); + }) + .catch(() => { + toast.error('Copy failed'); + }); + }, [openCodeStatusText]); + + return ( + + + + OpenCode Status + + Diagnostic snapshot for support and debugging. + + + +
+ +
+ +
+          {openCodeStatusText || 'No data.'}
+        
+
+
+ ); +}; diff --git a/packages/ui/src/components/ui/ScrollShadow.tsx b/packages/ui/src/components/ui/ScrollShadow.tsx index f1db4fd9..7e875abc 100644 --- a/packages/ui/src/components/ui/ScrollShadow.tsx +++ b/packages/ui/src/components/ui/ScrollShadow.tsx @@ -6,6 +6,7 @@ export type ScrollShadowProps = React.HTMLAttributes & { size?: number; isEnabled?: boolean; hideBottomShadow?: boolean; + observeMutations?: boolean; onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void; }; @@ -22,18 +23,19 @@ function mergeRefs(...refs: Array>): React.RefCallback { } export const ScrollShadow = React.forwardRef( - ( - { - orientation = "vertical", - offset = 72, - size = 48, - isEnabled = true, - hideBottomShadow = false, - onVisibilityChange, - style, - className, - children, - ...rest + ( + { + orientation = "vertical", + offset = 72, + size = 48, + isEnabled = true, + hideBottomShadow = false, + observeMutations = true, + onVisibilityChange, + style, + className, + children, + ...rest }, ref, ) => { @@ -119,13 +121,13 @@ export const ScrollShadow = React.forwardRef( const handleScroll = () => checkOverflow(); // Scroll should be immediate const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null; const mutationObserver = - typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null; + observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null; checkOverflow(); el.addEventListener("scroll", handleScroll, { passive: true }); resizeObserver?.observe(el); - mutationObserver?.observe(el, { childList: true, subtree: true, characterData: true }); + mutationObserver?.observe(el, { childList: true, subtree: true }); return () => { if (rafId !== null) cancelAnimationFrame(rafId); @@ -133,7 +135,7 @@ export const ScrollShadow = React.forwardRef( resizeObserver?.disconnect(); mutationObserver?.disconnect(); }; - }, [checkOverflow]); + }, [checkOverflow, observeMutations]); return (
{ setLineSelection(null); toast.success('Comment saved'); - }, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, getSessionKey]); + }, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, getSessionKey, extractSelectedCode]); const editorExtensions = React.useMemo(() => { const extensions = [createFlexokiCodeMirrorTheme(currentTheme)]; diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 15b53e2e..68ad56ca 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -104,10 +104,10 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const startXRef = React.useRef(0); const startWidthRef = React.useRef(sidebarWidth); - const [isDesktopApp, setIsDesktopApp] = React.useState(() => { + const isTauri = React.useMemo(() => { if (typeof window === 'undefined') return false; - return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined'; - }); + return Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__); + }, []); const isMacPlatform = React.useMemo(() => { if (typeof navigator === 'undefined') return false; @@ -118,10 +118,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const settingsSections = React.useMemo(() => getSettingsSections(isVSCode), [isVSCode]); - React.useEffect(() => { - if (typeof window === 'undefined') return; - setIsDesktopApp(typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined'); - }, []); + const isDesktopApp = isTauri; // Track container width for responsive tab labels React.useEffect(() => { diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index f9bcffbb..5f752e0e 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -17,7 +17,6 @@ import { useUIStore } from '@/stores/useUIStore'; import { Button } from '@/components/ui/button'; import { useDeviceInfo } from '@/lib/device'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { isDesktopRuntime, isWebRuntime } from '@/lib/desktop'; type Modifier = 'ctrl' | 'cmd'; type MobileKey = @@ -74,12 +73,13 @@ const getSequenceForKey = (key: MobileKey, modifier: Modifier | null): string | }; export const TerminalView: React.FC = () => { - const { terminal } = useRuntimeAPIs(); + const { terminal, runtime } = useRuntimeAPIs(); const { currentTheme } = useThemeSystem(); const { monoFont } = useFontPreferences(); const terminalFontSize = useUIStore(state => state.terminalFontSize); const { isMobile, hasTouchInput } = useDeviceInfo(); - const enableTabs = !isMobile && (isWebRuntime() || isDesktopRuntime()); + // Tabs are supported for web + desktop runtimes (not VSCode). + const enableTabs = !isMobile && runtime.platform !== 'vscode'; const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop); const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop; diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 5d0dfbef..31543653 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -73,19 +73,25 @@ type PullRequestDraftSnapshot = { const pullRequestDraftSnapshots = new Map(); +type TauriShell = { + shell?: { + open?: (url: string) => Promise; + }; +}; + const openExternal = async (url: string) => { if (typeof window === 'undefined') return; - const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise } }).opencodeDesktop; - if (desktop?.openExternal) { + + const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; + if (tauri?.shell?.open) { try { - const result = await desktop.openExternal(url); - if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) { - return; - } + await tauri.shell.open(url); + return; } catch { // fall through } } + try { window.open(url, '_blank', 'noopener,noreferrer'); } catch { diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index dba8ada4..c96a45cd 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -6,7 +6,7 @@ import React, { } from 'react'; import type { Theme, ThemeMode } from '@/types/theme'; import type { DesktopSettings } from '@/lib/desktop'; -import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop'; +import { isDesktopLocalOriginActive, isVSCodeRuntime } from '@/lib/desktop'; import { CSSVariableGenerator } from '@/lib/theme/cssGenerator'; import { updateDesktopSettings } from '@/lib/persistence'; import { @@ -190,7 +190,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro return existing || null; }); const isVSCode = useMemo(() => isVSCodeRuntime(), []); - const isDesktop = useMemo(() => isDesktopRuntime(), []); + const isLocalDesktopOrigin = useMemo(() => isDesktopLocalOriginActive(), []); const availableThemes = useMemo(() => { const merged: Theme[] = []; @@ -256,7 +256,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro try { const res = await fetch('/api/config/themes', { method: 'GET', - credentials: isDesktop ? 'omit' : 'include', + credentials: isLocalDesktopOrigin ? 'omit' : 'include', headers: { Accept: 'application/json', }, @@ -280,7 +280,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro } finally { setCustomThemesLoading(false); } - }, [isDesktop, isVSCode]); + }, [isLocalDesktopOrigin, isVSCode]); useEffect(() => { void reloadCustomThemes(); diff --git a/packages/ui/src/hooks/useAssistantStatus.ts b/packages/ui/src/hooks/useAssistantStatus.ts index 66cdb888..9ecb8ece 100644 --- a/packages/ui/src/hooks/useAssistantStatus.ts +++ b/packages/ui/src/hooks/useAssistantStatus.ts @@ -121,7 +121,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { })) ); - const { phase: activityPhase, isWorking: isPhaseWorking, isCooldown: isPhaseCooldown } = useCurrentSessionActivity(); + const { phase: activityPhase, isWorking: isPhaseWorking } = useCurrentSessionActivity(); const sessionMessages = React.useMemo>(() => { if (!currentSessionId) { @@ -298,7 +298,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { const isWorking = isPhaseWorking; const isStreaming = activityPhase === 'busy'; - const isCooldown = isPhaseCooldown; + const isCooldown = false; let activity: AssistantActivity = 'idle'; if (isWorking) { @@ -327,9 +327,9 @@ export function useAssistantStatus(): AssistantStatusSnapshot { wasAborted: false, abortActive: false, lastCompletionId: null, - isComplete: isCooldown, + isComplete: false, }; - }, [activityPhase, isPhaseWorking, isPhaseCooldown, parsedStatus, abortState]); + }, [activityPhase, isPhaseWorking, parsedStatus, abortState]); const forming = React.useMemo(() => { diff --git a/packages/ui/src/hooks/useDesktopServerInfo.ts b/packages/ui/src/hooks/useDesktopServerInfo.ts deleted file mode 100644 index cd01fc4f..00000000 --- a/packages/ui/src/hooks/useDesktopServerInfo.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect, useState } from "react"; -import { - fetchDesktopServerInfo, - isDesktopRuntime, - type DesktopServerInfo -} from "@/lib/desktop"; - -export const useDesktopServerInfo = (pollInterval = 5000): DesktopServerInfo | null => { - const [info, setInfo] = useState(null); - - useEffect(() => { - if (!isDesktopRuntime()) { - return; - } - - let cancelled = false; - let timer: ReturnType | null = null; - - const poll = async () => { - const payload = await fetchDesktopServerInfo(); - if (!cancelled) { - setInfo(payload); - timer = setTimeout(poll, pollInterval); - } - }; - - poll(); - - return () => { - cancelled = true; - if (timer) { - clearTimeout(timer); - } - }; - }, [pollInterval]); - - return info; -}; diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 1c2a9f90..44a87000 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -15,7 +15,7 @@ import { handleTodoUpdatedEvent } from '@/stores/useTodoStore'; import { useMcpStore } from '@/stores/useMcpStore'; import { useContextStore } from '@/stores/contextStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { isWebRuntime } from '@/lib/desktop'; +import { isDesktopLocalOriginActive } from '@/lib/desktop'; interface EventData { type: string; @@ -99,36 +99,6 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess return message; }; -const formatModelID = (raw: string): string => { - if (!raw) { - return 'Assistant'; - } - - const tokens: string[] = raw.split(/[-_]/); - const result: string[] = []; - let i = 0; - - while (i < tokens.length) { - const current = tokens[i]; - - if (/^\d+$/.test(current)) { - if (i + 1 < tokens.length && /^\d+$/.test(tokens[i + 1])) { - const combined = `${current}.${tokens[i + 1]}`; - result.push(combined); - i += 2; - continue; - } - } - - result.push(current); - i += 1; - } - - return result - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); -}; - export const useEventStream = () => { const { addStreamingPart, @@ -149,8 +119,6 @@ export const useEventStream = () => { const { checkConnection } = useConfigStore(); const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled); - const notificationMode = useUIStore((state) => state.notificationMode); - const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks); const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory); const activeSessionDirectory = React.useMemo(() => { @@ -412,7 +380,6 @@ export const useEventStream = () => { (sessionId: string, reason: string, limit?: number) => Promise >(() => Promise.resolve()); const scheduleReconnectRef = React.useRef<(hint?: string) => void>(() => {}); - const isDesktopRuntimeRef = React.useRef(false); const maybeBootstrapIfStale = React.useCallback( (reason: string) => { @@ -425,17 +392,6 @@ export const useEventStream = () => { [bootstrapState] ); - React.useEffect(() => { - if (typeof window !== 'undefined') { - const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isDesktop?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__; - if (apis?.runtime?.isDesktop) { - isDesktopRuntimeRef.current = true; - } - } - }, []); - - const sessionCooldownTimersRef = React.useRef>(new Map()); - const sessionActivityPhaseRef = React.useRef>(new Map()); const sessionStatusLastRefreshAtRef = React.useRef(0); const sessionStatusRefreshInFlightRef = React.useRef | null>(null); const currentSessionIdRef = React.useRef(currentSessionId); @@ -509,15 +465,46 @@ export const useEventStream = () => { ); - const updateSessionActivityPhase = React.useCallback((sessionId: string, phase: 'idle' | 'busy' | 'cooldown') => { - const storePhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId); - if (storePhase === phase) { - sessionActivityPhaseRef.current = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map()); - return; + type SessionStatusPayload = { + type: 'idle' | 'busy' | 'retry'; + attempt?: number; + message?: string; + next?: number; + }; + + const updateSessionStatus = React.useCallback(( + sessionId: string, + status: SessionStatusPayload, + source: string = 'unknown' + ) => { + if (!sessionId) return; + + const storeStatus = useSessionStore.getState().sessionStatus?.get(sessionId); + const prevType = storeStatus?.type ?? 'idle'; + const nextType = status?.type ?? 'idle'; + + if (prevType !== nextType) { + try { + console.info('[SESSION-STATUS]', { + sessionId, + from: prevType, + to: nextType, + source, + ...(nextType === 'retry' + ? { + attempt: status.attempt, + next: status.next, + message: status.message, + } + : {}), + }); + } catch { + // ignore + } } - const shouldArmMessageStallCheck = storePhase === 'idle' && (phase === 'busy' || phase === 'cooldown'); - const shouldDisarmMessageStallCheck = phase === 'idle'; + const shouldArmMessageStallCheck = prevType === 'idle' && (nextType === 'busy' || nextType === 'retry'); + const shouldDisarmMessageStallCheck = nextType === 'idle'; if (shouldDisarmMessageStallCheck) { const pending = pendingMessageStallTimersRef.current.get(sessionId); @@ -536,8 +523,8 @@ export const useEventStream = () => { const startAt = Date.now(); const timer = setTimeout(() => { - const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId); - if (currentPhase !== 'busy' && currentPhase !== 'cooldown') { + const current = useSessionStore.getState().sessionStatus?.get(sessionId); + if (current?.type !== 'busy' && current?.type !== 'retry') { return; } @@ -552,43 +539,25 @@ export const useEventStream = () => { } lastMessageStallRecoveryBySessionRef.current.set(sessionId, Date.now()); - - void scheduleSoftResyncRef.current(sessionId, 'activity_started_no_message', getActiveSessionWindow()) + void scheduleSoftResyncRef.current(sessionId, 'status_busy_no_message', getActiveSessionWindow()) .finally(() => { - scheduleReconnectRef.current('No message events after activity start'); + scheduleReconnectRef.current('No message events after busy status'); }); }, 2000); pendingMessageStallTimersRef.current.set(sessionId, timer); } - const existingTimer = sessionCooldownTimersRef.current.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - sessionCooldownTimersRef.current.delete(sessionId); - } - - const next = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map()); - next.set(sessionId, phase); - sessionActivityPhaseRef.current = next; - useSessionStore.setState({ sessionActivityPhase: next }); - - if (phase === 'cooldown') { - const timer = setTimeout(() => { - sessionCooldownTimersRef.current.delete(sessionId); - const current = useSessionStore.getState().sessionActivityPhase?.get(sessionId); - if (current === 'cooldown') { - const latest = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map()); - latest.set(sessionId, 'idle'); - sessionActivityPhaseRef.current = latest; - useSessionStore.setState({ sessionActivityPhase: latest }); - } - }, 2000); - sessionCooldownTimersRef.current.set(sessionId, timer); + const next = new Map(useSessionStore.getState().sessionStatus ?? new Map()); + if (nextType === 'idle') { + next.delete(sessionId); + } else { + next.set(sessionId, status); } + useSessionStore.setState({ sessionStatus: next }); }, []); - const refreshSessionActivityStatus = React.useCallback(async () => { + const refreshSessionStatus = React.useCallback(async () => { const now = Date.now(); if (sessionStatusRefreshInFlightRef.current) { return sessionStatusRefreshInFlightRef.current; @@ -598,8 +567,8 @@ export const useEventStream = () => { } sessionStatusLastRefreshAtRef.current = now; - const applyStatusMap = (statusMap: Record) => { - const observed = new Set(); + const applyStatusMap = (statusMap: Record) => { + const observed = new Set(); // Use getState() to avoid sessions dependency which causes cascading updates const currentSessions = useSessionStore.getState().sessions; const knownSessionIds = new Set(currentSessions.map((session) => session.id)); @@ -607,38 +576,37 @@ export const useEventStream = () => { for (const [sessionId, raw] of Object.entries(statusMap)) { if (!sessionId || !raw) continue; observed.add(sessionId); - const phase: 'idle' | 'busy' = - raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle'; - updateSessionActivityPhase(sessionId, phase); + const typeRaw = raw.type; + const status: SessionStatusPayload = + typeRaw === 'retry' + ? { + type: 'retry', + attempt: (raw as { attempt?: unknown }).attempt as number | undefined, + message: (raw as { message?: unknown }).message as string | undefined, + next: (raw as { next?: unknown }).next as number | undefined, + } + : typeRaw === 'busy' || typeRaw === 'cooldown' + ? { type: 'busy' } + : { type: 'idle' }; + updateSessionStatus(sessionId, status, 'poll:/session/status'); } // OpenCode's /session/status may omit idle sessions (returns only busy/retry). // Treat missing entries as idle to avoid sessions getting stuck "working". - const currentPhases = useSessionStore.getState().sessionActivityPhase; - if (!currentPhases) return; + const currentStatuses = useSessionStore.getState().sessionStatus; + if (!currentStatuses) return; - for (const [sessionId, phase] of currentPhases.entries()) { + for (const [sessionId, status] of currentStatuses.entries()) { if (!knownSessionIds.has(sessionId)) continue; - if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) { - updateSessionActivityPhase(sessionId, 'idle'); + if ((status.type === 'busy' || status.type === 'retry') && !observed.has(sessionId)) { + updateSessionStatus(sessionId, { type: 'idle' }, 'poll:missing->idle'); } } }; const task = (async (): Promise => { try { - // Try web server's tracked activity first - more reliable on visibility restore - // because it tracks activity even when UI is not listening to SSE. - // Only available in web runtime (desktop/vscode use native events instead). - if (isWebRuntime()) { - const webServerActivity = await opencodeClient.getWebServerSessionActivity(); - if (webServerActivity && Object.keys(webServerActivity).length > 0) { - applyStatusMap(webServerActivity); - return; - } - } - - // Fallback to OpenCode's global session status + // OpenCode global session status (busy/retry only; idle omitted) const globalStatusMap = await opencodeClient.getGlobalSessionStatus(); if (globalStatusMap && Object.keys(globalStatusMap).length > 0) { applyStatusMap(globalStatusMap); @@ -677,11 +645,11 @@ export const useEventStream = () => { } if (Object.keys(merged).length === 0) { - const hasActivePhases = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some( - (phase) => phase === 'busy' || phase === 'cooldown' + const hasActiveStatuses = Array.from(useSessionStore.getState().sessionStatus?.values?.() ?? []).some( + (status) => status?.type === 'busy' || status?.type === 'retry' ); - if (hasActivePhases) { + if (hasActiveStatuses) { const healthy = await opencodeClient.checkHealth().catch(() => false); if (!healthy) { return; @@ -699,7 +667,7 @@ export const useEventStream = () => { sessionStatusRefreshInFlightRef.current = task; return task; - }, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, updateSessionActivityPhase]); + }, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, updateSessionStatus]); React.useEffect(() => { const nextSessionId = currentSessionId ?? null; @@ -709,13 +677,13 @@ export const useEventStream = () => { if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) { if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) { - void refreshSessionActivityStatus(); + void refreshSessionStatus(); } } previousSessionIdRef.current = nextSessionId; previousSessionDirectoryRef.current = nextDirectory; - }, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]); + }, [currentSessionId, refreshSessionStatus, resolveSessionDirectoryForStatus]); const handleEvent = React.useCallback((event: EventData) => { lastEventTimestampRef.current = Date.now(); @@ -766,15 +734,6 @@ export const useEventStream = () => { void bootstrapState('server_disposed_event'); break; } - case 'openchamber:session-activity': { - const sessionId = typeof props.sessionId === 'string' ? props.sessionId : null; - const phase = typeof props.phase === 'string' ? props.phase : null; - if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) { - updateSessionActivityPhase(sessionId, phase); - requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null); - } - break; - } case 'mcp.tools.changed': { const directory = typeof props.directory === 'string' ? props.directory : effectiveDirectory; @@ -783,17 +742,25 @@ export const useEventStream = () => { } case 'session.status': - if (isDesktopRuntimeRef.current) break; { const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; const statusObj = (typeof props.status === 'object' && props.status !== null) ? props.status as Record : null; const statusType = typeof statusObj?.type === 'string' ? statusObj.type : null; + const statusInfo = statusObj ?? {}; if (sessionId && statusType) { - updateSessionActivityPhase( - sessionId, - statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle', - ); + if (statusType === 'busy') { + updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status'); + } else if (statusType === 'retry') { + updateSessionStatus(sessionId, { + type: 'retry', + attempt: typeof statusInfo.attempt === 'number' ? statusInfo.attempt : undefined, + message: typeof statusInfo.message === 'string' ? statusInfo.message : undefined, + next: typeof statusInfo.next === 'number' ? statusInfo.next : undefined, + }, 'sse:session.status'); + } else { + updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.status'); + } requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null); } } @@ -877,6 +844,25 @@ export const useEventStream = () => { type: part.type || 'text', } as Part; + // Fallback: if we see assistant parts but session.status hasn't arrived yet, mark busy. + if (roleInfo === 'assistant') { + const partType = (messagePart as { type?: unknown }).type; + const isStreamingPart = + partType === 'step-start' || + partType === 'text' || + partType === 'tool' || + partType === 'reasoning' || + partType === 'file' || + partType === 'patch'; + + if (isStreamingPart) { + const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId); + if (!currentStatus || currentStatus.type === 'idle') { + updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.updated'); + } + } + } + trackMessage(messageId, 'addStreamingPart_called'); addStreamingPart(sessionId, messageId, messagePart, roleInfo); break; @@ -926,7 +912,7 @@ export const useEventStream = () => { break; } - if (isDesktopRuntimeRef.current && streamDebugEnabled()) { + if (streamDebugEnabled()) { try { const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts || []; const textParts = Array.isArray(serverParts) @@ -1137,21 +1123,6 @@ export const useEventStream = () => { trackMessage(messageId, 'skipped_shrinking_update', { incomingLen, existingLen }); break; } - - if (isDesktopRuntimeRef.current) { - const zeroToleranceShrink = existingLen > 0 && incomingLen < existingLen; - const hasUsefulText = partsArray.some((p) => { - if (!p || p.type !== 'text') return false; - const textPart = p as { text?: string }; - return typeof textPart.text === 'string' && textPart.text.length > 0; - }); - const shrinkAllowed = eventHasStopFinish && hasUsefulText; - - if (zeroToleranceShrink && !shrinkAllowed) { - trackMessage(messageId, 'desktop_shrinking_update_suppressed', { incomingLen, existingLen }); - break; - } - } } updateMessageInfo(sessionId, messageId, message as unknown as Message); @@ -1168,9 +1139,7 @@ export const useEventStream = () => { { count: partsArray.length } ); - const partsToInject = isDesktopRuntimeRef.current && (messageExt as { role?: unknown }).role === 'assistant' - ? partsArray.filter((serverPart) => serverPart?.type !== 'text') - : partsArray; + const partsToInject = partsArray; for (let i = 0; i < partsToInject.length; i++) { const serverPart = partsToInject[i]; @@ -1213,11 +1182,6 @@ export const useEventStream = () => { const isActiveSession = currentSessionId === sessionId; if (isActiveSession && messageId !== latestAssistantMessageId) break; - if (!stopMarkerPresent && isDesktopRuntimeRef.current) { - trackMessage(messageId, 'desktop_completion_without_stop'); - break; - } - const timeCompleted = hasCompletedTimestamp ? (completedCandidate as number) @@ -1306,53 +1270,6 @@ export const useEventStream = () => { completeStreamingMessage(sessionId, messageId); - // Only notify when entire message is finished (finish === 'stop') - if (finish === 'stop' && isWebRuntime() && nativeNotificationsEnabled) { - const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden'; - - if (shouldNotify) { - // Check if this is a subtask and if we should notify for subtasks - if (!notifyOnSubtasks) { - const sessions = useSessionStore.getState().sessions; - const session = sessions.find(s => s.id === sessionId); - const isSubtask = session && 'parentID' in session && Boolean((session as { parentID?: string }).parentID); - if (isSubtask) { - // Skip notification for subtasks - return; - } - } - - const notifiedMessages = notifiedMessagesRef.current; - - if (!notifiedMessages.has(messageId)) { - notifiedMessages.add(messageId); - - const runtimeAPIs = getRegisteredRuntimeAPIs(); - - if (runtimeAPIs?.notifications) { - const rawMode = (messageExt as { mode?: string }).mode || 'agent'; - const rawModel = (messageExt as { modelID?: string }).modelID || 'assistant'; - - const title = `${rawMode.charAt(0).toUpperCase() + rawMode.slice(1)} agent is ready`; - const body = `${formatModelID(rawModel)} completed the task`; - - void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag: messageId }); - } - } - } - } - - // For web/vscode: trigger cooldown only when assistant message has finish === "stop" - // to match desktop backend semantics. - if (!isDesktopRuntimeRef.current) { - if (finish === 'stop') { - const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId); - if (currentPhase === 'busy') { - updateSessionActivityPhase(sessionId, 'cooldown'); - } - } - } - const rawMessageSessionId = (message as { sessionID?: string }).sessionID; const messageSessionId: string = typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0 @@ -1481,19 +1398,6 @@ export const useEventStream = () => { }); }); - if (isWebRuntime() && nativeNotificationsEnabled) { - const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden'; - if (shouldNotify) { - const runtimeAPIs = getRegisteredRuntimeAPIs(); - if (runtimeAPIs?.notifications) { - void runtimeAPIs.notifications.notifyAgentCompletion({ - title: 'Permission required', - body: sessionTitle, - tag: `permission-${toastKey}`, - }); - } - } - } }, 0); } @@ -1513,39 +1417,7 @@ export const useEventStream = () => { const toastKey = `${request.sessionID}:${request.id}`; - if (isWebRuntime() && nativeNotificationsEnabled) { - const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden'; - - if (shouldNotify) { - const notifiedQuestions = notifiedQuestionsRef.current; - - if (!notifiedQuestions.has(toastKey)) { - notifiedQuestions.add(toastKey); - - const runtimeAPIs = getRegisteredRuntimeAPIs(); - - if (runtimeAPIs?.notifications) { - const first = Array.isArray(request.questions) ? request.questions[0] : undefined; - const header = typeof first?.header === 'string' ? first.header.trim() : ''; - const questionText = typeof first?.question === 'string' ? first.question.trim() : ''; - - const title = /plan\s*mode/i.test(header) - ? 'Switch to plan mode' - : /build\s*agent/i.test(header) - ? 'Switch to build mode' - : header || 'Input needed'; - - const body = questionText || 'Agent is waiting for your response'; - - void runtimeAPIs.notifications.notifyAgentCompletion({ - title, - body, - tag: toastKey, - }); - } - } - } - } + // notifications are emitted server-side (see openchamber:notification) if (!questionToastShownRef.current.has(toastKey)) { setTimeout(() => { @@ -1606,6 +1478,34 @@ export const useEventStream = () => { break; } + case 'openchamber:notification': { + const title = typeof (props as { title?: unknown }).title === 'string' ? (props as { title: string }).title : ''; + const body = typeof (props as { body?: unknown }).body === 'string' ? (props as { body: string }).body : ''; + const tag = typeof (props as { tag?: unknown }).tag === 'string' ? (props as { tag: string }).tag : undefined; + const requireHidden = Boolean((props as { requireHidden?: unknown }).requireHidden); + + if (requireHidden && visibilityStateRef.current !== 'hidden') { + break; + } + + // Desktop local instance uses native notifications via sidecar stdout. + // Avoid duplicating via UI runtime notifications. + if (isDesktopLocalOriginActive()) { + break; + } + + if (!nativeNotificationsEnabled) { + break; + } + + const runtimeAPIs = getRegisteredRuntimeAPIs(); + if (runtimeAPIs?.notifications && title) { + void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag }); + } + + break; + } + case 'todo.updated': { const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; const todos = Array.isArray(props.todos) ? props.todos : null; @@ -1621,8 +1521,6 @@ export const useEventStream = () => { }, [ currentSessionId, nativeNotificationsEnabled, - notificationMode, - notifyOnSubtasks, addStreamingPart, completeStreamingMessage, updateMessageInfo, @@ -1635,7 +1533,7 @@ export const useEventStream = () => { applySessionMetadata, trackMessage, reportMessage, - updateSessionActivityPhase, + updateSessionStatus, updateSession, removeSessionFromStore, bootstrapState, @@ -1651,7 +1549,6 @@ export const useEventStream = () => { const debugConnectionState = React.useCallback(() => { if (streamDebugEnabled()) { console.debug('[useEventStream] Connection state:', { - isDesktopRuntime: isDesktopRuntimeRef.current, hasUnsubscribe: Boolean(unsubscribeRef.current), currentSessionId: currentSessionIdRef.current, effectiveDirectory, @@ -1663,8 +1560,6 @@ export const useEventStream = () => { } }, [effectiveDirectory]); - const waitForDesktopBridge = React.useCallback(async (): Promise => true, []); - const stopStream = React.useCallback(() => { if (isCleaningUpRef.current) { if (streamDebugEnabled()) { @@ -1708,13 +1603,6 @@ export const useEventStream = () => { return; } - if (isDesktopRuntimeRef.current) { - const bridgeReady = await waitForDesktopBridge(); - if (!bridgeReady) { - console.warn('[useEventStream] Desktop bridge not ready, falling back to SDK'); - } - } - if (options?.resetAttempts) { reconnectAttemptsRef.current = 0; } @@ -1740,9 +1628,9 @@ export const useEventStream = () => { publishStatus('connected', null); checkConnection(); - // Always refresh session activity status on connect to detect any + // Always refresh session status on connect to detect any // already-running sessions (e.g., started via CLI before UI opened) - void refreshSessionActivityStatus(); + void refreshSessionStatus(); if (shouldRefresh) { void bootstrapState('sse_reconnected'); @@ -1825,8 +1713,7 @@ export const useEventStream = () => { requestSessionMetadataRefresh, handleEvent, effectiveDirectory, - refreshSessionActivityStatus, - waitForDesktopBridge, + refreshSessionStatus, debugConnectionState, bootstrapState ]); @@ -1872,24 +1759,13 @@ export const useEventStream = () => { }, [scheduleReconnect]); React.useEffect(() => { - const cooldownTimers = sessionCooldownTimersRef.current; - if (typeof window !== 'undefined') { window.__messageTracker = trackMessage; } - let desktopActivityHandler: ((event: CustomEvent<{ sessionId?: string; phase?: string }>) => void) | null = null; - if (isDesktopRuntimeRef.current && typeof window !== 'undefined') { - desktopActivityHandler = (event: CustomEvent<{ sessionId?: string; phase?: string }>) => { - const sessionId = typeof event.detail?.sessionId === 'string' ? event.detail.sessionId : null; - const phase = typeof event.detail?.phase === 'string' ? event.detail.phase : null; - if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) { - updateSessionActivityPhase(sessionId, phase); - requestSessionMetadataRefresh(sessionId); - } - }; - window.addEventListener('openchamber:session-activity', desktopActivityHandler as EventListener); - } + // No-op + + const desktopActivityHandler = null; const clearPauseTimeout = () => { if (pauseTimeoutRef.current) { @@ -1924,7 +1800,7 @@ export const useEventStream = () => { requestSessionMetadataRefresh(sessionId); } - void refreshSessionActivityStatus(); + void refreshSessionStatus(); publishStatus('connecting', 'Resuming stream'); startStream({ resetAttempts: true }); } @@ -1949,7 +1825,7 @@ export const useEventStream = () => { requestSessionMetadataRefresh(sessionId); scheduleSoftResync(sessionId, 'window_focus', getActiveSessionWindow()); } - void refreshSessionActivityStatus(); + void refreshSessionStatus(); publishStatus('connecting', 'Resuming stream'); startStream({ resetAttempts: true }); @@ -1989,7 +1865,7 @@ export const useEventStream = () => { void scheduleSoftResync(sessionId, 'page_show', getActiveSessionWindow()); requestSessionMetadataRefresh(sessionId); } - void refreshSessionActivityStatus(); + void refreshSessionStatus(); startStream({ resetAttempts: true }); } }; @@ -2018,12 +1894,12 @@ export const useEventStream = () => { if (!shouldHoldConnection()) return; const now = Date.now(); - const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some( - (phase) => phase === 'busy' || phase === 'cooldown' + const hasBusySessions = Array.from(useSessionStore.getState().sessionStatus?.values?.() ?? []).some( + (status) => status?.type === 'busy' || status?.type === 'retry' ); if (hasBusySessions) { - void refreshSessionActivityStatus(); + void refreshSessionStatus(); } if (now - lastEventTimestampRef.current > 45000) { Promise.resolve().then(async () => { @@ -2048,9 +1924,7 @@ export const useEventStream = () => { return () => { clearTimeout(startTimer); - if (desktopActivityHandler && typeof window !== 'undefined') { - window.removeEventListener('openchamber:session-activity', desktopActivityHandler as EventListener); - } + void desktopActivityHandler; if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', handleVisibilityChange); @@ -2071,8 +1945,6 @@ export const useEventStream = () => { staleCheckIntervalRef.current = null; } - cooldownTimers.forEach((timer) => clearTimeout(timer)); - cooldownTimers.clear(); messageCache.clear(); // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time notifiedMessagesRef.current.clear(); @@ -2102,13 +1974,12 @@ export const useEventStream = () => { scheduleReconnect, loadMessages, requestSessionMetadataRefresh, - updateSessionActivityPhase, - refreshSessionActivityStatus, + updateSessionStatus, + refreshSessionStatus, shouldHoldConnection, loadSessions, maybeBootstrapIfStale, resyncMessages, scheduleSoftResync, - notifyOnSubtasks, ]); }; diff --git a/packages/ui/src/hooks/useFileSystemAccess.ts b/packages/ui/src/hooks/useFileSystemAccess.ts index 4c96a547..1ec659e6 100644 --- a/packages/ui/src/hooks/useFileSystemAccess.ts +++ b/packages/ui/src/hooks/useFileSystemAccess.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useState } from 'react'; -import { isDesktopRuntime, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop'; +import { isTauriShell, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop'; export const useFileSystemAccess = () => { const [isDesktop, setIsDesktop] = useState(false); useEffect(() => { - setIsDesktop(isDesktopRuntime()); + setIsDesktop(isTauriShell()); }, []); const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => { @@ -38,4 +38,4 @@ export const useFileSystemAccess = () => { startAccessing, stopAccessing }; -}; \ No newline at end of file +}; diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index d5a899e8..af941046 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -3,11 +3,11 @@ import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; -import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { hasModifier } from '@/lib/utils'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { useConfigStore } from '@/stores/useConfigStore'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { showOpenCodeStatus } from '@/lib/openCodeStatus'; export const useKeyboardShortcuts = () => { const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore(); @@ -24,7 +24,6 @@ export const useKeyboardShortcuts = () => { const { working } = useAssistantStatus(); const abortPrimedUntilRef = React.useRef(null); const abortPrimedTimeoutRef = React.useRef | null>(null); - const isDownloadingLogsRef = React.useRef(false); const resetAbortPriming = React.useCallback(() => { if (abortPrimedTimeoutRef.current) { @@ -44,35 +43,8 @@ export const useKeyboardShortcuts = () => { } if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') { - const runtimeAPIs = getRegisteredRuntimeAPIs(); - const diagnostics = runtimeAPIs?.diagnostics; - if (!diagnostics) { - return; - } - e.preventDefault(); - if (isDownloadingLogsRef.current) { - return; - } - isDownloadingLogsRef.current = true; - - diagnostics - .downloadLogs() - .then(({ fileName, content }) => { - const finalFileName = fileName || 'openchamber.log'; - const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = finalFileName; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - URL.revokeObjectURL(url); - }) - .finally(() => { - isDownloadingLogsRef.current = false; - }); + void showOpenCodeStatus(); return; } diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index 9564fd1b..9d8874ac 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -4,13 +4,25 @@ import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { sessionEvents } from '@/lib/sessionEvents'; -import { isDesktopRuntime } from '@/lib/desktop'; +import { isTauriShell } from '@/lib/desktop'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; +import { showOpenCodeStatus } from '@/lib/openCodeStatus'; const MENU_ACTION_EVENT = 'openchamber:menu-action'; +const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates'; + +type TauriEventApi = { + listen?: ( + event: string, + handler: (evt: { payload?: unknown }) => void + ) => Promise<() => void>; +}; + +type TauriGlobal = { + event?: TauriEventApi; +}; type MenuAction = | 'about' @@ -21,6 +33,7 @@ type MenuAction = | 'change-workspace' | 'open-git-tab' | 'open-diff-tab' + | 'open-files-tab' | 'open-terminal-tab' | 'theme-light' | 'theme-dark' @@ -46,10 +59,9 @@ export const useMenuActions = ( const { addProject } = useProjectsStore(); const { requestAccess, startAccessing } = useFileSystemAccess(); const { setThemeMode } = useThemeSystem(); - const isDownloadingLogsRef = React.useRef(false); const handleChangeWorkspace = React.useCallback(() => { - if (isDesktopRuntime()) { + if (isTauriShell()) { requestAccess('') .then(async (result) => { if (!result.success || !result.path) { @@ -80,15 +92,13 @@ export const useMenuActions = ( console.error('Desktop: Error selecting directory:', error); toast.error('Failed to select directory'); }); - } else { - sessionEvents.requestDirectoryDialog(); } + + sessionEvents.requestDirectoryDialog(); }, [addProject, requestAccess, startAccessing]); - React.useEffect(() => { - const handleMenuAction = (event: Event) => { - const action = (event as CustomEvent).detail; - + const handleAction = React.useCallback( + (action: MenuAction) => { switch (action) { case 'about': setAboutDialogOpen(true); @@ -130,6 +140,12 @@ export const useMenuActions = ( break; } + case 'open-files-tab': { + const { activeMainTab } = useUIStore.getState(); + setActiveMainTab(activeMainTab === 'files' ? 'chat' : 'files'); + break; + } + case 'open-terminal-tab': { const { activeMainTab } = useUIStore.getState(); setActiveMainTab(activeMainTab === 'terminal' ? 'chat' : 'terminal'); @@ -161,54 +177,86 @@ export const useMenuActions = ( break; case 'download-logs': { - const runtimeAPIs = getRegisteredRuntimeAPIs(); - const diagnostics = runtimeAPIs?.diagnostics; - if (!diagnostics || isDownloadingLogsRef.current) { - break; - } - - isDownloadingLogsRef.current = true; - diagnostics - .downloadLogs() - .then(({ fileName, content }) => { - const finalFileName = fileName || 'openchamber.log'; - const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = finalFileName; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - URL.revokeObjectURL(url); - toast.success('Logs saved', { - description: `Downloaded to ~/Downloads/${finalFileName}`, - }); - }) - .catch(() => { - toast.error('Failed to download logs'); - }) - .finally(() => { - isDownloadingLogsRef.current = false; - }); + void showOpenCodeStatus().catch(() => { + toast.error('Failed to collect OpenCode status'); + }); break; } } + }, + [ + handleChangeWorkspace, + onToggleMemoryDebug, + openNewSessionDraft, + setAboutDialogOpen, + setActiveMainTab, + setSessionSwitcherOpen, + setSettingsDialogOpen, + setThemeMode, + toggleCommandPalette, + toggleHelpDialog, + toggleSidebar, + ] + ); + + React.useEffect(() => { + const handleMenuAction = (event: Event) => { + const action = (event as CustomEvent).detail; + if (!action) return; + handleAction(action); }; window.addEventListener(MENU_ACTION_EVENT, handleMenuAction); return () => window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction); - }, [ - openNewSessionDraft, - toggleCommandPalette, - toggleHelpDialog, - toggleSidebar, - setSessionSwitcherOpen, - setActiveMainTab, - setSettingsDialogOpen, - setAboutDialogOpen, - setThemeMode, - onToggleMemoryDebug, - handleChangeWorkspace, - ]); + }, [handleAction]); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + const listen = tauri?.event?.listen; + if (typeof listen !== 'function') return; + + let unlistenMenu: null | (() => void | Promise) = null; + let unlistenUpdate: null | (() => void | Promise) = null; + + listen('openchamber:menu-action', (evt) => { + const action = evt?.payload; + if (typeof action !== 'string') return; + handleAction(action as MenuAction); + }) + .then((fn) => { + unlistenMenu = fn; + }) + .catch(() => { + // ignore + }); + + listen('openchamber:check-for-updates', () => { + window.dispatchEvent(new Event(CHECK_FOR_UPDATES_EVENT)); + }) + .then((fn) => { + unlistenUpdate = fn; + }) + .catch(() => { + // ignore + }); + + return () => { + const cleanup = async () => { + try { + const a = unlistenMenu?.(); + if (a instanceof Promise) await a; + } catch { + // ignore + } + try { + const b = unlistenUpdate?.(); + if (b instanceof Promise) await b; + } catch { + // ignore + } + }; + void cleanup(); + }; + }, [handleAction]); }; diff --git a/packages/ui/src/hooks/useRuntimeAPIs.ts b/packages/ui/src/hooks/useRuntimeAPIs.ts index 128d8131..a2c055ca 100644 --- a/packages/ui/src/hooks/useRuntimeAPIs.ts +++ b/packages/ui/src/hooks/useRuntimeAPIs.ts @@ -15,6 +15,4 @@ export const useRuntimeAPI = (selector: RuntimeAPISelector): TV return selector(apis); }; -export const useIsDesktopRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isDesktop); - export const useIsVSCodeRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isVSCode); diff --git a/packages/ui/src/hooks/useSessionActivity.ts b/packages/ui/src/hooks/useSessionActivity.ts index 8b15debd..a3668bfc 100644 --- a/packages/ui/src/hooks/useSessionActivity.ts +++ b/packages/ui/src/hooks/useSessionActivity.ts @@ -3,7 +3,8 @@ import React from 'react'; import { useSessionStore } from '@/stores/useSessionStore'; -export type SessionActivityPhase = 'idle' | 'busy' | 'cooldown'; +// Mirrors OpenCode SessionStatus: busy|retry|idle. +export type SessionActivityPhase = 'idle' | 'busy' | 'retry'; export interface SessionActivityResult { @@ -13,6 +14,7 @@ export interface SessionActivityResult { isBusy: boolean; + // Kept for backward compatibility; always false with server session.status. isCooldown: boolean; } @@ -26,10 +28,11 @@ const IDLE_RESULT: SessionActivityResult = { export function useSessionActivity(sessionId: string | null | undefined): SessionActivityResult { const phase = useSessionStore((state) => { - if (!sessionId || !state.sessionActivityPhase) { + if (!sessionId || !state.sessionStatus) { return 'idle' as SessionActivityPhase; } - return state.sessionActivityPhase.get(sessionId) ?? ('idle' as SessionActivityPhase); + const status = state.sessionStatus.get(sessionId); + return (status?.type ?? 'idle') as SessionActivityPhase; }); return React.useMemo(() => { @@ -37,10 +40,11 @@ export function useSessionActivity(sessionId: string | null | undefined): Sessio return IDLE_RESULT; } const isBusy = phase === 'busy'; - const isCooldown = phase === 'cooldown'; + // No cooldown in server session.status; treat retry as working. + const isCooldown = false; return { phase, - isWorking: isBusy || isCooldown, + isWorking: phase === 'busy' || phase === 'retry', isBusy, isCooldown, }; diff --git a/packages/ui/src/hooks/useSessionStatusBootstrap.ts b/packages/ui/src/hooks/useSessionStatusBootstrap.ts index 58a197b7..c77e18f7 100644 --- a/packages/ui/src/hooks/useSessionStatusBootstrap.ts +++ b/packages/ui/src/hooks/useSessionStatusBootstrap.ts @@ -20,17 +20,15 @@ export const useSessionStatusBootstrap = () => { const statusMap = await opencodeClient.getGlobalSessionStatus(); if (cancelled || !statusMap) return; - const phases = new Map(); + const nextStatus = new Map(); Object.entries(statusMap).forEach(([sessionId, raw]) => { if (!sessionId || !raw) return; const status = raw as SessionStatusPayload; - const phase: 'idle' | 'busy' | 'cooldown' = - status.type === 'busy' || status.type === 'retry' ? 'busy' : 'idle'; - phases.set(sessionId, phase); + nextStatus.set(sessionId, status); }); - if (phases.size > 0) { - useSessionStore.setState({ sessionActivityPhase: phases }); + if (nextStatus.size > 0) { + useSessionStore.setState({ sessionStatus: nextStatus }); } } catch { /* ignored */ } }; @@ -42,4 +40,3 @@ export const useSessionStatusBootstrap = () => { }; }, []); }; - diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index ac7a6621..65c9a5c9 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -56,6 +56,10 @@ textarea[data-chat-input="true"]:focus-visible { [data-scroll-shadow="true"][data-orientation="vertical"] { mask-mode: alpha; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; } [data-scroll-shadow="true"][data-orientation="vertical"][data-top-bottom-scroll="true"] { @@ -66,6 +70,13 @@ textarea[data-chat-input="true"]:focus-visible { #000 calc(100% - var(--scroll-shadow-size)), transparent 100% ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + #000 var(--scroll-shadow-size), + #000 calc(100% - var(--scroll-shadow-size)), + transparent 100% + ); } [data-scroll-shadow="true"][data-orientation="vertical"][data-top-scroll="true"] { @@ -75,6 +86,12 @@ textarea[data-chat-input="true"]:focus-visible { #000 var(--scroll-shadow-size), #000 100% ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + #000 var(--scroll-shadow-size), + #000 100% + ); } [data-scroll-shadow="true"][data-orientation="vertical"][data-bottom-scroll="true"] { @@ -84,6 +101,12 @@ textarea[data-chat-input="true"]:focus-visible { #000 calc(100% - var(--scroll-shadow-size)), transparent 100% ); + -webkit-mask-image: linear-gradient( + to bottom, + #000 0%, + #000 calc(100% - var(--scroll-shadow-size)), + transparent 100% + ); } diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 61d47906..5db3812e 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -378,6 +378,7 @@ export interface ProjectEntry { addedAt?: number; lastOpenedAt?: number; worktreeDefaults?: WorktreeDefaults; + sidebarCollapsed?: boolean; } export interface SettingsPayload { diff --git a/packages/ui/src/lib/appearancePersistence.ts b/packages/ui/src/lib/appearancePersistence.ts index d4cb231d..e133602b 100644 --- a/packages/ui/src/lib/appearancePersistence.ts +++ b/packages/ui/src/lib/appearancePersistence.ts @@ -1,4 +1,3 @@ -import { isDesktopRuntime } from '@/lib/desktop'; import { useUIStore } from '@/stores/useUIStore'; export interface AppearancePreferences { @@ -37,20 +36,14 @@ const extractRawAppearance = (data: unknown): RawAppearancePayload | null => { }; export const saveAppearancePreferences = (preferences: AppearancePreferences): boolean => { - if (typeof window === 'undefined' || !isDesktopRuntime()) { - return false; - } - - const api = window.opencodeAppearance; - if (!api || typeof api.save !== 'function') { + if (typeof window === 'undefined') { return false; } try { - void api.save(preferences); + localStorage.setItem('appearance-preferences', JSON.stringify(preferences)); return true; - } catch (error) { - console.warn('Failed to save appearance preferences to desktop storage:', error); + } catch { return false; } }; @@ -68,22 +61,6 @@ export const loadAppearancePreferences = async (): Promise { try { @@ -302,10 +296,10 @@ export const debugUtils = { const report = { runtime: { platform: runtimeApis?.runtime?.platform ?? null, - isDesktop: isDesktopRuntime, + isDesktop: isTauriShell, isVSCode: Boolean(runtimeApis?.runtime?.isVSCode), hasRuntimeApis: Boolean(runtimeApis), - desktopServerOrigin: desktopServer?.origin ?? null, + desktopServerOrigin: null, }, location: typeof window !== 'undefined' ? { diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 28d4c5fa..6e8714ad 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -21,14 +21,6 @@ export type UpdateProgress = { total?: number; }; -export type DesktopServerInfo = { - webPort: number | null; - openCodePort: number | null; - host: string | null; - ready: boolean; - cliAvailable: boolean; -}; - export type SkillCatalogConfig = { id: string; label: string; @@ -74,6 +66,9 @@ export type DesktopSettings = { padding?: number; cornerRadius?: number; inputBarOffset?: number; + + favoriteModels?: Array<{ providerID: string; modelID: string }>; + recentModels?: Array<{ providerID: string; modelID: string }>; diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side'; diffViewMode?: 'single' | 'stacked'; directoryShowHidden?: boolean; @@ -88,34 +83,58 @@ export type DesktopSettings = { skillCatalogs?: SkillCatalogConfig[]; }; -export type DesktopSettingsApi = { - getSettings: () => Promise; - updateSettings: (changes: Partial) => Promise; +type TauriGlobal = { + core?: { + invoke?: (cmd: string, args?: Record) => Promise; + }; + dialog?: { + open?: (options: Record) => Promise; + }; + event?: { + listen?: ( + event: string, + handler: (evt: { payload?: unknown }) => void, + ) => Promise<() => void>; + }; }; -export type DesktopApi = { - homeDirectory?: string; - macosMajorVersion?: number | null; - getServerInfo: () => Promise; - restartOpenCode: () => Promise<{ success: boolean }>; - shutdown: () => Promise<{ success: boolean }>; - markRendererReady?: () => Promise | void; - windowControl?: (action: 'close' | 'minimize' | 'maximize') => Promise<{ success: boolean }>; - getHomeDirectory?: () => Promise<{ success: boolean; path: string | null }>; - getSettings?: () => Promise; - updateSettings?: (changes: Partial) => Promise; - requestDirectoryAccess?: (path: string) => Promise<{ success: boolean; path?: string; projectId?: string; error?: string }>; - startAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>; - stopAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>; - notifyAssistantCompletion?: (payload?: AssistantNotificationPayload) => Promise<{ success: boolean }>; - checkForUpdates?: () => Promise; - downloadUpdate?: (onProgress?: (progress: UpdateProgress) => void) => Promise; - restartToUpdate?: () => Promise; - openExternal?: (url: string) => Promise<{ success: boolean; error?: string }>; +export const isTauriShell = (): boolean => { + if (typeof window === 'undefined') return false; + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + return typeof tauri?.core?.invoke === 'function'; }; -export const isDesktopRuntime = (): boolean => - typeof window !== "undefined" && typeof window.opencodeDesktop !== "undefined"; +const normalizeOrigin = (raw: string): string | null => { + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + return new URL(trimmed).origin; + } catch { + try { + return new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`).origin; + } catch { + return null; + } + } +}; + +export const isDesktopLocalOriginActive = (): boolean => { + if (typeof window === 'undefined') return false; + const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : ''; + const localOrigin = normalizeOrigin(local); + const currentOrigin = normalizeOrigin(window.location.origin) || window.location.origin; + return Boolean(localOrigin && currentOrigin && localOrigin === currentOrigin); +}; + +// Desktop shell detection that doesn't require Tauri IPC availability. +// (Remote pages can temporarily lose window.__TAURI__ if URL doesn't match remote allowlist.) +export const isDesktopShell = (): boolean => { + if (typeof window === 'undefined') return false; + if (typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' && window.__OPENCHAMBER_LOCAL_ORIGIN__.length > 0) { + return true; + } + return isTauriShell(); +}; export const isVSCodeRuntime = (): boolean => { if (typeof window === "undefined") return false; @@ -125,37 +144,19 @@ export const isVSCodeRuntime = (): boolean => { export const isWebRuntime = (): boolean => { if (typeof window === "undefined") return false; - // Web runtime: not desktop, not VSCode - return !isDesktopRuntime() && !isVSCodeRuntime(); -}; - -export const getDesktopApi = (): DesktopApi | null => { - if (!isDesktopRuntime()) { - return null; + const apis = (window as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { platform?: string } } }).__OPENCHAMBER_RUNTIME_APIS__; + const platform = apis?.runtime?.platform; + if (platform === 'web') { + return true; } - return window.opencodeDesktop ?? null; -}; - -export const getDesktopSettingsApi = (): DesktopSettingsApi | null => { - if (typeof window === 'undefined') { - return null; + if (platform === 'desktop' || platform === 'vscode') { + return false; } - if (window.opencodeDesktopSettings) { - return window.opencodeDesktopSettings; - } - const base = window.opencodeDesktop; - if (base?.getSettings && base?.updateSettings) { - return { - getSettings: base.getSettings.bind(base), - updateSettings: base.updateSettings.bind(base) - }; - } - return null; + // Default: anything that's not VSCode behaves like web (HTTP UI). + return !isVSCodeRuntime(); }; export const getDesktopHomeDirectory = async (): Promise => { - const api = getDesktopApi(); - if (typeof window !== 'undefined') { const embedded = window.__OPENCHAMBER_HOME__; if (embedded && embedded.length > 0) { @@ -163,148 +164,82 @@ export const getDesktopHomeDirectory = async (): Promise => { } } - if (!api) { - return null; - } - - if (typeof api.homeDirectory === 'string' && api.homeDirectory.length > 0) { - return api.homeDirectory; - } - - try { - if (!api.getHomeDirectory) { - return null; - } - const result = await api.getHomeDirectory(); - if (result?.success && typeof result.path === 'string' && result.path.length > 0) { - return result.path; - } - } catch (error) { - console.warn('Failed to obtain desktop home directory:', error); - } - return null; }; -export const fetchDesktopServerInfo = async (): Promise => { - const api = getDesktopApi(); - if (!api) { - return null; - } - - try { - return await api.getServerInfo(); - } catch (error) { - console.warn("Failed to read desktop server info", error); - return null; - } -}; - -export const isCliAvailable = (): boolean => { - if (typeof window === 'undefined') { - return false; - } - return window.__OPENCHAMBER_DESKTOP_SERVER__?.cliAvailable ?? false; -}; - -export const getDesktopSettings = async (): Promise => { - const api = getDesktopSettingsApi(); - if (!api) { - return null; - } - try { - return await api.getSettings(); - } catch (error) { - console.warn('Failed to read desktop settings', error); - return null; - } -}; - -export const updateDesktopSettings = async ( - changes: Partial -): Promise => { - const api = getDesktopSettingsApi(); - if (!api) { - return null; - } - try { - return await api.updateSettings(changes); - } catch (error) { - console.warn('[desktop] Failed to update desktop settings', error); - return null; - } -}; - export const requestDirectoryAccess = async ( directoryPath: string ): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => { - const api = getDesktopApi(); - if (!api || !api.requestDirectoryAccess) { - return { success: true, path: directoryPath }; - } - try { - return await api.requestDirectoryAccess(directoryPath); - } catch (error) { - console.warn('Failed to request directory access', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; + // Desktop shell: use native folder picker. + if (isTauriShell()) { + try { + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + const selected = await tauri?.dialog?.open?.({ + directory: true, + multiple: false, + title: 'Select Working Directory', + }); + if (!selected || typeof selected !== 'string') { + return { success: false, error: 'Directory selection cancelled' }; + } + return { success: true, path: selected }; + } catch (error) { + console.warn('Failed to request directory access (tauri)', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } } + + return { success: true, path: directoryPath }; }; export const startAccessingDirectory = async ( directoryPath: string ): Promise<{ success: boolean; error?: string }> => { - const api = getDesktopApi(); - if (!api || !api.startAccessingDirectory) { - return { success: true }; - } - try { - return await api.startAccessingDirectory(directoryPath); - } catch (error) { - console.warn('Failed to start accessing directory', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } + void directoryPath; + return { success: true }; }; export const stopAccessingDirectory = async ( directoryPath: string ): Promise<{ success: boolean; error?: string }> => { - const api = getDesktopApi(); - if (!api || !api.stopAccessingDirectory) { - return { success: true }; - } - try { - return await api.stopAccessingDirectory(directoryPath); - } catch (error) { - console.warn('Failed to stop accessing directory', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } + void directoryPath; + return { success: true }; }; export const sendAssistantCompletionNotification = async ( payload?: AssistantNotificationPayload ): Promise => { - const api = getDesktopApi(); - if (!api || !api.notifyAssistantCompletion) { - return false; - } - try { - const result = await api.notifyAssistantCompletion(payload ?? {}); - return Boolean(result?.success); - } catch (error) { - console.warn('Failed to send assistant completion notification', error); - return false; + if (isTauriShell()) { + try { + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + await tauri?.core?.invoke?.('desktop_notify', { + payload: { + title: payload?.title, + body: payload?.body, + tag: 'openchamber-agent-complete', + }, + }); + return true; + } catch (error) { + console.warn('Failed to send assistant completion notification (tauri)', error); + return false; + } } + + return false; }; export const checkForDesktopUpdates = async (): Promise => { - const api = getDesktopApi(); - if (!api || !api.checkForUpdates) { + if (!isTauriShell() || !isDesktopLocalOriginActive()) { return null; } + try { - return await api.checkForUpdates(); + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + const info = await tauri?.core?.invoke?.('desktop_check_for_updates'); + return info as UpdateInfo; } catch (error) { - console.warn('Failed to check for updates', error); + console.warn('Failed to check for updates (tauri)', error); return null; } }; @@ -312,29 +247,76 @@ export const checkForDesktopUpdates = async (): Promise => { export const downloadDesktopUpdate = async ( onProgress?: (progress: UpdateProgress) => void ): Promise => { - const api = getDesktopApi(); - if (!api || !api.downloadUpdate) { + if (!isTauriShell() || !isDesktopLocalOriginActive()) { return false; } + + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + let unlisten: null | (() => void | Promise) = null; + let downloaded = 0; + let total: number | undefined; + try { - await api.downloadUpdate(onProgress); + if (typeof onProgress === 'function' && tauri?.event?.listen) { + unlisten = await tauri.event.listen('openchamber:update-progress', (evt) => { + const payload = evt?.payload; + if (!payload || typeof payload !== 'object') return; + const data = payload as { event?: unknown; data?: unknown }; + const eventName = typeof data.event === 'string' ? data.event : null; + const eventData = data.data && typeof data.data === 'object' ? (data.data as Record) : null; + + if (eventName === 'Started') { + downloaded = 0; + total = typeof eventData?.contentLength === 'number' ? (eventData.contentLength as number) : undefined; + onProgress({ downloaded, total }); + return; + } + + if (eventName === 'Progress') { + const d = eventData?.downloaded; + const t = eventData?.total; + if (typeof d === 'number') downloaded = d; + if (typeof t === 'number') total = t; + onProgress({ downloaded, total }); + return; + } + + if (eventName === 'Finished') { + onProgress({ downloaded, total }); + } + }); + } + + await tauri?.core?.invoke?.('desktop_download_and_install_update'); return true; } catch (error) { - console.warn('Failed to download update', error); + console.warn('Failed to download update (tauri)', error); return false; + } finally { + if (unlisten) { + try { + const result = unlisten(); + if (result instanceof Promise) { + await result; + } + } catch { + // ignored + } + } } }; export const restartToApplyUpdate = async (): Promise => { - const api = getDesktopApi(); - if (!api || !api.restartToUpdate) { + if (!isTauriShell() || !isDesktopLocalOriginActive()) { return false; } + try { - await api.restartToUpdate(); + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + await tauri?.core?.invoke?.('desktop_restart'); return true; } catch (error) { - console.warn('Failed to restart for update', error); + console.warn('Failed to restart for update (tauri)', error); return false; } }; diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts new file mode 100644 index 00000000..6a3b1124 --- /dev/null +++ b/packages/ui/src/lib/desktopHosts.ts @@ -0,0 +1,110 @@ +import { isTauriShell } from '@/lib/desktop'; + +type TauriInvoke = (cmd: string, args?: Record) => Promise; + +type TauriGlobal = { + core?: { + invoke?: TauriInvoke; + }; +}; + +export type DesktopHost = { + id: string; + label: string; + url: string; +}; + +export type DesktopHostsConfig = { + hosts: DesktopHost[]; + defaultHostId: string | null; +}; + +export type HostProbeResult = { + status: 'ok' | 'auth' | 'unreachable'; + latencyMs: number; +}; + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null; +}; + +const readString = (obj: Record, key: string): string | null => { + const val = obj[key]; + return typeof val === 'string' ? val : null; +}; + +const readNumber = (obj: Record, key: string): number | null => { + const val = obj[key]; + return typeof val === 'number' && Number.isFinite(val) ? val : null; +}; + +const parseHost = (value: unknown): DesktopHost | null => { + if (!isRecord(value)) return null; + const id = readString(value, 'id'); + const label = readString(value, 'label'); + const url = readString(value, 'url'); + if (!id || !label || !url) return null; + return { id, label, url }; +}; + +const getInvoke = (): TauriInvoke | null => { + if (!isTauriShell()) return null; + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null; +}; + +export const desktopHostsGet = async (): Promise => { + const invoke = getInvoke(); + if (!invoke) { + return { hosts: [], defaultHostId: 'local' }; + } + + const raw = await invoke('desktop_hosts_get'); + if (!isRecord(raw)) { + return { hosts: [], defaultHostId: null }; + } + + const hostsRaw = raw.hosts; + const hosts = Array.isArray(hostsRaw) + ? hostsRaw.map(parseHost).filter((h): h is DesktopHost => Boolean(h)) + : []; + + const defaultHostId = + readString(raw, 'defaultHostId') || + readString(raw, 'default_host_id') || + readString(raw, 'defaultHostID'); + + return { hosts, defaultHostId }; +}; + +export const desktopHostsSet = async (config: DesktopHostsConfig): Promise => { + const invoke = getInvoke(); + if (!invoke) return; + await invoke('desktop_hosts_set', { + config: { + hosts: config.hosts, + defaultHostId: config.defaultHostId, + }, + }); +}; + +export const desktopHostProbe = async (url: string): Promise => { + const invoke = getInvoke(); + if (!invoke) { + return { status: 'unreachable', latencyMs: 0 }; + } + + const raw = await invoke('desktop_host_probe', { url }); + if (!isRecord(raw)) { + return { status: 'unreachable', latencyMs: 0 }; + } + + const rawStatus = raw.status; + const status: HostProbeResult['status'] = + rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'unreachable' + ? rawStatus + : 'unreachable'; + + const latencyMs = readNumber(raw, 'latencyMs') ?? readNumber(raw, 'latency_ms') ?? 0; + return { status, latencyMs }; +}; diff --git a/packages/ui/src/lib/device.ts b/packages/ui/src/lib/device.ts index 6f5673bf..a4337377 100644 --- a/packages/ui/src/lib/device.ts +++ b/packages/ui/src/lib/device.ts @@ -1,4 +1,5 @@ import React from 'react'; +import { isTauriShell } from '@/lib/desktop'; export type DeviceType = 'desktop' | 'mobile' | 'tablet'; @@ -28,7 +29,7 @@ export const BREAKPOINTS = { } as const; const setRootDeviceAttributes = ( - isDesktopRuntime: boolean, + isTauriShellRuntime: boolean, deviceType: DeviceType, hasTouchInput: boolean, ) => { @@ -49,7 +50,7 @@ const setRootDeviceAttributes = ( : 'device-desktop' ); - if (isDesktopRuntime) { + if (isTauriShellRuntime) { root.classList.add('desktop-runtime'); root.style.setProperty('--is-mobile', '0'); root.style.setProperty('--device-type', 'desktop'); @@ -81,7 +82,7 @@ export function getDeviceInfo(): DeviceInfo { const noHover = hoverQuery?.matches ?? false; const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0; - const isDesktopRuntime = typeof window !== 'undefined' && typeof window.opencodeDesktop !== 'undefined'; + const isTauriShellRuntime = isTauriShell(); const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0; @@ -93,7 +94,7 @@ export function getDeviceInfo(): DeviceInfo { let isDesktop = !hasTouchInput || width > BREAKPOINTS.lg; let deviceType: DeviceType = 'desktop'; - if (isDesktopRuntime) { + if (isTauriShellRuntime) { isMobile = false; isTablet = false; isDesktop = true; @@ -107,7 +108,7 @@ export function getDeviceInfo(): DeviceInfo { deviceType = 'desktop'; } - setRootDeviceAttributes(isDesktopRuntime, deviceType, hasTouchInput); + setRootDeviceAttributes(isTauriShellRuntime, deviceType, hasTouchInput); let breakpoint: keyof typeof BREAKPOINTS = 'xs'; for (const [key, value] of Object.entries(BREAKPOINTS)) { @@ -130,7 +131,7 @@ export function getDeviceInfo(): DeviceInfo { export function isMobileDeviceViaCSS(): boolean { if (typeof window === 'undefined') return false; - if (typeof window.opencodeDesktop !== 'undefined') { + if (typeof window !== 'undefined' && isTauriShell()) { return false; } @@ -205,7 +206,7 @@ export function useDeviceInfo(): DeviceInfo { React.useEffect(() => { if (typeof window === 'undefined') return; - const isDesktopRuntime = typeof window.opencodeDesktop !== 'undefined'; + const isTauriShellRuntime = isTauriShell(); const supportsMatchMedia = typeof window.matchMedia === 'function'; const pointerQuery = supportsMatchMedia ? window.matchMedia('(pointer: coarse)') : null; const hoverQuery = supportsMatchMedia ? window.matchMedia('(hover: none)') : null; @@ -213,7 +214,7 @@ export function useDeviceInfo(): DeviceInfo { const noHover = hoverQuery?.matches ?? false; const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0; const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0; - setRootDeviceAttributes(isDesktopRuntime, deviceInfo.deviceType, hasTouchInput); + setRootDeviceAttributes(isTauriShellRuntime, deviceInfo.deviceType, hasTouchInput); }, [deviceInfo.deviceType, deviceInfo.hasTouchInput]); return deviceInfo; diff --git a/packages/ui/src/lib/modelPrefsAutoSave.ts b/packages/ui/src/lib/modelPrefsAutoSave.ts new file mode 100644 index 00000000..3af5d116 --- /dev/null +++ b/packages/ui/src/lib/modelPrefsAutoSave.ts @@ -0,0 +1,76 @@ +import { useUIStore } from '@/stores/useUIStore'; +import { updateDesktopSettings } from '@/lib/persistence'; +import { isVSCodeRuntime } from '@/lib/desktop'; + +type ModelRef = { providerID: string; modelID: string }; + +const refsEqual = (a: ModelRef[], b: ModelRef[]): boolean => { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i]?.providerID !== b[i]?.providerID) return false; + if (a[i]?.modelID !== b[i]?.modelID) return false; + } + return true; +}; + +export const startModelPrefsAutoSave = () => { + if (typeof window === 'undefined') { + return () => {}; + } + if (isVSCodeRuntime()) { + return () => {}; + } + + let timer: number | null = null; + let lastSent: { favoriteModels: ModelRef[]; recentModels: ModelRef[] } | null = null; + let didSkipInitial = false; + + const flush = () => { + timer = null; + const state = useUIStore.getState(); + const payload = { favoriteModels: state.favoriteModels, recentModels: state.recentModels }; + + if ( + lastSent && + refsEqual(lastSent.favoriteModels, payload.favoriteModels) && + refsEqual(lastSent.recentModels, payload.recentModels) + ) { + return; + } + + lastSent = { + favoriteModels: payload.favoriteModels.slice(), + recentModels: payload.recentModels.slice(), + }; + + void updateDesktopSettings(payload).catch(() => {}); + }; + + const schedule = () => { + if (!didSkipInitial) { + didSkipInitial = true; + return; + } + if (timer !== null) { + window.clearTimeout(timer); + } + timer = window.setTimeout(flush, 1200); + }; + + const unsubscribe = useUIStore.subscribe((state, prevState) => { + const next = { favoriteModels: state.favoriteModels, recentModels: state.recentModels }; + const prev = { favoriteModels: prevState.favoriteModels, recentModels: prevState.recentModels }; + if (refsEqual(next.favoriteModels, prev.favoriteModels) && refsEqual(next.recentModels, prev.recentModels)) { + return; + } + schedule(); + }); + + return () => { + unsubscribe(); + if (timer !== null) { + window.clearTimeout(timer); + } + }; +}; diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts new file mode 100644 index 00000000..2579ad18 --- /dev/null +++ b/packages/ui/src/lib/openCodeStatus.ts @@ -0,0 +1,161 @@ +import { useSessionStore } from '@/stores/useSessionStore'; +import { useUIStore } from '@/stores/useUIStore'; + +declare const __APP_VERSION__: string | undefined; + +type ProbeResult = { + ok: boolean; + status: number; + elapsedMs: number; + summary: string; +}; + +const getCurrentDirectory = (): string => { + const state = useSessionStore.getState(); + const currentSessionId = state.currentSessionId; + if (!currentSessionId) return ''; + const session = state.sessions.find((s) => s.id === currentSessionId); + return typeof session?.directory === 'string' ? session.directory : ''; +}; + +const safeFetch = async (input: string, timeoutMs = 6000): Promise => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const startedAt = Date.now(); + + try { + const resp = await fetch(input, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + + const elapsedMs = Date.now() - startedAt; + const contentType = resp.headers.get('content-type') || ''; + const lower = contentType.toLowerCase(); + const isJson = lower.includes('json') && !lower.includes('text/html'); + + let summary = ''; + if (isJson) { + const json = await resp.json().catch(() => null); + if (Array.isArray(json)) { + summary = `json[array] len=${json.length}`; + } else if (json && typeof json === 'object') { + const keys = Object.keys(json).slice(0, 8); + summary = `json[object] keys=${keys.join(',')}${Object.keys(json).length > keys.length ? ',…' : ''}`; + } else { + summary = `json[${typeof json}]`; + } + } else { + summary = contentType ? `content-type=${contentType}` : 'no content-type'; + } + + return { ok: resp.ok && isJson, status: resp.status, elapsedMs, summary }; + } catch (error) { + const elapsedMs = Date.now() - startedAt; + const isAbort = + controller.signal.aborted || + (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))); + const message = isAbort + ? `timeout after ${timeoutMs}ms` + : error instanceof Error + ? error.message + : String(error); + return { ok: false, status: 0, elapsedMs, summary: `error=${message}` }; + } finally { + clearTimeout(timeout); + } +}; + +const formatIso = (timestamp: number | null | undefined): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '(n/a)'; + try { + return new Date(timestamp).toISOString(); + } catch { + return '(invalid)'; + } +}; + +export const buildOpenCodeStatusReport = async (): Promise => { + const now = new Date(); + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)'; + const platform = typeof navigator !== 'undefined' ? navigator.userAgent : '(no navigator)'; + const directory = getCurrentDirectory(); + const eventStreamStatus = useUIStore.getState().eventStreamStatus; + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + const apiBase = origin ? `${origin.replace(/\/+$/, '')}/api/` : ''; + + const buildProbeUrl = (pathname: string, includeDirectory = true): string | null => { + if (!apiBase) return null; + const url = new URL(pathname.replace(/^\/+/, ''), apiBase); + if (includeDirectory && directory) { + url.searchParams.set('directory', directory); + } + return url.toString(); + }; + + const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [ + { label: 'health', path: '/global/health', includeDirectory: false }, + { label: 'config', path: '/config', includeDirectory: true }, + { label: 'providers', path: '/config/providers', includeDirectory: true }, + { label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 }, + { label: 'commands', path: '/command', includeDirectory: true, timeoutMs: 10000 }, + { label: 'project', path: '/project/current', includeDirectory: true }, + { label: 'path', path: '/path', includeDirectory: true }, + { label: 'sessions', path: '/session', includeDirectory: true, timeoutMs: 12000 }, + { label: 'sessionStatus', path: '/session/status', includeDirectory: true }, + ]; + + const probes = apiBase + ? await Promise.all( + probeTargets.map(async (entry) => { + const url = buildProbeUrl(entry.path, entry.includeDirectory !== false); + if (!url) return { label: entry.label, url: '(none)', result: null as ProbeResult | null }; + const result = await safeFetch(url, typeof entry.timeoutMs === 'number' ? entry.timeoutMs : undefined); + return { label: entry.label, url, result }; + }) + ) + : []; + + const lines: string[] = []; + lines.push(`Time: ${now.toISOString()}`); + lines.push(`OpenChamber version: ${appVersion}`); + lines.push(`Runtime: ${origin || '(unknown)'} (api=${origin ? origin + '/api' : '(unknown)'})`); + lines.push(`Event stream: ${eventStreamStatus}`); + lines.push(`Directory: ${directory || '(none)'}`); + lines.push(`Platform: ${platform}`); + + if (typeof window !== 'undefined') { + const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__; + if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) { + lines.push(`macOS major: ${injected}`); + } + } + + lines.push(''); + if (probes.length) { + lines.push('OpenCode API probes:'); + for (const probe of probes) { + if (!probe.result) { + lines.push(`- ${probe.label}: (no url)`); + continue; + } + const { ok, status, elapsedMs, summary } = probe.result; + const suffix = ok ? '' : ` url=${probe.url}`; + lines.push(`- ${probe.label}: ${ok ? 'ok' : 'fail'} status=${status} time=${elapsedMs}ms ${summary}${suffix}`); + } + } else { + lines.push('OpenCode API probes: (skipped)'); + } + + lines.push(''); + lines.push(`Generated: ${formatIso(Date.now())}`); + return lines.join('\n'); +}; + +export const showOpenCodeStatus = async (): Promise => { + const text = await buildOpenCodeStatusReport(); + const ui = useUIStore.getState(); + ui.setOpenCodeStatusText(text); + ui.setOpenCodeStatusDialogOpen(true); +}; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 0b9195a4..01196441 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1,4 +1,3 @@ -import { getDesktopSettings, updateDesktopSettings as updateDesktopSettingsApi, isDesktopRuntime } from '@/lib/desktop'; import type { DesktopSettings } from '@/lib/desktop'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore } from '@/stores/messageQueueStore'; @@ -49,6 +48,18 @@ const persistToLocalStorage = (settings: DesktopSettings) => { } else { localStorage.removeItem('pinnedDirectories'); } + + if (Array.isArray(settings.projects) && settings.projects.length > 0) { + const collapsed = settings.projects + .filter((project) => (project as unknown as { sidebarCollapsed?: boolean }).sidebarCollapsed === true) + .map((project) => project.id) + .filter((id): id is string => typeof id === 'string' && id.length > 0); + if (collapsed.length > 0) { + localStorage.setItem('oc.sessions.projectCollapse', JSON.stringify(collapsed)); + } else { + localStorage.removeItem('oc.sessions.projectCollapse'); + } + } if (typeof settings.gitmojiEnabled === 'boolean') { localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled)); } else { @@ -143,6 +154,9 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin ) { project.lastOpenedAt = candidate.lastOpenedAt; } + if (typeof candidate.sidebarCollapsed === 'boolean') { + (project as unknown as Record).sidebarCollapsed = candidate.sidebarCollapsed; + } // Preserve worktreeDefaults if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') { const wt = candidate.worktreeDefaults as Record; @@ -164,6 +178,30 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin return result.length > 0 ? result : undefined; }; +const sanitizeModelRefs = (value: unknown, limit: number): Array<{ providerID: string; modelID: string }> | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + + const result: Array<{ providerID: string; modelID: string }> = []; + const seen = new Set(); + + for (const entry of value) { + if (!entry || typeof entry !== 'object') continue; + const candidate = entry as Record; + const providerID = typeof candidate.providerID === 'string' ? candidate.providerID.trim() : ''; + const modelID = typeof candidate.modelID === 'string' ? candidate.modelID.trim() : ''; + if (!providerID || !modelID) continue; + const key = `${providerID}/${modelID}`; + if (seen.has(key)) continue; + seen.add(key); + result.push({ providerID, modelID }); + if (result.length >= limit) break; + } + + return result; +}; + const getPersistApi = (): PersistApi | undefined => { const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist; if (candidate && typeof candidate === 'object') { @@ -240,6 +278,28 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { if (typeof settings.inputBarOffset === 'number' && Number.isFinite(settings.inputBarOffset) && settings.inputBarOffset !== store.inputBarOffset) { store.setInputBarOffset(settings.inputBarOffset); } + + if (Array.isArray(settings.favoriteModels)) { + const current = store.favoriteModels; + const next = settings.favoriteModels; + const same = + current.length === next.length && + current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID); + if (!same) { + useUIStore.setState({ favoriteModels: next }); + } + } + + if (Array.isArray(settings.recentModels)) { + const current = store.recentModels; + const next = settings.recentModels; + const same = + current.length === next.length && + current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID); + if (!same) { + useUIStore.setState({ recentModels: next }); + } + } if (typeof settings.diffLayoutPreference === 'string' && (settings.diffLayoutPreference === 'dynamic' || settings.diffLayoutPreference === 'inline' || settings.diffLayoutPreference === 'side-by-side')) { if (settings.diffLayoutPreference !== store.diffLayoutPreference) { @@ -391,6 +451,16 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) { result.inputBarOffset = candidate.inputBarOffset; } + + const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64); + if (favoriteModels) { + result.favoriteModels = favoriteModels; + } + + const recentModels = sanitizeModelRefs(candidate.recentModels, 16); + if (recentModels) { + result.recentModels = recentModels; + } if ( typeof candidate.diffLayoutPreference === 'string' && (candidate.diffLayoutPreference === 'dynamic' @@ -487,9 +557,9 @@ export const syncDesktopSettings = async (): Promise => { }; try { - const settings = isDesktopRuntime() ? await getDesktopSettings() : await fetchWebSettings(); - if (settings) { - applySettings(settings); + const webSettings = await fetchWebSettings(); + if (webSettings) { + applySettings(webSettings); } } catch (error) { console.warn('Failed to synchronise settings:', error); @@ -501,18 +571,7 @@ export const updateDesktopSettings = async (changes: Partial): return; } - if (isDesktopRuntime()) { - try { - const updated = await updateDesktopSettingsApi(changes); - if (updated) { - persistToLocalStorage(updated); - applyDesktopUiPreferences(updated); - } - } catch (error) { - console.warn('Failed to update desktop settings:', error); - } - return; - } + // Desktop shell uses the same HTTP settings API as web. const runtimeSettings = getRuntimeSettingsAPI(); if (runtimeSettings) { diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index fa15fdf4..687852d6 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -1,6 +1,6 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; -import { isDesktopRuntime } from "@/lib/desktop"; +import { isTauriShell } from "@/lib/desktop"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -21,7 +21,7 @@ export const isMacOS = (): boolean => { * Browser intercepts Cmd shortcuts, so we only use Cmd in Tauri desktop app. */ export const hasModifier = (e: KeyboardEvent | React.KeyboardEvent): boolean => { - return isMacOS() && isDesktopRuntime() ? e.metaKey : e.ctrlKey; + return isMacOS() && isTauriShell() ? e.metaKey : e.ctrlKey; }; /** @@ -30,7 +30,7 @@ export const hasModifier = (e: KeyboardEvent | React.KeyboardEvent): boolean => * Browser intercepts Cmd shortcuts, so we only show Cmd in Tauri desktop app. */ export const getModifierLabel = (): string => { - return isMacOS() && isDesktopRuntime() ? '⌘' : 'Ctrl'; + return isMacOS() && isTauriShell() ? '⌘' : 'Ctrl'; }; export const truncatePathMiddle = ( diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 462c238a..97445108 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -11,6 +11,7 @@ import { syncDesktopSettings, initializeAppearancePreferences } from './lib/pers import { startAppearanceAutoSave } from './lib/appearanceAutoSave' import { applyPersistedDirectoryPreferences } from './lib/directoryPersistence' import { startTypographyWatcher } from './lib/typographyWatcher' +import { startModelPrefsAutoSave } from './lib/modelPrefsAutoSave' import type { RuntimeAPIs } from './lib/api/types' declare global { @@ -26,6 +27,7 @@ const runtimeAPIs = (typeof window !== 'undefined' && window.__OPENCHAMBER_RUNTI await syncDesktopSettings(); await initializeAppearancePreferences(); startAppearanceAutoSave(); +startModelPrefsAutoSave(); startTypographyWatcher(); await applyPersistedDirectoryPreferences(); @@ -97,21 +99,3 @@ createRoot(rootElement).render( , ); - -if (typeof window !== 'undefined') { - const markRendererReady = () => { - try { - window.opencodeDesktop?.markRendererReady?.(); - } catch (error) { - console.warn('Failed to notify desktop runtime that renderer is ready:', error); - } - }; - - markRendererReady(); - - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - markRendererReady(); - } - }); -} diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index f9b9878b..da8cb7e6 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -134,7 +134,12 @@ export interface SessionStore { sessionAgentEditModes: Map>; - sessionActivityPhase?: Map; + // Server-owned session status (mirrors OpenCode SessionStatus: busy|retry|idle). + // Use as the single source of truth for "assistant working" UI. + sessionStatus?: Map< + string, + { type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number } + >; userSummaryTitles: Map; diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 07acb127..d400db18 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -8,7 +8,6 @@ import type { ModelMetadata } from "@/types"; import { getSafeStorage } from "./utils/safeStorage"; import type { SessionStore } from "./types/sessionTypes"; import { filterVisibleAgents } from "./useAgentsStore"; -import { isDesktopRuntime, getDesktopSettings } from "@/lib/desktop"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; import { updateDesktopSettings } from "@/lib/persistence"; import { useDirectoryStore } from "@/stores/useDirectoryStore"; @@ -30,19 +29,7 @@ interface OpenChamberDefaults { const fetchOpenChamberDefaults = async (): Promise => { try { - // 1. Desktop runtime (Tauri) - if (isDesktopRuntime()) { - const settings = await getDesktopSettings(); - return { - defaultModel: settings?.defaultModel, - defaultVariant: settings?.defaultVariant, - defaultAgent: settings?.defaultAgent, - autoCreateWorktree: settings?.autoCreateWorktree, - gitmojiEnabled: settings?.gitmojiEnabled, - }; - } - - // 2. Runtime settings API (VSCode) + // 1. Runtime settings API (VSCode) const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; if (runtimeSettings) { try { @@ -67,7 +54,7 @@ const fetchOpenChamberDefaults = async (): Promise => { } } - // 3. Fetch API (Web) + // 2. Fetch API (Web/server) const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' }, diff --git a/packages/ui/src/stores/useDirectoryStore.ts b/packages/ui/src/stores/useDirectoryStore.ts index 9bb00773..f27a5901 100644 --- a/packages/ui/src/stores/useDirectoryStore.ts +++ b/packages/ui/src/stores/useDirectoryStore.ts @@ -85,9 +85,7 @@ const getHomeDirectory = () => { const desktopHome = (typeof window.__OPENCHAMBER_HOME__ === 'string' && window.__OPENCHAMBER_HOME__.length > 0 ? window.__OPENCHAMBER_HOME__ - : window.opencodeDesktop && typeof window.opencodeDesktop.homeDirectory === 'string' - ? window.opencodeDesktop.homeDirectory - : null); + : null); if (desktopHome && desktopHome.length > 0) { cachedHomeDirectory = desktopHome; diff --git a/packages/ui/src/stores/useGitIdentitiesStore.ts b/packages/ui/src/stores/useGitIdentitiesStore.ts index dc3f354f..18c92a82 100644 --- a/packages/ui/src/stores/useGitIdentitiesStore.ts +++ b/packages/ui/src/stores/useGitIdentitiesStore.ts @@ -10,7 +10,6 @@ import { discoverGitCredentials, getGlobalGitIdentity } from "@/lib/gitApi"; -import { getDesktopSettings, isDesktopRuntime } from "@/lib/desktop"; import { updateDesktopSettings } from "@/lib/persistence"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; @@ -145,10 +144,7 @@ export const useGitIdentitiesStore = create()( try { let defaultId: string | null = null; - if (isDesktopRuntime()) { - const settings = await getDesktopSettings(); - defaultId = normalize((settings as { defaultGitIdentityId?: unknown } | null | undefined)?.defaultGitIdentityId); - } else { + if (defaultId === null) { const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; if (runtimeSettings) { try { @@ -159,20 +155,20 @@ export const useGitIdentitiesStore = create()( // fall through } } + } - if (defaultId === null) { - try { - const response = await fetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (response.ok) { - const data = (await response.json().catch(() => null)) as Record | null; - defaultId = normalize(data?.defaultGitIdentityId); - } - } catch { - // ignore + if (defaultId === null) { + try { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (response.ok) { + const data = (await response.json().catch(() => null)) as Record | null; + defaultId = normalize(data?.defaultGitIdentityId); } + } catch { + // ignore } } diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 7cd8394c..419d37a4 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -121,6 +121,9 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => { if (typeof candidate.lastOpenedAt === 'number' && Number.isFinite(candidate.lastOpenedAt) && candidate.lastOpenedAt >= 0) { project.lastOpenedAt = candidate.lastOpenedAt; } + if (typeof candidate.sidebarCollapsed === 'boolean') { + project.sidebarCollapsed = candidate.sidebarCollapsed; + } if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') { const wt = candidate.worktreeDefaults as Record; const defaults: WorktreeDefaults = {}; diff --git a/packages/ui/src/stores/useQuotaStore.ts b/packages/ui/src/stores/useQuotaStore.ts index f5faed2e..8d5b8cb4 100644 --- a/packages/ui/src/stores/useQuotaStore.ts +++ b/packages/ui/src/stores/useQuotaStore.ts @@ -3,7 +3,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import type { ProviderResult, QuotaProviderId } from '@/types'; import { QUOTA_PROVIDERS } from '@/lib/quota'; -import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; const DEFAULT_REFRESH_INTERVAL_MS = 60000; @@ -57,11 +57,6 @@ const parseSettings = (data: Record | null): QuotaSettingsState }; const loadSettingsFromRuntime = async (): Promise => { - if (isDesktopRuntime()) { - const data = await getDesktopSettings(); - return parseSettings((data as Record) ?? null); - } - const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; if (runtimeSettings) { try { diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index d719cb26..de815ada 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -98,7 +98,7 @@ export const useSessionStore = create()( sessionAgentEditModes: new Map(), abortPromptSessionId: null, abortPromptExpiresAt: null, - sessionActivityPhase: new Map(), + sessionStatus: new Map(), userSummaryTitles: new Map(), pendingInputText: null, newSessionDraft: { open: true, directoryOverride: null, parentID: null }, @@ -315,19 +315,11 @@ export const useSessionStore = create()( const draft = get().newSessionDraft; const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined; - const setBusyPhase = (sessionId: string) => { + const setStatus = (sessionId: string, type: 'idle' | 'busy') => { set((state) => { - const next = new Map(state.sessionActivityPhase ?? new Map()); - next.set(sessionId, 'busy'); - return { sessionActivityPhase: next }; - }); - }; - - const setIdlePhase = (sessionId: string) => { - set((state) => { - const next = new Map(state.sessionActivityPhase ?? new Map()); - next.set(sessionId, 'idle'); - return { sessionActivityPhase: next }; + const next = new Map(state.sessionStatus ?? new Map()); + next.set(sessionId, { type }); + return { sessionStatus: next }; }); }; @@ -391,14 +383,14 @@ export const useSessionStore = create()( } get().closeNewSessionDraft(); - setBusyPhase(created.id); + setStatus(created.id, 'busy'); try { return await useMessageStore .getState() .sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, additionalParts, variant); } catch (error) { - setIdlePhase(created.id); + setStatus(created.id, 'idle'); throw error; } } @@ -429,14 +421,14 @@ export const useSessionStore = create()( } if (currentSessionId) { - setBusyPhase(currentSessionId); + setStatus(currentSessionId, 'busy'); } try { return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant); } catch (error) { if (currentSessionId) { - setIdlePhase(currentSessionId); + setStatus(currentSessionId, 'idle'); } throw error; } @@ -504,9 +496,9 @@ export const useSessionStore = create()( updateViewportAnchor: (sessionId: string, anchor: number) => useMessageStore.getState().updateViewportAnchor(sessionId, anchor), trimToViewportWindow: (sessionId: string, targetSize?: number) => { const currentSessionId = useSessionManagementStore.getState().currentSessionId; - // Skip trimming for sessions in active phase (busy/cooldown) - const phase = get().sessionActivityPhase?.get(sessionId); - if (phase === 'busy' || phase === 'cooldown') { + // Skip trimming while session is working (busy/retry) + const status = get().sessionStatus?.get(sessionId); + if (status?.type === 'busy' || status?.type === 'retry') { return; } return useMessageStore.getState().trimToViewportWindow(sessionId, targetSize, currentSessionId || undefined); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 429b6026..08f76b95 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -34,6 +34,8 @@ interface UIStore { isCommandPaletteOpen: boolean; isHelpDialogOpen: boolean; isAboutDialogOpen: boolean; + isOpenCodeStatusDialogOpen: boolean; + openCodeStatusText: string; isSessionCreateDialogOpen: boolean; isSettingsDialogOpen: boolean; isModelSelectorOpen: boolean; @@ -89,6 +91,8 @@ interface UIStore { toggleHelpDialog: () => void; setHelpDialogOpen: (open: boolean) => void; setAboutDialogOpen: (open: boolean) => void; + setOpenCodeStatusDialogOpen: (open: boolean) => void; + setOpenCodeStatusText: (text: string) => void; setSessionCreateDialogOpen: (open: boolean) => void; setSettingsDialogOpen: (open: boolean) => void; setModelSelectorOpen: (open: boolean) => void; @@ -133,6 +137,7 @@ interface UIStore { openMultiRunLauncherWithPrompt: (prompt: string) => void; } + export const useUIStore = create()( devtools( persist( @@ -154,6 +159,8 @@ export const useUIStore = create()( isCommandPaletteOpen: false, isHelpDialogOpen: false, isAboutDialogOpen: false, + isOpenCodeStatusDialogOpen: false, + openCodeStatusText: '', isSessionCreateDialogOpen: false, isSettingsDialogOpen: false, isModelSelectorOpen: false, @@ -336,6 +343,14 @@ export const useUIStore = create()( set({ isAboutDialogOpen: open }); }, + setOpenCodeStatusDialogOpen: (open) => { + set({ isOpenCodeStatusDialogOpen: open }); + }, + + setOpenCodeStatusText: (text) => { + set({ openCodeStatusText: text }); + }, + setSessionCreateDialogOpen: (open) => { set({ isSessionCreateDialogOpen: open }); }, diff --git a/packages/ui/src/stores/useUpdateStore.ts b/packages/ui/src/stores/useUpdateStore.ts index d990ae08..7f5a7931 100644 --- a/packages/ui/src/stores/useUpdateStore.ts +++ b/packages/ui/src/stores/useUpdateStore.ts @@ -4,7 +4,8 @@ import { checkForDesktopUpdates, downloadDesktopUpdate, restartToApplyUpdate, - isDesktopRuntime, + isDesktopLocalOriginActive, + isTauriShell, isWebRuntime, } from '@/lib/desktop'; @@ -55,7 +56,11 @@ async function checkForWebUpdates(): Promise { } function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null { - if (isDesktopRuntime()) return 'desktop'; + if (isTauriShell()) { + // Only use Tauri updater when we're on the local instance. + // When viewing a remote host inside the desktop shell, treat update as web update. + return isDesktopLocalOriginActive() ? 'desktop' : 'web'; + } if (isWebRuntime()) return 'web'; return null; } @@ -115,9 +120,12 @@ export const useUpdateStore = create()((set, get) => ({ set({ downloading: true, error: null, progress: null }); try { - await downloadDesktopUpdate((progress) => { + const ok = await downloadDesktopUpdate((progress) => { set({ progress }); }); + if (!ok) { + throw new Error('Desktop update only works on Local instance'); + } set({ downloading: false, downloaded: true }); } catch (error) { set({ @@ -135,7 +143,10 @@ export const useUpdateStore = create()((set, get) => ({ } try { - await restartToApplyUpdate(); + const ok = await restartToApplyUpdate(); + if (!ok) { + throw new Error('Desktop restart only works on Local instance'); + } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to restart', diff --git a/packages/ui/src/stores/utils/streamDebug.ts b/packages/ui/src/stores/utils/streamDebug.ts index a21f2822..f478f673 100644 --- a/packages/ui/src/stores/utils/streamDebug.ts +++ b/packages/ui/src/stores/utils/streamDebug.ts @@ -6,3 +6,12 @@ export const streamDebugEnabled = (): boolean => { return false; } }; + +export const sessionStatusDebugEnabled = (): boolean => { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem('openchamber_session_status_debug') === '1'; + } catch { + return false; + } +}; diff --git a/packages/ui/src/styles/design-system.css b/packages/ui/src/styles/design-system.css index 9682a6ea..e6e9752d 100644 --- a/packages/ui/src/styles/design-system.css +++ b/packages/ui/src/styles/design-system.css @@ -124,6 +124,17 @@ font-weight: var(--ui-regular-font-weight, 400); } + /* Desktop shell: prevent rubber-band scrolling of the page itself. + App scroll should live inside dedicated scroll containers. */ + :root.desktop-runtime, + :root.desktop-runtime body, + :root.desktop-runtime #root { + height: 100%; + overflow: hidden; + overscroll-behavior: none; + overscroll-behavior-y: none; + } + .font-sans { font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif) !important; } diff --git a/packages/ui/src/types/desktop.d.ts b/packages/ui/src/types/desktop.d.ts index c40749fa..361cd17a 100644 --- a/packages/ui/src/types/desktop.d.ts +++ b/packages/ui/src/types/desktop.d.ts @@ -1,32 +1,9 @@ -import type { DesktopApi, DesktopSettingsApi } from "../lib/desktop"; +declare global { + interface Window { + __OPENCHAMBER_HOME__?: string; + __OPENCHAMBER_MACOS_MAJOR__?: number; + __OPENCHAMBER_LOCAL_ORIGIN__?: string; + } +} - type AppearanceBridgePayload = { - uiFont?: string; - monoFont?: string; - markdownDisplayMode?: string; - typographySizes?: { - markdown?: string; - code?: string; - uiHeader?: string; - uiLabel?: string; - meta?: string; - micro?: string; - } | null; - showReasoningTraces?: boolean; - }; - - type AppearanceBridgeApi = { - load: () => Promise; - save: (payload: AppearanceBridgePayload) => Promise<{ success: boolean; data?: AppearanceBridgePayload | null; error?: string }>; - }; - - declare global { - interface Window { - opencodeDesktop?: DesktopApi; - opencodeDesktopSettings?: DesktopSettingsApi; - opencodeAppearance?: AppearanceBridgeApi; - __OPENCHAMBER_HOME__?: string; - } - } - - export {}; +export {}; diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 50035a5b..24faa274 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -1,6 +1,9 @@ import * as vscode from 'vscode'; import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; import { execSync } from 'child_process'; +import { spawnSync } from 'child_process'; import { createOpencodeServer } from '@opencode-ai/sdk/v2/server'; const READY_CHECK_TIMEOUT_MS = 30000; @@ -53,6 +56,127 @@ function resolvePortFromUrl(url: string): number | null { } } +function isExecutable(filePath: string): boolean { + if (!filePath) return false; + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + // Windows executability is extension-based. + if (process.platform === 'win32') { + const ext = path.extname(filePath).toLowerCase(); + if (!ext) return true; + return ['.exe', '.cmd', '.bat', '.com'].includes(ext); + } + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function appendToPath(dir: string) { + const trimmed = (dir || '').trim(); + if (!trimmed) return; + const current = process.env.PATH || ''; + const parts = current.split(path.delimiter).filter(Boolean); + if (parts.includes(trimmed)) return; + process.env.PATH = [trimmed, ...parts].join(path.delimiter); +} + +function resolveOpencodeCliPath(): string | null { + const explicit = [ + process.env.OPENCODE_BINARY, + process.env.OPENCODE_PATH, + process.env.OPENCHAMBER_OPENCODE_PATH, + process.env.OPENCHAMBER_OPENCODE_BIN, + ] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + return candidate; + } + } + + const home = os.homedir(); + const unixFallbacks = [ + path.join(home, '.opencode', 'bin', 'opencode'), + path.join(home, '.local', 'bin', 'opencode'), + path.join(home, 'bin', 'opencode'), + ]; + + const winFallbacks = (() => { + const userProfile = process.env.USERPROFILE || home; + const appData = process.env.APPDATA || ''; + const localAppData = process.env.LOCALAPPDATA || ''; + const programData = process.env.ProgramData || 'C:\\ProgramData'; + + return [ + path.join(userProfile, '.opencode', 'bin', 'opencode.exe'), + path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'), + path.join(appData, 'npm', 'opencode.cmd'), + path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'), + path.join(programData, 'chocolatey', 'bin', 'opencode.exe'), + path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'), + // Bun global install + path.join(userProfile, '.bun', 'bin', 'opencode.exe'), + path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), + // Some installers use LocalAppData + localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', + ].filter(Boolean); + })(); + + const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; + for (const candidate of fallbacks) { + if (isExecutable(candidate)) { + return candidate; + } + } + + if (process.platform === 'win32') { + try { + const result = spawnSync('where', ['opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) return found; + } + } catch { + // ignore + } + return null; + } + + // Non-Windows: try a login shell PATH lookup. + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean) as string[]; + for (const shell of shells) { + if (!isExecutable(shell)) continue; + try { + const result = spawnSync(shell, ['-lic', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + // ignore + } + } + + return null; +} + type ReadyResult = | { ok: true; baseUrl: string; elapsedMs: number; attempts: number; version: string | null } | { ok: false; elapsedMs: number; attempts: number; version: null }; @@ -99,7 +223,7 @@ async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise controller.abort(), 3000); - // Keep using /config since the UI proxies to it (via /api -> strip prefix). + // OpenCode readiness check. const url = new URL(`${baseUrl}/global/health`); const res = await fetch(url.toString(), { method: 'GET', @@ -156,6 +280,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo let detectedPort: number | null = null; let cliMissing = false; + let cliPath: string | null = null; let pendingOperation: Promise | null = null; @@ -230,12 +355,20 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo setStatus('connecting'); cliMissing = false; + cliPath = null; detectedPort = null; lastExitCode = null; managedApiUrlOverride = null; - + try { + // Best-effort: locate CLI even when VS Code PATH is stale. + const resolvedCli = resolveOpencodeCliPath(); + if (resolvedCli) { + cliPath = resolvedCli; + appendToPath(path.dirname(resolvedCli)); + } + // SDK spawns `opencode serve` in current process cwd. // Some OpenCode endpoints behave differently based on server process cwd, // so ensure we start it from the workspace directory. @@ -280,13 +413,16 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo } } catch (err) { const message = err instanceof Error ? err.message : String(err); - + // Check for ENOENT or generic spawn failure which implies CLI missing if (message.includes('ENOENT') || message.includes('spawn opencode')) { cliMissing = true; - setStatus('error', 'OpenCode CLI not found. Install it or ensure it\'s in PATH.'); + if (!cliPath) { + cliPath = resolveOpencodeCliPath(); + } + setStatus('error', 'OpenCode CLI not found. Install it and ensure it\'s in PATH.'); vscode.window.showErrorMessage( - 'OpenCode CLI not found. Please install it or ensure it\'s in PATH.', + 'OpenCode CLI not found. Please install it and ensure it\'s in PATH.', 'More Info' ).then(selection => { if (selection === 'More Info') { @@ -301,7 +437,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo async function stopInternal(): Promise { const portToKill = detectedPort; - + if (server) { try { server.close(); @@ -315,9 +451,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo // Kill any process listening on our port to clean up orphaned children. if (portToKill) { try { - const lsofOutput = execSync(`lsof -ti:${portToKill} 2>/dev/null || true`, { + const lsofOutput = execSync(`lsof -ti:${portToKill} 2>/dev/null || true`, { encoding: 'utf8', - timeout: 5000 + timeout: 5000 }); const myPid = process.pid; for (const pidStr of lsofOutput.split(/\s+/)) { @@ -426,7 +562,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo lastError, workingDirectory, cliAvailable: !cliMissing, - cliPath: null, + cliPath, configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null, configuredPort, detectedPort, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 8d0141da..6064acd2 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -16,6 +16,8 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DEFAULT_PORT = 3000; +const DESKTOP_NOTIFY_PREFIX = '[OpenChamberDesktopNotify] '; +const uiNotificationClients = new Set(); const HEALTH_CHECK_INTERVAL = 15000; const SHUTDOWN_TIMEOUT = 10000; const MODELS_DEV_API_URL = 'https://models.dev/api.json'; @@ -796,6 +798,29 @@ const normalizeStringArray = (input) => { ); }; +const sanitizeModelRefs = (input, limit) => { + if (!Array.isArray(input)) { + return undefined; + } + + const result = []; + const seen = new Set(); + + for (const entry of input) { + if (!entry || typeof entry !== 'object') continue; + const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : ''; + const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : ''; + if (!providerID || !modelID) continue; + const key = `${providerID}/${modelID}`; + if (seen.has(key)) continue; + seen.add(key); + result.push({ providerID, modelID }); + if (result.length >= limit) break; + } + + return result; +}; + const sanitizeSkillCatalogs = (input) => { if (!Array.isArray(input)) { return undefined; @@ -884,6 +909,10 @@ const sanitizeProjects = (input) => { } } + if (typeof candidate.sidebarCollapsed === 'boolean') { + project.sidebarCollapsed = candidate.sidebarCollapsed; + } + result.push(project); } @@ -939,6 +968,7 @@ const sanitizeSettingsUpdate = (payload) => { result.pinnedDirectories = normalizeStringArray(candidate.pinnedDirectories); } + if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) { result.uiFont = candidate.uiFont; } @@ -1046,6 +1076,16 @@ const sanitizeSettingsUpdate = (payload) => { if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) { result.inputBarOffset = Math.max(0, Math.min(100, Math.round(candidate.inputBarOffset))); } + + const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64); + if (favoriteModels) { + result.favoriteModels = favoriteModels; + } + + const recentModels = sanitizeModelRefs(candidate.recentModels, 16); + if (recentModels) { + result.recentModels = recentModels; + } if (typeof candidate.diffLayoutPreference === 'string') { const mode = candidate.diffLayoutPreference.trim(); if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') { @@ -1305,14 +1345,59 @@ const migrateSettingsFromLegacyThemePreferences = async (current) => { return { settings: merged, changed: true }; }; +const migrateSettingsFromLegacyCollapsedProjects = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + const collapsed = Array.isArray(settings.collapsedProjects) + ? normalizeStringArray(settings.collapsedProjects) + : []; + + if (collapsed.length === 0 || !Array.isArray(settings.projects)) { + if (collapsed.length === 0) { + return { settings, changed: false }; + } + // Nothing to apply to; drop legacy key. + const next = { ...settings }; + delete next.collapsedProjects; + return { settings: next, changed: true }; + } + + const set = new Set(collapsed); + const projects = sanitizeProjects(settings.projects) || []; + let changed = false; + + const nextProjects = projects.map((project) => { + const shouldCollapse = set.has(project.id); + if (project.sidebarCollapsed !== shouldCollapse) { + changed = true; + return { ...project, sidebarCollapsed: shouldCollapse }; + } + return project; + }); + + if (!changed) { + // Still drop legacy key if present. + if (Object.prototype.hasOwnProperty.call(settings, 'collapsedProjects')) { + const next = { ...settings }; + delete next.collapsedProjects; + return { settings: next, changed: true }; + } + return { settings, changed: false }; + } + + const next = { ...settings, projects: nextProjects }; + delete next.collapsedProjects; + return { settings: next, changed: true }; +}; + const readSettingsFromDiskMigrated = async () => { const current = await readSettingsFromDisk(); const migration1 = await migrateSettingsFromLegacyLastDirectory(current); const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings); - if (migration1.changed || migration2.changed) { - await writeSettingsToDisk(migration2.settings); + const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings); + if (migration1.changed || migration2.changed || migration3.changed) { + await writeSettingsToDisk(migration3.settings); } - return migration2.settings; + return migration3.settings; }; const getOrCreateVapidKeys = async () => { @@ -1534,20 +1619,25 @@ const sessionActivityCooldowns = new Map(); // sessionId -> timeoutId const SESSION_COOLDOWN_DURATION_MS = 2000; const setSessionActivityPhase = (sessionId, phase) => { - if (!sessionId || typeof sessionId !== 'string') return; - - // Cancel existing cooldown timer + if (!sessionId || typeof sessionId !== 'string') return false; + + const current = sessionActivityPhases.get(sessionId); + if (current?.phase === phase) return false; // No change + + // Match desktop semantics: only enter cooldown from busy. + if (phase === 'cooldown' && current?.phase !== 'busy') { + return false; + } + + // Cancel existing cooldown timer only on phase change. const existingTimer = sessionActivityCooldowns.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); sessionActivityCooldowns.delete(sessionId); } - - const current = sessionActivityPhases.get(sessionId); - if (current?.phase === phase) return; // No change - + sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() }); - + // Schedule transition from cooldown to idle if (phase === 'cooldown') { const timer = setTimeout(() => { @@ -1559,6 +1649,8 @@ const setSessionActivityPhase = (sessionId, phase) => { }, SESSION_COOLDOWN_DURATION_MS); sessionActivityCooldowns.set(sessionId, timer); } + + return true; }; const getSessionActivitySnapshot = () => { @@ -1591,14 +1683,24 @@ const resolveVapidSubject = async () => { const originEnv = process.env.OPENCHAMBER_PUBLIC_ORIGIN; if (typeof originEnv === 'string' && originEnv.trim().length > 0) { - return originEnv.trim(); + const trimmed = originEnv.trim(); + // Convert http://localhost to mailto for VAPID compatibility + if (trimmed.startsWith('http://localhost')) { + return 'mailto:openchamber@localhost'; + } + return trimmed; } try { const settings = await readSettingsFromDiskMigrated(); const stored = settings?.publicOrigin; if (typeof stored === 'string' && stored.trim().length > 0) { - return stored.trim(); + const trimmed = stored.trim(); + // Convert http://localhost to mailto for VAPID compatibility + if (trimmed.startsWith('http://localhost')) { + return 'mailto:openchamber@localhost'; + } + return trimmed; } } catch { // ignore @@ -1751,7 +1853,8 @@ const ENV_CONFIGURED_OPENCODE_PORT = (() => { })(); const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' || - process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true'; + process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true'; +const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true'; const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || '' @@ -1816,9 +1919,11 @@ const startGlobalEventWatcher = async () => { const payload = parseSseDataPayload(block); void maybeSendPushForTrigger(payload); // Track session activity independently of UI (mirrors Tauri desktop behavior) - const activity = deriveSessionActivity(payload); - if (activity) { - setSessionActivityPhase(activity.sessionId, activity.phase); + const transitions = deriveSessionActivityTransitions(payload); + if (transitions && transitions.length > 0) { + for (const activity of transitions) { + setSessionActivityPhase(activity.sessionId, activity.phase); + } } } } @@ -2065,9 +2170,70 @@ function parseSseDataPayload(block) { } } -function deriveSessionActivity(payload) { +function emitDesktopNotification(payload) { + if (!ENV_DESKTOP_NOTIFY) { + return; + } + if (!payload || typeof payload !== 'object') { - return null; + return; + } + + try { + // One-line protocol consumed by the Tauri shell. + process.stdout.write(`${DESKTOP_NOTIFY_PREFIX}${JSON.stringify(payload)}\n`); + } catch { + // ignore + } +} + +function broadcastUiNotification(payload) { + if (!payload || typeof payload !== 'object') { + return; + } + + if (uiNotificationClients.size === 0) { + return; + } + + for (const res of uiNotificationClients) { + try { + writeSseEvent(res, { + type: 'openchamber:notification', + properties: payload, + }); + } catch { + // ignore + } + } +} + +function isStreamingAssistantPart(properties) { + if (!properties || typeof properties !== 'object') { + return false; + } + + const info = properties?.info; + const role = info?.role; + if (role !== 'assistant') { + return false; + } + + const part = properties?.part; + const partType = part?.type; + return ( + partType === 'step-start' || + partType === 'text' || + partType === 'tool' || + partType === 'reasoning' || + partType === 'file' || + partType === 'patch' + ); +} + +function deriveSessionActivityTransitions(payload) { + if (!payload || typeof payload !== 'object') { + return []; } if (payload.type === 'session.status') { @@ -2077,7 +2243,7 @@ function deriveSessionActivity(payload) { if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') { const phase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle'; - return { sessionId, phase }; + return [{ sessionId, phase }]; } } @@ -2087,7 +2253,7 @@ function deriveSessionActivity(payload) { const role = info?.role; const finish = info?.finish; if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') { - return { sessionId, phase: 'cooldown' }; + return [{ sessionId, phase: 'cooldown' }]; } } @@ -2096,19 +2262,32 @@ function deriveSessionActivity(payload) { const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId; const role = info?.role; const finish = info?.finish; - if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') { - return { sessionId, phase: 'cooldown' }; + + if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant') { + const transitions = []; + + // Desktop parity: mark busy when we see assistant parts streaming. + if (isStreamingAssistantPart(payload.properties)) { + transitions.push({ sessionId, phase: 'busy' }); + } + + // Desktop parity: enter cooldown when finish==stop. + if (finish === 'stop') { + transitions.push({ sessionId, phase: 'cooldown' }); + } + + return transitions; } } if (payload.type === 'session.idle') { const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId; if (typeof sessionId === 'string' && sessionId.length > 0) { - return { sessionId, phase: 'idle' }; + return [{ sessionId, phase: 'idle' }]; } } - return null; + return []; } const PUSH_READY_COOLDOWN_MS = 5000; @@ -2226,6 +2405,7 @@ const maybeSendPushForTrigger = async (payload) => { if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) { // Check if this is a subtask and if we should notify for subtasks const settings = await readSettingsFromDisk(); + if (settings.notifyOnSubtasks === false) { // Prefer parentID on payload (if present), else fetch from sessions list. const sessionInfo = payload.properties?.session; @@ -2250,6 +2430,19 @@ const maybeSendPushForTrigger = async (payload) => { const title = `${formatMode(info?.mode)} agent is ready`; const body = `${formatModelId(info?.modelID)} completed the task`; + if (settings.nativeNotificationsEnabled) { + const payload = { + title, + body, + tag: `ready-${sessionId}`, + kind: 'ready', + sessionId, + requireHidden: settings.notificationMode !== 'always', + }; + emitDesktopNotification(payload); + broadcastUiNotification(payload); + } + await sendPushToAllUiSessions( { title, @@ -2278,6 +2471,40 @@ const maybeSendPushForTrigger = async (payload) => { const timer = setTimeout(() => { pushQuestionDebounceTimers.delete(sessionId); + void readSettingsFromDisk().then((settings) => { + if (!settings.nativeNotificationsEnabled) { + return; + } + + const firstQuestion = payload.properties?.questions?.[0]; + const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : ''; + const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : ''; + const title = /plan\s*mode/i.test(header) + ? 'Switch to plan mode' + : /build\s*agent/i.test(header) + ? 'Switch to build mode' + : header || 'Input needed'; + const body = questionText || 'Agent is waiting for your response'; + + emitDesktopNotification({ + kind: 'question', + title, + body, + tag: `question-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + + broadcastUiNotification({ + kind: 'question', + title, + body, + tag: `question-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + }); + const firstQuestion = payload.properties?.questions?.[0]; const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : ''; const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : ''; @@ -2322,6 +2549,36 @@ const maybeSendPushForTrigger = async (payload) => { const timer = setTimeout(() => { pushPermissionDebounceTimers.delete(sessionId); + void readSettingsFromDisk().then((settings) => { + if (!settings.nativeNotificationsEnabled) { + return; + } + + const title = 'Permission required'; + const sessionTitle = payload.properties?.sessionTitle; + const body = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0 + ? sessionTitle.trim() + : 'Agent is waiting for your approval'; + + emitDesktopNotification({ + kind: 'permission', + title, + body, + tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + + broadcastUiNotification({ + kind: 'permission', + title, + body, + tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + }); + if (requestKey) { notifiedPermissionRequests.add(requestKey); } @@ -3367,6 +3624,13 @@ async function main(options = {}) { res.flushHeaders(); } + uiNotificationClients.add(res); + const cleanupClient = () => { + uiNotificationClients.delete(res); + }; + req.on('close', cleanupClient); + req.on('error', cleanupClient); + const heartbeatInterval = setInterval(() => { writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() }); }, 15000); @@ -3381,17 +3645,19 @@ async function main(options = {}) { `); const payload = parseSseDataPayload(block); - void maybeSendPushForTrigger(payload); - const activity = deriveSessionActivity(payload); - if (activity) { - setSessionActivityPhase(activity.sessionId, activity.phase); - writeSseEvent(res, { - type: 'openchamber:session-activity', - properties: { - sessionId: activity.sessionId, - phase: activity.phase, + const transitions = deriveSessionActivityTransitions(payload); + if (transitions && transitions.length > 0) { + for (const activity of transitions) { + if (setSessionActivityPhase(activity.sessionId, activity.phase)) { + writeSseEvent(res, { + type: 'openchamber:session-activity', + properties: { + sessionId: activity.sessionId, + phase: activity.phase, + } + }); } - }); + } } }; @@ -3418,6 +3684,7 @@ async function main(options = {}) { } } finally { clearInterval(heartbeatInterval); + cleanupClient(); cleanup(); try { res.end(); @@ -3502,17 +3769,19 @@ async function main(options = {}) { `); const payload = parseSseDataPayload(block); - void maybeSendPushForTrigger(payload); - const activity = deriveSessionActivity(payload); - if (activity) { - setSessionActivityPhase(activity.sessionId, activity.phase); - writeSseEvent(res, { - type: 'openchamber:session-activity', - properties: { - sessionId: activity.sessionId, - phase: activity.phase, + const transitions = deriveSessionActivityTransitions(payload); + if (transitions && transitions.length > 0) { + for (const activity of transitions) { + if (setSessionActivityPhase(activity.sessionId, activity.phase)) { + writeSseEvent(res, { + type: 'openchamber:session-activity', + properties: { + sessionId: activity.sessionId, + phase: activity.phase, + } + }); } - }); + } } }; @@ -7492,7 +7761,14 @@ Context: scheduleOpenCodeApiDetection(); } - const distPath = path.join(__dirname, '..', 'dist'); + const distPath = (() => { + const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : ''; + if (env) { + return path.resolve(env); + } + return path.join(__dirname, '..', 'dist'); + })(); + if (fs.existsSync(distPath)) { console.log(`Serving static files from ${distPath}`); app.use(express.static(distPath, { @@ -7516,13 +7792,17 @@ Context: let activePort = port; + const bindHost = typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0 + ? process.env.OPENCHAMBER_HOST.trim() + : null; + await new Promise((resolve, reject) => { const onError = (error) => { server.off('error', onError); reject(error); }; server.once('error', onError); - server.listen(port, async () => { + const onListening = async () => { server.off('error', onError); const addressInfo = server.address(); activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port; @@ -7559,7 +7839,13 @@ Context: } resolve(); - }); + }; + + if (bindHost) { + server.listen(port, bindHost, onListening); + } else { + server.listen(port, onListening); + } }); if (attachSignals && !signalsAttached) { diff --git a/packages/web/src/api/notifications.ts b/packages/web/src/api/notifications.ts index 12325535..43d066ed 100644 --- a/packages/web/src/api/notifications.ts +++ b/packages/web/src/api/notifications.ts @@ -31,9 +31,47 @@ const notifyWithWebAPI = async (payload?: NotificationPayload): Promise } }; +const notifyWithTauri = async (payload?: NotificationPayload): Promise => { + if (typeof window === 'undefined') { + return false; + } + + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + if (!tauri?.core?.invoke) { + return false; + } + + try { + await tauri.core.invoke('desktop_notify', { + payload: { + title: payload?.title, + body: payload?.body, + tag: payload?.tag, + }, + }); + return true; + } catch (error) { + console.warn('Failed to send native notification (tauri)', error); + return false; + } +}; + export const createWebNotificationsAPI = (): NotificationsAPI => ({ async notifyAgentCompletion(payload?: NotificationPayload): Promise { - return notifyWithWebAPI(payload); + return (await notifyWithTauri(payload)) || notifyWithWebAPI(payload); + }, + canNotify: () => { + if (typeof window !== 'undefined') { + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + if (tauri?.core?.invoke) { + return true; + } + } + return typeof Notification !== 'undefined' ? Notification.permission === 'granted' : false; }, - canNotify: () => (typeof Notification !== 'undefined' ? Notification.permission === 'granted' : false), }); +type TauriGlobal = { + core?: { + invoke?: (cmd: string, args?: Record) => Promise; + }; +};