diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d6390fce..8c3bb83a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -176,9 +176,6 @@ jobs: with: components: rustfmt, clippy - - name: Add WASI target - run: rustup target add wasm32-wasip1 - - name: Setup sccache uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 71532249..4d54cbbe 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -11,13 +11,14 @@ jobs: permissions: id-token: write contents: read - pull-requests: read - issues: read + pull-requests: write + issues: write steps: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: anomalyco/opencode/github@latest + # Pinned to an immutable commit for CodeQL (tag: github-v1.2.17) + - uses: anomalyco/opencode/github@2410593023d2c61f05123c9b0faf189a28dfbeee env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 5686e868..504e7f5c 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -19,8 +19,8 @@ jobs: permissions: id-token: write contents: read - pull-requests: read - issues: read + pull-requests: write + issues: write steps: - name: Checkout repository uses: actions/checkout@v6 @@ -28,7 +28,8 @@ jobs: persist-credentials: false - name: Run opencode - uses: anomalyco/opencode/github@latest + # Pinned to an immutable commit for CodeQL (tag: github-v1.2.17) + uses: anomalyco/opencode/github@2410593023d2c61f05123c9b0faf189a28dfbeee env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} with: diff --git a/.github/workflows/publish-stable.yml b/.github/workflows/publish-stable.yml index dcc607c7..ee5b4779 100644 --- a/.github/workflows/publish-stable.yml +++ b/.github/workflows/publish-stable.yml @@ -66,9 +66,6 @@ jobs: with: components: rustfmt, clippy - - name: Add WASI target - run: rustup target add wasm32-wasip1 - - name: Setup sccache uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 diff --git a/AGENTS.md b/AGENTS.md index 61d6e464..2b34fc17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,45 +1,181 @@ # Repository Guidelines -## Project Structure & Module Organization -- `Backend/`: Rust + Tauri application code (`src/`), commands (`src/tauri_commands/`), and plugin runtime. -- `Backend/built-in-plugins/`: git submodules for bundled plugins; initialize/update with `git submodule update --init --recursive`. -- `Frontend/`: TypeScript + Vite UI (`src/scripts/`, `src/styles/`, `src/modals/`), with Vitest tests colocated as `*.test.ts`. -- `docs/`: architecture and plugin docs plus UI assets. -- `packaging/flatpak/`: Flatpak manifests and packaging notes. -- Root files: workspace `Cargo.toml`, `Justfile`, and project docs (`README.md`, `ARCHITECTURE.md`, `SECURITY.md`). - -## Build, Test, and Development Commands -- `just build` (or `just build client|plugins`): build frontend, backend, and plugin bundles. -- `git submodule update --init --recursive`: fetch submodule content (required before plugin builds/tests). -- `just test`: workspace Rust tests + frontend TypeScript check + Vitest run. -- `just fix`: run `cargo fmt`, `cargo clippy --fix`, and frontend typecheck. -- `cargo tauri dev`: run the desktop app in development mode. -- `npm --prefix Frontend run dev`: run frontend-only Vite dev server. -- `just tauri-build`: production Tauri build wrapper. - -## Coding Style & Naming Conventions -- Rust: format with `cargo fmt --all`; keep clippy-clean (`cargo clippy --all-targets -- -D warnings`). -- TypeScript: 2-space indentation, ES modules, and small feature-focused files under `Frontend/src/scripts/features/`. -- Tests: name frontend tests `*.test.ts` near the implementation (example: `Frontend/src/scripts/lib/dom.test.ts`). -- Naming: use `snake_case` for Rust modules/functions and `camelCase` for TypeScript variables/functions. - -# ExecPlans - -When writing complex features or significant refactors, use an ExecPlan (as described in .agent/PLANS.md) from design to implementation. - -## Testing Guidelines -- Run full checks before opening a PR: `just test`. -- For frontend-only work, run `cd Frontend && npm test` and `npm exec tsc -- -p tsconfig.json --noEmit`. -- Add or update tests for behavior changes; prefer focused unit tests over broad snapshots. - -## Commit & Pull Request Guidelines -- Follow existing commit style: short imperative subject, optional scope prefix (examples: `backend: fix tauri precommands`, `ci: add wasm32-wasip1 target`, `chore(deps): bump @types/node`). -- Keep commits logically scoped; avoid mixing frontend/backend refactors unless required. -- Do not directly modify plugin code under `Backend/built-in-plugins/`; only update submodule pointers in this repository when explicitly requested. -- PRs should include: summary of behavior changes, linked issue(s), test evidence (command output), and screenshots for UI changes. -- Target the `Dev` branch for normal development work. - -## Security & Configuration Tips -- Review `SECURITY.md` before changing update, plugin, or network-related code paths. -- Do not commit secrets; keep local overrides in files like `.env.tauri.local`. -- Do not directly edit code inside git submodules (including `Backend/built-in-plugins/*`) unless the task explicitly requires a submodule update; treat submodule changes as pointer-only updates in this repo. +## Project structure & module organization + +- `Backend/`: Rust + Tauri backend (`src/`), commands (`src/tauri_commands/`), plugin runtime (`src/plugin_runtime/`), and bundled plugin support (`src/plugin_runtime`, `scripts/`). +- `Backend/built-in-plugins/`: local copies of bundled plugins (do not edit their code unless explicitly requested; update submodule pointers instead). +- `Frontend/`: TypeScript + Vite UI code (`src/scripts/`, `src/styles/`, `src/modals/`), with Vitest tests colocated as `*.test.ts` files. +- `docs/`: UX docs, plugin architecture notes, and plugin/theme packaging guides referenced by contributors. +- `packaging/flatpak/`: Flatpak manifests and Flatpak-specific build notes. +- Supporting files at the repo root include the workspace `Cargo.toml`, `Justfile`, `README.md`, `ARCHITECTURE.md`, `SECURITY.md`, and installer scripts. + +## Build, test, and development commands + +### Full builds +- `just build` (or `just build client|plugins`): builds the backend, frontend, and plugin bundles from the workspace Justfile. +- `just tauri-build`: production Tauri build wrapper (AppImage/Flatpak). `git submodule update --init --recursive` is required before building bundled plugins. + +### Running tests + +**All tests:** +- `just test`: runs workspace Rust tests plus frontend type-check + Vitest via the Justfile. + +**Frontend (Vitest):** +- `npm --prefix Frontend test`: run all tests +- `npm --prefix Frontend test run`: run tests once (non-watch mode) +- `npm --prefix Frontend test -- --run`: explicit non-watch mode +- `npm --prefix Frontend test -- src/scripts/lib/dom.test.ts`: run single test file +- `npm --prefix Frontend test -- --run -t "qs and qsa"`: run single test by name pattern +- `npm --prefix Frontend test -- --watch`: watch mode for development + +**Backend (Rust):** +- `cargo test --workspace`: run all Rust tests +- `cargo test`: run tests for current crate +- `cargo test --package openvcs_lib`: test specific crate +- `cargo test --lib`: run only library tests (not integration tests) +- `cargo test --lib -- branch`: run tests matching "branch" in name +- `cargo test --lib -- --test-threads=1`: run tests sequentially (for flaky tests) + +### Linting and formatting + +- `just fix`: formatter/lint quick fixes (runs `cargo fmt`, `cargo clippy --fix`, and frontend type-check). +- `cargo fmt --all`: format Rust code +- `cargo clippy --all-targets -- -D warnings`: check Rust for issues +- `cargo clippy --fix --all-targets --allow-dirty`: auto-fix clippy issues +- `npm --prefix Frontend exec tsc -- -p tsconfig.json --noEmit`: TypeScript type-check + +### Development servers + +- `cargo tauri dev`: run the desktop app in dev mode (`Backend/` directory). +- `npm --prefix Frontend run dev`: run the frontend-only Vite dev server. + +## Plugin runtime & host expectations + +- Plugin components live under `Backend/built-in-plugins/` and follow the manifest format in `openvcs.plugin.json`. Built-in bundles ship with the AppImage/Flatpak and are also built by the SDK (`cargo openvcs dist`). +- The backend loads plugin modules as Node.js runtime scripts (`*.mjs|*.js|*.cjs`) via `Backend/src/plugin_runtime/node_instance.rs`. +- The canonical host/plugin contract is JSON-RPC over stdio with method names in `Backend/src/plugin_runtime/protocol.rs`. +- When changing host APIs or runtime behavior, update protocol constants and runtime logic in `Backend/src/plugin_runtime`. + +## Coding style & conventions + +### Rust + +**Formatting:** +- Run `cargo fmt --all` before committing +- Use default Rust formatting (4-space indentation, etc.) +- Keep lines under ~100 characters when reasonable + +**Naming:** +- `snake_case` for modules, functions, and variables +- `PascalCase` for structs, enums, and trait names +- Prefix private fields with underscore (e.g., `self._field`) +- Use descriptive names; avoid single letters except in tight loops + +**Imports:** +- Group imports: std library first, then external crates, then local modules +- Use `use` statements for frequently used items +- Prefer absolute paths from crate root (`crate::module::Item`) + +**Error handling:** +- Use `Result` for fallible operations; avoid `panic!` in library code +- Use `?` operator for propagating errors +- Include context in error messages: `some_func().context("failed to load config")?` +- Log warnings for recoverable errors with `log::warn` +- Use `anyhow` for application code (commands, main) when context is needed + +**Types & patterns:** +- Prefer `Arc` for shared ownership across threads +- Use `tokio` async runtime for I/O-bound async operations +- Use `parking_lot` mutexes (faster than std) +- Derive `Clone`, `Debug`, `Serialize`, `Deserialize` as needed + +### Documentation + +- **ALL code must be documented**, not just public APIs. This includes: + - Rust: Use doc comments (`///` for items, `//!` for modules) for all functions, structs, enums, traits, and fields. + - TypeScript: Use JSDoc comments (`/** ... */`) for all functions, classes, interfaces, and types. +- All functions must include documentation comments. +- All code files MUST be no more than 1000 lines; split files before they exceed this limit. +- When you change behavior, workflows, commands, paths, config, or plugin/runtime expectations, ALWAYS update the relevant documentation in the same change, even if the user does not explicitly ask. +- Include usage examples for complex functions. +- Keep README files in sync with code changes. +- Document configuration options and environment variables. +- All new files must include the following copyright header: + +```rust +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +``` + +```typescript +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +``` + +### TypeScript + +**Formatting:** +- 2-space indentation (no tabs) +- Use ES modules (`import`/`export`) +- Keep lines under ~100 characters when reasonable +- Use semicolons at statement ends + +**Naming:** +- `camelCase` for variables, functions, and methods +- `PascalCase` for classes, types, and interfaces +- Prefix private class members with underscore (e.g., `this._field`) +- Use descriptive names; avoid abbreviations except common ones (e.g., `btn`, `cfg`) + +**Imports:** +- Use absolute imports from project root when possible +- Group imports: external libs, then relative `./` paths, then `../` paths +- Prefer named imports: `import { foo, bar } from './module'` +- Use type-only imports (`import type { Foo }`) when only using types + +**Types:** +- Use explicit types for function parameters and return values +- Prefer interfaces for object shapes, types for unions/intersections +- Use `null` for "intentionally empty", `undefined` for "not yet set" +- Avoid `any`; use `unknown` when type is truly unknown + +**Error handling:** +- Use try/catch for async operations; always handle or re-throw +- Use `Promise.catch(() => {})` for fire-and-forget async calls +- Show user-facing errors via `notify()` from `lib/notify` +- Log internal errors with console for debugging + +**UI patterns:** +- Use `qs('#id')` for single element queries from `lib/dom` +- Use `qsa('.class')` for multiple elements +- Use event delegation for list items +- Keep feature modules focused and small (<200 lines when possible) +- Colocate tests as `*.test.ts` next to the source file + +### Testing + +- Run `just test` before PRs +- Frontend-only work: run `npm --prefix Frontend exec tsc -- -p tsconfig.json --noEmit` and `npm --prefix Frontend test` +- Use Vitest's `describe`/`it`/`expect` for frontend tests +- Use descriptive test names: `it('finds elements by selector')` +- Use `beforeEach` to reset DOM state in DOM tests + +## ExecPlans + +- For multi-component features or refactors, create/update an ExecPlan (`Client/PLANS.md`). Outline design, component impacts, and how the plugin runtime is exercised. + +## Testing guidelines + +- Run `just test` before PRs; frontend-only work should at least cover `npm --prefix Frontend exec tsc -- -p tsconfig.json --noEmit` and `npm --prefix Frontend test`. +- Use `cargo tauri dev` to verify runtime plugin interactions (especially when touching `Backend/src/plugin_runtime/`), and make sure `docs/plugin architecture.md` stays aligned with behavior. + +## Commit & PR guidelines + +- Use short, imperative commit subjects (optionally scoped, e.g., `backend: refresh plugin runtime config`). Keep changelist focused; avoid mixing UI and backend refactors unless necessary. +- PRs should target the `Dev` branch, include a summary, issue links, commands/tests run, and highlight architecture implications (host API/protocol changes and security decisions). +- Do not modify plugin code inside submodules unless explicitly asked; treat submodule updates as pointer bumps after upstream changes. +- Keep this AGENTS (and other module-level copies you rely on) current whenever workflows, tooling, or responsibilities change so future contributors can find accurate guidance. + +## Security & configuration notes + +- Review `SECURITY.md` before making plugin, plugin-install, or network-related changes. +- Keep secrets out of the repo; use `.env.tauri.local` for local overrides and do not check them in. If new config flags are introduced, document them in `docs/` and update relevant settings screens/logs. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9cd2ba06..8a54dc5f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -22,7 +22,6 @@ Primary flow: Frontend: - `Frontend/src/scripts/main.ts`: UI bootstrap and feature wiring. - `Frontend/src/scripts/lib/tauri.ts`: minimal bridge wrapper for `invoke`/`listen`. -- `Frontend/src/scripts/plugins.ts`: UI plugin runtime and plugin-contributed UI hooks. - `Frontend/src/scripts/features/`: feature modules grouped by domain. - `Frontend/src/styles/`: tokens, layout, modal, and component styles. @@ -32,8 +31,8 @@ Backend: - `Backend/src/state.rs`: app config, repo state, recents, output log. - `Backend/src/repo.rs`: repository handle wrapper around `Arc`. - `Backend/src/plugin_vcs_backends.rs`: backend discovery and open logic. -- `Backend/src/plugin_bundles.rs`: `.ovcsp` install/index/component resolution. -- `Backend/src/plugin_runtime/stdio_rpc.rs`: plugin process spawn, RPC transport, restarts/timeouts. +- `Backend/src/plugin_bundles.rs`: `.ovcsp` install/index/runtime resolution. +- `Backend/src/plugin_runtime/node_instance.rs`: plugin process lifecycle and JSON-RPC calls. - `Backend/src/plugin_runtime/vcs_proxy.rs`: `Vcs` trait proxy over plugin RPC. - `Backend/src/plugins.rs`: plugin discovery/manifest summarization for UI. @@ -44,15 +43,14 @@ Backend: - Command boundary: Feature-facing backend API lives under `Backend/src/tauri_commands/`. - Backend/plugin boundary: - Backend communicates with plugin components over stdio JSON-RPC, not in-process APIs. + Backend communicates with plugin processes over JSON-RPC over stdio. - Settings boundary: Backend persists/loads app configuration and mediates environment application. ## Architecture Invariants - Active repo backend is treated as dynamic availability; stale handles are rejected when backend disappears. -- Plugin components that request capabilities require approval before execution. -- Frontend plugin scripts can extend UI, but repository operations still route through backend commands. +- Plugin modules require approval before execution. - Output/log/progress signaling is centralized through backend event emission. ## Cross-Cutting Concerns @@ -60,7 +58,7 @@ Backend: - State lifecycle: Startup config load, optional reopen-last-repo, runtime config updates. - Plugin lifecycle: - Built-in/user plugin discovery, install/uninstall, capability approvals. + Built-in/user plugin discovery, install/uninstall, and approval gating. - Reliability: RPC timeout handling, respawn backoff, and auto-disable after repeated crashes. - UX: diff --git a/Backend/Cargo.toml b/Backend/Cargo.toml index ad77422d..403f7e7b 100644 --- a/Backend/Cargo.toml +++ b/Backend/Cargo.toml @@ -25,11 +25,12 @@ serde_json = "1.0" default = [] [dependencies] -openvcs-core = { version = "0.1", features = ["plugin-protocol", "vcs"] } +openvcs-core = { path = "../../Core", features = ["vcs"] } tauri = { version = "2.9", features = [] } tauri-plugin-opener = "2.5" serde = { version = "1", features = ["derive"] } +notify = "6" tauri-plugin-dialog = "2.5" tauri-plugin-updater = "2.9" tokio = { version = "1.49", features = ["io-util", "process", "rt", "sync", "time"] } @@ -49,8 +50,7 @@ shlex = "1.2" sha2 = "0.10" hex = "0.4" os_pipe = "1.2" -wasmtime = "41" -wasmtime-wasi = "41" +base64 = "0.22" [dev-dependencies] tempfile = "3" diff --git a/Backend/build.rs b/Backend/build.rs index 43eb7180..2281c8dc 100644 --- a/Backend/build.rs +++ b/Backend/build.rs @@ -1,5 +1,8 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::{env, fs, path::PathBuf, process::Command}; +/// Returns whether the build is running for Flatpak packaging. fn is_flatpak_build() -> bool { matches!( env::var("OPENVCS_FLATPAK").as_deref(), @@ -7,10 +10,12 @@ fn is_flatpak_build() -> bool { ) } +/// Returns whether the build is running under `cargo tauri dev`. fn is_tauri_dev() -> bool { matches!(env::var("DEP_TAURI_DEV").as_deref(), Ok("true")) } +/// Returns whether an environment variable is set to a truthy value. fn is_truthy_env(key: &str) -> bool { matches!( env::var(key).as_deref(), @@ -18,6 +23,7 @@ fn is_truthy_env(key: &str) -> bool { ) } +/// Runs `git` with arguments and returns trimmed stdout on success. fn run_git(args: &[&str]) -> Option { let out = Command::new("git").args(args).output().ok()?; if !out.status.success() { @@ -27,6 +33,7 @@ fn run_git(args: &[&str]) -> Option { (!s.is_empty()).then_some(s) } +/// Resolves the current branch name from CI environment or local Git. fn git_branch() -> Option { if let Ok(v) = env::var("GITHUB_REF_NAME") { let v = v.trim().to_string(); @@ -51,15 +58,18 @@ fn git_branch() -> Option { } } +/// Returns the current commit short hash. fn git_short_hash() -> Option { run_git(&["rev-parse", "--short=8", "HEAD"]) } +/// Returns whether the Git worktree has uncommitted changes. fn git_is_dirty() -> Option { let s = run_git(&["status", "--porcelain"])?; Some(!s.trim().is_empty()) } +/// Sanitizes arbitrary text into a semver-safe build metadata identifier. fn sanitize_semver_ident(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut last_was_dash = false; @@ -86,6 +96,7 @@ fn sanitize_semver_ident(s: &str) -> String { } } +/// Ensures the generated built-in plugin resource directory exists. fn ensure_generated_builtins_resource_dir(manifest_dir: &std::path::Path) { // Keep `bundle.resources` valid for plain `cargo build` runs even before // plugin bundles are generated. @@ -99,6 +110,19 @@ fn ensure_generated_builtins_resource_dir(manifest_dir: &std::path::Path) { } } +/// Ensures the generated bundled Node runtime resource directory exists. +fn ensure_generated_node_runtime_resource_dir(manifest_dir: &std::path::Path) { + let generated = manifest_dir.join("../target/openvcs/node-runtime"); + if let Err(err) = fs::create_dir_all(&generated) { + panic!( + "failed to create generated node runtime resource dir {}: {}", + generated.display(), + err + ); + } +} + +/// Generates Tauri build config and exports build-time metadata env vars. fn main() { // Base config path (in the Backend crate) let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); @@ -243,6 +267,7 @@ fn main() { println!("cargo:rustc-env=OPENVCS_BUILD={}", build_id); ensure_generated_builtins_resource_dir(&manifest_dir); + ensure_generated_node_runtime_resource_dir(&manifest_dir); // Proceed with tauri build steps tauri_build::build(); diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index 7e35d0aa..a02bb5fc 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit 7e35d0aab7c80f0df7ffff98a28117a4bbc33fd1 +Subproject commit a02bb5fc215a10a5ec0eed279c61d60d781791d4 diff --git a/Backend/built-in-plugins/openvcs.git.ovcsp b/Backend/built-in-plugins/openvcs.git.ovcsp deleted file mode 100644 index 2c27021e..00000000 --- a/Backend/built-in-plugins/openvcs.git.ovcsp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1a6039d5d659cd84df3d0314442e0f840013330903f7b1b8bc5d35ea536b9aad -size 174232 diff --git a/Backend/built-in-plugins/openvcs.official-themes.ovcsp b/Backend/built-in-plugins/openvcs.official-themes.ovcsp deleted file mode 100644 index 04d452f5..00000000 --- a/Backend/built-in-plugins/openvcs.official-themes.ovcsp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6930423bd40dc9315a1274756610eb77bbe931b6e4e8f506c697c87cfaac7deb -size 17424 diff --git a/Backend/scripts/ensure-built-in-plugins.js b/Backend/scripts/ensure-built-in-plugins.js index 423ba9df..9592f5d7 100644 --- a/Backend/scripts/ensure-built-in-plugins.js +++ b/Backend/scripts/ensure-built-in-plugins.js @@ -9,6 +9,7 @@ const backendDir = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(backendDir, '..'); const pluginSources = path.join(backendDir, 'built-in-plugins'); const pluginBundles = path.join(repoRoot, 'target', 'openvcs', 'built-in-plugins'); +const nodeRuntimeDir = path.join(repoRoot, 'target', 'openvcs', 'node-runtime'); const skipDirs = new Set(['target', '.git', 'node_modules', 'dist']); @@ -71,6 +72,39 @@ function ensureBundlesDir() { fs.mkdirSync(pluginBundles, { recursive: true }); } +function ensureNodeRuntimeDir() { + fs.mkdirSync(nodeRuntimeDir, { recursive: true }); +} + +function ensureBundledNodeRuntime() { + const src = process.execPath; + const outName = process.platform === 'win32' ? 'node.exe' : 'node'; + const dest = path.join(nodeRuntimeDir, outName); + + let shouldCopy = true; + if (fs.existsSync(dest)) { + try { + const srcStat = fs.statSync(src); + const destStat = fs.statSync(dest); + shouldCopy = srcStat.size !== destStat.size || srcStat.mtimeMs > destStat.mtimeMs; + } catch { + shouldCopy = true; + } + } + + if (!shouldCopy) return; + + fs.copyFileSync(src, dest); + if (process.platform !== 'win32') { + try { + fs.chmodSync(dest, 0o755); + } catch { + // Ignore chmod errors on restricted filesystems. + } + } + console.log(`Bundled node runtime -> ${dest}`); +} + function runDistCommand() { console.log('Built-in plugin bundles need rebuilding; running cargo openvcs dist …'); const pluginDirArg = 'built-in-plugins'; @@ -90,6 +124,8 @@ function runDistCommand() { } ensureBundlesDir(); +ensureNodeRuntimeDir(); +ensureBundledNodeRuntime(); const outdated = findOutdatedPlugin(); if (outdated) { diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index f4b2f511..abf8c6d0 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! OpenVCS backend application crate. //! //! This crate wires together Tauri command handlers, runtime state, @@ -5,6 +7,7 @@ use log::warn; use openvcs_core::BackendId; +use std::path::PathBuf; use std::sync::Arc; use tauri::path::BaseDirectory; use tauri::WindowEvent; @@ -80,7 +83,10 @@ fn try_reopen_last_repo(app_handle: &tauri::AppHandle) { let path_str = path.to_string_lossy().to_string(); if crate::plugin_vcs_backends::has_plugin_vcs_backend(&backend) { + let runtime_manager = state.plugin_runtime(); match crate::plugin_vcs_backends::open_repo_via_plugin_vcs_backend( + runtime_manager.as_ref(), + &app_config, backend, Path::new(&path), ) { @@ -99,6 +105,27 @@ fn try_reopen_last_repo(app_handle: &tauri::AppHandle) { } } +/// Resolves a development fallback path for the bundled Node runtime. +/// +/// In `cargo tauri dev`, the generated runtime is placed under +/// `target/openvcs/node-runtime`, while Tauri resource resolution can point at +/// `target/debug/node-runtime`. This helper probes the generated location. +/// +/// # Returns +/// - `Some(PathBuf)` when the dev bundled node binary exists. +/// - `None` when the path cannot be derived or does not exist. +fn resolve_dev_bundled_node_fallback() -> Option { + let exe = std::env::current_exe().ok()?; + let exe_dir = exe.parent()?; + let target_dir = exe_dir.parent()?; + let node_name = if cfg!(windows) { "node.exe" } else { "node" }; + let candidate = target_dir + .join("openvcs") + .join("node-runtime") + .join(node_name); + candidate.is_file().then_some(candidate) +} + /// Starts the OpenVCS backend runtime and Tauri application. /// /// This configures logging, plugin bundle synchronization, startup restore @@ -118,10 +145,15 @@ pub fn run() { tauri::Builder::default() .manage(state::AppState::new_with_config()) .setup(|app| { - let store = crate::plugin_bundles::PluginBundleStore::new_default(); - if let Err(err) = store.sync_built_in_plugins() { - warn!("plugins: failed to sync built-in bundles: {}", err); - } + crate::plugin_runtime::host_api::set_status_event_emitter({ + let app_handle = app.handle().clone(); + move |message| { + if let Err(error) = app_handle.emit("status:set", message.to_string()) { + log::warn!("status:set emit failed: {}", error); + } + } + }); + // If the application bundle includes a `built-in-plugins` resource // directory, resolve its location via Tauri and register the // containing resource directory so runtime discovery can include @@ -144,6 +176,42 @@ pub fn run() { ); } } + let node_name = if cfg!(windows) { "node.exe" } else { "node" }; + let mut node_candidates: Vec = Vec::new(); + if let Ok(node_runtime_dir) = app.path().resolve("node-runtime", BaseDirectory::Resource) + { + node_candidates.push(node_runtime_dir.join(node_name)); + } + if let Some(dev_fallback) = resolve_dev_bundled_node_fallback() { + if !node_candidates.iter().any(|p| p == &dev_fallback) { + node_candidates.push(dev_fallback); + } + } + + if let Some(bundled_node) = node_candidates.iter().find(|path| path.is_file()) { + crate::plugin_paths::set_node_executable_path(bundled_node.to_path_buf()); + log::info!( + "plugins: using bundled node runtime: {}", + bundled_node.display() + ); + } else if let Some(primary) = node_candidates.first() { + log::warn!( + "plugins: bundled node runtime missing at {}; plugin modules will not start", + primary.display() + ); + } else { + log::warn!( + "plugins: bundled node runtime path could not be resolved; plugin modules will not start" + ); + } + let store = crate::plugin_bundles::PluginBundleStore::new_default(); + if let Err(err) = store.sync_built_in_plugins() { + warn!("plugins: failed to sync built-in bundles: {}", err); + } + let state = app.state::(); + if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { + warn!("plugins: failed to sync runtime on startup: {}", err); + } // On startup, optionally reopen the last repository if enabled in settings. try_reopen_last_repo(app.handle()); @@ -172,6 +240,8 @@ pub fn run() { // If the main window is closed, exit the app even if auxiliary windows are open. if window.label() == "main" { if let WindowEvent::CloseRequested { .. } = event { + let state = window.app_handle().state::(); + state.plugin_runtime().stop_all_plugins(); window.app_handle().exit(0); } } @@ -200,7 +270,6 @@ fn build_invoke_handler( tauri_commands::list_vcs_backends_cmd, tauri_commands::set_vcs_backend_cmd, tauri_commands::reopen_current_repo_cmd, - tauri_commands::call_vcs_backend_method, tauri_commands::validate_git_url, tauri_commands::validate_add_path, tauri_commands::validate_clone_input, @@ -256,14 +325,18 @@ fn build_invoke_handler( tauri_commands::list_themes, tauri_commands::load_theme, tauri_commands::list_plugins, + tauri_commands::list_plugin_start_failures, tauri_commands::load_plugin, tauri_commands::install_ovcsp, tauri_commands::list_installed_bundles, tauri_commands::uninstall_plugin, - tauri_commands::approve_plugin_capabilities, - tauri_commands::list_plugin_functions, - tauri_commands::invoke_plugin_function, - tauri_commands::call_plugin_module_method, + tauri_commands::set_plugin_enabled, + tauri_commands::set_plugin_approval, + tauri_commands::list_plugin_menus, + tauri_commands::invoke_plugin_action, + tauri_commands::get_plugin_settings, + tauri_commands::save_plugin_settings, + tauri_commands::reset_plugin_settings, tauri_commands::get_global_settings, tauri_commands::set_global_settings, tauri_commands::get_repo_settings, @@ -278,6 +351,7 @@ fn build_invoke_handler( tauri_commands::open_output_log_window, tauri_commands::get_output_log, tauri_commands::clear_output_log, + tauri_commands::log_frontend_message, tauri_commands::tail_app_log, tauri_commands::clear_app_log, tauri_commands::exit_app, diff --git a/Backend/src/logging.rs b/Backend/src/logging.rs index a9266ac5..853028d7 100644 --- a/Backend/src/logging.rs +++ b/Backend/src/logging.rs @@ -1,12 +1,91 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::settings::{AppConfig, LogLevel}; use std::fs::{self, OpenOptions}; use std::io::{Seek, SeekFrom, Write}; use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Instant; use time::{OffsetDateTime, UtcOffset}; use zip::{write::FileOptions, CompressionMethod, ZipWriter}; static ACTIVE_LOG_FILE: OnceLock>> = OnceLock::new(); +/// RAII timer that logs operation duration on drop. +/// +/// Use this to measure and log timing for long-running operations. +/// The duration is logged at trace level when the timer goes out of scope. +pub struct LogTimer { + start: Instant, + operation: &'static str, +} + +impl LogTimer { + /// Creates a new timer for the given operation. + /// + /// # Parameters + /// - `_module`: Module/component name (unused, kept for API compatibility). + /// - `operation`: Operation name (e.g., "fetch", "push"). + /// + /// # Returns + /// - A new `LogTimer` instance. + pub fn new(_module: &'static str, operation: &'static str) -> Self { + Self { + start: Instant::now(), + operation, + } + } + + /// Returns elapsed time in milliseconds. + /// + /// # Returns + /// - Elapsed time in milliseconds. + #[allow(dead_code)] + pub fn elapsed_ms(&self) -> u64 { + self.start.elapsed().as_millis() as u64 + } +} + +impl Drop for LogTimer { + fn drop(&mut self) { + let elapsed = self.start.elapsed(); + let ms = elapsed.as_millis(); + let us = elapsed.as_micros() - (ms * 1000); + log::trace!("{} completed in {}.{:03}ms", self.operation, ms, us); + } +} + +/// Logs an operation entry at info level. +/// +/// # Parameters +/// - `module`: Module/component name. +/// - `operation`: Operation name. +/// - `details`: Additional details string. +#[macro_export] +macro_rules! log_op_enter { + ($module:expr, $operation:expr) => { + log::info!("[{}] {}: starting", $module, $operation) + }; + ($module:expr, $operation:expr, $($arg:tt)*) => { + log::info!("[{}] {}: {}", $module, $operation, format!($($arg)*)) + }; +} + +/// Logs an operation exit at info level. +/// +/// # Parameters +/// - `module`: Module/component name. +/// - `operation`: Operation name. +/// - `details`: Additional details string. +#[macro_export] +macro_rules! log_op_exit { + ($module:expr, $operation:expr) => { + log::info!("[{}] {}: completed", $module, $operation) + }; + ($module:expr, $operation:expr, $($arg:tt)*) => { + log::info!("[{}] {}: {}", $module, $operation, format!($($arg)*)) + }; +} + /// Truncates the currently active `logs/openvcs.log` file in place. /// /// # Returns @@ -25,6 +104,26 @@ pub fn clear_active_log_file() -> Result<(), String> { Ok(()) } +/// Writes a line directly to the active log file. +/// +/// # Parameters +/// - `line`: The line to write (without trailing newline). +/// +/// # Returns +/// - `Ok(())` if the line was written. +/// - `Err(String)` if writing fails or log file not initialized. +pub fn write_to_log(line: &str) -> Result<(), String> { + let Some(file) = ACTIVE_LOG_FILE.get() else { + return Ok(()); + }; + let mut f = file + .lock() + .map_err(|_| "log file lock poisoned".to_string())?; + writeln!(f, "{}", line).map_err(|e| e.to_string())?; + f.flush().map_err(|e| e.to_string())?; + Ok(()) +} + /// Initialize logging: console (env_logger) + append to `./logs/openvcs.log`. /// Respects `RUST_LOG` for filtering; sets a sensible default if missing. /// @@ -78,16 +177,152 @@ pub fn init() { } } - // Build console logger (with timestamps) and then mirror to a file if possible. + // Build console logger with custom format let mut builder = env_logger::Builder::from_default_env(); - builder.format_timestamp_millis(); + builder.format(|buf, record| { + use std::io::Write; + let ts = record.level(); + let target = record.target(); + let args = record.args(); + + // Format: [YYYY-MM-DD] [HH:MM:SS] LEVEL [SOURCE]: message + let now = time::OffsetDateTime::now_utc(); + let date = format!( + "{:04}-{:02}-{:02}", + now.year(), + now.month() as u8, + now.day() + ); + let time = format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second()); + + // Extract source from target (e.g., "openvcs_lib::tauri_commands::output_log" -> "output_log") + let source = target.split("::").last().unwrap_or(target).to_uppercase(); + + // For Debug/Trace level, try to pretty-print JSON-like content in messages + let msg = args.to_string(); + + let format_braced = |body: &str| { + let mut out = String::with_capacity(body.len() + 64); + let mut indent: usize = 0; + let mut in_string = false; + let mut escaped = false; + + for ch in body.chars() { + if in_string { + out.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + + match ch { + '"' => { + in_string = true; + out.push(ch); + } + '{' => { + out.push('{'); + indent += 1; + out.push('\n'); + out.push_str(&" ".repeat(indent)); + } + '}' => { + indent = indent.saturating_sub(1); + out.push('\n'); + out.push_str(&" ".repeat(indent)); + out.push('}'); + } + ',' => { + out.push(','); + out.push('\n'); + out.push_str(&" ".repeat(indent)); + } + _ => out.push(ch), + } + } + + out + }; + + let clean_label = |prefix: &str| { + let mut label = prefix.trim().trim_end_matches(':').trim().to_string(); + for suffix in ["Object", "RemoteRelease"] { + if let Some(stripped) = label.strip_suffix(suffix) { + label = stripped.trim().to_string(); + } + } + if label.is_empty() { + "payload".to_string() + } else { + label + } + }; + + // Check if message contains JSON-like structure that can be extracted and formatted. + if msg.len() > 100 && (ts == log::Level::Debug || ts == log::Level::Trace) { + if let (Some(start), Some(end)) = (msg.find('{'), msg.rfind('}')) { + let json_part = &msg[start..=end]; + + let regex = regex::Regex::new(r#"String\("([^"]*)"\)"#).ok(); + + // Strategy 1: strict conversion of common Rust debug wrappers. + let mut attempts: Vec = Vec::with_capacity(2); + let mut cleaned = json_part.replace("Object ", ""); + if let Some(re) = ®ex { + cleaned = re.replace_all(&cleaned, r#""$1""#).to_string(); + } + attempts.push(cleaned); + + // Strategy 2: aggressive conversion fallback for odd wrapper nesting. + let aggressive = json_part + .replace("Object ", "") + .replace("String(\"", "\"") + .replace("\")", "\""); + attempts.push(aggressive); + + for json_clean in attempts { + if let Ok(value) = serde_json::from_str::(&json_clean) { + if let Ok(pretty) = serde_json::to_string_pretty(&value) { + let label = clean_label(&msg[..start]); + let header = + format!("[{}] [{}] {:5} [{}]: {} ", date, time, ts, source, label); + let lines: Vec<&str> = pretty.lines().collect(); + return writeln!( + buf, + "{}{}", + header, + lines.join(&format!("\n{}", " ".repeat(header.len()))) + ); + } + } + } + + // Fallback: non-JSON Rust debug structs (e.g., RemoteRelease { ... }) + let label = clean_label(&msg[..start]); + let pretty = format_braced(json_part); + let header = format!("[{}] [{}] {:5} [{}]: {} ", date, time, ts, source, label); + let lines: Vec<&str> = pretty.lines().collect(); + return writeln!( + buf, + "{}{}", + header, + lines.join(&format!("\n{}", " ".repeat(header.len()))) + ); + } + } + + writeln!(buf, "[{}] [{}] {:5} [{}]: {}", date, time, ts, source, msg) + }); - // Wasmtime/Cranelift can be extremely verbose at TRACE/DEBUG and drown out OpenVCS logs. + // Cranelift can be extremely verbose at TRACE/DEBUG and drown out OpenVCS logs. // Keep these at WARN+ even if the user enables a global TRACE filter. - builder.filter_module("wasmtime", log::LevelFilter::Warn); builder.filter_module("cranelift", log::LevelFilter::Warn); builder.filter_module("cranelift_codegen", log::LevelFilter::Warn); - builder.filter_module("cranelift_wasm", log::LevelFilter::Warn); builder.filter_module("cranelift_native", log::LevelFilter::Warn); // If RUST_LOG is unset, apply level from settings diff --git a/Backend/src/main.rs b/Backend/src/main.rs index edc984fd..6bfb9186 100644 --- a/Backend/src/main.rs +++ b/Backend/src/main.rs @@ -1,6 +1,9 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + /// Binary entrypoint that launches the backend runtime. /// /// # Returns diff --git a/Backend/src/output_log.rs b/Backend/src/output_log.rs index 8b5dda1a..2b833f49 100644 --- a/Backend/src/output_log.rs +++ b/Backend/src/output_log.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Shared output log types used by backend command execution and UI display. use serde::{Deserialize, Serialize}; diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index bfb2ff2f..40412261 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -1,7 +1,10 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Plugin bundle installation, indexing, and component discovery. +use crate::logging::LogTimer; use crate::plugin_paths::{built_in_plugin_dirs, ensure_dir, plugins_dir, PLUGIN_MANIFEST_NAME}; -use log::warn; +use log::{debug, error, info, trace, warn}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; @@ -11,6 +14,8 @@ use std::path::{Component, Path, PathBuf}; use std::sync::OnceLock; use xz2::read::XzDecoder; +const MODULE: &str = "plugin_bundles"; + /// Safety and resource limits enforced during bundle extraction. #[derive(Debug, Clone, Copy)] pub struct InstallerLimits { @@ -31,7 +36,7 @@ impl Default for InstallerLimits { /// - Default [`InstallerLimits`] values. fn default() -> Self { Self { - max_files: 4096, + max_files: 50_000, max_file_bytes: 64 * 1024 * 1024, max_total_bytes: 512 * 1024 * 1024, max_compression_ratio: 200, @@ -39,7 +44,9 @@ impl Default for InstallerLimits { } } -/// Capability approval status for an installed plugin version. +/// Legacy approval status for an installed plugin version. +/// +/// Node runtime currently operates in trust mode and auto-approves installs. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ApprovalState { @@ -116,13 +123,6 @@ pub struct PluginManifestModule { pub vcs_backends: Vec, } -/// Manifest functions component declaration. -#[derive(Debug, Deserialize)] -pub struct PluginManifestFunctions { - #[serde(default)] - pub exec: Option, -} - /// Parsed plugin manifest payload. #[derive(Debug, Deserialize)] pub struct PluginManifest { @@ -138,7 +138,7 @@ pub struct PluginManifest { #[serde(default)] pub module: Option, #[serde(default)] - pub functions: Option, + pub functions: Option, } /// Returns current Unix timestamp in milliseconds. @@ -226,23 +226,13 @@ pub struct ModuleComponent { pub vcs_backends: Vec<(String, Option)>, } -/// Installed functions component metadata and resolved executable path. -#[derive(Debug, Clone)] -pub struct FunctionsComponent { - pub exec: String, - pub exec_path: PathBuf, -} - /// Active component metadata for a plugin selected by `current.json`. #[derive(Debug, Clone)] pub struct InstalledPluginComponents { pub plugin_id: String, pub name: Option, - pub version: String, pub default_enabled: bool, - pub requested_capabilities: Vec, pub module: Option, - pub functions: Option, } impl PluginBundleStore { @@ -253,20 +243,10 @@ impl PluginBundleStore { pub fn new_default() -> Self { let root = plugins_dir(); ensure_dir(&root); + trace!("PluginBundleStore::new_default: root={}", root.display()); Self { root } } - /// Returns the root directory for a specific plugin id. - /// - /// # Parameters - /// - `plugin_id`: Plugin identifier used as a directory name under the store root. - /// - /// # Returns - /// - Path to the plugin root directory. - pub fn plugin_root_dir(&self, plugin_id: &str) -> PathBuf { - self.root.join(plugin_id.trim()) - } - #[cfg(test)] /// Creates a store rooted at an explicit test directory. /// @@ -275,7 +255,7 @@ impl PluginBundleStore { /// /// # Returns /// - Store instance rooted at `root`. - fn new_at(root: PathBuf) -> Self { + pub(crate) fn new_at(root: PathBuf) -> Self { Self { root } } @@ -305,24 +285,59 @@ impl PluginBundleStore { bundle_path: &Path, limits: InstallerLimits, ) -> Result { + let _timer = LogTimer::new(MODULE, "install_ovcsp_with_limits"); + let start = std::time::Instant::now(); + info!( + "install_ovcsp_with_limits: bundle={}", + bundle_path.display() + ); + debug!( + "install_ovcsp_with_limits: limits={{max_files={}, max_file_bytes={}, max_total_bytes={}}}", limits.max_files, limits.max_file_bytes, limits.max_total_bytes + ); + if !bundle_path.is_file() { + error!( + "install_ovcsp_with_limits: bundle is not a file: {}", + bundle_path.display() + ); return Err(format!("bundle is not a file: {}", bundle_path.display())); } - fs::create_dir_all(&self.root) - .map_err(|e| format!("create {}: {e}", self.root.display()))?; + fs::create_dir_all(&self.root).map_err(|e| { + error!( + "install_ovcsp_with_limits: failed to create {}: {}", + self.root.display(), + e + ); + format!("create {}: {e}", self.root.display()) + })?; let bundle_sha256 = sha256_hex_file(bundle_path)?; let bundle_compressed_bytes = fs::metadata(bundle_path) - .map_err(|e| format!("metadata {}: {e}", bundle_path.display()))? + .map_err(|e| { + error!("install_ovcsp_with_limits: failed to get metadata: {}", e); + format!("metadata {}: {e}", bundle_path.display()) + })? .len(); + debug!( + "install_ovcsp_with_limits: bundle size={} bytes, sha256={}", + bundle_compressed_bytes, + &bundle_sha256[..12] + ); + let (manifest_bundle_path, manifest) = locate_manifest_tar_xz(bundle_path)?; let plugin_id = manifest.id.trim().to_string(); if plugin_id.is_empty() { + error!("install_ovcsp_with_limits: manifest id is empty",); return Err("manifest id is empty".to_string()); } + debug!( + "install_ovcsp_with_limits: plugin_id={}, version={:?}", + plugin_id, manifest.version + ); + // Enforce that the top-level directory name matches the manifest id. let bundle_root = manifest_bundle_path .parent() @@ -331,6 +346,10 @@ impl PluginBundleStore { .unwrap_or_default() .to_string(); if bundle_root != plugin_id { + error!( + "install_ovcsp_with_limits: bundle root '{}' does not match manifest id '{}'", + bundle_root, plugin_id + ); return Err(format!( "bundle root folder '{}' does not match manifest id '{}'", bundle_root, plugin_id @@ -350,6 +369,13 @@ impl PluginBundleStore { } fs::create_dir_all(&staging).map_err(|e| format!("create {}: {e}", staging.display()))?; let staging_version_dir = staging.join(&version); + + trace!( + "install_ovcsp_with_limits: staging_dir={}, plugin_dir={}", + staging_version_dir.display(), + plugin_dir.display() + ); + fs::create_dir_all(&staging_version_dir) .map_err(|e| format!("create {}: {e}", staging_version_dir.display()))?; @@ -422,6 +448,18 @@ impl PluginBundleStore { return Err(format!("unsupported tar entry type: {}", raw_name)); } + if stripped + .as_os_str() + .to_string_lossy() + .to_ascii_lowercase() + .ends_with(".node") + { + return Err(format!( + "bundle contains unsupported native Node addon: {}", + raw_name + )); + } + total_files += 1; if total_files > limits.max_files { return Err(format!( @@ -522,26 +560,46 @@ impl PluginBundleStore { // Validate required files. let extracted_manifest = staging_version_dir.join(PLUGIN_MANIFEST_NAME); if !extracted_manifest.is_file() { + error!( + "install_ovcsp_with_limits: missing manifest at {}", + extracted_manifest.display() + ); return Err(format!( "installed bundle is missing {}", extracted_manifest.display() )); } - let (module_exec, functions_exec) = ( - normalize_exec(manifest.module.and_then(|m| m.exec)), - normalize_exec(manifest.functions.and_then(|f| f.exec)), - ); + if manifest.functions.is_some() { + error!("install_ovcsp_with_limits: manifest uses deprecated 'functions' field",); + return Err( + "manifest uses unsupported field 'functions'; use module.exec only".to_string(), + ); + } + + let module_exec = normalize_exec(manifest.module.and_then(|m| m.exec)); validate_entrypoint(&staging_version_dir, module_exec.as_deref(), "module")?; - validate_entrypoint(&staging_version_dir, functions_exec.as_deref(), "functions")?; + + debug!( + "install_ovcsp_with_limits: extracted {} files, promoting to final location", + total_files + ); // Promote staged version into place (flat layout, drop old version directory). if plugin_dir.exists() { + trace!( + "install_ovcsp_with_limits: removing existing plugin dir {}", + plugin_dir.display() + ); fs::remove_dir_all(&plugin_dir) .map_err(|e| format!("remove {}: {e}", plugin_dir.display()))?; } fs::rename(&staging_version_dir, &plugin_dir).map_err(|e| { + error!( + "install_ovcsp_with_limits: failed to move plugin into place: {}", + e + ); format!( "move installed plugin into place {} -> {}: {e}", staging_version_dir.display(), @@ -578,6 +636,12 @@ impl PluginBundleStore { }, )?; + let elapsed = start.elapsed(); + info!( + "install_ovcsp_with_limits: installed plugin {} v{} in {:?}", + plugin_id, version, elapsed + ); + Ok(InstalledPlugin { plugin_id, version, @@ -594,17 +658,28 @@ impl PluginBundleStore { /// - `Ok(())` when all built-in bundles are synchronized. /// - `Err(String)` when one or more bundles fail to sync. pub fn sync_built_in_plugins(&self) -> Result<(), String> { + let _timer = LogTimer::new(MODULE, "sync_built_in_plugins"); + let bundles = builtin_bundle_paths(); + info!( + "sync_built_in_plugins: syncing {} built-in bundles", + bundles.len() + ); + let mut errors = Vec::new(); - for bundle in builtin_bundle_paths() { - if let Err(err) = self.ensure_built_in_bundle(&bundle) { + for bundle in &bundles { + debug!("sync_built_in_plugins: checking {}", bundle.display()); + if let Err(err) = self.ensure_built_in_bundle(bundle) { let msg = format!("{}: {}", bundle.display(), err); - warn!("plugins: failed to sync built-in bundle: {}", msg); + warn!("sync_built_in_plugins: failed to sync: {}", msg); errors.push(msg); } } + if errors.is_empty() { + debug!("sync_built_in_plugins: all bundles synced successfully",); Ok(()) } else { + error!("sync_built_in_plugins: {} bundles failed", errors.len()); Err(errors.join("; ")) } } @@ -618,25 +693,45 @@ impl PluginBundleStore { /// - `Ok(())` when bundle is already current or installed successfully. /// - `Err(String)` on install/validation failures. fn ensure_built_in_bundle(&self, bundle_path: &Path) -> Result<(), String> { + trace!("ensure_built_in_bundle: {}", bundle_path.display()); + let bundle_sha256 = sha256_hex_file(bundle_path)?; let (_manifest_path, manifest) = locate_manifest_tar_xz(bundle_path)?; let plugin_id = manifest.id.trim(); if plugin_id.is_empty() { + error!("ensure_built_in_bundle: bundle manifest id is empty",); return Err("bundle manifest id is empty".to_string()); } let plugin_id = plugin_id.to_string(); let version = derive_install_version(&manifest, &bundle_sha256); + if let Some(installed) = self.get_current_installed(&plugin_id)? { if installed.bundle_sha256 == bundle_sha256 && installed.version == version { + trace!( + "ensure_built_in_bundle: {} already installed and current", + plugin_id + ); return Ok(()); } + debug!( + "ensure_built_in_bundle: {} needs update (installed={}, new={})", + plugin_id, installed.version, version + ); } + + debug!("ensure_built_in_bundle: installing {}", plugin_id); self.install_ovcsp_with_limits(bundle_path, InstallerLimits::default())?; + if let Err(err) = self.approve_capabilities(&plugin_id, &version, true) { warn!( - "plugins: failed to auto-approve built-in {} ({}): {}", + "ensure_built_in_bundle: failed to auto-approve built-in {} ({}): {}", plugin_id, version, err ); + } else { + debug!( + "ensure_built_in_bundle: auto-approved built-in {} ({})", + plugin_id, version + ); } Ok(()) } @@ -650,19 +745,34 @@ impl PluginBundleStore { /// - `Ok(())` when the plugin is removed or not installed. /// - `Err(String)` if the id is invalid, built-in, or removal fails. pub fn uninstall_plugin(&self, plugin_id: &str) -> Result<(), String> { + let _timer = LogTimer::new(MODULE, "uninstall_plugin"); let id = plugin_id.trim(); + info!("uninstall_plugin: plugin={}", id); + if id.is_empty() { + warn!("uninstall_plugin: empty plugin id"); return Err("plugin id is empty".to_string()); } let lower = id.to_ascii_lowercase(); if built_in_plugin_ids().contains(&lower) { + warn!("uninstall_plugin: cannot uninstall built-in plugin {}", id); return Err("built-in plugins cannot be removed".to_string()); } let dir = self.root.join(id); if !dir.exists() { + debug!("uninstall_plugin: plugin {} not installed", id); return Ok(()); } - fs::remove_dir_all(&dir).map_err(|e| format!("remove {}: {e}", dir.display())) + fs::remove_dir_all(&dir).map_err(|e| { + error!( + "uninstall_plugin: failed to remove {}: {}", + dir.display(), + e + ); + format!("remove {}: {e}", dir.display()) + })?; + debug!("uninstall_plugin: plugin {} removed", id); + Ok(()) } /// Lists installed plugin indices discovered from the plugin store root. @@ -671,12 +781,22 @@ impl PluginBundleStore { /// - `Ok(Vec)` sorted by plugin id. /// - `Err(String)` when the plugin root cannot be read. pub fn list_installed(&self) -> Result, String> { + let _timer = LogTimer::new(MODULE, "list_installed"); + trace!("list_installed: scanning {}", self.root.display()); + if !self.root.is_dir() { + debug!("list_installed: root does not exist"); return Ok(Vec::new()); } let mut out = Vec::new(); - let entries = - fs::read_dir(&self.root).map_err(|e| format!("read {}: {e}", self.root.display()))?; + let entries = fs::read_dir(&self.root).map_err(|e| { + error!( + "list_installed: failed to read {}: {}", + self.root.display(), + e + ); + format!("read {}: {e}", self.root.display()) + })?; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { @@ -693,6 +813,7 @@ impl PluginBundleStore { } } out.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); + debug!("list_installed: found {} installed plugins", out.len()); Ok(out) } @@ -742,17 +863,31 @@ impl PluginBundleStore { &self, plugin_id: &str, ) -> Result, String> { + trace!("get_current_installed: plugin_id='{}'", plugin_id); + let id = plugin_id.trim(); + debug!("get_current_installed: trimmed id='{}'", id); + if id.is_empty() { return Err("plugin id is empty".to_string()); } + + trace!("get_current_installed: reading index"); let Some(index) = self.read_index(id) else { + debug!("get_current_installed: no index found for '{}'", id); return Ok(None); }; + + trace!("get_current_installed: checking current pointer"); let Some(ver) = index.current.as_deref() else { + debug!("get_current_installed: no current version for '{}'", id); return Ok(None); }; - Ok(index.versions.get(ver).cloned()) + + debug!("get_current_installed: current version='{}'", ver); + let result = index.versions.get(ver).cloned(); + debug!("get_current_installed: found={}", result.is_some()); + Ok(result) } /// Updates capability approval for a specific plugin version. @@ -814,6 +949,12 @@ impl PluginBundleStore { .map_err(|e| format!("read {}: {e}", manifest_path.display()))?; let manifest: PluginManifest = serde_json::from_str(&text) .map_err(|e| format!("parse {}: {e}", manifest_path.display()))?; + if manifest.functions.is_some() { + return Err(format!( + "manifest {} uses unsupported field 'functions'; use module.exec only", + manifest_path.display() + )); + } let id = manifest.id.trim().to_string(); if id.is_empty() { return Err("manifest id is empty".to_string()); @@ -826,22 +967,6 @@ impl PluginBundleStore { )); } - let version = manifest - .version - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| { - version_dir - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - }); - - let requested_capabilities = normalize_capabilities(manifest.capabilities.clone()); - let module = manifest.module.and_then(|m| { let exec = m.exec?.trim().to_string(); if exec.is_empty() { @@ -876,22 +1001,10 @@ impl PluginBundleStore { }) }); - let functions = manifest.functions.and_then(|f| { - let exec = f.exec?.trim().to_string(); - if exec.is_empty() { - return None; - } - let exec_path = version_dir.join("bin").join(platform_exec_name(&exec)); - Some(FunctionsComponent { exec, exec_path }) - }); - // Validate that declared entrypoints exist (defense-in-depth; installer should have ensured). if let Some(m) = &module { validate_entrypoint(&version_dir, Some(&m.exec), "module")?; } - if let Some(f) = &functions { - validate_entrypoint(&version_dir, Some(&f.exec), "functions")?; - } Ok(Some(InstalledPluginComponents { plugin_id: id, @@ -899,12 +1012,8 @@ impl PluginBundleStore { .name .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()), - version, default_enabled: manifest.default_enabled, - - requested_capabilities, module, - functions, })) } @@ -914,12 +1023,19 @@ impl PluginBundleStore { /// - `Ok(Vec)` sorted by plugin id. /// - `Err(String)` when store traversal fails. pub fn list_current_components(&self) -> Result, String> { + trace!("list_current_components: root='{}'", self.root.display()); + if !self.root.is_dir() { + debug!("list_current_components: root is not a directory, returning empty"); return Ok(Vec::new()); } + + trace!("list_current_components: reading directory"); let entries = fs::read_dir(&self.root).map_err(|e| format!("read {}: {e}", self.root.display()))?; let mut out = Vec::new(); + + trace!("list_current_components: iterating entries"); for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { @@ -929,11 +1045,36 @@ impl PluginBundleStore { Some(s) => s.to_string(), None => continue, }; - if let Some(c) = self.load_current_components(&plugin_id)? { - out.push(c); + + debug!("list_current_components: checking plugin '{}'", plugin_id); + match self.load_current_components(&plugin_id) { + Ok(Some(c)) => { + debug!( + "list_current_components: plugin '{}' has components, has_module={}", + plugin_id, + c.module.is_some() + ); + out.push(c); + } + Ok(None) => { + debug!( + "list_current_components: plugin '{}' has no current components", + plugin_id + ); + } + Err(err) => { + warn!( + "list_current_components: skipping invalid plugin '{}': {}", + plugin_id, err + ); + } } } out.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); + debug!( + "list_current_components: returning {} components", + out.len() + ); Ok(out) } @@ -946,9 +1087,31 @@ impl PluginBundleStore { /// - `Some(InstalledPluginIndex)` when present and parseable. /// - `None` otherwise. fn read_index(&self, plugin_id: &str) -> Option { + trace!("read_index: plugin_id='{}'", plugin_id); let p = self.root.join(plugin_id).join("index.json"); - let text = fs::read_to_string(p).ok()?; - serde_json::from_str(&text).ok() + debug!("read_index: path='{}'", p.display()); + + let text = match fs::read_to_string(&p) { + Ok(t) => { + debug!("read_index: file read successfully"); + t + } + Err(e) => { + debug!("read_index: failed to read file: {}", e); + return None; + } + }; + + match serde_json::from_str(&text) { + Ok(index) => { + debug!("read_index: parsed index for '{}'", plugin_id); + Some(index) + } + Err(e) => { + debug!("read_index: failed to parse index: {}", e); + None + } + } } /// Writes plugin index metadata atomically. @@ -1175,7 +1338,7 @@ fn platform_exec_name(base: &str) -> String { base.to_string() } -/// Validates that a declared entrypoint exists and is a wasm module. +/// Validates that a declared entrypoint exists and is a Node module. /// /// # Parameters /// - `version_dir`: Installed version directory. @@ -1194,9 +1357,11 @@ fn validate_entrypoint(version_dir: &Path, exec: Option<&str>, label: &str) -> R return Ok(()); } - if !trimmed.ends_with(".wasm") { + let lower = trimmed.to_ascii_lowercase(); + let is_supported = lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs"); + if !is_supported { return Err(format!( - "{} entrypoint must be a .wasm module, got: {}", + "{} entrypoint must be a .js/.mjs/.cjs Node module, got: {}", label, trimmed )); } @@ -1221,15 +1386,23 @@ mod tests { use tempfile::tempdir; use xz2::write::XzEncoder; + /// Synthetic tar entry kind used by bundle-construction helpers. enum TarEntryKind { + /// Regular file entry. File, + /// Symbolic-link entry targeting `target`. Symlink { target: String }, } + /// Synthetic tar entry used to build fixture bundles in tests. struct TarEntry { + /// Path written into the tar header. name: String, + /// Raw entry payload bytes. data: Vec, + /// Optional Unix mode to set in the tar header. unix_mode: Option, + /// Tar entry type selector. kind: TarEntryKind, } @@ -1450,7 +1623,7 @@ mod tests { name: "test.plugin/openvcs.plugin.json".into(), data: basic_manifest( "test.plugin", - ",\"module\":{\"exec\":\"missing.wasm\",\"vcs_backends\":[\"x\"]}", + ",\"module\":{\"exec\":\"missing.mjs\",\"vcs_backends\":[\"x\"]}", ), unix_mode: None, kind: TarEntryKind::File, @@ -1464,6 +1637,30 @@ mod tests { assert!(err.is_err()); } + #[test] + /// Verifies installer rejects deprecated functions component manifests. + /// + /// # Returns + /// - `()`. + fn install_rejects_functions_component() { + let bundle = make_tar_xz_bundle(vec![TarEntry { + name: "test.plugin/openvcs.plugin.json".into(), + data: basic_manifest("test.plugin", ",\"functions\":{\"exec\":\"legacy.mjs\"}"), + unix_mode: None, + kind: TarEntryKind::File, + }]); + + let (_tmp, bundle_path) = write_bundle_to_temp(&bundle); + let store_root = tempdir().unwrap(); + let store = PluginBundleStore::new_at(store_root.path().to_path_buf()); + + let err = store.install_ovcsp(&bundle_path).unwrap_err(); + assert_eq!( + err, + "manifest uses unsupported field 'functions'; use module.exec only" + ); + } + #[test] /// Verifies installer accepts valid tar.xz bundles. /// @@ -1475,14 +1672,14 @@ mod tests { name: "test.plugin/openvcs.plugin.json".into(), data: basic_manifest( "test.plugin", - ",\"module\":{\"exec\":\"mod.wasm\",\"vcs_backends\":[]}", + ",\"module\":{\"exec\":\"mod.mjs\",\"vcs_backends\":[]}", ), unix_mode: None, kind: TarEntryKind::File, }, TarEntry { - name: "test.plugin/bin/mod.wasm".into(), - data: b"\0asm".to_vec(), + name: "test.plugin/bin/mod.mjs".into(), + data: b"export {};\n".to_vec(), unix_mode: Some(0o100644), kind: TarEntryKind::File, }, @@ -1586,4 +1783,96 @@ mod tests { let err = store.install_ovcsp_with_limits(&bundle_path, limits); assert!(err.is_err()); } + + #[test] + /// Verifies component listing skips invalid plugins instead of failing globally. + fn list_current_components_skips_invalid_plugins() { + let root = tempdir().expect("tempdir"); + let store = PluginBundleStore::new_at(root.path().to_path_buf()); + + write_installed_plugin(root.path(), "valid.theme", "1.0.0", None); + write_installed_plugin(root.path(), "broken.runtime", "1.0.0", Some("plugin.wasm")); + + let components = store + .list_current_components() + .expect("list current components"); + + assert_eq!(components.len(), 1); + assert_eq!(components[0].plugin_id, "valid.theme"); + assert!(components[0].module.is_none()); + } + + /// Writes an installed plugin directory with optional module entrypoint. + fn write_installed_plugin( + root: &std::path::Path, + plugin_id: &str, + version: &str, + module_exec: Option<&str>, + ) { + let plugin_dir = root.join(plugin_id); + fs::create_dir_all(&plugin_dir).expect("create plugin dir"); + + let manifest = if let Some(exec) = module_exec { + serde_json::json!({ + "id": plugin_id, + "name": "Test", + "version": version, + "default_enabled": false, + "module": { + "exec": exec, + "vcs_backends": [] + } + }) + } else { + serde_json::json!({ + "id": plugin_id, + "name": "Test", + "version": version, + "default_enabled": false + }) + }; + + fs::write( + plugin_dir.join(PLUGIN_MANIFEST_NAME), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let index = InstalledPluginIndex { + plugin_id: plugin_id.to_string(), + current: Some(version.to_string()), + versions: { + let mut versions = BTreeMap::new(); + versions.insert( + version.to_string(), + InstalledPluginVersion { + version: version.to_string(), + bundle_sha256: "sha".to_string(), + installed_at_unix_ms: 0, + requested_capabilities: Vec::new(), + approval: ApprovalState::Approved { + capabilities: Vec::new(), + approved_at_unix_ms: 0, + }, + }, + ); + versions + }, + }; + + fs::write( + plugin_dir.join("index.json"), + serde_json::to_vec_pretty(&index).expect("serialize index"), + ) + .expect("write index"); + + let current = CurrentPointer { + version: version.to_string(), + }; + fs::write( + plugin_dir.join("current.json"), + serde_json::to_vec_pretty(¤t).expect("serialize current"), + ) + .expect("write current"); + } } diff --git a/Backend/src/plugin_paths.rs b/Backend/src/plugin_paths.rs index 62ae72a2..be9504a1 100644 --- a/Backend/src/plugin_paths.rs +++ b/Backend/src/plugin_paths.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Path resolution helpers for installed and built-in plugins. use directories::ProjectDirs; @@ -5,7 +7,10 @@ use log::{info, warn}; use std::{ env, path::{Path, PathBuf}, - sync::OnceLock, + sync::{ + atomic::{AtomicBool, Ordering}, + OnceLock, + }, }; /// File name expected for plugin manifests. @@ -17,6 +22,8 @@ pub const BUILT_IN_PLUGINS_DIR_NAME: &str = "built-in-plugins"; // it here so plugin discovery can include resources embedded in the // application bundle. static RESOURCE_DIR: OnceLock = OnceLock::new(); +static NODE_EXECUTABLE: OnceLock = OnceLock::new(); +static LOGGED_BUILTIN_DIRS: AtomicBool = AtomicBool::new(false); /// Returns the user-writable plugin installation directory. /// @@ -102,15 +109,20 @@ pub fn built_in_plugin_dirs() -> Vec { }) .collect(); - if result.is_empty() { - info!("plugins: no built-in plugin directories found"); - } else { - let joined = result - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(", "); - info!("plugins: checked built-in plugin directories: {}", joined); + if LOGGED_BUILTIN_DIRS + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + if result.is_empty() { + info!("plugins: no built-in plugin directories found"); + } else { + let joined = result + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + info!("plugins: checked built-in plugin directories: {}", joined); + } } result @@ -129,3 +141,23 @@ pub fn set_resource_dir(path: PathBuf) { // it's fine if this fails to set more than once; first set wins. let _ = RESOURCE_DIR.set(path); } + +/// Sets the resolved bundled Node executable path used by plugin runtime. +/// +/// # Parameters +/// - `path`: Absolute path to the bundled Node binary. +/// +/// # Returns +/// - `()`. +pub fn set_node_executable_path(path: PathBuf) { + let _ = NODE_EXECUTABLE.set(path); +} + +/// Returns the bundled Node executable path when configured. +/// +/// # Returns +/// - `Some(PathBuf)` when a bundled runtime was resolved. +/// - `None` when host should fall back to `node` on PATH. +pub fn node_executable_path() -> Option { + NODE_EXECUTABLE.get().cloned() +} diff --git a/Backend/src/plugin_runtime/events.rs b/Backend/src/plugin_runtime/events.rs index cd99fd8c..44a3f368 100644 --- a/Backend/src/plugin_runtime/events.rs +++ b/Backend/src/plugin_runtime/events.rs @@ -1,20 +1,13 @@ -use openvcs_core::plugin_protocol::PluginMessage; -use openvcs_core::plugin_protocol::RpcRequest; +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + use serde_json::Value; use std::collections::{HashMap, HashSet}; -use std::io::Write; -use std::sync::{Arc, Mutex, OnceLock}; - -pub type PluginStdin = Arc>>>>; - -#[derive(Clone)] -pub struct PluginIoHandle { - pub stdin: PluginStdin, -} +use std::sync::{Mutex, OnceLock}; +/// In-memory mapping of plugin subscriptions by plugin id. struct Registry { - next_id: HashMap, - io: HashMap, + /// Event names subscribed by each plugin id. subs: HashMap>, // plugin_id -> event names } @@ -27,29 +20,11 @@ static REGISTRY: OnceLock> = OnceLock::new(); fn registry() -> &'static Mutex { REGISTRY.get_or_init(|| { Mutex::new(Registry { - next_id: HashMap::new(), - io: HashMap::new(), subs: HashMap::new(), }) }) } -/// Registers a plugin's stdin handle for outbound host->plugin messages. -/// -/// # Parameters -/// - `plugin_id`: Plugin id to register. -/// - `stdin`: IO handle containing the writable plugin stdin channel. -/// -/// # Returns -/// - `()`. -pub fn register_plugin_io(plugin_id: &str, stdin: PluginIoHandle) { - if let Ok(mut lock) = registry().lock() { - lock.io.insert(plugin_id.to_string(), stdin); - lock.next_id.entry(plugin_id.to_string()).or_insert(1); - lock.subs.entry(plugin_id.to_string()).or_default(); - } -} - #[allow(dead_code)] /// Removes a plugin from the runtime event registry. /// @@ -60,26 +35,7 @@ pub fn register_plugin_io(plugin_id: &str, stdin: PluginIoHandle) { /// - `()`. pub fn unregister_plugin(plugin_id: &str) { if let Ok(mut lock) = registry().lock() { - lock.io.remove(plugin_id); lock.subs.remove(plugin_id); - lock.next_id.remove(plugin_id); - } -} - -/// Subscribes a plugin to a named host/plugin event channel. -/// -/// # Parameters -/// - `plugin_id`: Subscriber plugin id. -/// - `event`: Event name to subscribe to. -/// -/// # Returns -/// - `()`. -pub fn subscribe(plugin_id: &str, event: &str) { - if let Ok(mut lock) = registry().lock() { - lock.subs - .entry(plugin_id.to_string()) - .or_default() - .insert(event.to_string()); } } @@ -93,8 +49,8 @@ pub fn subscribe(plugin_id: &str, event: &str) { /// # Returns /// - `()`. pub fn emit_from_plugin(plugin_id: &str, name: &str, payload: Value) { - // For now this just fans out to other plugin subscribers. - // Host-side internal listeners can be added later. + // Current component runtime transport is in-process only. + // Keep the subscription graph updated, but there is no cross-plugin delivery channel yet. emit_to_plugins(Some(plugin_id), name, payload); } @@ -108,12 +64,9 @@ pub fn emit_from_plugin(plugin_id: &str, name: &str, payload: Value) { /// # Returns /// - `()`. pub fn emit_to_plugins(origin_plugin_id: Option<&str>, name: &str, payload: Value) { - let targets: Vec<(String, PluginIoHandle, u64)> = { - let Ok(mut lock) = registry().lock() else { - return; - }; - - let matching: Vec = lock + let _ = payload; + if let Ok(lock) = registry().lock() { + let _targets: Vec = lock .subs .iter() .filter_map(|(plugin_id, events)| { @@ -126,33 +79,5 @@ pub fn emit_to_plugins(origin_plugin_id: Option<&str>, name: &str, payload: Valu Some(plugin_id.clone()) }) .collect(); - - let mut out = Vec::new(); - for plugin_id in matching { - let Some(io) = lock.io.get(&plugin_id).cloned() else { - continue; - }; - let id = lock.next_id.entry(plugin_id.clone()).or_insert(1); - let req_id = *id; - *id = id.saturating_add(1); - out.push((plugin_id, io, req_id)); - } - out - }; - - for (_plugin_id, io, req_id) in targets { - if let Ok(mut lock) = io.stdin.lock() { - if let Some(stdin) = lock.as_mut() { - let req = RpcRequest { - id: req_id, - method: "event.dispatch".to_string(), - params: serde_json::json!({ "name": name, "payload": payload }), - }; - if let Ok(line) = serde_json::to_string(&PluginMessage::Request(req)) { - let _ = writeln!(stdin, "{line}"); - let _ = stdin.flush(); - } - } - } } } diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs new file mode 100644 index 00000000..553ff4a6 --- /dev/null +++ b/Backend/src/plugin_runtime/host_api.rs @@ -0,0 +1,56 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +//! Minimal host-side plugin runtime APIs. + +use parking_lot::RwLock; +use std::sync::OnceLock; + +/// Global status emitter callback used by backend->frontend bridge. +static STATUS_EVENT_EMITTER: OnceLock> = OnceLock::new(); +/// Shared in-memory status text for plugin updates. +static STATUS_TEXT: OnceLock> = OnceLock::new(); + +/// Returns global status storage singleton. +fn status_text_store() -> &'static RwLock { + STATUS_TEXT.get_or_init(|| RwLock::new(String::new())) +} + +/// Emits a status text event through the configured backend emitter. +fn emit_status_event(message: &str) { + if let Some(emitter) = STATUS_EVENT_EMITTER.get() { + emitter(message); + } +} + +/// Installs status event emitter callback for plugin-originated status updates. +/// +/// # Parameters +/// - `emitter`: Callback invoked with status text updates. +/// +/// # Returns +/// - `()`. +pub fn set_status_event_emitter(emitter: F) +where + F: Fn(&str) + Send + Sync + 'static, +{ + let _ = STATUS_EVENT_EMITTER.set(Box::new(emitter)); +} + +/// Sets status text without permission checks. +/// +/// This is used by the Node plugin runtime where plugin trust is explicit and +/// capability gates are disabled. +/// +/// # Parameters +/// - `message`: New status text. +/// +/// # Returns +/// - `()`. +pub fn set_status_text_unchecked(message: &str) { + let trimmed = message.trim(); + if trimmed.is_empty() { + return; + } + *status_text_store().write() = trimmed.to_string(); + emit_status_event(trimmed); +} diff --git a/Backend/src/plugin_runtime/instance.rs b/Backend/src/plugin_runtime/instance.rs new file mode 100644 index 00000000..56b0b10b --- /dev/null +++ b/Backend/src/plugin_runtime/instance.rs @@ -0,0 +1,54 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +use openvcs_core::models::VcsEvent; +use openvcs_core::settings::SettingKv; +use openvcs_core::ui::Menu; +use std::sync::Arc; + +/// Runtime instance abstraction used by the plugin runtime manager. +pub trait PluginRuntimeInstance: Send + Sync { + /// Ensures the underlying runtime instance is started. + fn ensure_running(&self) -> Result<(), String>; + + /// Returns plugin-contributed UI menus. + fn get_menus(&self) -> Result, String> { + Ok(Vec::new()) + } + + /// Invokes a plugin action by id. + fn handle_action(&self, _id: &str) -> Result<(), String> { + Ok(()) + } + + /// Returns plugin settings defaults. + fn settings_defaults(&self) -> Result, String> { + Ok(Vec::new()) + } + + /// Applies plugin settings values after host load. + #[allow(dead_code)] + fn settings_on_load(&self, values: Vec) -> Result, String> { + Ok(values) + } + + /// Applies effective plugin settings values at runtime. + fn settings_on_apply(&self, _values: Vec) -> Result<(), String> { + Ok(()) + } + + /// Validates and normalizes plugin settings before host save. + fn settings_on_save(&self, values: Vec) -> Result, String> { + Ok(values) + } + + /// Handles plugin settings reset callbacks. + fn settings_on_reset(&self) -> Result<(), String> { + Ok(()) + } + + /// Installs an optional event sink for runtime-emitted events. + fn set_event_sink(&self, _sink: Option>) {} + + /// Stops the runtime instance. + fn stop(&self); +} diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs new file mode 100644 index 00000000..f9abf3ec --- /dev/null +++ b/Backend/src/plugin_runtime/manager.rs @@ -0,0 +1,1000 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +use crate::plugin_bundles::{InstalledPluginComponents, PluginBundleStore}; +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::runtime_select::create_runtime_instance; +use crate::plugin_runtime::spawn::SpawnConfig; +use crate::settings::AppConfig; +use log::{debug, info, trace, warn}; +use parking_lot::Mutex; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; + +#[derive(Clone)] +/// Fully resolved runtime spec for a module-capable plugin. +struct ModuleRuntimeSpec { + /// Canonical plugin identifier. + plugin_id: String, + /// Normalized lowercase process map key. + key: String, + /// Manifest default-enabled value for this plugin. + default_enabled: bool, + /// Spawn configuration used to instantiate runtime transport. + spawn: SpawnConfig, +} + +/// Owns long-lived module plugin processes and coordinates lifecycle actions. +pub struct PluginRuntimeManager { + /// Bundle store used to resolve installed plugin metadata. + store: PluginBundleStore, + /// Running plugin runtime instances keyed by normalized plugin id. + processes: Mutex>, + /// Last known runtime startup failures keyed by normalized plugin id. + start_failures: Mutex>, +} + +/// Runtime handle tracked for a running plugin. +struct RunningPlugin { + /// Runtime instance for dispatching plugin RPC calls. + runtime: Arc, + /// Workspace confinement root associated with the runtime instance. + workspace_root: Option, +} + +impl Default for PluginRuntimeManager { + /// Creates a runtime manager backed by the default plugin bundle store. + /// + /// # Returns + /// - Default [`PluginRuntimeManager`]. + fn default() -> Self { + Self::new(PluginBundleStore::new_default()) + } +} + +impl PluginRuntimeManager { + /// Creates a runtime manager using a specific plugin bundle store. + /// + /// # Parameters + /// - `store`: Plugin bundle store used to resolve installed components. + /// + /// # Returns + /// - New runtime manager instance. + pub fn new(store: PluginBundleStore) -> Self { + Self { + store, + processes: Mutex::new(HashMap::new()), + start_failures: Mutex::new(HashMap::new()), + } + } + + /// Records the latest startup failure for a plugin id. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// - `error`: Startup error message. + fn record_start_failure(&self, plugin_id: &str, error: &str) { + let key = plugin_id.trim().to_ascii_lowercase(); + if key.is_empty() { + return; + } + self.start_failures + .lock() + .insert(key, error.trim().to_string()); + } + + /// Clears any tracked startup failure for a plugin id. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + fn clear_start_failure(&self, plugin_id: &str) { + let key = plugin_id.trim().to_ascii_lowercase(); + if key.is_empty() { + return; + } + self.start_failures.lock().remove(&key); + } + + /// Returns plugin ids whose last startup attempt failed. + /// + /// # Returns + /// - Sorted plugin id list for startup failures. + pub fn failed_plugin_starts(&self) -> Vec { + let mut out = self + .start_failures + .lock() + .keys() + .cloned() + .collect::>(); + out.sort(); + out + } + + /// Starts a plugin module process if needed. + /// + /// This operation is idempotent. If the plugin is already running, it + /// validates readiness and returns success. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// + /// # Returns + /// - `Ok(())` when the plugin is running. + /// - `Err(String)` when plugin lookup/startup fails. + pub fn start_plugin(&self, plugin_id: &str) -> Result<(), String> { + trace!("start_plugin: plugin_id='{}'", plugin_id); + let key = normalize_plugin_key(plugin_id)?; + debug!("start_plugin: key='{}'", key); + + if let Some(existing) = self.processes.lock().get(&key) { + debug!("start_plugin: found existing runtime for key='{}'", key); + match existing.runtime.ensure_running() { + Ok(()) => { + self.clear_start_failure(plugin_id); + return Ok(()); + } + Err(err) => { + self.record_start_failure(plugin_id, &err); + return Err(err); + } + } + } + + trace!("start_plugin: resolving module runtime spec"); + let spec = self.resolve_module_runtime_spec(plugin_id, None)?; + debug!( + "start_plugin: resolved spec for plugin_id='{}', key='{}'", + spec.plugin_id, spec.key + ); + + trace!("start_plugin: starting plugin spec"); + if let Err(err) = self.start_plugin_spec(spec) { + self.record_start_failure(plugin_id, &err); + return Err(err); + } + self.clear_start_failure(plugin_id); + trace!("start_plugin: completed successfully"); + info!("plugin: started '{}'", plugin_id); + Ok(()) + } + + /// Stops a plugin module process when present. + /// + /// This operation is idempotent. Stopping an already-stopped plugin + /// returns success. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// + /// # Returns + /// - `Ok(())` after stop/removal. + /// - `Err(String)` if the identifier is invalid. + pub fn stop_plugin(&self, plugin_id: &str) -> Result<(), String> { + trace!("stop_plugin: plugin_id='{}'", plugin_id); + let key = normalize_plugin_key(plugin_id)?; + debug!("stop_plugin: key='{}'", key); + + let process = self.processes.lock().remove(&key); + debug!("stop_plugin: removed from processes={}", process.is_some()); + + if let Some(process) = process { + trace!("stop_plugin: calling runtime.stop()"); + process.runtime.stop(); + info!("plugin: stopped '{}'", plugin_id); + } else { + trace!("stop_plugin: no running process found"); + } + self.clear_start_failure(plugin_id); + Ok(()) + } + + /// Returns a running runtime instance for a plugin, without starting it. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// + /// # Returns + /// - `Ok(Some(runtime))` when currently running. + /// - `Ok(None)` when not running. + /// - `Err(String)` if the identifier is invalid. + pub fn running_runtime_for_plugin( + &self, + plugin_id: &str, + ) -> Result>, String> { + let key = normalize_plugin_key(plugin_id)?; + Ok(self + .processes + .lock() + .get(&key) + .map(|process| Arc::clone(&process.runtime))) + } + + /// Stops all running plugins. + pub fn stop_all_plugins(&self) { + let running: Vec = { + let mut processes = self.processes.lock(); + let keys: Vec = processes.keys().cloned().collect(); + for (_, process) in processes.iter_mut() { + process.runtime.stop(); + } + processes.clear(); + keys + }; + if !running.is_empty() { + info!("plugin: stopped all ({} plugins)", running.len()); + } + } + + /// Checks if a plugin has a runtime module component. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// + /// # Returns + /// - `Ok(Some(true))` - plugin has a module. + /// - `Ok(Some(false))` - plugin has no module. + /// - `Err(String)` - plugin not found or other error. + pub fn has_module(&self, plugin_id: &str) -> Result, String> { + trace!("has_module: plugin_id='{}'", plugin_id); + let requested = plugin_id.trim(); + if requested.is_empty() { + return Err("plugin id is empty".to_string()); + } + + let components = self.find_components(requested)?; + let has_module = components.module.is_some(); + debug!( + "has_module: plugin_id='{}', has_module={}", + plugin_id, has_module + ); + Ok(Some(has_module)) + } + + /// Ensures a plugin is running or stopped based on enabled state. + /// + /// This is more efficient than sync_plugin_runtime_with_config when + /// only one plugin's state has changed. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// - `enabled`: Whether the plugin should be running. + /// + /// # Returns + /// - `Ok(())` when the operation succeeds. + /// - `Err(String)` when the operation fails. + pub fn set_plugin_enabled(&self, plugin_id: &str, enabled: bool) -> Result<(), String> { + trace!( + "set_plugin_enabled: plugin_id='{}', enabled={}", + plugin_id, + enabled + ); + let key = normalize_plugin_key(plugin_id)?; + let is_running = self.processes.lock().contains_key(&key); + debug!( + "set_plugin_enabled: key='{}', currently_running={}", + key, is_running + ); + + if enabled && !is_running { + let components = self.find_components(plugin_id)?; + match components.module { + Some(module) => { + if !module.vcs_backends.is_empty() { + info!( + "plugin '{}' is a VCS backend; runtime starts when opening a repository", + plugin_id + ); + } else { + trace!("set_plugin_enabled: calling start_plugin"); + self.start_plugin(plugin_id)?; + info!("plugin: enabled '{}'", plugin_id); + } + } + None => { + info!( + "plugin '{}' has no runtime module, marked as enabled", + plugin_id + ); + } + } + } else if !enabled && is_running { + trace!("set_plugin_enabled: calling stop_plugin"); + self.stop_plugin(plugin_id)?; + info!("plugin: disabled '{}'", plugin_id); + } else { + trace!("set_plugin_enabled: no action needed (already in desired state)"); + } + Ok(()) + } + + /// Synchronizes runtime process state with current persisted plugin settings. + /// + /// # Returns + /// - `Ok(())` when sync succeeds. + /// - `Err(String)` when one or more start/stop operations fail. + pub fn sync_plugin_runtime(&self) -> Result<(), String> { + let cfg = AppConfig::load_or_default(); + self.sync_plugin_runtime_with_config(&cfg) + } + + /// Synchronizes runtime process state with an explicit configuration snapshot. + /// + /// # Parameters + /// - `cfg`: Application config used to determine enabled plugins. + /// + /// # Returns + /// - `Ok(())` when sync succeeds. + /// - `Err(String)` when one or more start/stop operations fail. + pub fn sync_plugin_runtime_with_config(&self, cfg: &AppConfig) -> Result<(), String> { + let components = self.store.list_current_components()?; + let mut desired_running = HashSet::new(); + let mut errors = Vec::new(); + + let before: Vec = self.processes.lock().keys().cloned().collect(); + + for component in components { + let plugin_id = component.plugin_id.trim(); + if plugin_id.is_empty() || component.module.is_none() { + continue; + } + + let is_vcs_backend = component + .module + .as_ref() + .is_some_and(|module| !module.vcs_backends.is_empty()); + if is_vcs_backend { + continue; + } + + let key = plugin_id.to_ascii_lowercase(); + if cfg.is_plugin_enabled(plugin_id, component.default_enabled) { + desired_running.insert(key.clone()); + if let Err(err) = self.start_plugin(plugin_id) { + errors.push(format!("start {}: {}", plugin_id, err)); + } + } + } + + let running: Vec = self.processes.lock().keys().cloned().collect(); + for plugin_id in running { + if !desired_running.contains(&plugin_id) { + if let Err(err) = self.stop_plugin(&plugin_id) { + errors.push(format!("stop {}: {}", plugin_id, err)); + } + } + } + + let after: Vec = self.processes.lock().keys().cloned().collect(); + let started: Vec<&String> = after.iter().filter(|p| !before.contains(p)).collect(); + let stopped: Vec<&String> = before.iter().filter(|p| !after.contains(p)).collect(); + + if !started.is_empty() || !stopped.is_empty() { + let mut parts = Vec::new(); + if !started.is_empty() { + parts.push(format!( + "started: {}", + started + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )); + } + if !stopped.is_empty() { + parts.push(format!( + "stopped: {}", + stopped + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )); + } + info!("plugin: sync complete - {}", parts.join("; ")); + } + + if errors.is_empty() { + Ok(()) + } else { + warn!("plugin: sync completed with errors: {}", errors.join("; ")); + Err(errors.join("; ")) + } + } + + /// Returns the persistent runtime instance for a plugin workspace. + /// + /// # Parameters + /// - `cfg`: App config snapshot used for enabled-state checks. + /// - `plugin_id`: Plugin identifier. + /// - `allowed_workspace_root`: Optional workspace root for host capability confinement. + /// + /// # Returns + /// - `Ok(Arc)` running runtime instance. + /// - `Err(String)` when plugin state validation fails or runtime is not running. + pub fn runtime_for_workspace_with_config( + &self, + cfg: &AppConfig, + plugin_id: &str, + allowed_workspace_root: Option, + ) -> Result, String> { + let spec = self.resolve_module_runtime_spec(plugin_id, allowed_workspace_root)?; + if !cfg.is_plugin_enabled(&spec.plugin_id, spec.default_enabled) { + return Err(format!("plugin `{}` is disabled", spec.plugin_id)); + } + self.processes + .lock() + .get(&spec.key) + .map(|p| Arc::clone(&p.runtime)) + .ok_or_else(|| { + format!( + "plugin `{}` is not running; enable the plugin to start its runtime", + spec.plugin_id + ) + }) + } + + /// Resolves spawn configuration for a VCS backend plugin within a workspace root. + /// + /// # Parameters + /// - `cfg`: App config snapshot used for enabled-state checks. + /// - `plugin_id`: Plugin identifier. + /// - `workspace_root`: Canonical workspace root for host capability confinement. + /// + /// # Returns + /// - `Ok(SpawnConfig)` resolved spawn settings for a VCS backend runtime. + /// - `Err(String)` when plugin is disabled, missing runtime module, or is not a VCS backend. + pub fn vcs_spawn_for_workspace_with_config( + &self, + cfg: &AppConfig, + plugin_id: &str, + workspace_root: PathBuf, + ) -> Result { + let spec = self.resolve_module_runtime_spec(plugin_id, Some(workspace_root))?; + if !cfg.is_plugin_enabled(&spec.plugin_id, spec.default_enabled) { + return Err(format!("plugin `{}` is disabled", spec.plugin_id)); + } + if !spec.spawn.is_vcs_backend { + return Err(format!("plugin `{}` is not a VCS backend", spec.plugin_id)); + } + Ok(spec.spawn) + } + + /// Starts or reuses a runtime for a resolved plugin runtime spec. + fn start_plugin_spec(&self, spec: ModuleRuntimeSpec) -> Result<(), String> { + trace!( + "start_plugin_spec: key='{}', workspace_root={:?}", + spec.key, + spec.spawn.allowed_workspace_root + ); + + if let Some(existing) = self.processes.lock().get(&spec.key) { + debug!( + "start_plugin_spec: found existing runtime for key='{}'", + spec.key + ); + if existing.workspace_root == spec.spawn.allowed_workspace_root { + trace!("start_plugin_spec: reusing existing runtime with matching workspace"); + return existing.runtime.ensure_running(); + } + debug!("start_plugin_spec: workspace mismatch, will replace runtime"); + } + + trace!("start_plugin_spec: creating new instance"); + let instance = self.create_instance(&spec)?; + debug!("start_plugin_spec: instance created, ensuring running"); + instance.ensure_running()?; + + let mut lock = self.processes.lock(); + if let Some(existing) = lock.get(&spec.key) { + if existing.workspace_root == spec.spawn.allowed_workspace_root { + let runtime = Arc::clone(&existing.runtime); + drop(lock); + trace!("start_plugin_spec: found concurrent insert, reusing"); + return runtime.ensure_running(); + } + } + + let runtime_to_stop = lock + .get(&spec.key) + .map(|existing| Arc::clone(&existing.runtime)); + if let Some(runtime) = runtime_to_stop { + debug!( + "start_plugin_spec: stopping old runtime for key='{}'", + spec.key + ); + runtime.stop(); + lock.remove(&spec.key); + } + + trace!( + "start_plugin_spec: inserting new runtime for key='{}'", + spec.key + ); + lock.insert( + spec.key, + RunningPlugin { + runtime: instance, + workspace_root: spec.spawn.allowed_workspace_root.clone(), + }, + ); + Ok(()) + } + + /// Creates a runtime instance for a resolved plugin spec. + fn create_instance( + &self, + spec: &ModuleRuntimeSpec, + ) -> Result, String> { + create_runtime_instance(spec.spawn.clone()) + } + + /// Resolves a plugin id into a module runtime specification. + fn resolve_module_runtime_spec( + &self, + plugin_id: &str, + allowed_workspace_root: Option, + ) -> Result { + trace!( + "resolve_module_runtime_spec: plugin_id='{}', workspace_root={:?}", + plugin_id, + allowed_workspace_root + ); + + let requested = plugin_id.trim(); + debug!("resolve_module_runtime_spec: trimmed='{}'", requested); + + if requested.is_empty() { + return Err("plugin id is empty".to_string()); + } + + trace!("resolve_module_runtime_spec: calling find_components"); + let components = self.find_components(requested)?; + debug!( + "resolve_module_runtime_spec: found components for plugin_id='{}', has_module={}", + components.plugin_id, + components.module.is_some() + ); + + trace!("resolve_module_runtime_spec: checking module component"); + let module = match &components.module { + Some(m) => { + debug!( + "resolve_module_runtime_spec: module exec_path='{}'", + m.exec_path.display() + ); + m + } + None => { + warn!( + "resolve_module_runtime_spec: plugin '{}' has NO module component", + components.plugin_id + ); + return Err("plugin has no module component".to_string()); + } + }; + + trace!("resolve_module_runtime_spec: getting installed plugin info"); + let installed = self + .store + .get_current_installed(&components.plugin_id)? + .ok_or_else(|| "plugin is not installed".to_string())?; + debug!( + "resolve_module_runtime_spec: installed approval={:?}", + installed.approval + ); + + if !matches!( + installed.approval, + crate::plugin_bundles::ApprovalState::Approved { .. } + ) { + return Err(format!( + "plugin '{}' is not approved to run", + components.plugin_id + )); + } + + let key = components.plugin_id.to_ascii_lowercase(); + debug!("resolve_module_runtime_spec: resolved key='{}'", key); + + trace!("resolve_module_runtime_spec: building ModuleRuntimeSpec"); + let exec_path = module.exec_path.clone(); + let is_vcs_backend = !module.vcs_backends.is_empty(); + Ok(ModuleRuntimeSpec { + plugin_id: components.plugin_id.clone(), + key, + default_enabled: components.default_enabled, + spawn: SpawnConfig { + plugin_id: components.plugin_id, + exec_path, + allowed_workspace_root, + is_vcs_backend, + }, + }) + } + + /// Finds installed plugin components by plugin id (case-insensitive). + fn find_components(&self, plugin_id: &str) -> Result { + trace!("find_components: plugin_id='{}'", plugin_id); + let normalized = normalize_plugin_key(plugin_id)?; + match self.store.load_current_components(&normalized)? { + Some(comp) => { + debug!("find_components: matched plugin_id='{}'", comp.plugin_id); + Ok(comp) + } + None => { + warn!("find_components: no plugin found matching '{}' (plugin may exist but has no current version)", plugin_id); + Err("plugin has no current version".to_string()) + } + } + } +} + +impl Drop for PluginRuntimeManager { + /// Stops all runtimes when the manager is dropped. + fn drop(&mut self) { + let running = std::mem::take(&mut *self.processes.get_mut()); + for (_, process) in running { + process.runtime.stop(); + } + } +} + +/// Normalizes plugin ids to process map keys. +fn normalize_plugin_key(plugin_id: &str) -> Result { + trace!("normalize_plugin_key: input='{}'", plugin_id); + let plugin_id = plugin_id.trim().to_ascii_lowercase(); + if plugin_id.is_empty() { + return Err("plugin id is empty".to_string()); + } + debug!("normalize_plugin_key: output='{}'", plugin_id); + Ok(plugin_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin_bundles::{ApprovalState, CurrentPointer, InstalledPluginIndex}; + use crate::plugin_bundles::{InstalledPluginVersion, PluginBundleStore}; + use std::collections::BTreeMap; + use std::fs; + use tempfile::tempdir; + + const MINIMAL_NODE_MODULE: &str = "export {};\n"; + + #[test] + /// Verifies repeated start/stop calls keep runtime state stable. + fn start_and_stop_are_idempotent() { + let temp = tempdir().expect("tempdir"); + write_plugin(temp.path(), "test.plugin", true); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + manager.start_plugin("test.plugin").expect("start"); + manager.start_plugin("TEST.PLUGIN").expect("start twice"); + assert_eq!(manager.processes.lock().len(), 1); + + manager.stop_plugin("test.plugin").expect("stop"); + manager.stop_plugin("test.plugin").expect("stop twice"); + assert!(manager.processes.lock().is_empty()); + } + + #[test] + /// Verifies sync starts and stops plugins according to config toggles. + fn sync_tracks_enabled_state() { + let temp = tempdir().expect("tempdir"); + write_plugin(temp.path(), "alpha.plugin", true); + write_plugin(temp.path(), "beta.plugin", false); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let mut cfg = AppConfig::default(); + cfg.plugins.disabled.clear(); + cfg.plugins.enabled.clear(); + + manager + .sync_plugin_runtime_with_config(&cfg) + .expect("initial sync"); + assert!(manager.processes.lock().contains_key("alpha.plugin")); + assert!(!manager.processes.lock().contains_key("beta.plugin")); + + cfg.plugins.disabled = vec!["alpha.plugin".into()]; + cfg.plugins.enabled = vec!["beta.plugin".into()]; + cfg.validate(); + + manager + .sync_plugin_runtime_with_config(&cfg) + .expect("second sync"); + assert!(!manager.processes.lock().contains_key("alpha.plugin")); + assert!(manager.processes.lock().contains_key("beta.plugin")); + } + + #[test] + /// Verifies runtime start rejects plugins without module components. + fn start_plugin_rejects_plugins_without_module_component() { + let temp = tempdir().expect("tempdir"); + write_non_runtime_plugin(temp.path(), "themes.plugin", true); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let err = manager + .start_plugin("themes.plugin") + .expect_err("expected missing module error"); + assert!(err.contains("plugin has no module component")); + } + + #[test] + /// Verifies sync ignores non-runtime plugins without module components. + fn sync_ignores_plugins_without_module_component() { + let temp = tempdir().expect("tempdir"); + write_plugin(temp.path(), "runtime.plugin", true); + write_non_runtime_plugin(temp.path(), "themes.plugin", true); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let cfg = AppConfig::default(); + manager + .sync_plugin_runtime_with_config(&cfg) + .expect("sync succeeds"); + + let running = manager.processes.lock(); + assert!(running.contains_key("runtime.plugin")); + assert!(!running.contains_key("themes.plugin")); + } + + #[test] + /// Verifies startup sync does not eagerly start VCS backend runtimes. + fn sync_does_not_autostart_vcs_backend_plugins() { + let temp = tempdir().expect("tempdir"); + write_vcs_plugin(temp.path(), "git.plugin", true); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let cfg = AppConfig::default(); + manager + .sync_plugin_runtime_with_config(&cfg) + .expect("sync succeeds"); + + let running = manager.processes.lock(); + assert!(!running.contains_key("git.plugin")); + } + + #[test] + /// Verifies VCS spawn resolution includes workspace confinement. + fn vcs_spawn_resolution_sets_workspace_root() { + let temp = tempdir().expect("tempdir"); + write_vcs_plugin(temp.path(), "git.plugin", true); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let cfg = AppConfig::default(); + let workspace_root = temp.path().join("repo"); + std::fs::create_dir_all(&workspace_root).expect("create repo root"); + + let spawn = manager + .vcs_spawn_for_workspace_with_config(&cfg, "git.plugin", workspace_root.clone()) + .expect("resolve vcs spawn"); + + assert!(spawn.is_vcs_backend); + assert_eq!( + spawn.allowed_workspace_root.as_deref(), + Some(workspace_root.as_path()) + ); + } + + #[test] + /// Verifies spawn config marks VCS backend plugins using manifest data. + fn resolve_spec_sets_vcs_backend_flag_from_manifest() { + let temp = tempdir().expect("tempdir"); + write_plugin(temp.path(), "utility.plugin", true); + write_vcs_plugin(temp.path(), "git.plugin", true); + + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let utility = manager + .resolve_module_runtime_spec("utility.plugin", None) + .expect("resolve utility plugin"); + assert!(!utility.spawn.is_vcs_backend); + + let vcs = manager + .resolve_module_runtime_spec("git.plugin", None) + .expect("resolve vcs plugin"); + assert!(vcs.spawn.is_vcs_backend); + } + + #[test] + /// Verifies enabling a non-runtime plugin does not depend on other plugins. + fn enabling_non_runtime_plugin_ignores_unrelated_invalid_plugin() { + let temp = tempdir().expect("tempdir"); + write_non_runtime_plugin(temp.path(), "themes.plugin", false); + write_invalid_runtime_plugin(temp.path(), "broken.plugin"); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + manager + .set_plugin_enabled("themes.plugin", true) + .expect("enable themes plugin"); + } + + /// Writes a minimal module-capable plugin layout into a temp store. + fn write_plugin(root: &std::path::Path, plugin_id: &str, default_enabled: bool) { + write_plugin_with_backends(root, plugin_id, default_enabled, false); + } + + /// Writes a minimal VCS-backend plugin layout into a temp store. + fn write_vcs_plugin(root: &std::path::Path, plugin_id: &str, default_enabled: bool) { + write_plugin_with_backends(root, plugin_id, default_enabled, true); + } + + /// Writes a minimal module-capable plugin layout with optional VCS backends. + fn write_plugin_with_backends( + root: &std::path::Path, + plugin_id: &str, + default_enabled: bool, + include_vcs_backends: bool, + ) { + let plugin_dir = root.join(plugin_id); + fs::create_dir_all(plugin_dir.join("bin")).expect("create plugin dir"); + fs::write( + plugin_dir.join("bin").join("plugin.mjs"), + MINIMAL_NODE_MODULE, + ) + .expect("write node module"); + + let vcs_backends = if include_vcs_backends { + vec![serde_json::json!({ "id": "git", "name": "Git" })] + } else { + Vec::new() + }; + + let manifest = serde_json::json!({ + "id": plugin_id, + "name": "Test Plugin", + "version": "1.0.0", + "default_enabled": default_enabled, + "module": { + "exec": "plugin.mjs", + "vcs_backends": vcs_backends + } + }); + fs::write( + plugin_dir.join("openvcs.plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let mut versions = BTreeMap::new(); + versions.insert( + "1.0.0".to_string(), + InstalledPluginVersion { + version: "1.0.0".to_string(), + bundle_sha256: "sha".to_string(), + installed_at_unix_ms: 0, + requested_capabilities: Vec::new(), + approval: ApprovalState::Approved { + capabilities: Vec::new(), + approved_at_unix_ms: 0, + }, + }, + ); + let index = InstalledPluginIndex { + plugin_id: plugin_id.to_string(), + current: Some("1.0.0".to_string()), + versions, + }; + fs::write( + plugin_dir.join("index.json"), + serde_json::to_vec_pretty(&index).expect("serialize index"), + ) + .expect("write index"); + + let current = CurrentPointer { + version: "1.0.0".to_string(), + }; + fs::write( + plugin_dir.join("current.json"), + serde_json::to_vec_pretty(¤t).expect("serialize current"), + ) + .expect("write current"); + } + + /// Writes a plugin layout with manifest/index but no runtime module. + fn write_non_runtime_plugin(root: &std::path::Path, plugin_id: &str, default_enabled: bool) { + let plugin_dir = root.join(plugin_id); + fs::create_dir_all(&plugin_dir).expect("create plugin dir"); + + let manifest = serde_json::json!({ + "id": plugin_id, + "name": "Theme Plugin", + "version": "1.0.0", + "default_enabled": default_enabled + }); + fs::write( + plugin_dir.join("openvcs.plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let mut versions = BTreeMap::new(); + versions.insert( + "1.0.0".to_string(), + InstalledPluginVersion { + version: "1.0.0".to_string(), + bundle_sha256: "sha".to_string(), + installed_at_unix_ms: 0, + requested_capabilities: Vec::new(), + approval: ApprovalState::Approved { + capabilities: Vec::new(), + approved_at_unix_ms: 0, + }, + }, + ); + let index = InstalledPluginIndex { + plugin_id: plugin_id.to_string(), + current: Some("1.0.0".to_string()), + versions, + }; + fs::write( + plugin_dir.join("index.json"), + serde_json::to_vec_pretty(&index).expect("serialize index"), + ) + .expect("write index"); + + let current = CurrentPointer { + version: "1.0.0".to_string(), + }; + fs::write( + plugin_dir.join("current.json"), + serde_json::to_vec_pretty(¤t).expect("serialize current"), + ) + .expect("write current"); + } + + /// Writes a plugin with an invalid runtime module entrypoint extension. + fn write_invalid_runtime_plugin(root: &std::path::Path, plugin_id: &str) { + let plugin_dir = root.join(plugin_id); + fs::create_dir_all(&plugin_dir).expect("create plugin dir"); + + let manifest = serde_json::json!({ + "id": plugin_id, + "name": "Broken Plugin", + "version": "1.0.0", + "default_enabled": false, + "module": { + "exec": "broken.wasm", + "vcs_backends": [] + } + }); + fs::write( + plugin_dir.join("openvcs.plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let mut versions = BTreeMap::new(); + versions.insert( + "1.0.0".to_string(), + InstalledPluginVersion { + version: "1.0.0".to_string(), + bundle_sha256: "sha".to_string(), + installed_at_unix_ms: 0, + requested_capabilities: Vec::new(), + approval: ApprovalState::Approved { + capabilities: Vec::new(), + approved_at_unix_ms: 0, + }, + }, + ); + let index = InstalledPluginIndex { + plugin_id: plugin_id.to_string(), + current: Some("1.0.0".to_string()), + versions, + }; + fs::write( + plugin_dir.join("index.json"), + serde_json::to_vec_pretty(&index).expect("serialize index"), + ) + .expect("write index"); + + let current = CurrentPointer { + version: "1.0.0".to_string(), + }; + fs::write( + plugin_dir.join("current.json"), + serde_json::to_vec_pretty(¤t).expect("serialize current"), + ) + .expect("write current"); + } +} diff --git a/Backend/src/plugin_runtime/mod.rs b/Backend/src/plugin_runtime/mod.rs index 3137e468..fb454c5a 100644 --- a/Backend/src/plugin_runtime/mod.rs +++ b/Backend/src/plugin_runtime/mod.rs @@ -1,3 +1,29 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +//! Plugin runtime subsystem modules. +//! +//! These modules provide plugin process lifecycle management, +//! host API bridging, and backend proxy adapters. +/// In-memory plugin event subscription registry. pub mod events; -pub mod stdio_rpc; +/// Host functions exposed to plugin modules. +pub mod host_api; +/// Runtime instance trait used by the manager. +pub mod instance; +/// Long-lived plugin runtime lifecycle manager. +pub mod manager; +/// Node.js runtime implementation and JSON-RPC client. +pub mod node_instance; +/// JSON-RPC protocol constants and framing helpers. +pub mod protocol; +/// Runtime transport selection and factory helpers. +pub mod runtime_select; +/// Plugin settings persistence helpers. +pub mod settings_store; +/// Runtime spawn configuration types. +pub mod spawn; +/// `Vcs` trait adapter backed by plugin runtime RPC. pub mod vcs_proxy; + +/// Re-exported runtime manager type used by application state. +pub use manager::PluginRuntimeManager; diff --git a/Backend/src/plugin_runtime/node_instance.rs b/Backend/src/plugin_runtime/node_instance.rs new file mode 100644 index 00000000..befc025b --- /dev/null +++ b/Backend/src/plugin_runtime/node_instance.rs @@ -0,0 +1,900 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +//! Node.js plugin runtime implementation. +//! +//! This runtime spawns long-lived Node processes and exchanges JSON-RPC 2.0 +//! messages over stdio using an LSP-style framing protocol. + +use crate::plugin_paths; +use crate::plugin_runtime::events; +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::protocol::{ + read_framed_message, write_framed_message, Methods, NotificationMethods, RpcError, RpcRequest, + RpcResponse, PROTOCOL_VERSION, +}; +use crate::plugin_runtime::spawn::SpawnConfig; +use base64::Engine; +use log::{debug, info, trace, warn}; +use openvcs_core::models::{ + Capabilities, ConflictDetails, ConflictSide, FetchOptions, LogQuery, StashItem, StatusPayload, + StatusSummary, VcsEvent, +}; +use openvcs_core::settings::SettingKv; +use openvcs_core::ui::Menu; +use parking_lot::{Mutex, RwLock}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::io::BufReader; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::Arc; + +/// Live stdio-backed JSON-RPC process handle. +struct NodeRpcProcess { + /// Child process hosting the plugin runtime. + child: Child, + /// Writable stdin stream for requests. + stdin: ChildStdin, + /// Readable stdout stream for responses and notifications. + stdout: BufReader, + /// Monotonic request id counter. + next_request_id: u64, +} + +impl NodeRpcProcess { + /// Sends one JSON-RPC request and waits for a matching response. + /// + /// # Parameters + /// - `method`: RPC method name. + /// - `params`: RPC params payload. + /// - `plugin_id`: Plugin id for diagnostics. + /// - `on_notification`: Callback for incoming notifications. + /// + /// # Returns + /// - `Ok(T)` decoded response result. + /// - `Err(String)` when transport/protocol/plugin errors occur. + fn call( + &mut self, + method: &str, + params: Value, + plugin_id: &str, + on_notification: &mut dyn FnMut(&str, &Value) -> Result<(), String>, + ) -> Result + where + T: DeserializeOwned, + { + let request_id = self.next_request_id; + self.next_request_id = self + .next_request_id + .checked_add(1) + .ok_or_else(|| "rpc request id overflow".to_string())?; + + let request = RpcRequest { + jsonrpc: "2.0".to_string(), + id: request_id, + method: method.to_string(), + params, + }; + let request_value = + serde_json::to_value(request).map_err(|e| format!("encode rpc request: {e}"))?; + write_framed_message(&mut self.stdin, &request_value)?; + + loop { + let message = read_framed_message(&mut self.stdout)?; + + if let Some(method_name) = message.get("method").and_then(Value::as_str) { + let params = message.get("params").cloned().unwrap_or(Value::Null); + on_notification(method_name, ¶ms)?; + continue; + } + + let response: RpcResponse = serde_json::from_value(message) + .map_err(|e| format!("decode rpc response for '{method}': {e}"))?; + if response.id != request_id { + debug!( + "node rpc: ignoring out-of-order response id={} for method='{}' (expected={})", + response.id, method, request_id + ); + continue; + } + + if let Some(error) = response.error { + return Err(format_rpc_error(plugin_id, method, &error)); + } + + let result = response.result.unwrap_or(Value::Null); + return serde_json::from_value(result) + .map_err(|e| format!("decode rpc result for '{method}': {e}")); + } + } +} + +/// Parsed plugin initialize response payload. +#[derive(Debug, Deserialize)] +struct InitializeResponse { + /// Protocol version accepted by plugin. + protocol_version: u32, +} + +/// Parsed session-open response payload. +#[derive(Debug, Deserialize)] +struct OpenSessionResponse { + /// Stable plugin-owned session id. + session_id: String, +} + +/// Parsed identity response payload. +#[derive(Debug, Deserialize)] +struct IdentityResponse { + /// User name. + name: String, + /// User email. + email: String, +} + +/// Parsed remote descriptor response payload. +#[derive(Debug, Deserialize)] +struct RemoteEntry { + /// Remote name. + name: String, + /// Remote URL. + url: String, +} + +/// Node runtime implementation backing plugin RPC calls. +pub struct NodePluginRuntimeInstance { + /// Spawn configuration for this runtime instance. + spawn: SpawnConfig, + /// Lazily started plugin process. + process: Mutex>, + /// Active VCS session id for backend plugins. + vcs_session_id: Mutex>, + /// Optional sink for VCS progress events. + event_sink: RwLock>>, +} + +impl NodePluginRuntimeInstance { + /// Creates a node runtime instance for the given spawn config. + /// + /// # Parameters + /// - `spawn`: Runtime spawn details. + /// + /// # Returns + /// - New runtime instance. + pub fn new(spawn: SpawnConfig) -> Self { + Self { + spawn, + process: Mutex::new(None), + vcs_session_id: Mutex::new(None), + event_sink: RwLock::new(None), + } + } + + /// Resolves the bundled Node executable path used to launch plugins. + /// + /// # Returns + /// - `Ok(String)` with absolute path to bundled node runtime. + /// - `Err(String)` when bundled runtime path is unavailable. + fn node_executable(&self) -> Result { + let Some(path) = plugin_paths::node_executable_path() else { + return Err( + "bundled node runtime is unavailable; plugin execution requires app-bundled node" + .to_string(), + ); + }; + Ok(path.display().to_string()) + } + + /// Starts the plugin process and performs initialization handshake. + /// + /// # Returns + /// - `Ok(NodeRpcProcess)` when startup succeeds. + /// - `Err(String)` when process startup or init RPC fails. + fn spawn_process(&self) -> Result { + let node_exec = self.node_executable()?; + info!( + "plugin runtime: starting node plugin '{}' via '{}'", + self.spawn.plugin_id, node_exec + ); + + let mut cmd = Command::new(&node_exec); + cmd.arg(&self.spawn.exec_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .env("OPENVCS_PLUGIN_ID", self.spawn.plugin_id.trim()); + + let mut child = cmd.spawn().map_err(|e| { + format!( + "spawn node runtime '{}': {e}", + self.spawn.exec_path.display() + ) + })?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| "node runtime missing stdin pipe".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "node runtime missing stdout pipe".to_string())?; + + let mut process = NodeRpcProcess { + child, + stdin, + stdout: BufReader::new(stdout), + next_request_id: 1, + }; + + let initialize: InitializeResponse = process.call( + Methods::PLUGIN_INITIALIZE, + json!({ + "plugin_id": self.spawn.plugin_id, + "protocol_version": PROTOCOL_VERSION, + }), + self.spawn.plugin_id.as_str(), + &mut |method, params| { + self.handle_notification(method, params); + Ok(()) + }, + )?; + if initialize.protocol_version != PROTOCOL_VERSION { + return Err(format!( + "plugin '{}' protocol mismatch: expected {}, got {}", + self.spawn.plugin_id, PROTOCOL_VERSION, initialize.protocol_version + )); + } + + let _: Value = process.call( + Methods::PLUGIN_INIT, + Value::Object(serde_json::Map::new()), + self.spawn.plugin_id.as_str(), + &mut |method, params| { + self.handle_notification(method, params); + Ok(()) + }, + )?; + + Ok(process) + } + + /// Ensures a process exists and runs a closure against it. + /// + /// # Parameters + /// - `f`: Closure to execute with mutable process access. + /// + /// # Returns + /// - Closure return value. + fn with_process( + &self, + f: impl FnOnce(&mut NodeRpcProcess) -> Result, + ) -> Result { + let mut lock = self.process.lock(); + if lock.is_none() { + *lock = Some(self.spawn_process()?); + } + let process = lock + .as_mut() + .ok_or_else(|| "node runtime did not initialize".to_string())?; + f(process) + } + + /// Sends one RPC request to the plugin process. + /// + /// # Parameters + /// - `method`: Method name. + /// - `params`: Params object. + /// + /// # Returns + /// - Decoded result value. + fn rpc_call(&self, method: &str, params: Value) -> Result + where + T: DeserializeOwned, + { + self.with_process(|process| { + process.call( + method, + params, + self.spawn.plugin_id.as_str(), + &mut |notif_method, notif_params| { + self.handle_notification(notif_method, notif_params); + Ok(()) + }, + ) + }) + } + + /// Sends one RPC request to the plugin process where result is ignored. + /// + /// # Parameters + /// - `method`: Method name. + /// - `params`: Params object. + /// + /// # Returns + /// - `Ok(())` when request succeeds. + fn rpc_call_unit(&self, method: &str, params: Value) -> Result<(), String> { + let _: Value = self.rpc_call(method, params)?; + Ok(()) + } + + /// Builds a session-scoped RPC params object. + /// + /// # Parameters + /// - `extra`: Additional method parameters. + /// + /// # Returns + /// - Session-scoped params object. + fn session_params(&self, extra: Value) -> Result { + let session_id = self + .vcs_session_id + .lock() + .clone() + .ok_or_else(|| "vcs session is not open".to_string())?; + + let mut map = serde_json::Map::new(); + map.insert("session_id".to_string(), Value::String(session_id)); + if let Value::Object(extra_map) = extra { + for (key, value) in extra_map { + map.insert(key, value); + } + } + Ok(Value::Object(map)) + } + + /// Handles one plugin-originated notification. + /// + /// # Parameters + /// - `method`: Notification method. + /// - `params`: Notification payload. + fn handle_notification(&self, method: &str, params: &Value) { + match method { + NotificationMethods::HOST_LOG => { + let level = params + .get("level") + .and_then(Value::as_str) + .unwrap_or("info") + .to_ascii_lowercase(); + let target = params + .get("target") + .and_then(Value::as_str) + .unwrap_or("plugin") + .trim(); + let message = params + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + match level.as_str() { + "trace" => log::trace!(target: target, "{}", message), + "debug" => log::debug!(target: target, "{}", message), + "warn" => log::warn!(target: target, "{}", message), + "error" => log::error!(target: target, "{}", message), + _ => log::info!(target: target, "{}", message), + } + } + NotificationMethods::HOST_UI_NOTIFY => { + let message = params + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + if !message.is_empty() { + info!("plugin ui notify ({}): {}", self.spawn.plugin_id, message); + } + } + NotificationMethods::HOST_STATUS_SET => { + let message = params + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + crate::plugin_runtime::host_api::set_status_text_unchecked(message); + } + NotificationMethods::HOST_EVENT_EMIT => { + let event_name = params + .get("event_name") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + if event_name.is_empty() { + return; + } + let payload = params.get("payload").cloned().unwrap_or(Value::Null); + events::emit_from_plugin(self.spawn.plugin_id.as_str(), event_name, payload); + } + NotificationMethods::VCS_EVENT => { + let Some(raw_event) = params.get("event") else { + return; + }; + let event: VcsEvent = match serde_json::from_value(raw_event.clone()) { + Ok(value) => value, + Err(err) => { + warn!( + "plugin '{}' emitted invalid vcs.event payload: {}", + self.spawn.plugin_id, err + ); + return; + } + }; + if let Some(sink) = self.event_sink.read().clone() { + sink(event); + } + } + other => { + trace!( + "plugin '{}' emitted unknown notification '{}'", + self.spawn.plugin_id, + other + ); + } + } + } + + /// Closes an active VCS session when present. + fn close_vcs_session(&self) { + let session_id = self.vcs_session_id.lock().take(); + if let Some(session_id) = session_id { + let _ = self.rpc_call_unit(Methods::VCS_CLOSE, json!({ "session_id": session_id })); + } + } + + /// Calls `vcs.get-caps` and returns backend capability flags. + pub fn vcs_get_caps(&self) -> Result { + self.rpc_call(Methods::VCS_GET_CAPS, Value::Object(serde_json::Map::new())) + } + + /// Calls `vcs.open` and stores active session id. + /// + /// # Parameters + /// - `path`: Repository workdir path. + /// - `config`: Serialized open config JSON bytes. + pub fn vcs_open(&self, path: &str, config: &[u8]) -> Result<(), String> { + let config_value = if config.is_empty() { + Value::Object(serde_json::Map::new()) + } else { + serde_json::from_slice(config).unwrap_or_else(|_| Value::Object(serde_json::Map::new())) + }; + let result: OpenSessionResponse = self.rpc_call( + Methods::VCS_OPEN, + json!({ + "path": path, + "config": config_value, + }), + )?; + *self.vcs_session_id.lock() = Some(result.session_id); + Ok(()) + } + + /// Calls `vcs.get-current-branch`. + pub fn vcs_get_current_branch(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_GET_CURRENT_BRANCH, params) + } + + /// Calls `vcs.list-branches`. + pub fn vcs_list_branches(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_LIST_BRANCHES, params) + } + + /// Calls `vcs.list-local-branches`. + pub fn vcs_list_local_branches(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_LIST_LOCAL_BRANCHES, params) + } + + /// Calls `vcs.create-branch`. + pub fn vcs_create_branch(&self, name: &str, checkout: bool) -> Result<(), String> { + let params = self.session_params(json!({ "name": name, "checkout": checkout }))?; + self.rpc_call_unit(Methods::VCS_CREATE_BRANCH, params) + } + + /// Calls `vcs.checkout-branch`. + pub fn vcs_checkout_branch(&self, name: &str) -> Result<(), String> { + let params = self.session_params(json!({ "name": name }))?; + self.rpc_call_unit(Methods::VCS_CHECKOUT_BRANCH, params) + } + + /// Calls `vcs.ensure-remote`. + pub fn vcs_ensure_remote(&self, name: &str, url: &str) -> Result<(), String> { + let params = self.session_params(json!({ "name": name, "url": url }))?; + self.rpc_call_unit(Methods::VCS_ENSURE_REMOTE, params) + } + + /// Calls `vcs.list-remotes`. + pub fn vcs_list_remotes(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + let entries: Vec = self.rpc_call(Methods::VCS_LIST_REMOTES, params)?; + Ok(entries + .into_iter() + .map(|entry| (entry.name, entry.url)) + .collect()) + } + + /// Calls `vcs.remove-remote`. + pub fn vcs_remove_remote(&self, name: &str) -> Result<(), String> { + let params = self.session_params(json!({ "name": name }))?; + self.rpc_call_unit(Methods::VCS_REMOVE_REMOTE, params) + } + + /// Calls `vcs.fetch`. + pub fn vcs_fetch(&self, remote: &str, refspec: &str) -> Result<(), String> { + let params = self.session_params(json!({ "remote": remote, "refspec": refspec }))?; + self.rpc_call_unit(Methods::VCS_FETCH, params) + } + + /// Calls `vcs.fetch-with-options`. + pub fn vcs_fetch_with_options( + &self, + remote: &str, + refspec: &str, + opts: FetchOptions, + ) -> Result<(), String> { + let params = self.session_params(json!({ + "remote": remote, + "refspec": refspec, + "opts": opts, + }))?; + self.rpc_call_unit(Methods::VCS_FETCH_WITH_OPTIONS, params) + } + + /// Calls `vcs.push`. + pub fn vcs_push(&self, remote: &str, refspec: &str) -> Result<(), String> { + let params = self.session_params(json!({ "remote": remote, "refspec": refspec }))?; + self.rpc_call_unit(Methods::VCS_PUSH, params) + } + + /// Calls `vcs.pull-ff-only`. + pub fn vcs_pull_ff_only(&self, remote: &str, branch: &str) -> Result<(), String> { + let params = self.session_params(json!({ "remote": remote, "branch": branch }))?; + self.rpc_call_unit(Methods::VCS_PULL_FF_ONLY, params) + } + + /// Calls `vcs.commit`. + pub fn vcs_commit( + &self, + message: &str, + name: &str, + email: &str, + paths: &[String], + ) -> Result { + let params = self.session_params(json!({ + "message": message, + "name": name, + "email": email, + "paths": paths, + }))?; + self.rpc_call(Methods::VCS_COMMIT, params) + } + + /// Calls `vcs.commit-index`. + pub fn vcs_commit_index( + &self, + message: &str, + name: &str, + email: &str, + ) -> Result { + let params = self.session_params(json!({ + "message": message, + "name": name, + "email": email, + }))?; + self.rpc_call(Methods::VCS_COMMIT_INDEX, params) + } + + /// Calls `vcs.get-status-summary`. + pub fn vcs_get_status_summary(&self) -> Result { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_GET_STATUS_SUMMARY, params) + } + + /// Calls `vcs.get-status-payload`. + pub fn vcs_get_status_payload(&self) -> Result { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_GET_STATUS_PAYLOAD, params) + } + + /// Calls `vcs.list-commits`. + pub fn vcs_list_commits( + &self, + query: &LogQuery, + ) -> Result, String> { + let params = self.session_params(json!({ "query": query }))?; + self.rpc_call(Methods::VCS_LIST_COMMITS, params) + } + + /// Calls `vcs.diff-file`. + pub fn vcs_diff_file(&self, path: &str) -> Result, String> { + let params = self.session_params(json!({ "path": path }))?; + self.rpc_call(Methods::VCS_DIFF_FILE, params) + } + + /// Calls `vcs.diff-commit`. + pub fn vcs_diff_commit(&self, rev: &str) -> Result, String> { + let params = self.session_params(json!({ "rev": rev }))?; + self.rpc_call(Methods::VCS_DIFF_COMMIT, params) + } + + /// Calls `vcs.get-conflict-details`. + pub fn vcs_get_conflict_details(&self, path: &str) -> Result { + let params = self.session_params(json!({ "path": path }))?; + self.rpc_call(Methods::VCS_GET_CONFLICT_DETAILS, params) + } + + /// Calls `vcs.checkout-conflict-side`. + pub fn vcs_checkout_conflict_side(&self, path: &str, side: ConflictSide) -> Result<(), String> { + let params = self.session_params(json!({ "path": path, "side": side }))?; + self.rpc_call_unit(Methods::VCS_CHECKOUT_CONFLICT_SIDE, params) + } + + /// Calls `vcs.write-merge-result`. + pub fn vcs_write_merge_result(&self, path: &str, content: &[u8]) -> Result<(), String> { + let content_b64 = base64::engine::general_purpose::STANDARD.encode(content); + let params = self.session_params(json!({ + "path": path, + "content_b64": content_b64, + }))?; + self.rpc_call_unit(Methods::VCS_WRITE_MERGE_RESULT, params) + } + + /// Calls `vcs.stage-patch`. + pub fn vcs_stage_patch(&self, patch: &str) -> Result<(), String> { + let params = self.session_params(json!({ "patch": patch }))?; + self.rpc_call_unit(Methods::VCS_STAGE_PATCH, params) + } + + /// Calls `vcs.discard-paths`. + pub fn vcs_discard_paths(&self, paths: &[String]) -> Result<(), String> { + let params = self.session_params(json!({ "paths": paths }))?; + self.rpc_call_unit(Methods::VCS_DISCARD_PATHS, params) + } + + /// Calls `vcs.apply-reverse-patch`. + pub fn vcs_apply_reverse_patch(&self, patch: &str) -> Result<(), String> { + let params = self.session_params(json!({ "patch": patch }))?; + self.rpc_call_unit(Methods::VCS_APPLY_REVERSE_PATCH, params) + } + + /// Calls `vcs.delete-branch`. + pub fn vcs_delete_branch(&self, name: &str, force: bool) -> Result<(), String> { + let params = self.session_params(json!({ "name": name, "force": force }))?; + self.rpc_call_unit(Methods::VCS_DELETE_BRANCH, params) + } + + /// Calls `vcs.rename-branch`. + pub fn vcs_rename_branch(&self, old: &str, new: &str) -> Result<(), String> { + let params = self.session_params(json!({ "old": old, "new": new }))?; + self.rpc_call_unit(Methods::VCS_RENAME_BRANCH, params) + } + + /// Calls `vcs.merge-into-current`. + pub fn vcs_merge_into_current(&self, name: &str, message: Option<&str>) -> Result<(), String> { + let params = self.session_params(json!({ "name": name, "message": message }))?; + self.rpc_call_unit(Methods::VCS_MERGE_INTO_CURRENT, params) + } + + /// Calls `vcs.merge-abort`. + pub fn vcs_merge_abort(&self) -> Result<(), String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call_unit(Methods::VCS_MERGE_ABORT, params) + } + + /// Calls `vcs.merge-continue`. + pub fn vcs_merge_continue(&self) -> Result<(), String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call_unit(Methods::VCS_MERGE_CONTINUE, params) + } + + /// Calls `vcs.is-merge-in-progress`. + pub fn vcs_is_merge_in_progress(&self) -> Result { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_IS_MERGE_IN_PROGRESS, params) + } + + /// Calls `vcs.set-branch-upstream`. + pub fn vcs_set_branch_upstream(&self, branch: &str, upstream: &str) -> Result<(), String> { + let params = self.session_params(json!({ "branch": branch, "upstream": upstream }))?; + self.rpc_call_unit(Methods::VCS_SET_BRANCH_UPSTREAM, params) + } + + /// Calls `vcs.get-branch-upstream`. + pub fn vcs_get_branch_upstream(&self, branch: &str) -> Result, String> { + let params = self.session_params(json!({ "branch": branch }))?; + self.rpc_call(Methods::VCS_GET_BRANCH_UPSTREAM, params) + } + + /// Calls `vcs.hard-reset-head`. + pub fn vcs_hard_reset_head(&self) -> Result<(), String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call_unit(Methods::VCS_HARD_RESET_HEAD, params) + } + + /// Calls `vcs.reset-soft-to`. + pub fn vcs_reset_soft_to(&self, rev: &str) -> Result<(), String> { + let params = self.session_params(json!({ "rev": rev }))?; + self.rpc_call_unit(Methods::VCS_RESET_SOFT_TO, params) + } + + /// Calls `vcs.get-identity`. + pub fn vcs_get_identity(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + let identity: Option = + self.rpc_call(Methods::VCS_GET_IDENTITY, params)?; + Ok(identity.map(|value| (value.name, value.email))) + } + + /// Calls `vcs.set-identity-local`. + pub fn vcs_set_identity_local(&self, name: &str, email: &str) -> Result<(), String> { + let params = self.session_params(json!({ "name": name, "email": email }))?; + self.rpc_call_unit(Methods::VCS_SET_IDENTITY_LOCAL, params) + } + + /// Calls `vcs.list-stashes`. + pub fn vcs_list_stashes(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_LIST_STASHES, params) + } + + /// Calls `vcs.stash-push`. + pub fn vcs_stash_push( + &self, + message: Option<&str>, + include_untracked: bool, + ) -> Result { + let params = self.session_params(json!({ + "message": message, + "include_untracked": include_untracked, + }))?; + self.rpc_call(Methods::VCS_STASH_PUSH, params) + } + + /// Calls `vcs.stash-apply`. + pub fn vcs_stash_apply(&self, selector: &str) -> Result<(), String> { + let params = self.session_params(json!({ "selector": selector }))?; + self.rpc_call_unit(Methods::VCS_STASH_APPLY, params) + } + + /// Calls `vcs.stash-pop`. + pub fn vcs_stash_pop(&self, selector: &str) -> Result<(), String> { + let params = self.session_params(json!({ "selector": selector }))?; + self.rpc_call_unit(Methods::VCS_STASH_POP, params) + } + + /// Calls `vcs.stash-drop`. + pub fn vcs_stash_drop(&self, selector: &str) -> Result<(), String> { + let params = self.session_params(json!({ "selector": selector }))?; + self.rpc_call_unit(Methods::VCS_STASH_DROP, params) + } + + /// Calls `vcs.stash-show`. + pub fn vcs_stash_show(&self, selector: &str) -> Result { + let params = self.session_params(json!({ "selector": selector }))?; + self.rpc_call(Methods::VCS_STASH_SHOW, params) + } + + /// Calls `vcs.cherry-pick`. + pub fn vcs_cherry_pick(&self, commit: &str) -> Result<(), String> { + let params = self.session_params(json!({ "commit": commit }))?; + self.rpc_call_unit(Methods::VCS_CHERRY_PICK, params) + } + + /// Calls `vcs.revert-commit`. + pub fn vcs_revert_commit(&self, commit: &str, no_edit: bool) -> Result<(), String> { + let params = self.session_params(json!({ "commit": commit, "no_edit": no_edit }))?; + self.rpc_call_unit(Methods::VCS_REVERT_COMMIT, params) + } +} + +impl PluginRuntimeInstance for NodePluginRuntimeInstance { + /// Ensures the underlying Node process is running. + fn ensure_running(&self) -> Result<(), String> { + self.with_process(|_| Ok(())) + } + + /// Calls `plugin.get-menus`. + fn get_menus(&self) -> Result, String> { + self.rpc_call( + Methods::PLUGIN_GET_MENUS, + Value::Object(serde_json::Map::new()), + ) + } + + /// Calls `plugin.handle-action`. + fn handle_action(&self, id: &str) -> Result<(), String> { + self.rpc_call_unit(Methods::PLUGIN_HANDLE_ACTION, json!({ "id": id })) + } + + /// Calls `plugin.settings-defaults`. + fn settings_defaults(&self) -> Result, String> { + self.rpc_call( + Methods::PLUGIN_SETTINGS_DEFAULTS, + Value::Object(serde_json::Map::new()), + ) + } + + /// Calls `plugin.settings-on-load`. + fn settings_on_load(&self, values: Vec) -> Result, String> { + self.rpc_call( + Methods::PLUGIN_SETTINGS_ON_LOAD, + json!({ "values": values }), + ) + } + + /// Calls `plugin.settings-on-apply`. + fn settings_on_apply(&self, values: Vec) -> Result<(), String> { + self.rpc_call_unit( + Methods::PLUGIN_SETTINGS_ON_APPLY, + json!({ "values": values }), + ) + } + + /// Calls `plugin.settings-on-save`. + fn settings_on_save(&self, values: Vec) -> Result, String> { + self.rpc_call( + Methods::PLUGIN_SETTINGS_ON_SAVE, + json!({ "values": values }), + ) + } + + /// Calls `plugin.settings-on-reset`. + fn settings_on_reset(&self) -> Result<(), String> { + self.rpc_call_unit( + Methods::PLUGIN_SETTINGS_ON_RESET, + Value::Object(serde_json::Map::new()), + ) + } + + /// Updates the optional VCS event sink. + fn set_event_sink(&self, sink: Option>) { + *self.event_sink.write() = sink; + } + + /// Stops the runtime and clears local session state. + fn stop(&self) { + self.close_vcs_session(); + + let process = self.process.lock().take(); + if let Some(mut process) = process { + let _ = process.call::( + Methods::PLUGIN_DEINIT, + Value::Object(serde_json::Map::new()), + self.spawn.plugin_id.as_str(), + &mut |method, params| { + self.handle_notification(method, params); + Ok(()) + }, + ); + + let _ = process.child.kill(); + let _ = process.child.wait(); + } + *self.vcs_session_id.lock() = None; + } +} + +impl Drop for NodePluginRuntimeInstance { + /// Ensures child process cleanup on drop. + fn drop(&mut self) { + if let Some(mut process) = self.process.get_mut().take() { + let _ = process.child.kill(); + let _ = process.child.wait(); + } + } +} + +/// Formats an RPC error payload into a user-facing message. +fn format_rpc_error(plugin_id: &str, method: &str, error: &RpcError) -> String { + let detail = error + .data + .as_ref() + .and_then(|value| value.get("message")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| error.message.trim()); + format!( + "plugin '{}' rpc '{}' failed (code {}): {}", + plugin_id, method, error.code, detail + ) +} diff --git a/Backend/src/plugin_runtime/protocol.rs b/Backend/src/plugin_runtime/protocol.rs new file mode 100644 index 00000000..3762eb07 --- /dev/null +++ b/Backend/src/plugin_runtime/protocol.rs @@ -0,0 +1,263 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +//! JSON-RPC protocol primitives for Node-based plugins. +//! +//! The Node plugin runtime uses JSON-RPC 2.0 messages framed with an +//! LSP-style `Content-Length` header over stdio. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{BufRead, Write}; + +/// Protocol version used by host and plugin at initialization. +pub const PROTOCOL_VERSION: u32 = 1; + +/// Host-to-plugin request method names. +pub struct Methods; + +#[allow(dead_code)] +impl Methods { + /// Performs runtime handshake and capability discovery. + pub const PLUGIN_INITIALIZE: &'static str = "plugin.initialize"; + /// Starts plugin lifecycle state. + pub const PLUGIN_INIT: &'static str = "plugin.init"; + /// Stops plugin lifecycle state. + pub const PLUGIN_DEINIT: &'static str = "plugin.deinit"; + /// Requests plugin-contributed menus. + pub const PLUGIN_GET_MENUS: &'static str = "plugin.get_menus"; + /// Invokes a plugin action by id. + pub const PLUGIN_HANDLE_ACTION: &'static str = "plugin.handle_action"; + /// Requests plugin settings defaults. + pub const PLUGIN_SETTINGS_DEFAULTS: &'static str = "plugin.settings.defaults"; + /// Invokes settings on-load callback. + pub const PLUGIN_SETTINGS_ON_LOAD: &'static str = "plugin.settings.on_load"; + /// Invokes settings on-apply callback. + pub const PLUGIN_SETTINGS_ON_APPLY: &'static str = "plugin.settings.on_apply"; + /// Invokes settings on-save callback. + pub const PLUGIN_SETTINGS_ON_SAVE: &'static str = "plugin.settings.on_save"; + /// Invokes settings on-reset callback. + pub const PLUGIN_SETTINGS_ON_RESET: &'static str = "plugin.settings.on_reset"; + + /// Returns backend capability flags. + pub const VCS_GET_CAPS: &'static str = "vcs.get_caps"; + /// Opens a repository and creates a VCS session. + pub const VCS_OPEN: &'static str = "vcs.open"; + /// Closes a VCS session. + pub const VCS_CLOSE: &'static str = "vcs.close"; + /// Clones a repository. + pub const VCS_CLONE_REPO: &'static str = "vcs.clone_repo"; + /// Returns session workdir. + pub const VCS_GET_WORKDIR: &'static str = "vcs.get_workdir"; + /// Returns current branch. + pub const VCS_GET_CURRENT_BRANCH: &'static str = "vcs.get_current_branch"; + /// Lists branches. + pub const VCS_LIST_BRANCHES: &'static str = "vcs.list_branches"; + /// Lists local branch names. + pub const VCS_LIST_LOCAL_BRANCHES: &'static str = "vcs.list_local_branches"; + /// Creates a branch. + pub const VCS_CREATE_BRANCH: &'static str = "vcs.create_branch"; + /// Checks out a branch. + pub const VCS_CHECKOUT_BRANCH: &'static str = "vcs.checkout_branch"; + /// Creates or updates a remote. + pub const VCS_ENSURE_REMOTE: &'static str = "vcs.ensure_remote"; + /// Lists remotes. + pub const VCS_LIST_REMOTES: &'static str = "vcs.list_remotes"; + /// Removes a remote. + pub const VCS_REMOVE_REMOTE: &'static str = "vcs.remove_remote"; + /// Performs fetch. + pub const VCS_FETCH: &'static str = "vcs.fetch"; + /// Performs fetch with options. + pub const VCS_FETCH_WITH_OPTIONS: &'static str = "vcs.fetch_with_options"; + /// Performs push. + pub const VCS_PUSH: &'static str = "vcs.push"; + /// Performs fast-forward-only pull. + pub const VCS_PULL_FF_ONLY: &'static str = "vcs.pull_ff_only"; + /// Creates commit from selected paths. + pub const VCS_COMMIT: &'static str = "vcs.commit"; + /// Creates commit from index. + pub const VCS_COMMIT_INDEX: &'static str = "vcs.commit_index"; + /// Returns status summary. + pub const VCS_GET_STATUS_SUMMARY: &'static str = "vcs.get_status_summary"; + /// Returns status payload. + pub const VCS_GET_STATUS_PAYLOAD: &'static str = "vcs.get_status_payload"; + /// Lists commits by query. + pub const VCS_LIST_COMMITS: &'static str = "vcs.list_commits"; + /// Diffs a file. + pub const VCS_DIFF_FILE: &'static str = "vcs.diff_file"; + /// Diffs a commit. + pub const VCS_DIFF_COMMIT: &'static str = "vcs.diff_commit"; + /// Returns conflict details. + pub const VCS_GET_CONFLICT_DETAILS: &'static str = "vcs.get_conflict_details"; + /// Checks out one side of a conflict. + pub const VCS_CHECKOUT_CONFLICT_SIDE: &'static str = "vcs.checkout_conflict_side"; + /// Writes merge conflict resolution content. + pub const VCS_WRITE_MERGE_RESULT: &'static str = "vcs.write_merge_result"; + /// Stages a text patch. + pub const VCS_STAGE_PATCH: &'static str = "vcs.stage_patch"; + /// Discards path changes. + pub const VCS_DISCARD_PATHS: &'static str = "vcs.discard_paths"; + /// Applies reverse patch. + pub const VCS_APPLY_REVERSE_PATCH: &'static str = "vcs.apply_reverse_patch"; + /// Deletes a branch. + pub const VCS_DELETE_BRANCH: &'static str = "vcs.delete_branch"; + /// Renames a branch. + pub const VCS_RENAME_BRANCH: &'static str = "vcs.rename_branch"; + /// Merges a branch into current. + pub const VCS_MERGE_INTO_CURRENT: &'static str = "vcs.merge_into_current"; + /// Aborts merge. + pub const VCS_MERGE_ABORT: &'static str = "vcs.merge_abort"; + /// Continues merge. + pub const VCS_MERGE_CONTINUE: &'static str = "vcs.merge_continue"; + /// Returns whether a merge is in progress. + pub const VCS_IS_MERGE_IN_PROGRESS: &'static str = "vcs.is_merge_in_progress"; + /// Sets branch upstream. + pub const VCS_SET_BRANCH_UPSTREAM: &'static str = "vcs.set_branch_upstream"; + /// Gets branch upstream. + pub const VCS_GET_BRANCH_UPSTREAM: &'static str = "vcs.get_branch_upstream"; + /// Hard resets HEAD. + pub const VCS_HARD_RESET_HEAD: &'static str = "vcs.hard_reset_head"; + /// Soft resets to revision. + pub const VCS_RESET_SOFT_TO: &'static str = "vcs.reset_soft_to"; + /// Returns configured identity. + pub const VCS_GET_IDENTITY: &'static str = "vcs.get_identity"; + /// Sets configured identity. + pub const VCS_SET_IDENTITY_LOCAL: &'static str = "vcs.set_identity_local"; + /// Lists stashes. + pub const VCS_LIST_STASHES: &'static str = "vcs.list_stashes"; + /// Pushes stash. + pub const VCS_STASH_PUSH: &'static str = "vcs.stash_push"; + /// Applies stash. + pub const VCS_STASH_APPLY: &'static str = "vcs.stash_apply"; + /// Pops stash. + pub const VCS_STASH_POP: &'static str = "vcs.stash_pop"; + /// Drops stash. + pub const VCS_STASH_DROP: &'static str = "vcs.stash_drop"; + /// Shows stash diff. + pub const VCS_STASH_SHOW: &'static str = "vcs.stash_show"; + /// Cherry-picks commit. + pub const VCS_CHERRY_PICK: &'static str = "vcs.cherry_pick"; + /// Reverts commit. + pub const VCS_REVERT_COMMIT: &'static str = "vcs.revert_commit"; +} + +/// Plugin-to-host notification method names. +pub struct NotificationMethods; + +impl NotificationMethods { + /// Emits plugin log records to host logging. + pub const HOST_LOG: &'static str = "host.log"; + /// Requests a host UI notification. + pub const HOST_UI_NOTIFY: &'static str = "host.ui_notify"; + /// Requests setting host status text. + pub const HOST_STATUS_SET: &'static str = "host.status_set"; + /// Emits plugin-scoped events. + pub const HOST_EVENT_EMIT: &'static str = "host.event_emit"; + /// Emits VCS operation progress events. + pub const VCS_EVENT: &'static str = "vcs.event"; +} + +/// JSON-RPC error object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcError { + /// Numeric error code. + pub code: i32, + /// Human-readable message. + pub message: String, + /// Optional extra error payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// JSON-RPC request object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcRequest { + /// JSON-RPC protocol version string. + pub jsonrpc: String, + /// Request id. + pub id: u64, + /// Request method name. + pub method: String, + /// Request params payload. + pub params: Value, +} + +/// JSON-RPC response object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcResponse { + /// JSON-RPC protocol version string. + pub jsonrpc: String, + /// Response id. + pub id: u64, + /// Optional success payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + /// Optional error payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Writes one framed JSON message to a plugin stdio stream. +/// +/// # Parameters +/// - `writer`: Target stream. +/// - `value`: JSON value to encode. +/// +/// # Returns +/// - `Ok(())` when write succeeds. +/// - `Err(String)` when serialization or IO fails. +pub fn write_framed_message(writer: &mut impl Write, value: &Value) -> Result<(), String> { + let payload = serde_json::to_vec(value).map_err(|e| format!("serialize rpc payload: {e}"))?; + let header = format!("Content-Length: {}\r\n\r\n", payload.len()); + writer + .write_all(header.as_bytes()) + .map_err(|e| format!("write rpc header: {e}"))?; + writer + .write_all(&payload) + .map_err(|e| format!("write rpc payload: {e}"))?; + writer.flush().map_err(|e| format!("flush rpc stream: {e}")) +} + +/// Reads one framed JSON message from a plugin stdio stream. +/// +/// # Parameters +/// - `reader`: Source stream. +/// +/// # Returns +/// - `Ok(Value)` decoded JSON message. +/// - `Err(String)` when framing, decode, or IO fails. +pub fn read_framed_message(reader: &mut impl BufRead) -> Result { + let mut content_length: Option = None; + + loop { + let mut line = String::new(); + let read = reader + .read_line(&mut line) + .map_err(|e| format!("read rpc header: {e}"))?; + if read == 0 { + return Err("plugin rpc stream closed".to_string()); + } + + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + break; + } + + let Some((name, value)) = trimmed.split_once(':') else { + return Err(format!("invalid rpc header line: {trimmed}")); + }; + if name.eq_ignore_ascii_case("content-length") { + let parsed = value + .trim() + .parse::() + .map_err(|e| format!("invalid Content-Length '{value}': {e}"))?; + content_length = Some(parsed); + } + } + + let length = content_length.ok_or_else(|| "missing Content-Length header".to_string())?; + let mut payload = vec![0u8; length]; + reader + .read_exact(&mut payload) + .map_err(|e| format!("read rpc payload: {e}"))?; + serde_json::from_slice(&payload).map_err(|e| format!("parse rpc payload: {e}")) +} diff --git a/Backend/src/plugin_runtime/runtime_select.rs b/Backend/src/plugin_runtime/runtime_select.rs new file mode 100644 index 00000000..64eed933 --- /dev/null +++ b/Backend/src/plugin_runtime/runtime_select.rs @@ -0,0 +1,89 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +//! Plugin runtime selection helpers. +//! +//! OpenVCS now runs plugin modules as long-lived Node.js scripts. + +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; +use crate::plugin_runtime::spawn::SpawnConfig; +use log::{debug, trace}; +use std::path::Path; +use std::sync::Arc; + +/// Returns whether a module path is a supported Node.js entry file. +pub fn is_node_module(path: &Path) -> bool { + trace!("is_node_module: path='{}'", path.display()); + + if !path.is_file() { + debug!("is_node_module: path is not a file"); + return false; + } + let ext = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.trim().to_ascii_lowercase()) + .unwrap_or_default(); + matches!(ext.as_str(), "js" | "mjs" | "cjs") +} + +/// Selects and creates a runtime instance for a plugin module. +pub fn create_runtime_instance( + spawn: SpawnConfig, +) -> Result, String> { + trace!( + "create_runtime_instance: plugin_id='{}', exec_path='{}'", + spawn.plugin_id, + spawn.exec_path.display() + ); + + if !is_node_module(&spawn.exec_path) { + return Err(format!( + "plugin runtime: '{}' must be a .js/.mjs/.cjs Node entrypoint", + spawn.exec_path.display() + )); + } + + let runtime: Arc = create_node_runtime_instance(spawn)?; + Ok(runtime) +} + +/// Creates a concrete runtime instance used by VCS proxy adapters. +pub fn create_node_runtime_instance( + spawn: SpawnConfig, +) -> Result, String> { + if !is_node_module(&spawn.exec_path) { + return Err(format!( + "plugin runtime: '{}' must be a .js/.mjs/.cjs Node entrypoint", + spawn.exec_path.display() + )); + } + Ok(Arc::new(NodePluginRuntimeInstance::new(spawn))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + /// Verifies non-script files are rejected by node runtime detection. + fn non_script_path_is_not_detected_as_node_module() { + let temp = tempdir().expect("tempdir"); + let file_path = temp.path().join("plugin.bin"); + fs::write(&file_path, b"binary").expect("write file"); + + assert!(!is_node_module(&file_path)); + } + + #[test] + /// Verifies `.mjs` files are accepted as runtime modules. + fn mjs_path_is_detected_as_node_module() { + let temp = tempdir().expect("tempdir"); + let script_path = temp.path().join("plugin.mjs"); + fs::write(&script_path, b"export {}\n").expect("write script"); + + assert!(is_node_module(&script_path)); + } +} diff --git a/Backend/src/plugin_runtime/settings_store.rs b/Backend/src/plugin_runtime/settings_store.rs new file mode 100644 index 00000000..deebc455 --- /dev/null +++ b/Backend/src/plugin_runtime/settings_store.rs @@ -0,0 +1,87 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Filesystem persistence for plugin settings JSON. + +use directories::ProjectDirs; +use serde_json::{Map, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Returns the root plugin data directory under the app config directory. +fn plugin_data_root() -> PathBuf { + if let Some(pd) = ProjectDirs::from("dev", "OpenVCS", "OpenVCS") { + pd.config_dir().join("plugin-data") + } else { + PathBuf::from("plugin-data") + } +} + +/// Builds the plugin settings JSON file path for a plugin id. +fn settings_file(plugin_id: &str) -> PathBuf { + plugin_data_root() + .join(plugin_id.trim().to_ascii_lowercase()) + .join("settings.json") +} + +/// Loads plugin settings from disk. +/// +/// # Parameters +/// - `plugin_id`: Plugin id used to resolve settings path. +/// +/// # Returns +/// - Loaded JSON object map. +/// - Empty map when no settings file exists. +pub fn load_settings(plugin_id: &str) -> Result, String> { + let path = settings_file(plugin_id); + if !path.is_file() { + return Ok(Map::new()); + } + let text = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?; + let value: Value = + serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?; + Ok(value.as_object().cloned().unwrap_or_default()) +} + +/// Saves plugin settings to disk using an atomic rename. +/// +/// # Parameters +/// - `plugin_id`: Plugin id used to resolve settings path. +/// - `settings`: Settings object map to persist. +/// +/// # Returns +/// - `Ok(())` when saved. +/// - `Err(String)` on IO or serialization failure. +pub fn save_settings(plugin_id: &str, settings: &Map) -> Result<(), String> { + let path = settings_file(plugin_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + let data = serde_json::to_string_pretty(settings) + .map_err(|e| format!("serialize settings for {plugin_id}: {e}"))?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, data).map_err(|e| format!("write {}: {e}", tmp.display()))?; + fs::rename(&tmp, &path) + .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display())) +} + +/// Removes persisted settings for a plugin. +/// +/// # Parameters +/// - `plugin_id`: Plugin id used to resolve settings path. +/// +/// # Returns +/// - `Ok(())` when removed or not present. +/// - `Err(String)` on IO failure. +pub fn reset_settings(plugin_id: &str) -> Result<(), String> { + let path = settings_file(plugin_id); + remove_file_if_exists(&path) +} + +/// Removes a file if it exists. +fn remove_file_if_exists(path: &Path) -> Result<(), String> { + if path.is_file() { + fs::remove_file(path).map_err(|e| format!("remove {}: {e}", path.display()))?; + } + Ok(()) +} diff --git a/Backend/src/plugin_runtime/spawn.rs b/Backend/src/plugin_runtime/spawn.rs new file mode 100644 index 00000000..86f725dd --- /dev/null +++ b/Backend/src/plugin_runtime/spawn.rs @@ -0,0 +1,16 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +use std::path::PathBuf; + +/// Runtime launch configuration for a plugin module instance. +#[derive(Debug, Clone)] +pub struct SpawnConfig { + /// Canonical plugin identifier used for logging and routing. + pub plugin_id: String, + /// Path to the plugin Node.js module executable. + pub exec_path: PathBuf, + /// Optional workspace root captured for backward-compatible APIs. + pub allowed_workspace_root: Option, + /// Whether this plugin exports a VCS backend interface. + pub is_vcs_backend: bool, +} diff --git a/Backend/src/plugin_runtime/stdio_rpc.rs b/Backend/src/plugin_runtime/stdio_rpc.rs deleted file mode 100644 index c4b6f587..00000000 --- a/Backend/src/plugin_runtime/stdio_rpc.rs +++ /dev/null @@ -1,1317 +0,0 @@ -use crate::plugin_bundles::ApprovalState; -use crate::plugin_runtime::events::{register_plugin_io, PluginIoHandle, PluginStdin}; -use openvcs_core::models::VcsEvent; -use openvcs_core::plugin_protocol::{PluginMessage, RpcRequest, RpcResponse}; -use serde_json::Value; -use std::collections::HashMap; -use std::ffi::OsString; -use std::fs; -use std::io::{self, BufRead, BufReader, LineWriter, Read, Write}; -#[cfg(unix)] -use std::os::fd::{FromRawFd, IntoRawFd}; -#[cfg(windows)] -use std::os::windows::io::{FromRawHandle, IntoRawHandle}; -use std::path::{Component, Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -type EventSink = Arc>>>; - -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); -const BACKOFF_MS: u64 = 250; -const MAX_BACKOFF_MS: u64 = 30_000; -// Whitelisted environment variables that are forwarded to plugin processes. -// Centralized here to make adding/removing entries easier. -const SANITIZED_ENV_KEYS: &[&str] = &[ - "HOME", - "USER", - "USERPROFILE", - "TMPDIR", - "TEMP", - "TMP", - "LANG", - "LC_ALL", - // SSH / Git authentication - "SSH_AUTH_SOCK", - "SSH_AGENT_PID", - "GIT_SSH_COMMAND", - "OPENVCS_SSH_MODE", - "OPENVCS_SSH", -]; - -#[cfg(unix)] -const DEFAULT_PATH_UNIX: &str = "/usr/bin:/bin"; -#[cfg(windows)] -const DEFAULT_PATH_WINDOWS_SUFFIX: &str = "\\System32"; -const STDERR_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024; -const STDERR_LOG_MAX_FILES: usize = 5; -const MAX_PENDING: usize = 1024; -const MAX_CRASHES: u32 = 5; - -/// Detects the runtime container type for diagnostics/feature gating. -/// -/// # Returns -/// - Container label (`flatpak`, `appimage`, or `native`). -fn runtime_container_kind() -> &'static str { - if matches!( - std::env::var("OPENVCS_FLATPAK").as_deref(), - Ok("1") | Ok("true") | Ok("yes") | Ok("on") - ) { - "flatpak" - } else if std::env::var_os("APPIMAGE").is_some() || std::env::var_os("APPDIR").is_some() { - "appimage" - } else { - "native" - } -} - -#[derive(Debug, Clone)] -pub struct SpawnConfig { - pub plugin_id: String, - pub component_label: String, - pub exec_path: PathBuf, - pub args: Vec, - pub requested_capabilities: Vec, - pub approval: ApprovalState, - pub allowed_workspace_root: Option, -} - -#[derive(Debug, Clone)] -pub struct RpcConfig { - pub timeout: Duration, -} - -impl Default for RpcConfig { - /// Returns default RPC configuration values. - /// - /// # Returns - /// - Default [`RpcConfig`] with standard timeout. - fn default() -> Self { - Self { - timeout: DEFAULT_TIMEOUT, - } - } -} - -#[derive(Debug)] -pub struct RpcError { - pub code: String, - pub message: String, -} - -struct PendingMap { - next_id: u64, - pending: HashMap>, -} - -pub struct StdioRpcProcess { - spawn: SpawnConfig, - cfg: RpcConfig, - child: Mutex>, - stdin: PluginStdin, - pending: Arc>, - on_event: EventSink, - crash_count: Mutex, - backoff_ms: Mutex, - disabled: Mutex, -} - -struct ProcessHandle { - #[allow(dead_code)] - join: std::thread::JoinHandle<()>, - #[allow(dead_code)] - stdin_writer: os_pipe::PipeWriter, -} - -impl StdioRpcProcess { - /// Creates a new lazily-started stdio RPC process wrapper. - /// - /// # Parameters - /// - `spawn`: Process spawn configuration and capability policy. - /// - `cfg`: RPC behavior configuration (timeouts, etc.). - /// - /// # Returns - /// - A new [`StdioRpcProcess`] instance. - pub fn new(spawn: SpawnConfig, cfg: RpcConfig) -> Self { - Self { - spawn, - cfg, - child: Mutex::new(None), - stdin: Arc::new(Mutex::new(None)), - pending: Arc::new(Mutex::new(PendingMap { - next_id: 1, - pending: HashMap::new(), - })), - on_event: Arc::new(Mutex::new(None)), - crash_count: Mutex::new(0), - backoff_ms: Mutex::new(BACKOFF_MS), - disabled: Mutex::new(false), - } - } - - /// Sets or clears the event sink used for plugin-emitted `VcsEvent` values. - /// - /// # Parameters - /// - `sink`: Optional callback invoked for plugin events. - /// - /// # Returns - /// - `()`. - pub fn set_event_sink(&self, sink: Option>) { - if let Ok(mut lock) = self.on_event.lock() { - *lock = sink; - } - } - - /// Ensures the backing plugin process is running and ready for RPC calls. - /// - /// # Returns - /// - `Ok(())` when the process is running. - /// - `Err(String)` if startup/validation fails or the plugin is disabled. - pub fn ensure_running(&self) -> Result<(), String> { - if *self.disabled.lock().unwrap() { - return Err(format!( - "plugin {} is disabled after repeated crashes", - self.spawn.plugin_id - )); - } - - if self.child.lock().ok().map(|c| c.is_some()).unwrap_or(false) { - return Ok(()); - } - - if !self.spawn.exec_path.is_file() { - return Err(format!( - "plugin executable not found: {}", - self.spawn.exec_path.display() - )); - } - - if !is_wasm_module(&self.spawn.exec_path) { - return Err(format!( - "invalid plugin entrypoint (expected .wasm module): {}", - self.spawn.exec_path.display() - )); - } - - // Exponential backoff between respawns after failures. - let delay = *self.backoff_ms.lock().unwrap(); - let crashes = *self.crash_count.lock().unwrap(); - if crashes > 0 && delay > 0 { - std::thread::sleep(Duration::from_millis(delay)); - } - - if !matches!(self.spawn.approval, ApprovalState::Approved { .. }) - && !self.spawn.requested_capabilities.is_empty() - { - return Err(format!( - "plugin {} requires user approval for capabilities before execution", - self.spawn.plugin_id - )); - } - - self.spawn_wasm() - } - - /// Sends a JSON-RPC style request to the plugin and waits for a response. - /// - /// # Parameters - /// - `method`: RPC method name. - /// - `params`: JSON parameters payload. - /// - /// # Returns - /// - `Ok(Value)` with the response result payload. - /// - `Err(RpcError)` when the plugin is unavailable, times out, or returns an error. - pub fn call(&self, method: &str, params: Value) -> Result { - if let Err(e) = self.ensure_running() { - return Err(RpcError { - code: "plugin.not_ready".into(), - message: e, - }); - } - - let (id, rx) = { - let (tx, rx) = std::sync::mpsc::channel::(); - let mut lock = self.pending.lock().unwrap(); - if lock.pending.len() >= MAX_PENDING { - return Err(RpcError { - code: "plugin.busy".into(), - message: "too many pending plugin requests".into(), - }); - } - let id = lock.next_id; - lock.next_id = lock.next_id.saturating_add(1); - lock.pending.insert(id, tx); - (id, rx) - }; - - let req = RpcRequest { - id, - method: method.to_string(), - params, - }; - - self.write_message(&PluginMessage::Request(req)) - .map_err(|e| RpcError { - code: "plugin.io".into(), - message: e, - })?; - - let resp = rx.recv_timeout(self.cfg.timeout).map_err(|_| { - self.record_crash(); - self.kill_process(); - RpcError { - code: "plugin.timeout".into(), - message: "plugin request timed out".into(), - } - })?; - - if resp.ok { - Ok(resp.result) - } else { - Err(RpcError { - code: resp.error_code.unwrap_or_else(|| "plugin.error".into()), - message: resp.error.unwrap_or_else(|| "error".into()), - }) - } - } - - /// Serializes and writes a plugin message to child stdin. - /// - /// # Parameters - /// - `msg`: Message to send. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(String)` when serialization/write/flush fails. - fn write_message(&self, msg: &PluginMessage) -> Result<(), String> { - let line = serde_json::to_string(msg).map_err(|e| format!("serialize: {e}"))?; - let mut lock = self.stdin.lock().unwrap(); - let Some(stdin) = lock.as_mut() else { - return Err("plugin stdin not available".to_string()); - }; - let stdin: &mut LineWriter> = stdin; - if let Err(e) = writeln!(stdin, "{line}") { - drop(lock); - self.record_crash(); - self.kill_process(); - return Err(format!("write stdin: {e}")); - } - if let Err(e) = stdin.flush() { - drop(lock); - self.record_crash(); - self.kill_process(); - return Err(format!("flush stdin: {e}")); - } - Ok(()) - } - - /// Increments crash counters and updates disable/backoff policy. - /// - /// # Returns - /// - `()`. - fn record_crash(&self) { - let mut crashes = self.crash_count.lock().unwrap(); - *crashes = crashes.saturating_add(1); - if *crashes >= MAX_CRASHES { - *self.disabled.lock().unwrap() = true; - } else { - let mut backoff = self.backoff_ms.lock().unwrap(); - *backoff = (*backoff).saturating_mul(2).min(MAX_BACKOFF_MS); - } - } - - /// Tears down the running plugin process and clears pending requests. - /// - /// # Returns - /// - `()`. - fn kill_process(&self) { - // Drop stdin so the child sees EOF. - *self.stdin.lock().unwrap() = None; - - // Clear pending so callers don't leak memory (timeouts still handle the callsite). - if let Ok(mut lock) = self.pending.lock() { - lock.pending.clear(); - } - - let mut child_lock = self.child.lock().unwrap(); - if let Some(ProcessHandle { - join, - mut stdin_writer, - }) = child_lock.take() - { - // Try to flush any buffered data (ignore errors) and drop the - // write end so the plugin's stdin reader sees EOF. - let _ = stdin_writer.flush(); - drop(stdin_writer); - - // If the thread has already finished, join synchronously (cheap). - // Otherwise detach a background joiner so shutdown remains - // non-blocking while ensuring the thread is eventually reaped. - if join.is_finished() { - let _ = join.join(); - } else { - std::thread::spawn(move || { - let _ = join.join(); - }); - } - } - } - - /// Spawns the WASI plugin process and wire-up IO/event threads. - /// - /// # Returns - /// - `Ok(())` when spawn succeeds. - /// - `Err(String)` when pipe/setup operations fail. - fn spawn_wasm(&self) -> Result<(), String> { - let (stdin_reader, stdin_writer) = - os_pipe::pipe().map_err(|e| format!("create stdin pipe: {e}"))?; - let (stdout_reader, stdout_writer) = - os_pipe::pipe().map_err(|e| format!("create stdout pipe: {e}"))?; - let (stderr_reader, stderr_writer) = - os_pipe::pipe().map_err(|e| format!("create stderr pipe: {e}"))?; - - let wasm_path = self.spawn.exec_path.clone(); - let plugin_id = self.spawn.plugin_id.clone(); - let args = self.spawn.args.clone(); - let host_timeout = self.cfg.timeout; - let (approved_caps, allowed_workspace_root) = approved_caps_and_workspace(&self.spawn); - - let join = std::thread::spawn(move || { - let cfg = RunWasiConfig { - wasm_path, - plugin_id, - args, - host_timeout, - approved_caps, - allowed_workspace_root, - stdin: stdin_reader, - stdout: stdout_writer, - stderr: stderr_writer, - }; - if let Err(e) = run_wasi_module(cfg) { - log::error!("wasi plugin crashed: {}", e); - } - }); - - let stdin: Box = Box::new( - stdin_writer - .try_clone() - .map_err(|e| format!("clone stdin pipe: {e}"))?, - ); - let stdin = LineWriter::new(stdin); - *self.stdin.lock().unwrap() = Some(stdin); - - register_plugin_io( - &self.spawn.plugin_id, - PluginIoHandle { - stdin: Arc::clone(&self.stdin), - }, - ); - - let pending = Arc::clone(&self.pending); - let spawn = self.spawn.clone(); - let stdin_for_responses = Arc::clone(&self.stdin); - let on_event = Arc::clone(&self.on_event); - let stdout_log_path = - plugin_stdout_log_path(&self.spawn.plugin_id, &self.spawn.component_label); - - std::thread::spawn(move || { - read_stdout_loop( - stdout_reader, - spawn, - pending, - stdin_for_responses, - on_event, - stdout_log_path, - ) - }); - - let stderr_path = - plugin_stderr_log_path(&self.spawn.plugin_id, &self.spawn.component_label); - let stderr_plugin_id = self.spawn.plugin_id.clone(); - let stderr_component = self.spawn.component_label.clone(); - std::thread::spawn(move || { - read_stderr_loop( - stderr_reader, - stderr_path, - stderr_plugin_id, - stderr_component, - ) - }); - - *self.child.lock().unwrap() = Some(ProcessHandle { join, stdin_writer }); - Ok(()) - } -} - -impl Drop for StdioRpcProcess { - /// Ensures the child process is cleaned up during drop. - /// - /// # Returns - /// - `()`. - fn drop(&mut self) { - // Best-effort cleanup; ignore errors. - self.kill_process(); - } -} - -/// Reads plugin stdout lines and dispatches responses/events/host requests. -/// -/// # Parameters -/// - `stdout`: Readable stdout stream. -/// - `spawn`: Spawn metadata used for host request handling. -/// - `pending`: Pending-response map. -/// - `stdin_for_responses`: Writable stdin for sending host responses. -/// - `on_event`: Optional event callback sink. -/// - `stdout_log_path`: Path used for logging unparsable lines. -/// -/// # Returns -/// - `()`. -fn read_stdout_loop( - stdout: impl io::Read, - spawn: SpawnConfig, - pending: Arc>, - stdin_for_responses: PluginStdin, - on_event: EventSink, - stdout_log_path: PathBuf, -) { - let reader = BufReader::new(stdout); - for line in reader.lines().map_while(Result::ok) { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let msg: PluginMessage = match serde_json::from_str(trimmed) { - Ok(m) => m, - Err(_) => { - let _ = append_log_line(&stdout_log_path, trimmed); - continue; - } - }; - match msg { - PluginMessage::Response(resp) => { - let tx = pending - .lock() - .ok() - .and_then(|mut p| p.pending.remove(&resp.id)); - if let Some(tx) = tx { - let _ = tx.send(resp); - } - } - PluginMessage::Event { event } => { - if let Ok(lock) = on_event.lock() { - if let Some(cb) = lock.as_ref() { - cb(event); - } - } - } - PluginMessage::Request(req) => { - let resp = handle_host_request(&spawn, req); - if let Ok(mut lock) = stdin_for_responses.lock() { - if let Some(stdin) = lock.as_mut() { - let stdin: &mut LineWriter> = stdin; - if let Ok(line) = serde_json::to_string(&PluginMessage::Response(resp)) { - let _ = writeln!(stdin, "{line}"); - let _ = stdin.flush(); - } - } - } - } - } - } -} - -/// Reads plugin stderr, persists logs, and forwards messages to host logging. -/// -/// # Parameters -/// - `stderr`: Readable stderr stream. -/// - `path`: Destination stderr log path. -/// - `plugin_id`: Plugin id for log prefixes. -/// - `component`: Component label for log prefixes. -/// -/// # Returns -/// - `()`. -fn read_stderr_loop(stderr: impl io::Read, path: PathBuf, plugin_id: String, component: String) { - let reader = BufReader::new(stderr); - for line in reader.lines().map_while(Result::ok) { - let _ = append_log_line(&path, &line); - - // Also forward plugin stderr into the main OpenVCS-Client logs. - // - // openvcs-core's plugin logger prints: - // [INFO] some::target: message - // Parse the level prefix when present; otherwise treat it as INFO. - let prefix = format!("[plugin:{plugin_id}:{component}] "); - if let Some((lvl, rest)) = parse_plugin_stderr_level(&line) { - log::log!(lvl, "{}{}", prefix, rest); - } else { - log::info!("{}{}", prefix, line); - } - } -} - -/// Checks whether a file is a wasm module by extension and magic bytes. -/// -/// # Parameters -/// - `path`: Candidate executable path. -/// -/// # Returns -/// - `true` when file looks like wasm. -/// - `false` otherwise. -fn is_wasm_module(path: &Path) -> bool { - if path.extension().and_then(|s| s.to_str()) != Some("wasm") { - return false; - } - let mut f = match fs::File::open(path) { - Ok(f) => f, - Err(_) => return false, - }; - let mut magic = [0u8; 4]; - matches!( - f.read(&mut magic), - Ok(n) if n == magic.len() && magic == [0x00, 0x61, 0x73, 0x6d] - ) -} - -/// Parses `[LEVEL]` prefixes emitted by plugin stderr logging. -/// -/// # Parameters -/// - `line`: Raw stderr line. -/// -/// # Returns -/// - `Some((Level, &str))` parsed level and message tail. -/// - `None` when no recognized level prefix exists. -fn parse_plugin_stderr_level(line: &str) -> Option<(log::Level, &str)> { - let line = line.trim(); - if !line.starts_with('[') { - return None; - } - let end = line.find(']')?; - let level = &line[1..end]; - let level = match level { - "ERROR" => log::Level::Error, - "WARN" | "WARNING" => log::Level::Warn, - "INFO" => log::Level::Info, - "DEBUG" => log::Level::Debug, - "TRACE" => log::Level::Trace, - _ => return None, - }; - - let rest = line[end + 1..].trim_start(); - Some((level, rest)) -} - -/// Returns approved capabilities and workspace root from spawn config. -/// -/// # Parameters -/// - `spawn`: Spawn config. -/// -/// # Returns -/// - Tuple of approved capability ids and optional workspace root. -fn approved_caps_and_workspace(spawn: &SpawnConfig) -> (Vec, Option) { - let approved_caps = match &spawn.approval { - ApprovalState::Approved { capabilities, .. } => capabilities.clone(), - _ => Vec::new(), - }; - (approved_caps, spawn.allowed_workspace_root.clone()) -} - -pub struct RunWasiConfig { - pub wasm_path: PathBuf, - pub plugin_id: String, - pub args: Vec, - pub host_timeout: Duration, - pub approved_caps: Vec, - pub allowed_workspace_root: Option, - pub stdin: os_pipe::PipeReader, - pub stdout: os_pipe::PipeWriter, - pub stderr: os_pipe::PipeWriter, -} - -/// Executes a plugin wasm module inside a WASI runtime. -/// -/// # Parameters -/// - `cfg`: WASI execution configuration and IO handles. -/// -/// # Returns -/// - `Ok(())` when module exits successfully. -/// - `Err(String)` when module load/instantiate/call fails. -fn run_wasi_module(cfg: RunWasiConfig) -> Result<(), String> { - let RunWasiConfig { - wasm_path, - plugin_id, - args, - host_timeout, - approved_caps, - allowed_workspace_root, - stdin, - stdout, - stderr, - } = cfg; - let allowed_workspace_root = allowed_workspace_root.as_deref(); - use wasmtime::{Engine, Module, Store}; - use wasmtime_wasi::cli::{AsyncStdinStream, OutputFile}; - use wasmtime_wasi::WasiCtxBuilder; - - // Convert os_pipe readers/writers into std::fs::File using - // the platform-specific helpers defined below. - let stdin_file = into_file_from_reader(stdin); - let stdout_file = into_file_from_writer(stdout); - let stderr_file = into_file_from_writer(stderr); - - let engine = Engine::default(); - let module = Module::from_file(&engine, &wasm_path).map_err(|e| format!("load module: {e}"))?; - - let mut linker = wasmtime::Linker::new(&engine); - wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |cx| cx).map_err(|e| format!("{e}"))?; - - let stdin_tokio = tokio::fs::File::from_std(stdin_file); - let stdin_stream = AsyncStdinStream::new(stdin_tokio); - let stdout_stream = OutputFile::new(stdout_file); - let stderr_stream = OutputFile::new(stderr_file); - - // Override args: keep deterministic while still allowing component args. - let mut argv = Vec::with_capacity(1 + args.len()); - argv.push( - wasm_path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("plugin.wasm") - .to_string(), - ); - argv.extend(args.iter().cloned()); - - let mut builder = WasiCtxBuilder::new(); - builder.stdin(stdin_stream); - builder.stdout(stdout_stream); - builder.stderr(stderr_stream); - builder.env("OPENVCS_PLUGIN_ID", plugin_id.as_str()); - builder.env( - "OPENVCS_PLUGIN_HOST_TIMEOUT_MS", - host_timeout.as_millis().to_string(), - ); - builder.args(&argv); - - // Do not preopen the host filesystem into WASI. All file I/O must go through - // host RPCs which are scoped to `allowed_workspace_root` and capability-gated. - let _ = (approved_caps.as_slice(), allowed_workspace_root); - - let mut store = Store::new(&engine, builder.build_p1()); - - let instance = linker - .instantiate(&mut store, &module) - .map_err(|e| format!("instantiate: {e}"))?; - let start = instance - .get_typed_func::<(), ()>(&mut store, "_start") - .map_err(|e| format!("missing _start: {e}"))?; - start - .call(&mut store, ()) - .map_err(|e| format!("wasi trap: {e}"))?; - - Ok(()) -} - -/// Handles host-side RPC methods requested by plugin code. -/// -/// # Parameters -/// - `spawn`: Plugin spawn/config metadata. -/// - `req`: Incoming RPC request. -/// -/// # Returns -/// - RPC response payload. -fn handle_host_request(spawn: &SpawnConfig, req: RpcRequest) -> RpcResponse { - let deny = |code: &str, msg: &str| RpcResponse { - id: req.id, - ok: false, - result: Value::Null, - error: Some(msg.to_string()), - error_code: Some(code.to_string()), - error_data: None, - }; - - let (approved_caps, _) = approved_caps_and_workspace(spawn); - let caps = approved_caps - .into_iter() - .collect::>(); - - match req.method.as_str() { - "runtime.info" => RpcResponse { - id: req.id, - ok: true, - result: serde_json::json!({ - "os": std::env::consts::OS, - "arch": std::env::consts::ARCH, - "container": runtime_container_kind(), - }), - error: None, - error_code: None, - error_data: None, - }, - "events.subscribe" => { - let name = req - .params - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if name.is_empty() { - return deny("invalid.params", "missing params.name"); - } - crate::plugin_runtime::events::subscribe(&spawn.plugin_id, &name); - RpcResponse { - id: req.id, - ok: true, - result: Value::Null, - error: None, - error_code: None, - error_data: None, - } - } - "events.emit" => { - let name = req - .params - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if name.is_empty() { - return deny("invalid.params", "missing params.name"); - } - let payload = req.params.get("payload").cloned().unwrap_or(Value::Null); - crate::plugin_runtime::events::emit_from_plugin(&spawn.plugin_id, &name, payload); - RpcResponse { - id: req.id, - ok: true, - result: Value::Null, - error: None, - error_code: None, - error_data: None, - } - } - "ui.notify" => { - if !caps.contains("ui.notifications") { - return deny("capability.denied", "missing capability: ui.notifications"); - } - let msg = req - .params - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if !msg.is_empty() { - log::info!("plugin[{}] notify: {}", spawn.plugin_id, msg); - } - RpcResponse { - id: req.id, - ok: true, - result: Value::Null, - error: None, - error_code: None, - error_data: None, - } - } - "workspace.readFile" => { - if !caps.contains("workspace.read") && !caps.contains("workspace.write") { - return deny( - "capability.denied", - "missing capability: workspace.read (or workspace.write)", - ); - } - let Some(root) = spawn.allowed_workspace_root.as_ref() else { - return deny("workspace.denied", "no workspace context"); - }; - let rel = req - .params - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - match read_file_under_root(root, &rel) { - Ok(bytes) => RpcResponse { - id: req.id, - ok: true, - result: Value::String(String::from_utf8_lossy(&bytes).to_string()), - error: None, - error_code: None, - error_data: None, - }, - Err(e) => deny("workspace.error", &e), - } - } - "workspace.writeFile" => { - if !caps.contains("workspace.write") { - return deny("capability.denied", "missing capability: workspace.write"); - } - let Some(root) = spawn.allowed_workspace_root.as_ref() else { - return deny("workspace.denied", "no workspace context"); - }; - let rel = req - .params - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let data = req - .params - .get("content") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - match write_file_under_root(root, &rel, data.as_bytes()) { - Ok(()) => RpcResponse { - id: req.id, - ok: true, - result: Value::Null, - error: None, - error_code: None, - error_data: None, - }, - Err(e) => deny("workspace.error", &e), - } - } - "process.exec" => { - if !caps.contains("process.exec") { - return deny("capability.denied", "missing capability: process.exec"); - } - let program = req - .params - .get("program") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if program != "git" { - return deny("process.denied", "only 'git' is allowed"); - } - let argv = req - .params - .get("args") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default() - .into_iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect::>(); - - let cwd_param = req.params.get("cwd").and_then(|v| v.as_str()).unwrap_or(""); - let cwd = if cwd_param.trim().is_empty() { - spawn.allowed_workspace_root.clone() - } else { - let Some(root) = spawn.allowed_workspace_root.as_ref() else { - return deny("workspace.denied", "no workspace context"); - }; - match resolve_under_root(root, cwd_param) { - Ok(p) => Some(p), - Err(e) => return deny("workspace.denied", &e), - } - }; - - let mut cmd = Command::new("git"); - if let Some(cwd) = cwd.as_ref() { - cmd.current_dir(cwd); - } - cmd.args(argv); - cmd.env_clear(); - for (k, v) in sanitized_env() { - cmd.env(k, v); - } - if let Some(env) = req.params.get("env").and_then(|v| v.as_object()) { - for (k, v) in env { - let Some(val) = v.as_str() else { continue }; - if matches!(k.as_str(), "GIT_SSH_COMMAND" | "GIT_TERMINAL_PROMPT") { - cmd.env(k, val); - } - } - } - - let stdin_text = req - .params - .get("stdin") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let out = if stdin_text.is_empty() { - match cmd.output() { - Ok(o) => o, - Err(e) => return deny("process.error", &format!("spawn git: {e}")), - } - } else { - cmd.stdin(Stdio::piped()); - let mut child = match cmd.spawn() { - Ok(c) => c, - Err(e) => return deny("process.error", &format!("spawn git: {e}")), - }; - if let Some(mut stdin) = child.stdin.take() { - if let Err(e) = stdin.write_all(stdin_text.as_bytes()) { - let _ = child.kill(); - return deny("process.error", &format!("write stdin: {e}")); - } - } - match child.wait_with_output() { - Ok(o) => o, - Err(e) => return deny("process.error", &format!("wait: {e}")), - } - }; - - RpcResponse { - id: req.id, - ok: true, - result: serde_json::json!({ - "status": out.status.code().unwrap_or(-1), - "success": out.status.success(), - "stdout": String::from_utf8_lossy(&out.stdout), - "stderr": String::from_utf8_lossy(&out.stderr), - }), - error: None, - error_code: None, - error_data: None, - } - } - _ => deny("method.not_found", "unknown host method"), - } -} - -/// Resolves a path under a workspace root and blocks escapes. -/// -/// # Parameters -/// - `root`: Allowed workspace root. -/// - `path`: Relative or absolute candidate path. -/// -/// # Returns -/// - `Ok(PathBuf)` resolved safe path. -/// - `Err(String)` when invalid or outside root. -fn resolve_under_root(root: &Path, path: &str) -> Result { - if path.contains('\0') { - return Err("path contains NUL".to_string()); - } - - let p = Path::new(path); - - // Allow absolute paths if they are within the allowed workspace root. - if p.is_absolute() { - let root = root - .canonicalize() - .map_err(|e| format!("canonicalize root {}: {e}", root.display()))?; - let p = p - .canonicalize() - .map_err(|e| format!("canonicalize path {}: {e}", p.display()))?; - if p.starts_with(&root) { - return Ok(p); - } - return Err("path escapes workspace root".to_string()); - } - - // Otherwise require a clean, relative path with no `..`. - let mut clean = PathBuf::new(); - for comp in p.components() { - match comp { - Component::Normal(c) => clean.push(c), - Component::CurDir => {} - Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - return Err("path must be relative and not contain '..'".to_string()) - } - } - } - Ok(root.join(clean)) -} - -/// Writes bytes to a file constrained to workspace root. -/// -/// # Parameters -/// - `root`: Allowed workspace root. -/// - `rel`: Relative file path. -/// - `bytes`: File bytes to write. -/// -/// # Returns -/// - `Ok(())` when write succeeds. -/// - `Err(String)` when resolution/IO fails. -fn write_file_under_root(root: &Path, rel: &str, bytes: &[u8]) -> Result<(), String> { - let path = resolve_under_root(root, rel)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; - } - fs::write(&path, bytes).map_err(|e| format!("write {}: {e}", path.display())) -} - -// Move helper functions above the test module to avoid `items_after_test_module` warnings. -/// Reads bytes from a file constrained to workspace root. -/// -/// # Parameters -/// - `root`: Allowed workspace root. -/// - `rel`: Relative file path. -/// -/// # Returns -/// - `Ok(Vec)` file bytes. -/// - `Err(String)` when resolution/IO fails. -fn read_file_under_root(root: &Path, rel: &str) -> Result, String> { - let joined = resolve_under_root(root, rel)?; - fs::read(&joined).map_err(|e| format!("read {}: {e}", joined.display())) -} - -/// Builds a sanitized environment map for child process execution. -/// -/// # Returns -/// - Whitelisted environment key/value pairs. -fn sanitized_env() -> Vec<(OsString, OsString)> { - let mut out: Vec<(OsString, OsString)> = Vec::new(); - - for &k in SANITIZED_ENV_KEYS { - if let Ok(v) = std::env::var(k) { - out.push((k.into(), v.into())); - } - } - - #[cfg(unix)] - { - out.push(("PATH".into(), DEFAULT_PATH_UNIX.into())); - } - #[cfg(windows)] - { - if let Ok(sysroot) = std::env::var("SystemRoot") { - out.push(( - "PATH".into(), - format!("{sysroot}{}", DEFAULT_PATH_WINDOWS_SUFFIX).into(), - )); - } - } - - out -} - -/// Returns per-plugin stderr log path. -/// -/// # Parameters -/// - `plugin_id`: Plugin identifier. -/// - `component`: Component label. -/// -/// # Returns -/// - Log file path. -fn plugin_stderr_log_path(plugin_id: &str, component: &str) -> PathBuf { - crate::plugin_bundles::PluginBundleStore::new_default() - .plugin_root_dir(plugin_id) - .join("logs") - .join(format!("{component}.stderr.log")) -} - -/// Returns per-plugin stdout log path. -/// -/// # Parameters -/// - `plugin_id`: Plugin identifier. -/// - `component`: Component label. -/// -/// # Returns -/// - Log file path. -fn plugin_stdout_log_path(plugin_id: &str, component: &str) -> PathBuf { - crate::plugin_bundles::PluginBundleStore::new_default() - .plugin_root_dir(plugin_id) - .join("logs") - .join(format!("{component}.stdout.log")) -} - -// Platform-specific conversions from os_pipe types into `std::fs::File`. -// Implemented as separate functions per-platform to keep unsafe blocks small -// and clearly documented. -#[cfg(unix)] -/// Converts a unix pipe reader into an owned `File`. -/// -/// # Parameters -/// - `r`: Pipe reader handle. -/// -/// # Returns -/// - Owned file descriptor wrapper. -fn into_file_from_reader(r: os_pipe::PipeReader) -> std::fs::File { - // Safety: we consume the PipeReader and immediately create a File which - // becomes the sole owner of the underlying fd. - unsafe { std::fs::File::from_raw_fd(r.into_raw_fd()) } -} - -#[cfg(unix)] -/// Converts a unix pipe writer into an owned `File`. -/// -/// # Parameters -/// - `w`: Pipe writer handle. -/// -/// # Returns -/// - Owned file descriptor wrapper. -fn into_file_from_writer(w: os_pipe::PipeWriter) -> std::fs::File { - // Safety: we consume the PipeWriter and immediately create a File which - // becomes the sole owner of the underlying fd. - unsafe { std::fs::File::from_raw_fd(w.into_raw_fd()) } -} - -#[cfg(windows)] -/// Converts a windows pipe reader into an owned `File`. -/// -/// # Parameters -/// - `r`: Pipe reader handle. -/// -/// # Returns -/// - Owned file handle wrapper. -fn into_file_from_reader(r: os_pipe::PipeReader) -> std::fs::File { - // Safety: we consume the PipeReader and immediately create a File which - // becomes the sole owner of the underlying handle. - unsafe { std::fs::File::from_raw_handle(r.into_raw_handle()) } -} - -#[cfg(windows)] -/// Converts a windows pipe writer into an owned `File`. -/// -/// # Parameters -/// - `w`: Pipe writer handle. -/// -/// # Returns -/// - Owned file handle wrapper. -fn into_file_from_writer(w: os_pipe::PipeWriter) -> std::fs::File { - // Safety: we consume the PipeWriter and immediately create a File which - // becomes the sole owner of the underlying handle. - unsafe { std::fs::File::from_raw_handle(w.into_raw_handle()) } -} - -/// Appends a timestamped line to a plugin log file. -/// -/// # Parameters -/// - `path`: Log file path. -/// - `line`: Log line text. -/// -/// # Returns -/// - `Ok(())` when append succeeds. -/// - `Err(io::Error)` on IO failure. -fn append_log_line(path: &Path, line: &str) -> io::Result<()> { - if let Some(parent) = path.parent() { - let _ = fs::create_dir_all(parent); - } - rotate_if_needed(path)?; - let mut f = fs::OpenOptions::new() - .create(true) - .append(true) - .open(path)?; - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - writeln!(f, "[{ts}] {line}")?; - Ok(()) -} - -/// Rotates a plugin log file when it exceeds configured size. -/// -/// # Parameters -/// - `path`: Log file path. -/// -/// # Returns -/// - `Ok(())` on success. -/// - `Err(io::Error)` on metadata/IO failure. -fn rotate_if_needed(path: &Path) -> io::Result<()> { - let meta = match fs::metadata(path) { - Ok(m) => m, - Err(_) => return Ok(()), - }; - if meta.len() < STDERR_LOG_MAX_BYTES { - return Ok(()); - } - - let dir = path.parent().unwrap_or_else(|| Path::new(".")); - let stem = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("plugin.stderr.log"); - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let rotated = dir.join(format!("{stem}.{ts}.log")); - let _ = fs::rename(path, rotated); - - // Best-effort prune old logs. - let mut logs: Vec = fs::read_dir(dir) - .map(|rd| rd.flatten().map(|e| e.path()).collect()) - .unwrap_or_default(); - logs.sort(); - if logs.len() > STDERR_LOG_MAX_FILES { - let excess = logs.len() - STDERR_LOG_MAX_FILES; - for p in logs.into_iter().take(excess) { - let _ = fs::remove_file(p); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - /// Verifies a minimal wasm plugin can be started. - /// - /// # Returns - /// - `()`. - fn runs_minimal_wasm_module() { - // Minimal wasm module exporting an empty `_start`. - // (module (func (export "_start"))) - let wasm: &[u8] = &[ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // header - 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, // type section - 0x03, 0x02, 0x01, 0x00, // func section - 0x07, 0x0b, 0x01, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x00, 0x00, // export - 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b, // code - ]; - - let dir = tempfile::tempdir().expect("tempdir"); - let wasm_path = dir.path().join("plugin.wasm"); - std::fs::write(&wasm_path, wasm).expect("write wasm"); - - let rpc = StdioRpcProcess::new( - SpawnConfig { - plugin_id: "test".into(), - component_label: "functions".into(), - exec_path: wasm_path, - args: Vec::new(), - requested_capabilities: Vec::new(), - approval: ApprovalState::Approved { - capabilities: Vec::new(), - approved_at_unix_ms: 0, - }, - allowed_workspace_root: None, - }, - RpcConfig::default(), - ); - - rpc.ensure_running().expect("ensure_running"); - } - - #[test] - /// Verifies absolute paths under root are accepted by resolver. - /// - /// # Returns - /// - `()`. - fn resolve_under_root_allows_absolute_under_root() { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path().join("root"); - std::fs::create_dir_all(&root).expect("mkdir root"); - let child = root.join("child"); - std::fs::create_dir_all(&child).expect("mkdir child"); - - let resolved = - resolve_under_root(&root, child.to_string_lossy().as_ref()).expect("resolve"); - assert!(resolved.starts_with(&root)); - } - - #[test] - /// Verifies parent-directory escapes are rejected for writes. - /// - /// # Returns - /// - `()`. - fn write_file_under_root_rejects_parent_dir_escape() { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path().join("root"); - std::fs::create_dir_all(&root).expect("mkdir root"); - - let err = write_file_under_root(&root, "../escape.txt", b"nope").unwrap_err(); - assert!(err.contains("relative") || err.contains("escape") || err.contains("..")); - } -} - -// Duplicate helper definitions removed (handled earlier in the file). diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index 4d582cc5..dab62f34 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -1,164 +1,111 @@ -use crate::plugin_bundles::ApprovalState; -use crate::plugin_runtime::stdio_rpc::{RpcConfig, RpcError, SpawnConfig, StdioRpcProcess}; -use crate::settings::AppConfig; +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use crate::logging::LogTimer; +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; +use log::{debug, error, info, warn}; use openvcs_core::models::{ Capabilities, ConflictDetails, ConflictSide, FetchOptions, LogQuery, StashItem, StatusPayload, StatusSummary, VcsEvent, }; use openvcs_core::{BackendId, OnEvent, Result as VcsResult, Vcs, VcsError}; -use serde::de::DeserializeOwned; -use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::sync::Arc; +const MODULE: &str = "vcs_proxy"; + +/// [`Vcs`] implementation that forwards operations to typed plugin runtime calls. pub struct PluginVcsProxy { + /// Backend identifier represented by this proxy instance. backend_id: BackendId, + /// Repository worktree path associated with this backend session. workdir: PathBuf, - rpc: StdioRpcProcess, + /// Started plugin runtime used for JSON-RPC calls. + runtime: Arc, } impl PluginVcsProxy { - /// Opens a repository through a plugin module process and returns a VCS trait object. - /// - /// # Parameters - /// - `plugin_id`: Owning plugin identifier. - /// - `backend_id`: Backend id exposed by the plugin. - /// - `exec_path`: Path to the plugin wasm/module executable. - /// - `approval`: Capability approval state for the plugin version. - /// - `requested_capabilities`: Capabilities requested by the plugin. - /// - `repo_path`: Repository working-tree path to open. - /// - /// # Returns - /// - `Ok(Arc)` when the plugin backend is opened successfully. - /// - `Err(VcsError)` when startup or open RPC fails. + /// Opens a repository through a previously started plugin module runtime. pub fn open_with_process( - plugin_id: String, backend_id: BackendId, - exec_path: PathBuf, - approval: ApprovalState, - requested_capabilities: Vec, + runtime: Arc, repo_path: &Path, + cfg: serde_json::Value, ) -> Result, VcsError> { - let workdir = repo_path.to_path_buf(); - let cfg = AppConfig::load_or_default(); - let cfg = serde_json::to_value(cfg).map_err(|e| VcsError::Backend { - backend: backend_id.clone(), - msg: format!("serialize config: {e}"), - })?; - let spawn = SpawnConfig { - plugin_id, - component_label: format!("vcs-backend-{}", backend_id.as_ref()), - exec_path, - args: vec!["--backend".into(), backend_id.as_ref().to_string()], - requested_capabilities, - approval, - allowed_workspace_root: Some(workdir.clone()), - }; - let rpc = StdioRpcProcess::new(spawn, RpcConfig::default()); + let _timer = LogTimer::new(MODULE, "open_with_process"); + let path_str = repo_path.to_string_lossy(); + info!( + "open_with_process: backend={}, path={}", + backend_id, path_str + ); + let p = PluginVcsProxy { - backend_id, - workdir, - rpc, + backend_id: backend_id.clone(), + workdir: repo_path.to_path_buf(), + runtime, }; - p.rpc - .call( - "open", - json!({ "path": path_to_utf8(repo_path)?, "config": cfg }), - ) - .map_err(map_rpc_err)?; - Ok(Arc::new(p)) - } - /// Calls a plugin RPC method and maps transport errors to [`VcsError`]. - /// - /// # Parameters - /// - `method`: RPC method name. - /// - `params`: JSON method parameters. - /// - /// # Returns - /// - `Ok(Value)` RPC result payload. - /// - `Err(VcsError)` on RPC failure. - fn call_value(&self, method: &str, params: Value) -> Result { - self.rpc.call(method, params).map_err(map_rpc_err) - } - - /// Calls a plugin RPC method and deserializes its JSON result. - /// - /// # Parameters - /// - `method`: RPC method name. - /// - `params`: JSON method parameters. - /// - /// # Returns - /// - `Ok(T)` deserialized result. - /// - `Err(VcsError)` on RPC or decode failure. - fn call_json(&self, method: &str, params: Value) -> Result { - let v = self.call_value(method, params)?; - serde_json::from_value(v).map_err(|e| VcsError::Backend { - backend: self.backend_id.clone(), - msg: format!("invalid plugin response for {method}: {e}"), - }) - } + p.runtime.ensure_running().map_err(|e| VcsError::Backend { + backend: p.backend_id.clone(), + msg: e, + })?; - /// Calls a plugin RPC method that returns no meaningful value. - /// - /// # Parameters - /// - `method`: RPC method name. - /// - `params`: JSON method parameters. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on RPC failure. - fn call_unit(&self, method: &str, params: Value) -> Result<(), VcsError> { - let _ = self.call_value(method, params)?; - Ok(()) + let config = serde_json::to_vec(&cfg).map_err(|e| VcsError::Backend { + backend: p.backend_id.clone(), + msg: format!("serialize open config: {e}"), + })?; + p.runtime + .vcs_open(path_to_utf8(repo_path)?.as_str(), &config) + .map_err(|e| { + error!("open_with_process: open call failed: {}", e); + VcsError::Backend { + backend: p.backend_id.clone(), + msg: e, + } + })?; + + info!( + "open_with_process: opened backend {} for {}", + backend_id, path_str + ); + Ok(Arc::new(p)) } /// Runs an operation while temporarily installing an event callback sink. - /// - /// # Parameters - /// - `on`: Optional event callback. - /// - `f`: Operation to execute while the callback is installed. - /// - /// # Returns - /// - `Ok(R)` operation result. - /// - `Err(VcsError)` operation error. fn with_events(&self, on: Option, f: F) -> Result where F: FnOnce() -> Result, { let sink: Option> = on.map(|cb| Arc::new(move |evt| cb(evt)) as _); - self.rpc.set_event_sink(sink); + self.runtime.set_event_sink(sink); let res = f(); - self.rpc.set_event_sink(None); + self.runtime.set_event_sink(None); res } + + /// Maps string runtime errors into backend-scoped VCS errors. + fn map_runtime_error(&self, err: String) -> VcsError { + VcsError::Backend { + backend: self.backend_id.clone(), + msg: err, + } + } } impl Vcs for PluginVcsProxy { - /// Returns the backend identifier for this proxy. - /// - /// # Returns - /// - Backend id value. fn id(&self) -> BackendId { self.backend_id.clone() } - /// Returns capability flags reported by the plugin. - /// - /// # Returns - /// - Capability set; defaults on decode failure. fn caps(&self) -> Capabilities { - self.call_json("caps", Value::Null).unwrap_or_default() + self.runtime.vcs_get_caps().unwrap_or_else(|e| { + warn!("caps: failed to query capabilities: {}", e); + Capabilities::default() + }) } - /// Unsupported direct constructor for this proxy. - /// - /// # Parameters - /// - `_path`: Ignored path argument. - /// - /// # Returns - /// - Always `Err(VcsError)`. fn open(_path: &Path) -> VcsResult where Self: Sized, @@ -169,15 +116,6 @@ impl Vcs for PluginVcsProxy { }) } - /// Unsupported direct clone constructor for this proxy. - /// - /// # Parameters - /// - `_url`: Ignored URL argument. - /// - `_dest`: Ignored destination argument. - /// - `_on`: Ignored event callback. - /// - /// # Returns - /// - Always `Err(VcsError)`. fn clone(_url: &str, _dest: &Path, _on: Option) -> VcsResult where Self: Sized, @@ -188,131 +126,66 @@ impl Vcs for PluginVcsProxy { }) } - /// Returns repository workdir associated with this proxy. - /// - /// # Returns - /// - Workdir path reference. fn workdir(&self) -> &Path { &self.workdir } - /// Returns current local branch if attached. - /// - /// # Returns - /// - `Ok(Some(String))` branch name. - /// - `Ok(None)` on detached HEAD. - /// - `Err(VcsError)` on backend failure. fn current_branch(&self) -> VcsResult> { - self.call_json("current_branch", Value::Null) + self.runtime + .vcs_get_current_branch() + .map_err(|e| self.map_runtime_error(e)) } - /// Returns local/remote branch records. - /// - /// # Returns - /// - `Ok(Vec)` branch list. - /// - `Err(VcsError)` on backend failure. fn branches(&self) -> VcsResult> { - self.call_json("branches", Value::Null) + self.runtime + .vcs_list_branches() + .map_err(|e| self.map_runtime_error(e)) } - /// Returns local branch names. - /// - /// # Returns - /// - `Ok(Vec)` local branch names. - /// - `Err(VcsError)` on backend failure. fn local_branches(&self) -> VcsResult> { - self.call_json("local_branches", Value::Null) - } - - /// Creates a branch and optionally checks it out. - /// - /// # Parameters - /// - `name`: Branch name. - /// - `checkout`: Whether to checkout the new branch. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_list_local_branches() + .map_err(|e| self.map_runtime_error(e)) + } + fn create_branch(&self, name: &str, checkout: bool) -> VcsResult<()> { - self.call_unit( - "create_branch", - json!({ "name": name, "checkout": checkout }), - ) - } - - /// Checks out an existing branch. - /// - /// # Parameters - /// - `name`: Branch name. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_create_branch(name, checkout) + .map_err(|e| self.map_runtime_error(e)) + } + fn checkout_branch(&self, name: &str) -> VcsResult<()> { - self.call_unit("checkout_branch", json!({ "name": name })) - } - - /// Creates or updates a remote URL. - /// - /// # Parameters - /// - `name`: Remote name. - /// - `url`: Remote URL. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_checkout_branch(name) + .map_err(|e| self.map_runtime_error(e)) + } + fn ensure_remote(&self, name: &str, url: &str) -> VcsResult<()> { - self.call_unit("ensure_remote", json!({ "name": name, "url": url })) + self.runtime + .vcs_ensure_remote(name, url) + .map_err(|e| self.map_runtime_error(e)) } - /// Lists configured remotes. - /// - /// # Returns - /// - `Ok(Vec<(String, String)>)` name/url pairs. - /// - `Err(VcsError)` on backend failure. fn list_remotes(&self) -> VcsResult> { - self.call_json("list_remotes", Value::Null) + self.runtime + .vcs_list_remotes() + .map_err(|e| self.map_runtime_error(e)) } - /// Removes a configured remote. - /// - /// # Parameters - /// - `name`: Remote name. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn remove_remote(&self, name: &str) -> VcsResult<()> { - self.call_unit("remove_remote", json!({ "name": name })) - } - - /// Fetches a refspec from a remote. - /// - /// # Parameters - /// - `remote`: Remote name. - /// - `refspec`: Refspec expression. - /// - `on`: Optional progress callback. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_remove_remote(name) + .map_err(|e| self.map_runtime_error(e)) + } + fn fetch(&self, remote: &str, refspec: &str, on: Option) -> VcsResult<()> { self.with_events(on, || { - self.call_unit("fetch", json!({ "remote": remote, "refspec": refspec })) + self.runtime + .vcs_fetch(remote, refspec) + .map_err(|e| self.map_runtime_error(e)) }) } - /// Fetches using explicit options payload. - /// - /// # Parameters - /// - `remote`: Remote name. - /// - `refspec`: Refspec expression. - /// - `opts`: Fetch option flags. - /// - `on`: Optional progress callback. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn fetch_with_options( &self, remote: &str, @@ -321,59 +194,28 @@ impl Vcs for PluginVcsProxy { on: Option, ) -> VcsResult<()> { self.with_events(on, || { - self.call_unit( - "fetch_with_options", - json!({ "remote": remote, "refspec": refspec, "opts": opts }), - ) + self.runtime + .vcs_fetch_with_options(remote, refspec, opts) + .map_err(|e| self.map_runtime_error(e)) }) } - /// Pushes a refspec to a remote. - /// - /// # Parameters - /// - `remote`: Remote name. - /// - `refspec`: Refspec expression. - /// - `on`: Optional progress callback. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn push(&self, remote: &str, refspec: &str, on: Option) -> VcsResult<()> { self.with_events(on, || { - self.call_unit("push", json!({ "remote": remote, "refspec": refspec })) + self.runtime + .vcs_push(remote, refspec) + .map_err(|e| self.map_runtime_error(e)) }) } - /// Pulls from upstream using fast-forward-only strategy. - /// - /// # Parameters - /// - `remote`: Remote name. - /// - `branch`: Branch name. - /// - `on`: Optional progress callback. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn pull_ff_only(&self, remote: &str, branch: &str, on: Option) -> VcsResult<()> { self.with_events(on, || { - self.call_unit( - "pull_ff_only", - json!({ "remote": remote, "branch": branch }), - ) + self.runtime + .vcs_pull_ff_only(remote, branch) + .map_err(|e| self.map_runtime_error(e)) }) } - /// Creates a commit from selected paths. - /// - /// # Parameters - /// - `message`: Commit message. - /// - `name`: Author name. - /// - `email`: Author email. - /// - `paths`: Paths to include. - /// - /// # Returns - /// - `Ok(String)` created commit id. - /// - `Err(VcsError)` on backend failure. fn commit( &self, message: &str, @@ -381,424 +223,245 @@ impl Vcs for PluginVcsProxy { email: &str, paths: &[PathBuf], ) -> VcsResult { - let paths: Vec = paths + let paths = paths .iter() .map(|p| p.to_string_lossy().to_string()) - .collect(); - self.call_json( - "commit", - json!({ "message": message, "name": name, "email": email, "paths": paths }), - ) - } - - /// Creates a commit from the index. - /// - /// # Parameters - /// - `message`: Commit message. - /// - `name`: Author name. - /// - `email`: Author email. - /// - /// # Returns - /// - `Ok(String)` commit id. - /// - `Err(VcsError)` on backend failure. + .collect::>(); + self.runtime + .vcs_commit(message, name, email, &paths) + .map_err(|e| self.map_runtime_error(e)) + } + fn commit_index(&self, message: &str, name: &str, email: &str) -> VcsResult { - self.call_json( - "commit_index", - json!({ "message": message, "name": name, "email": email }), - ) + self.runtime + .vcs_commit_index(message, name, email) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns summarized status information. - /// - /// # Returns - /// - `Ok(StatusSummary)` summary payload. - /// - `Err(VcsError)` on backend failure. fn status_summary(&self) -> VcsResult { - self.call_json("status_summary", Value::Null) + self.runtime + .vcs_get_status_summary() + .map_err(|e| self.map_runtime_error(e)) } - /// Returns full status payload. - /// - /// # Returns - /// - `Ok(StatusPayload)` status payload. - /// - `Err(VcsError)` on backend failure. fn status_payload(&self) -> VcsResult { - self.call_json("status_payload", Value::Null) + self.runtime + .vcs_get_status_payload() + .map_err(|e| self.map_runtime_error(e)) } - /// Returns commit log entries for a query. - /// - /// # Parameters - /// - `query`: Log query payload. - /// - /// # Returns - /// - `Ok(Vec)` commit entries. - /// - `Err(VcsError)` on backend failure. fn log_commits(&self, query: &LogQuery) -> VcsResult> { - self.call_json("log_commits", json!({ "query": query })) + self.runtime + .vcs_list_commits(query) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns diff lines for a file path. - /// - /// # Parameters - /// - `path`: Repository-relative path. - /// - /// # Returns - /// - `Ok(Vec)` diff lines. - /// - `Err(VcsError)` on backend failure. fn diff_file(&self, path: &Path) -> VcsResult> { - self.call_json("diff_file", json!({ "path": path_to_utf8(path)? })) + self.runtime + .vcs_diff_file(path_to_utf8(path)?.as_str()) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns diff lines for a commit/revision. - /// - /// # Parameters - /// - `rev`: Revision selector. - /// - /// # Returns - /// - `Ok(Vec)` diff lines. - /// - `Err(VcsError)` on backend failure. fn diff_commit(&self, rev: &str) -> VcsResult> { - self.call_json("diff_commit", json!({ "rev": rev })) + self.runtime + .vcs_diff_commit(rev) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns merge-conflict details for a file. - /// - /// # Parameters - /// - `path`: Conflict file path. - /// - /// # Returns - /// - `Ok(ConflictDetails)` conflict payload. - /// - `Err(VcsError)` on backend failure. fn conflict_details(&self, path: &Path) -> VcsResult { - self.call_json("conflict_details", json!({ "path": path_to_utf8(path)? })) - } - - /// Checks out a specific conflict side for a file. - /// - /// # Parameters - /// - `path`: Conflict file path. - /// - `side`: Conflict side selector. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_get_conflict_details(path_to_utf8(path)?.as_str()) + .map_err(|e| self.map_runtime_error(e)) + } + fn checkout_conflict_side(&self, path: &Path, side: ConflictSide) -> VcsResult<()> { - self.call_unit( - "checkout_conflict_side", - json!({ "path": path_to_utf8(path)?, "side": side }), - ) - } - - /// Writes merged file content for a conflict path. - /// - /// # Parameters - /// - `path`: Conflict file path. - /// - `content`: Resolved bytes. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_checkout_conflict_side(path_to_utf8(path)?.as_str(), side) + .map_err(|e| self.map_runtime_error(e)) + } + fn write_merge_result(&self, path: &Path, content: &[u8]) -> VcsResult<()> { - let content = String::from_utf8_lossy(content).to_string(); - self.call_unit( - "write_merge_result", - json!({ "path": path_to_utf8(path)?, "content": content }), - ) - } - - /// Stages a patch in the index. - /// - /// # Parameters - /// - `patch`: Unified patch text. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_write_merge_result(path_to_utf8(path)?.as_str(), content) + .map_err(|e| self.map_runtime_error(e)) + } + fn stage_patch(&self, patch: &str) -> VcsResult<()> { - self.call_unit("stage_patch", json!({ "patch": patch })) + self.runtime + .vcs_stage_patch(patch) + .map_err(|e| self.map_runtime_error(e)) } - /// Discards changes for explicit paths. - /// - /// # Parameters - /// - `paths`: Paths to discard. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn discard_paths(&self, paths: &[PathBuf]) -> VcsResult<()> { - let paths: Vec = paths + let paths = paths .iter() .map(|p| p.to_string_lossy().to_string()) - .collect(); - self.call_unit("discard_paths", json!({ "paths": paths })) - } - - /// Applies a patch in reverse to discard hunks. - /// - /// # Parameters - /// - `patch`: Unified patch text. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + .collect::>(); + self.runtime + .vcs_discard_paths(&paths) + .map_err(|e| self.map_runtime_error(e)) + } + fn apply_reverse_patch(&self, patch: &str) -> VcsResult<()> { - self.call_unit("apply_reverse_patch", json!({ "patch": patch })) - } - - /// Deletes a branch. - /// - /// # Parameters - /// - `name`: Branch name. - /// - `force`: Force-delete flag. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_apply_reverse_patch(patch) + .map_err(|e| self.map_runtime_error(e)) + } + fn delete_branch(&self, name: &str, force: bool) -> VcsResult<()> { - self.call_unit("delete_branch", json!({ "name": name, "force": force })) - } - - /// Renames a branch. - /// - /// # Parameters - /// - `old`: Existing branch name. - /// - `new`: New branch name. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_delete_branch(name, force) + .map_err(|e| self.map_runtime_error(e)) + } + fn rename_branch(&self, old: &str, new: &str) -> VcsResult<()> { - self.call_unit("rename_branch", json!({ "old": old, "new": new })) + self.runtime + .vcs_rename_branch(old, new) + .map_err(|e| self.map_runtime_error(e)) } - /// Merges a branch into the current branch. - /// - /// # Parameters - /// - `name`: Source branch name. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn merge_into_current(&self, name: &str) -> VcsResult<()> { - self.call_unit("merge_into_current", json!({ "name": name })) + self.runtime + .vcs_merge_into_current(name, None) + .map_err(|e| self.map_runtime_error(e)) + } + + fn merge_into_current_with_message(&self, name: &str, message: Option<&str>) -> VcsResult<()> { + self.runtime + .vcs_merge_into_current(name, message) + .map_err(|e| self.map_runtime_error(e)) } - /// Aborts an in-progress merge. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn merge_abort(&self) -> VcsResult<()> { - self.call_unit("merge_abort", Value::Null) + self.runtime + .vcs_merge_abort() + .map_err(|e| self.map_runtime_error(e)) } - /// Continues an in-progress merge. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn merge_continue(&self) -> VcsResult<()> { - self.call_unit("merge_continue", Value::Null) + self.runtime + .vcs_merge_continue() + .map_err(|e| self.map_runtime_error(e)) } - /// Returns whether a merge is currently in progress. - /// - /// # Returns - /// - `Ok(bool)` merge state. - /// - `Err(VcsError)` on backend failure. fn merge_in_progress(&self) -> VcsResult { - self.call_json("merge_in_progress", Value::Null) - } - - /// Sets upstream tracking branch for a local branch. - /// - /// # Parameters - /// - `branch`: Local branch name. - /// - `upstream`: Upstream ref name. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_is_merge_in_progress() + .map_err(|e| self.map_runtime_error(e)) + } + fn set_branch_upstream(&self, branch: &str, upstream: &str) -> VcsResult<()> { - self.call_unit( - "set_branch_upstream", - json!({ "branch": branch, "upstream": upstream }), - ) - } - - /// Returns upstream ref for a local branch. - /// - /// # Parameters - /// - `branch`: Local branch name. - /// - /// # Returns - /// - `Ok(Some(String))` upstream ref. - /// - `Ok(None)` when unset. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_set_branch_upstream(branch, upstream) + .map_err(|e| self.map_runtime_error(e)) + } + fn branch_upstream(&self, branch: &str) -> VcsResult> { - self.call_json("branch_upstream", json!({ "branch": branch })) + self.runtime + .vcs_get_branch_upstream(branch) + .map_err(|e| self.map_runtime_error(e)) } - /// Performs a hard reset of HEAD/worktree. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn hard_reset_head(&self) -> VcsResult<()> { - self.call_unit("hard_reset_head", Value::Null) + self.runtime + .vcs_hard_reset_head() + .map_err(|e| self.map_runtime_error(e)) } - /// Performs a soft reset to a revision. - /// - /// # Parameters - /// - `rev`: Target revision. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn reset_soft_to(&self, rev: &str) -> VcsResult<()> { - self.call_unit("reset_soft_to", json!({ "rev": rev })) + self.runtime + .vcs_reset_soft_to(rev) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns configured repository identity if available. - /// - /// # Returns - /// - `Ok(Some((String, String)))` name/email pair. - /// - `Ok(None)` when unset. - /// - `Err(VcsError)` on backend failure. fn get_identity(&self) -> VcsResult> { - self.call_json("get_identity", Value::Null) - } - - /// Sets repository-local identity. - /// - /// # Parameters - /// - `name`: Author name. - /// - `email`: Author email. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_get_identity() + .map_err(|e| self.map_runtime_error(e)) + } + fn set_identity_local(&self, name: &str, email: &str) -> VcsResult<()> { - self.call_unit( - "set_identity_local", - json!({ "name": name, "email": email }), - ) + self.runtime + .vcs_set_identity_local(name, email) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns stash entries. - /// - /// # Returns - /// - `Ok(Vec)` stash list. - /// - `Err(VcsError)` on backend failure. fn stash_list(&self) -> VcsResult> { - self.call_json("stash_list", Value::Null) - } - - /// Creates a stash entry. - /// - /// # Parameters - /// - `message`: Stash message. - /// - `include_untracked`: Whether to include untracked files. - /// - `paths`: Optional path subset. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + self.runtime + .vcs_list_stashes() + .map_err(|e| self.map_runtime_error(e)) + } + fn stash_push( &self, message: &str, include_untracked: bool, - paths: &[PathBuf], + _paths: &[PathBuf], ) -> VcsResult<()> { - let paths: Vec = paths - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - self.call_unit( - "stash_push", - json!({ "message": message, "include_untracked": include_untracked, "paths": paths }), - ) - } - - /// Applies a stash entry. - /// - /// # Parameters - /// - `selector`: Stash selector. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. + let message = if message.trim().is_empty() { + None + } else { + Some(message) + }; + let _ = self + .runtime + .vcs_stash_push(message, include_untracked) + .map_err(|e| self.map_runtime_error(e))?; + Ok(()) + } + fn stash_apply(&self, selector: &str) -> VcsResult<()> { - self.call_unit("stash_apply", json!({ "selector": selector })) + self.runtime + .vcs_stash_apply(selector) + .map_err(|e| self.map_runtime_error(e)) } - /// Pops a stash entry. - /// - /// # Parameters - /// - `selector`: Stash selector. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn stash_pop(&self, selector: &str) -> VcsResult<()> { - self.call_unit("stash_pop", json!({ "selector": selector })) + self.runtime + .vcs_stash_pop(selector) + .map_err(|e| self.map_runtime_error(e)) } - /// Drops a stash entry. - /// - /// # Parameters - /// - `selector`: Stash selector. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn stash_drop(&self, selector: &str) -> VcsResult<()> { - self.call_unit("stash_drop", json!({ "selector": selector })) + self.runtime + .vcs_stash_drop(selector) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns patch lines for a stash entry. - /// - /// # Parameters - /// - `selector`: Stash selector. - /// - /// # Returns - /// - `Ok(Vec)` stash diff lines. - /// - `Err(VcsError)` on backend failure. fn stash_show(&self, selector: &str) -> VcsResult> { - self.call_json("stash_show", json!({ "selector": selector })) + self.runtime + .vcs_stash_show(selector) + .map(|value| value.lines().map(|line| line.to_string()).collect()) + .map_err(|e| self.map_runtime_error(e)) + } + + fn cherry_pick(&self, rev: &str) -> VcsResult<()> { + self.runtime + .vcs_cherry_pick(rev) + .map_err(|e| self.map_runtime_error(e)) + } + + fn revert_commit(&self, rev: &str, no_edit: bool) -> VcsResult<()> { + self.runtime + .vcs_revert_commit(rev, no_edit) + .map_err(|e| self.map_runtime_error(e)) } } -/// Converts an RPC error into a backend-scoped [`VcsError`]. -/// -/// # Parameters -/// - `err`: RPC error payload. -/// -/// # Returns -/// - Converted backend error. -fn map_rpc_err(err: RpcError) -> VcsError { - VcsError::Backend { - backend: BackendId::from("plugin"), - msg: format!("{}: {}", err.code, err.message), +impl Drop for PluginVcsProxy { + /// Stops the underlying plugin runtime when the proxy is dropped. + fn drop(&mut self) { + debug!("drop: stopping VCS plugin runtime for {}", self.backend_id); + self.runtime.stop(); } } -/// Converts a filesystem path to UTF-8 text for JSON RPC transport. -/// -/// # Parameters -/// - `path`: Filesystem path. -/// -/// # Returns -/// - `Ok(String)` UTF-8 path. -/// - `Err(VcsError)` when path is non-UTF8. +/// Converts a filesystem path to UTF-8 text. fn path_to_utf8(path: &Path) -> Result { path.to_str() - .map(|s| s.to_string()) + .map(str::to_string) .ok_or_else(|| VcsError::Backend { backend: BackendId::from("plugin"), - msg: "non-utf8 path".into(), + msg: format!("non-utf8 path: {}", path.display()), }) } diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 5a6314ee..3f9d2ffa 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -1,10 +1,16 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Discovery and opening logic for plugin-provided VCS backends. -use crate::plugin_bundles::{ApprovalState, PluginBundleStore, PluginManifest, VcsBackendProvide}; +use crate::logging::LogTimer; +use crate::plugin_bundles::{PluginBundleStore, PluginManifest, VcsBackendProvide}; use crate::plugin_paths::{built_in_plugin_dirs, PLUGIN_MANIFEST_NAME}; -use crate::plugin_runtime::vcs_proxy::PluginVcsProxy; +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::runtime_select::create_node_runtime_instance; +use crate::plugin_runtime::settings_store; +use crate::plugin_runtime::{vcs_proxy::PluginVcsProxy, PluginRuntimeManager}; use crate::settings::AppConfig; -use log::warn; +use log::{debug, error, info, trace, warn}; use openvcs_core::{BackendId, Result as VcsResult, Vcs, VcsError}; use std::{ collections::BTreeMap, @@ -13,6 +19,13 @@ use std::{ sync::Arc, }; +const MODULE: &str = "plugin_vcs_backends"; + +/// Returns plugin-scoped open config for a VCS backend plugin. +fn plugin_open_config(plugin_id: &str) -> serde_json::Value { + serde_json::Value::Object(settings_store::load_settings(plugin_id).unwrap_or_default()) +} + /// Determines whether a plugin is enabled considering config overrides. /// /// # Parameters @@ -23,34 +36,15 @@ use std::{ /// - `true` when plugin should be active. /// - `false` otherwise. fn is_plugin_enabled_in_settings(plugin_id: &str, default_enabled: bool) -> bool { - let plugin_id = plugin_id.trim().to_lowercase(); - if plugin_id.is_empty() { - return false; - } - let cfg = AppConfig::load_or_default(); - let disabled: Vec = cfg - .plugins - .disabled - .iter() - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .collect(); - if disabled.iter().any(|id| id == &plugin_id) { - return false; - } - - let enabled: Vec = cfg - .plugins - .enabled - .iter() - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .collect(); - - // `enabled` is additive (explicit opt-in), not an allowlist. - // Plugins remain active by their manifest default unless explicitly disabled. - default_enabled || enabled.iter().any(|id| id == &plugin_id) + let enabled = cfg.is_plugin_enabled(plugin_id, default_enabled); + trace!( + "is_plugin_enabled_in_settings: plugin={}, default={}, result={}", + plugin_id, + default_enabled, + enabled + ); + enabled } /// Metadata describing a single plugin-provided backend implementation. @@ -64,29 +58,6 @@ pub struct PluginBackendDescriptor { pub plugin_id: String, /// Optional human-readable plugin name. pub plugin_name: Option, - /// Executable used to proxy backend operations. - pub exec_path: std::path::PathBuf, - /// Capabilities requested by the plugin version providing this backend. - pub requested_capabilities: Vec, - /// Current capability approval state for the plugin version. - pub approval: ApprovalState, -} - -/// Normalizes capability ids (trim/sort/dedup). -/// -/// # Parameters -/// - `caps`: Raw capability list. -/// -/// # Returns -/// - Normalized capability list. -fn normalize_capabilities(mut caps: Vec) -> Vec { - for cap in &mut caps { - *cap = cap.trim().to_string(); - } - caps.retain(|cap| !cap.is_empty()); - caps.sort(); - caps.dedup(); - caps } /// Reads a plugin manifest from a plugin directory. @@ -99,8 +70,19 @@ fn normalize_capabilities(mut caps: Vec) -> Vec { /// - `None` on read/parse failure. fn load_manifest_from_dir(plugin_dir: &Path) -> Option { let manifest_path = plugin_dir.join(PLUGIN_MANIFEST_NAME); + trace!( + "load_manifest_from_dir: loading from {}", + manifest_path.display() + ); + let text = fs::read_to_string(&manifest_path).ok()?; - serde_json::from_str(&text).ok() + let manifest: PluginManifest = serde_json::from_str(&text).ok()?; + + debug!( + "load_manifest_from_dir: loaded manifest for plugin '{}'", + manifest.id + ); + Some(manifest) } /// Lists manifests from built-in plugin directories. @@ -108,14 +90,34 @@ fn load_manifest_from_dir(plugin_dir: &Path) -> Option { /// # Returns /// - Directory/manifest pairs for readable built-in plugins. fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { + let _timer = LogTimer::new(MODULE, "builtin_plugin_manifests"); + trace!("builtin_plugin_manifests: scanning built-in plugin dirs",); + let mut out = Vec::new(); - for root in built_in_plugin_dirs() { + let dirs = built_in_plugin_dirs(); + debug!( + "builtin_plugin_manifests: found {} built-in plugin directories", + dirs.len() + ); + + for root in dirs { if !root.is_dir() { + trace!( + "builtin_plugin_manifests: {} is not a directory", + root.display() + ); continue; } let entries = match fs::read_dir(&root) { Ok(entries) => entries, - Err(_) => continue, + Err(e) => { + warn!( + "builtin_plugin_manifests: failed to read {}: {}", + root.display(), + e + ); + continue; + } }; for entry in entries.flatten() { let plugin_dir = entry.path(); @@ -127,6 +129,11 @@ fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { } } } + + debug!( + "builtin_plugin_manifests: found {} built-in manifests", + out.len() + ); out } @@ -136,37 +143,67 @@ fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { /// - `Ok(Vec)` containing discovered backend descriptors. /// - `Err(String)` if installed plugin components cannot be loaded. pub fn list_plugin_vcs_backends() -> Result, String> { + let _timer = LogTimer::new(MODULE, "list_plugin_vcs_backends"); + info!("list_plugin_vcs_backends: discovering VCS backends",); + let store = PluginBundleStore::new_default(); - let plugins = store.list_current_components()?; + let plugins = store.list_current_components().map_err(|e| { + error!("list_plugin_vcs_backends: failed to list components: {}", e); + e + })?; + + debug!( + "list_plugin_vcs_backends: found {} installed plugins", + plugins.len() + ); let mut map: BTreeMap = BTreeMap::new(); for p in plugins { if !is_plugin_enabled_in_settings(&p.plugin_id, p.default_enabled) { + trace!( + "list_plugin_vcs_backends: plugin {} is disabled", + p.plugin_id + ); continue; } + + let approved = store + .get_current_installed(&p.plugin_id) + .ok() + .flatten() + .is_some_and(|installed| { + matches!( + installed.approval, + crate::plugin_bundles::ApprovalState::Approved { .. } + ) + }); + if !approved { + trace!( + "list_plugin_vcs_backends: plugin {} is not approved", + p.plugin_id + ); + continue; + } + let Some(module) = p.module else { + trace!( + "list_plugin_vcs_backends: plugin {} has no module", + p.plugin_id + ); continue; }; - let installed = store - .get_current_installed(&p.plugin_id)? - .unwrap_or_else(|| crate::plugin_bundles::InstalledPluginVersion { - version: p.version.clone(), - bundle_sha256: String::new(), - installed_at_unix_ms: 0, - requested_capabilities: p.requested_capabilities.clone(), - approval: ApprovalState::Pending, - }); for (id, name) in module.vcs_backends { let backend_id = BackendId::from(id.as_str()); + debug!( + "list_plugin_vcs_backends: found backend '{}' from plugin '{}'", + backend_id, p.plugin_id + ); let candidate = PluginBackendDescriptor { backend_id: backend_id.clone(), backend_name: name, plugin_id: p.plugin_id.clone(), plugin_name: p.name.clone(), - exec_path: module.exec_path.clone(), - requested_capabilities: installed.requested_capabilities.clone(), - approval: installed.approval.clone(), }; let key = backend_id.as_ref().to_string(); map.insert(key, candidate); @@ -176,12 +213,24 @@ pub fn list_plugin_vcs_backends() -> Result, String for (plugin_dir, manifest) in builtin_plugin_manifests() { let plugin_id = manifest.id.trim(); if plugin_id.is_empty() { + warn!( + "list_plugin_vcs_backends: manifest has empty id at {}", + plugin_dir.display() + ); continue; } if !is_plugin_enabled_in_settings(plugin_id, manifest.default_enabled) { + trace!( + "list_plugin_vcs_backends: built-in plugin {} is disabled", + plugin_id + ); continue; } let Some(module) = &manifest.module else { + trace!( + "list_plugin_vcs_backends: built-in plugin {} has no module", + plugin_id + ); continue; }; let Some(exec_name) = module @@ -190,19 +239,28 @@ pub fn list_plugin_vcs_backends() -> Result, String .map(str::trim) .filter(|s| !s.is_empty()) else { + trace!( + "list_plugin_vcs_backends: built-in plugin {} has no exec", + plugin_id + ); continue; }; let exec_path = plugin_dir.join("bin").join(exec_name); if !exec_path.is_file() { warn!( - "plugin_vcs_backends: built-in plugin {} is missing module exec {}", + "list_plugin_vcs_backends: built-in plugin {} is missing module exec {}", plugin_id, exec_path.display() ); continue; } - let requested_capabilities = normalize_capabilities(manifest.capabilities.clone()); - let approval_caps = requested_capabilities.clone(); + + debug!( + "list_plugin_vcs_backends: processing built-in plugin {} at {}", + plugin_id, + plugin_dir.display() + ); + let plugin_name = manifest.name.clone(); for provide in &module.vcs_backends { let (id, label) = match provide { @@ -212,25 +270,32 @@ pub fn list_plugin_vcs_backends() -> Result, String let backend_id = BackendId::from(id.as_str()); let key = backend_id.as_ref().to_string(); if map.contains_key(&key) { + trace!( + "list_plugin_vcs_backends: backend {} already registered", + backend_id + ); continue; } + debug!( + "list_plugin_vcs_backends: registering built-in backend '{}' from plugin '{}'", + backend_id, plugin_id + ); let candidate = PluginBackendDescriptor { backend_id: backend_id.clone(), backend_name: label, plugin_id: plugin_id.to_string(), plugin_name: plugin_name.clone(), - exec_path: exec_path.clone(), - requested_capabilities: requested_capabilities.clone(), - approval: ApprovalState::Approved { - capabilities: approval_caps.clone(), - approved_at_unix_ms: 0, - }, }; map.insert(key, candidate); } } - Ok(map.into_values().collect()) + let result: Vec<_> = map.into_values().collect(); + info!( + "list_plugin_vcs_backends: discovered {} VCS backends", + result.len() + ); + Ok(result) } /// Returns whether a plugin-provided backend exists for the given backend id. @@ -242,10 +307,13 @@ pub fn list_plugin_vcs_backends() -> Result, String /// - `true` when a matching plugin backend is available. /// - `false` otherwise. pub fn has_plugin_vcs_backend(backend_id: &BackendId) -> bool { - list_plugin_vcs_backends().ok().is_some_and(|v| { + trace!("has_plugin_vcs_backend: checking for {}", backend_id); + let result = list_plugin_vcs_backends().ok().is_some_and(|v| { v.iter() .any(|b| b.backend_id.as_ref() == backend_id.as_ref()) - }) + }); + debug!("has_plugin_vcs_backend: {} -> {}", backend_id, result); + result } /// Resolves the descriptor for a specific plugin-provided backend id. @@ -259,10 +327,25 @@ pub fn has_plugin_vcs_backend(backend_id: &BackendId) -> bool { pub fn plugin_vcs_backend_descriptor( backend_id: &BackendId, ) -> Result { - list_plugin_vcs_backends()? + trace!("plugin_vcs_backend_descriptor: resolving {}", backend_id); + + let backends = list_plugin_vcs_backends()?; + let result = backends .into_iter() .find(|d| d.backend_id.as_ref() == backend_id.as_ref()) - .ok_or_else(|| format!("Unknown VCS backend: {backend_id}")) + .ok_or_else(|| { + warn!( + "plugin_vcs_backend_descriptor: unknown backend {}", + backend_id + ); + format!("Unknown VCS backend: {backend_id}") + })?; + + debug!( + "plugin_vcs_backend_descriptor: found {} from plugin {}", + backend_id, result.plugin_id + ); + Ok(result) } /// Opens a repository through a plugin backend process. @@ -275,18 +358,93 @@ pub fn plugin_vcs_backend_descriptor( /// - `Ok(Arc)` with an opened backend proxy. /// - `Err(VcsError)` when descriptor resolution or backend startup fails. pub fn open_repo_via_plugin_vcs_backend( + runtime_manager: &PluginRuntimeManager, + cfg: &AppConfig, backend_id: BackendId, path: &Path, ) -> VcsResult> { - let desc = plugin_vcs_backend_descriptor(&backend_id) - .map_err(|_| VcsError::Unsupported(backend_id.clone()))?; - - PluginVcsProxy::open_with_process( - desc.plugin_id, + let _timer = LogTimer::new(MODULE, "open_repo_via_plugin_vcs_backend"); + info!( + "open_repo_via_plugin_vcs_backend: backend={}, path={}", backend_id, - desc.exec_path, - desc.approval, - desc.requested_capabilities, - path, - ) + path.display() + ); + + let desc = plugin_vcs_backend_descriptor(&backend_id).map_err(|e| { + error!( + "open_repo_via_plugin_vcs_backend: failed to resolve backend {}: {}", + backend_id, e + ); + VcsError::Unsupported(backend_id.clone()) + })?; + + debug!( + "open_repo_via_plugin_vcs_backend: resolved to plugin {}", + desc.plugin_id + ); + + let cfg_value = plugin_open_config(&desc.plugin_id); + + let workspace_root = std::fs::canonicalize(path).map_err(|e| VcsError::Backend { + backend: backend_id.clone(), + msg: format!("canonicalize repo root: {e}"), + })?; + + trace!( + "open_repo_via_plugin_vcs_backend: resolving spawn for plugin {}", + desc.plugin_id + ); + + let spawn = runtime_manager + .vcs_spawn_for_workspace_with_config(cfg, &desc.plugin_id, workspace_root) + .map_err(|e| { + error!( + "open_repo_via_plugin_vcs_backend: failed to resolve spawn for plugin {}: {}", + desc.plugin_id, e + ); + VcsError::Backend { + backend: backend_id.clone(), + msg: e, + } + })?; + + let runtime = create_node_runtime_instance(spawn).map_err(|e| { + error!( + "open_repo_via_plugin_vcs_backend: failed to create runtime for plugin {}: {}", + desc.plugin_id, e + ); + VcsError::Backend { + backend: backend_id.clone(), + msg: e, + } + })?; + + runtime.ensure_running().map_err(|e| VcsError::Backend { + backend: backend_id.clone(), + msg: e, + })?; + + debug!("open_repo_via_plugin_vcs_backend: opening via plugin proxy",); + + let result = PluginVcsProxy::open_with_process(backend_id.clone(), runtime, path, cfg_value); + + match &result { + Ok(_) => { + info!( + "open_repo_via_plugin_vcs_backend: successfully opened {} via {}", + path.display(), + backend_id + ); + } + Err(e) => { + error!( + "open_repo_via_plugin_vcs_backend: failed to open {} via {}: {}", + path.display(), + backend_id, + e + ); + } + } + + result } diff --git a/Backend/src/plugins.rs b/Backend/src/plugins.rs index 6b282a17..3563d76e 100644 --- a/Backend/src/plugins.rs +++ b/Backend/src/plugins.rs @@ -1,10 +1,18 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +use crate::plugin_bundles::PluginBundleStore; use crate::plugin_paths::{built_in_plugin_dirs, ensure_dir, plugins_dir, PLUGIN_MANIFEST_NAME}; -use log::warn; +use log::{debug, warn}; +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; use serde::{Deserialize, Serialize}; use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, fs, path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, RwLock, + }, }; const PLUGIN_THEMES_DIR_NAME: &str = "themes"; @@ -54,7 +62,7 @@ pub struct PluginThemeDir { pub path: PathBuf, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct RawPluginManifest { id: String, name: String, @@ -146,6 +154,155 @@ impl PluginOrigin { } } +#[derive(Clone)] +struct CachedPlugin { + resolved: PathBuf, + manifest: RawPluginManifest, + origin: PluginOrigin, +} + +#[derive(Default)] +struct CacheData { + list: Vec, + entries: HashMap, + loaded: bool, +} + +struct PluginCache { + data: RwLock, + dirty: AtomicBool, + watcher: Mutex>, +} + +impl PluginCache { + fn initialize() -> Arc { + let cache = Arc::new(Self { + data: RwLock::new(CacheData::default()), + dirty: AtomicBool::new(true), + watcher: Mutex::new(None), + }); + cache.ensure_fresh(); + cache.watch_directories(); + cache + } + + fn list(&self) -> Vec { + self.ensure_fresh(); + self.data.read().unwrap().list.clone() + } + + fn load_cached_plugin(&self, id: &str) -> Option { + self.ensure_fresh(); + self.data.read().unwrap().entries.get(id).cloned() + } + + fn mark_dirty(&self) { + self.dirty.store(true, Ordering::SeqCst); + } + + fn ensure_fresh(&self) { + let needs_reload = self.dirty.swap(false, Ordering::SeqCst) || { + let data = self.data.read().unwrap(); + !data.loaded + }; + if needs_reload { + self.reload(); + } + } + + fn reload(&self) { + let built_in_ids = crate::plugin_bundles::built_in_plugin_ids(); + let bundle_store = PluginBundleStore::new_default(); + let mut seen = HashSet::new(); + let mut summaries: Vec = Vec::new(); + let mut entries: HashMap = HashMap::new(); + + for (root, origin) in plugin_roots() { + match fs::read_dir(&root) { + Ok(iter) => { + for entry in iter.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + if let Ok((resolved, manifest)) = read_manifest_from_directory(&path) { + let norm = manifest.id.trim().to_ascii_lowercase(); + if !seen.insert(norm.clone()) { + continue; + } + + let is_built_in = built_in_ids.contains(&norm); + + if !is_built_in { + if bundle_store.get_current_dir(&norm).ok().flatten().is_none() { + debug!("plugins: skipping '{}' - not properly installed (no current version)", norm); + continue; + } + } + + let effective_origin = if is_built_in { + PluginOrigin::BuiltIn + } else { + origin + }; + let summary = + manifest_to_summary(&resolved, manifest.clone(), effective_origin); + summaries.push(summary); + entries.insert( + norm, + CachedPlugin { + resolved: resolved.clone(), + manifest, + origin: effective_origin, + }, + ); + } + } + } + Err(err) => warn!("plugins: failed to list {}: {}", root.display(), err), + } + } + + summaries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + let mut data = self.data.write().unwrap(); + data.list = summaries; + data.entries = entries; + data.loaded = true; + } + + fn watch_directories(self: &Arc) { + let cache = Arc::clone(self); + let mut watcher = match RecommendedWatcher::new( + move |res: notify::Result| match res { + Ok(_) => cache.mark_dirty(), + Err(err) => warn!("plugins: watcher error: {}", err), + }, + Config::default(), + ) { + Ok(w) => w, + Err(err) => { + warn!("plugins: failed to start directory watcher: {}", err); + return; + } + }; + + for (root, _) in plugin_roots() { + if let Err(err) = watcher.watch(&root, RecursiveMode::Recursive) { + warn!("plugins: failed to watch {}: {}", root.display(), err); + } + } + + let mut guard = self.watcher.lock().unwrap(); + *guard = Some(watcher); + } +} + +static PLUGIN_CACHE: OnceLock> = OnceLock::new(); + +fn plugin_cache() -> &'static Arc { + PLUGIN_CACHE.get_or_init(PluginCache::initialize) +} + /// Resolves plugin root directories (user + built-in). /// /// # Returns @@ -504,46 +661,9 @@ fn discover_theme_dirs_recursive(dir: &Path, depth: usize, out: &mut Vec Vec { - let mut out: Vec = Vec::new(); - let mut seen = HashSet::new(); - let built_in_ids = crate::plugin_bundles::built_in_plugin_ids(); - - let roots = plugin_roots(); - - for (root, origin) in roots { - match fs::read_dir(&root) { - Ok(entries) => { - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - if let Ok((resolved, manifest)) = read_manifest_from_directory(&path) { - let norm = manifest.id.trim().to_ascii_lowercase(); - let is_built_in = built_in_ids.contains(&norm); - if !seen.insert(norm) { - continue; - } - let effective_origin = if is_built_in { - PluginOrigin::BuiltIn - } else { - origin - }; - out.push(manifest_to_summary(&resolved, manifest, effective_origin)); - } - } - } - Err(err) => warn!("plugins: failed to list {}: {}", root.display(), err), - } - } - - out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - out + plugin_cache().list() } /// Loads plugin metadata and optional entry script text by plugin id. @@ -559,65 +679,33 @@ pub fn load_plugin(id: &str) -> Result { if requested.is_empty() { return Err("plugin id is empty".to_string()); } - let requested_lower = requested.to_ascii_lowercase(); - let built_in_ids = crate::plugin_bundles::built_in_plugin_ids(); - - let roots = plugin_roots(); - - for (root, origin) in roots { - let entries = match fs::read_dir(&root) { - Ok(entries) => entries, + let normalized = requested.to_ascii_lowercase(); + let cached = plugin_cache() + .load_cached_plugin(&normalized) + .ok_or_else(|| format!("plugin `{}` not found", requested))?; + + let summary = manifest_to_summary(&cached.resolved, cached.manifest.clone(), cached.origin); + let entry_path = clean_opt(cached.manifest.entry.clone()); + let entry_code = entry_path.and_then(|entry| { + let target = cached.resolved.join(entry.trim()); + match fs::read_to_string(&target) { + Ok(text) => Some(text), Err(err) => { - warn!("plugins: failed to list {}: {}", root.display(), err); - continue; - } - }; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let (resolved, manifest) = match read_manifest_from_directory(&path) { - Ok(m) => m, - Err(_) => continue, - }; - let entry_path = clean_opt(manifest.entry.clone()); - if manifest.id.trim().to_ascii_lowercase() != requested_lower { - continue; + warn!( + "plugins: failed to read entry {} for {}: {}", + target.display(), + summary.id, + err + ); + None } - - let effective_origin = if built_in_ids.contains(&requested_lower) { - PluginOrigin::BuiltIn - } else { - origin - }; - let summary = manifest_to_summary(&resolved, manifest, effective_origin); - let entry_code = entry_path.and_then(|entry| { - let target = resolved.join(entry.trim()); - match fs::read_to_string(&target) { - Ok(text) => Some(text), - Err(err) => { - warn!( - "plugins: failed to read entry {} for {}: {}", - target.display(), - summary.id, - err - ); - None - } - } - }); - - return Ok(PluginPayload { - summary, - // Plugin code does not execute in-process; the UI runtime uses out-of-process components. - entry: entry_code, - }); } - } + }); - Err(format!("plugin `{}` not found", requested)) + Ok(PluginPayload { + summary, + entry: entry_code, + }) } /// Lists all discovered theme directories grouped by plugin id. diff --git a/Backend/src/repo.rs b/Backend/src/repo.rs index 4a18c0c2..2eea4f21 100644 --- a/Backend/src/repo.rs +++ b/Backend/src/repo.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Thin wrapper around an opened backend repository handle. use openvcs_core::{BackendId, Vcs}; diff --git a/Backend/src/repo_settings.rs b/Backend/src/repo_settings.rs index e2c2143b..62136cdb 100644 --- a/Backend/src/repo_settings.rs +++ b/Backend/src/repo_settings.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Repository-local settings payloads exchanged with the frontend. use serde::{Deserialize, Serialize}; @@ -5,7 +7,9 @@ use serde::{Deserialize, Serialize}; /// Name/URL pair for a configured Git remote. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RemoteConfig { + /// Remote name (for example `origin`). pub name: String, + /// Remote fetch/push URL. pub url: String, } diff --git a/Backend/src/settings.rs b/Backend/src/settings.rs index ddc98c58..2dd721a6 100644 --- a/Backend/src/settings.rs +++ b/Backend/src/settings.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Global application configuration types and persistence helpers. use directories::ProjectDirs; @@ -68,7 +70,7 @@ impl Default for AppConfig { } /// Settings for app-wide behavior and startup UX. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct General { #[serde(default)] pub theme: Theme, @@ -118,7 +120,7 @@ fn default_theme_pack() -> String { } /// Settings that control Git backend behavior. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Git { #[serde(default)] pub backend: String, @@ -171,7 +173,7 @@ impl Default for Git { } /// Settings for authentication and signing tools. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Credentials { #[serde(default)] pub helper: CredentialHelper, @@ -205,7 +207,7 @@ impl Default for Credentials { } /// Settings that control diff rendering and external tools. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Diff { #[serde(default)] pub tab_width: u8, @@ -245,7 +247,7 @@ impl Default for Diff { } /// Git LFS behavior settings. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Lfs { #[serde(default)] pub enabled: bool, @@ -272,7 +274,7 @@ impl Default for Lfs { } /// Performance and animation tuning options. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Performance { #[serde(default)] pub progressive_render: bool, @@ -296,7 +298,7 @@ impl Default for Performance { } /// Integrations with editors and issue providers. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Integrations { #[serde(default)] pub default_editor: EditorChoice, @@ -321,7 +323,7 @@ impl Default for Integrations { } /// Plugin enable/disable overrides. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct Plugins { /// Plugin ids that are installed but disabled. /// @@ -336,7 +338,7 @@ pub struct Plugins { } /// User interface and accessibility options. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Ux { #[serde(default)] pub ui_scale: f32, @@ -367,7 +369,7 @@ impl Default for Ux { } /// Advanced networking and force-push safety options. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Advanced { #[serde(default)] pub confirm_force_push: ForcePushPolicy, @@ -391,7 +393,7 @@ impl Default for Advanced { } /// Experimental features that may change between releases. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct Experimental { #[serde(default)] pub parallel_history_scan: bool, @@ -402,7 +404,7 @@ pub struct Experimental { } /// Logging verbosity and retention options. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Logging { #[serde(default)] pub level: LogLevel, @@ -525,7 +527,7 @@ pub enum WhitespaceMode { } /// Executable and arguments for an optional external diff/merge tool. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ExternalTool { #[serde(default)] pub enabled: bool, @@ -607,7 +609,7 @@ pub enum ForcePushPolicy { } /// HTTP proxy configuration used for network operations. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Proxy { #[serde(default)] pub mode: ProxyMode, @@ -705,6 +707,36 @@ impl AppConfig { fs::rename(tmp, p) } + /// Returns whether a plugin should be considered enabled by current settings. + /// + /// # Parameters + /// - `plugin_id`: Plugin id to evaluate. + /// - `default_enabled`: Manifest-provided default enabled flag. + /// + /// # Returns + /// - `true` when plugin should be active. + /// - `false` otherwise. + pub fn is_plugin_enabled(&self, plugin_id: &str, default_enabled: bool) -> bool { + let plugin_id = plugin_id.trim().to_ascii_lowercase(); + if plugin_id.is_empty() { + return false; + } + if self + .plugins + .disabled + .iter() + .any(|id| id.trim().eq_ignore_ascii_case(&plugin_id)) + { + return false; + } + default_enabled + || self + .plugins + .enabled + .iter() + .any(|id| id.trim().eq_ignore_ascii_case(&plugin_id)) + } + /// Future-proof migrations between schema versions. /// /// # Returns diff --git a/Backend/src/state.rs b/Backend/src/state.rs index 39938d19..e475097c 100644 --- a/Backend/src/state.rs +++ b/Backend/src/state.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Application-level mutable state and persistence helpers. use std::{fs, io}; @@ -7,6 +9,7 @@ use log::{debug, info}; use parking_lot::RwLock; use crate::output_log::OutputLogEntry; +use crate::plugin_runtime::PluginRuntimeManager; use crate::repo::Repo; use crate::repo_settings::RepoConfig; use crate::settings::AppConfig; @@ -73,6 +76,9 @@ pub struct AppState { /// MRU list for “Recents” recents: RwLock>, + + /// Long-lived plugin process runtime manager. + plugin_runtime: Arc, } impl AppState { @@ -108,21 +114,6 @@ impl AppState { self.config.read().clone() } - /// Read-only closure access (avoid cloning if you’re just reading). - /// - /// # Parameters - /// - `f`: Closure invoked with a shared reference to the current config. - /// - /// # Returns - /// - Whatever value the closure returns. - pub fn with_config(&self, f: F) -> R - where - F: FnOnce(&AppConfig) -> R, - { - let cfg = self.config.read(); - f(&cfg) - } - /// Replace whole config: validate → save → swap (readers never see an unsaved state). /// /// # Parameters @@ -270,6 +261,14 @@ impl AppState { pub fn recents(&self) -> Vec { self.recents.read().clone() } + + /// Returns the shared plugin runtime manager. + /// + /// # Returns + /// - Plugin runtime manager reference. + pub fn plugin_runtime(&self) -> Arc { + Arc::clone(&self.plugin_runtime) + } } // ────────────────────────────────────────────────────────────────────────────── @@ -279,6 +278,7 @@ impl AppState { #[derive(Debug, Clone, Serialize, Deserialize)] struct RecentFileEntry { + /// Stored repository path string. path: String, } diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index 6bf52d59..472f5b02 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -1,18 +1,17 @@ -use std::path::{Path, PathBuf}; +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +use std::path::Path; use std::sync::Arc; use log::{error, info, warn}; -use serde_json::Value; -use tauri::{async_runtime, Manager, Runtime, State, Window}; +use tauri::{async_runtime, State}; use openvcs_core::BackendId; use std::collections::BTreeMap; -use crate::plugin_runtime::stdio_rpc::{RpcConfig, SpawnConfig, StdioRpcProcess}; use crate::plugin_vcs_backends; use crate::repo::Repo; use crate::state::AppState; -use crate::tauri_commands::shared::progress_bridge; #[tauri::command] /// Lists VCS backends currently available from plugins. @@ -102,8 +101,15 @@ pub async fn set_vcs_backend_cmd( let open_path = path.clone(); let backend_label = backend_id.as_ref().to_string(); + let cfg = state.config(); + let runtime_manager = state.plugin_runtime(); let handle = async_runtime::spawn_blocking(move || { - plugin_vcs_backends::open_repo_via_plugin_vcs_backend(backend_id, Path::new(&open_path)) + plugin_vcs_backends::open_repo_via_plugin_vcs_backend( + runtime_manager.as_ref(), + &cfg, + backend_id, + Path::new(&open_path), + ) }) .await .map_err(|e| format!("set_vcs_backend_cmd task failed: {e}"))? @@ -153,8 +159,15 @@ pub async fn reopen_current_repo_cmd(state: State<'_, AppState>) -> Result<(), S let backend_label = backend_id.as_ref().to_string(); let open_path = path.clone(); + let cfg = state.config(); + let runtime_manager = state.plugin_runtime(); let handle = async_runtime::spawn_blocking(move || { - plugin_vcs_backends::open_repo_via_plugin_vcs_backend(backend_id, Path::new(&open_path)) + plugin_vcs_backends::open_repo_via_plugin_vcs_backend( + runtime_manager.as_ref(), + &cfg, + backend_id, + Path::new(&open_path), + ) }) .await .map_err(|e| format!("reopen_current_repo_cmd task failed: {e}"))? @@ -164,120 +177,3 @@ pub async fn reopen_current_repo_cmd(state: State<'_, AppState>) -> Result<(), S state.set_current_repo(new_repo); Ok(()) } - -/// Call an arbitrary RPC method on a VCS backend module. -/// -/// This is intentionally backend-agnostic so plugin UI can access backend-specific helpers -/// (e.g. Git LFS) without hardcoding them into the host's generic VCS trait. -/// -/// # Parameters -/// - `window`: Calling Tauri window handle. -/// - `state`: Shared application state. -/// - `backend_id`: Backend id to invoke. -/// - `method`: RPC method name. -/// - `params`: JSON payload passed to the backend method. -/// -/// # Returns -/// - `Ok(Value)` containing the backend method result. -/// - `Err(String)` when validation, backend resolution, or RPC execution fails. -#[tauri::command] -pub async fn call_vcs_backend_method( - window: Window, - state: State<'_, AppState>, - backend_id: BackendId, - method: String, - params: Value, -) -> Result { - let backend_id_str = backend_id.as_ref().to_string(); - let method = method.trim().to_string(); - if method.is_empty() { - return Err("method is empty".to_string()); - } - - let desc = plugin_vcs_backends::plugin_vcs_backend_descriptor(&backend_id) - .map_err(|_| format!("Unknown VCS backend: {backend_id_str}"))?; - - let repo_root = state - .current_repo() - .map(|repo| repo.inner().workdir().to_path_buf()) - .ok_or_else(|| "No repository selected".to_string())?; - let allowed_workspace_root = resolve_allowed_workspace_root(&repo_root, ¶ms)?; - - // Run the backend RPC on a blocking thread so the Tauri main thread and - // webview are not blocked by long-running operations (e.g. LFS transfers). - let backend_id_clone = backend_id_str.clone(); - let method_clone = method.clone(); - let params_clone = params.clone(); - let desc_clone = desc.clone(); - let on_event = progress_bridge(window.app_handle().clone()); - - let call_task = async_runtime::spawn_blocking(move || { - let rpc = StdioRpcProcess::new( - SpawnConfig { - plugin_id: desc_clone.plugin_id, - component_label: format!("vcs-backend-{}", backend_id_clone), - exec_path: desc_clone.exec_path, - args: vec!["--backend".into(), backend_id_clone.clone()], - requested_capabilities: desc_clone.requested_capabilities, - approval: desc_clone.approval, - allowed_workspace_root, - }, - RpcConfig::default(), - ); - rpc.set_event_sink(Some(on_event)); - - rpc.call(&method_clone, params_clone) - }); - - let call_res = call_task - .await - .map_err(|e| format!("call_vcs_backend_method task failed: {e}"))?; - - call_res.map_err(|e| format!("{}: {}", e.code, e.message)) -} - -/// Resolves optional backend workspace path and enforces repo-root confinement. -/// -/// # Parameters -/// - `repo_root`: Repository root path. -/// - `params`: Backend method params. -/// -/// # Returns -/// - `Ok(Some(PathBuf))` resolved workspace path. -/// - `Ok(None)` when no path restriction should be applied. -/// - `Err(String)` when requested path escapes repo root. -fn resolve_allowed_workspace_root( - repo_root: &Path, - params: &Value, -) -> Result, String> { - let repo_root = std::fs::canonicalize(repo_root) - .map_err(|e| format!("Failed to resolve repository root: {e}"))?; - - let requested = params - .get("path") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(PathBuf::from); - - let Some(requested) = requested else { - return Ok(Some(repo_root)); - }; - - let requested_abs = if requested.is_absolute() { - requested - } else { - repo_root.join(requested) - }; - let requested_abs = std::fs::canonicalize(&requested_abs) - .map_err(|e| format!("Invalid backend workspace path: {e}"))?; - - if requested_abs == repo_root || requested_abs.starts_with(&repo_root) { - Ok(Some(requested_abs)) - } else { - Err(format!( - "Backend workspace path escapes repository root: {}", - requested_abs.display() - )) - } -} diff --git a/Backend/src/tauri_commands/branches.rs b/Backend/src/tauri_commands/branches.rs index 9894d89e..b64106de 100644 --- a/Backend/src/tauri_commands/branches.rs +++ b/Backend/src/tauri_commands/branches.rs @@ -1,14 +1,21 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::collections::HashSet; use log::{debug, error, info, warn}; use tauri::State; use openvcs_core::models::{BranchItem, BranchKind}; +use openvcs_core::BackendId; +use crate::plugin_runtime::settings_store; +use crate::plugin_vcs_backends; use crate::state::AppState; use super::{current_repo_or_err, run_repo_task}; +const DEFAULT_MERGE_TEMPLATE: &str = "Merged branch '{branch:source}' into '{branch:target}'"; + /// Extracts repository owner/user segment from remote URL. /// /// # Parameters @@ -102,6 +109,23 @@ fn apply_merge_template( .replace("{repo:username}", repo_username) } +/// Returns the merge message template from the active backend plugin settings. +fn backend_merge_message_template(backend_id: &BackendId) -> String { + let value = plugin_vcs_backends::plugin_vcs_backend_descriptor(backend_id) + .ok() + .and_then(|descriptor| settings_store::load_settings(&descriptor.plugin_id).ok()) + .and_then(|settings| settings.get("merge_commit_message_template").cloned()) + .and_then(|value| value.as_str().map(str::to_string)) + .unwrap_or_default(); + + let trimmed = value.trim(); + if trimmed.is_empty() { + DEFAULT_MERGE_TEMPLATE.to_string() + } else { + trimmed.to_string() + } +} + #[tauri::command] /// Returns normalized local/remote branches for the current repository. /// @@ -213,9 +237,13 @@ pub async fn git_list_branches(state: State<'_, AppState>) -> Result, + /// Current HEAD commit id when available. pub commit: Option, } @@ -366,7 +394,7 @@ pub async fn git_merge_branch(state: State<'_, AppState>, name: String) -> Resul } let repo = current_repo_or_err(&state)?; let branch = name.to_string(); - let template = state.with_config(|cfg| cfg.git.merge_commit_message_template.clone()); + let template = backend_merge_message_template(&repo.id()); run_repo_task("git_merge_branch", repo, move |repo| { let vcs = repo.inner(); let target_branch = vcs @@ -416,7 +444,9 @@ pub async fn git_merge_branch(state: State<'_, AppState>, name: String) -> Resul } #[derive(serde::Serialize)] +/// Merge-state payload consumed by the UI. pub struct MergeContext { + /// Whether an in-progress merge is detected in the repository. pub in_progress: bool, } @@ -558,9 +588,13 @@ pub async fn git_create_branch( } #[derive(serde::Serialize)] +/// Compact repository snapshot used by quick status views. pub struct RepoSummary { + /// Absolute repository worktree path. path: String, + /// Current branch name, or `HEAD` when detached. current_branch: String, + /// Normalized local/remote branch list. branches: Vec, } diff --git a/Backend/src/tauri_commands/commit.rs b/Backend/src/tauri_commands/commit.rs index a0136f9e..2fdc73c6 100644 --- a/Backend/src/tauri_commands/commit.rs +++ b/Backend/src/tauri_commands/commit.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::path::PathBuf; use log::{error, info}; diff --git a/Backend/src/tauri_commands/conflicts.rs b/Backend/src/tauri_commands/conflicts.rs index 1939b632..b2d1b62d 100644 --- a/Backend/src/tauri_commands/conflicts.rs +++ b/Backend/src/tauri_commands/conflicts.rs @@ -1,7 +1,9 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::path::PathBuf; use std::process::Command; -use log::{debug, warn}; +use log::{debug, error, info, trace, warn}; use openvcs_core::models::{ConflictDetails, ConflictSide}; use shlex::split; use tauri::State; @@ -25,13 +27,36 @@ pub async fn git_conflict_details( state: State<'_, AppState>, path: String, ) -> Result { + let start = std::time::Instant::now(); + info!("git_conflict_details: path='{}'", path); + let repo = current_repo_or_err(&state)?; - run_repo_task("git_conflict_details", repo, move |repo| { + let path_clone = path.clone(); + let result = run_repo_task("git_conflict_details", repo, move |repo| { repo.inner() .conflict_details(&PathBuf::from(&path)) - .map_err(|e| e.to_string()) + .map_err(|e| { + error!("git_conflict_details: failed for '{}': {}", path, e); + e.to_string() + }) }) - .await + .await; + + match &result { + Ok(details) => { + debug!( + "git_conflict_details: found conflict details for '{}' ({:?})", + path_clone, + start.elapsed() + ); + trace!("git_conflict_details: binary={}", details.binary); + } + Err(e) => { + error!("git_conflict_details: failed: {}", e); + } + } + + result } #[tauri::command] @@ -50,20 +75,48 @@ pub async fn git_resolve_conflict_side( path: String, side: String, ) -> Result<(), String> { + let start = std::time::Instant::now(); + info!( + "git_resolve_conflict_side: path='{}', side='{}'", + path, side + ); + let repo = current_repo_or_err(&state)?; - run_repo_task("git_resolve_conflict_side", repo, move |repo| { + let path_clone = path.clone(); + let side_clone = side.clone(); + let result = run_repo_task("git_resolve_conflict_side", repo, move |repo| { let which = match side.to_lowercase().as_str() { "ours" => ConflictSide::Ours, "theirs" => ConflictSide::Theirs, other => { + warn!("git_resolve_conflict_side: invalid side '{}'", other); return Err(format!("invalid conflict side '{other}'")); } }; repo.inner() .checkout_conflict_side(&PathBuf::from(&path), which) - .map_err(|e| e.to_string()) + .map_err(|e| { + error!("git_resolve_conflict_side: failed for '{}': {}", path, e); + e.to_string() + }) }) - .await + .await; + + match &result { + Ok(()) => { + debug!( + "git_resolve_conflict_side: resolved '{}' with '{}' ({:?})", + path_clone, + side_clone, + start.elapsed() + ); + } + Err(e) => { + error!("git_resolve_conflict_side: failed: {}", e); + } + } + + result } #[tauri::command] @@ -82,13 +135,39 @@ pub async fn git_save_merge_result( path: String, content: String, ) -> Result<(), String> { + let start = std::time::Instant::now(); + info!( + "git_save_merge_result: path='{}', content_len={}", + path, + content.len() + ); + let repo = current_repo_or_err(&state)?; - run_repo_task("git_save_merge_result", repo, move |repo| { + let path_clone = path.clone(); + let result = run_repo_task("git_save_merge_result", repo, move |repo| { repo.inner() .write_merge_result(&PathBuf::from(&path), content.as_bytes()) - .map_err(|e| e.to_string()) + .map_err(|e| { + error!("git_save_merge_result: failed for '{}': {}", path, e); + e.to_string() + }) }) - .await + .await; + + match &result { + Ok(()) => { + debug!( + "git_save_merge_result: saved '{}' ({:?})", + path_clone, + start.elapsed() + ); + } + Err(e) => { + error!("git_save_merge_result: failed: {}", e); + } + } + + result } /// Splits external tool config into executable path and args. @@ -104,6 +183,7 @@ fn tool_args(tool: &ExternalTool) -> (String, Vec) { .unwrap_or_default() .into_iter() .collect::>(); + trace!("tool_args: path='{}', args={:?}", path, args); (path, args) } @@ -118,18 +198,34 @@ fn tool_args(tool: &ExternalTool) -> (String, Vec) { /// - `Ok(())` when the tool process is started. /// - `Err(String)` when tool config is missing or spawn fails. pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> Result<(), String> { + let start = std::time::Instant::now(); + info!("git_launch_merge_tool: path='{}'", path); + let cfg = state.config(); let tool = cfg.diff.external_merge.clone(); - if !tool.enabled || tool.path.trim().is_empty() { + + if !tool.enabled { + warn!("git_launch_merge_tool: external merge tool is disabled"); return Err("no external merge tool configured".into()); } + if tool.path.trim().is_empty() { + warn!("git_launch_merge_tool: no tool path configured"); + return Err("no external merge tool configured".into()); + } + + debug!( + "git_launch_merge_tool: tool='{}', args='{}'", + tool.path, tool.args + ); + let repo = current_repo_or_err(&state)?; let (tool_path, args_template) = tool_args(&tool); let has_args = !tool.args.trim().is_empty(); let includes_placeholder = args_template.iter().any(|arg| arg.contains("{path}")); + let path_for_log = path.clone(); - run_repo_task("git_launch_merge_tool", repo, move |repo| { + let result = run_repo_task("git_launch_merge_tool", repo, move |repo| { let repo_root = repo.inner().workdir().to_path_buf(); let rel = PathBuf::from(&path); let abs = if rel.is_absolute() { @@ -138,6 +234,12 @@ pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> repo_root.join(&rel) }; + trace!( + "git_launch_merge_tool: repo_root='{}', abs_path='{}'", + repo_root.display(), + abs.display() + ); + let mut cmd = Command::new(&tool_path); cmd.current_dir(&repo_root); @@ -163,14 +265,32 @@ pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> } debug!( - "git_launch_merge_tool: spawning {} with args {:?}", + "git_launch_merge_tool: spawning '{}' with args {:?}", tool_path, expanded ); cmd.spawn().map(|_| ()).map_err(|e| { - warn!("git_launch_merge_tool: failed to spawn merge tool: {e}"); + error!( + "git_launch_merge_tool: failed to spawn '{}': {}", + tool_path, e + ); e.to_string() }) }) - .await + .await; + + match &result { + Ok(()) => { + info!( + "git_launch_merge_tool: launched tool for '{}' ({:?})", + path_for_log, + start.elapsed() + ); + } + Err(e) => { + error!("git_launch_merge_tool: failed: {}", e); + } + } + + result } diff --git a/Backend/src/tauri_commands/general.rs b/Backend/src/tauri_commands/general.rs index f6f6aefb..cd7a8ec4 100644 --- a/Backend/src/tauri_commands/general.rs +++ b/Backend/src/tauri_commands/general.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -20,8 +22,11 @@ use super::progress_bridge; const WIKI_URL: &str = "https://github.com/jordonbc/OpenVCS/wiki"; #[derive(serde::Serialize)] +/// Event payload emitted after selecting/opening a repository. struct RepoSelectedPayload { + /// Selected repository path. path: String, + /// Backend identifier that opened the repository. backend: String, } @@ -171,8 +176,12 @@ pub async fn add_repo_internal( let open_path = path.clone(); let backend_label = backend_id.as_ref().to_string(); let backend_id_for_task = backend_id.clone(); + let cfg = state.config(); + let runtime_manager = state.plugin_runtime(); let handle = async_runtime::spawn_blocking(move || { plugin_vcs_backends::open_repo_via_plugin_vcs_backend( + runtime_manager.as_ref(), + &cfg, backend_id_for_task, Path::new(&open_path), ) @@ -316,8 +325,11 @@ pub fn current_repo_path(state: State<'_, AppState>) -> Option { } #[derive(serde::Serialize)] +/// Serializable recent-repository item for frontend rendering. pub struct RecentRepoDto { + /// Absolute repository path. path: String, + /// Last path segment used as a display name when available. name: Option, } diff --git a/Backend/src/tauri_commands/mod.rs b/Backend/src/tauri_commands/mod.rs index 7af3588a..47f2f3a9 100644 --- a/Backend/src/tauri_commands/mod.rs +++ b/Backend/src/tauri_commands/mod.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Aggregates the backend's Tauri command modules. //! //! Each submodule defines command handlers grouped by feature area, and this diff --git a/Backend/src/tauri_commands/output_log.rs b/Backend/src/tauri_commands/output_log.rs index 7df5b585..d04cebb8 100644 --- a/Backend/src/tauri_commands/output_log.rs +++ b/Backend/src/tauri_commands/output_log.rs @@ -1,6 +1,8 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use tauri::{Manager, Runtime, WebviewUrl, WebviewWindowBuilder, Window}; -use crate::output_log::OutputLogEntry; +use crate::output_log::{OutputLevel, OutputLogEntry}; use crate::state::AppState; /// Reads up to the last `max_lines` lines from a log file efficiently. @@ -74,6 +76,54 @@ pub fn clear_output_log(state: tauri::State<'_, AppState>) { state.clear_output_log(); } +#[tauri::command] +/// Handles log messages from the frontend, forwarding them to the output log. +/// +/// # Parameters +/// - `state`: Shared application state. +/// - `level`: Log severity level ("debug", "info", "warn", "error"). +/// - `source`: Source subsystem (e.g., "ui", "plugin"). +/// - `message`: Log message text. +/// +/// # Returns +/// - `()`. +pub fn log_frontend_message(state: tauri::State<'_, AppState>, level: String, message: String) { + let (output_level, log_level) = match level.to_lowercase().as_str() { + "trace" => (OutputLevel::Info, log::Level::Trace), + "debug" => (OutputLevel::Info, log::Level::Debug), + "info" => (OutputLevel::Info, log::Level::Info), + "warn" | "warning" => (OutputLevel::Warn, log::Level::Warn), + "error" | "err" => (OutputLevel::Error, log::Level::Error), + _ => (OutputLevel::Info, log::Level::Info), + }; + + // Write directly to stderr with [FRONTEND] tag + let now = time::OffsetDateTime::now_utc(); + let timestamp = format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + now.year(), + now.month() as u8, + now.day(), + now.hour(), + now.minute(), + now.second() + ); + let log_line = format!("{} {:5} [FRONTEND]: {}", timestamp, log_level, message); + eprintln!("{}", log_line); + let _ = crate::logging::write_to_log(&log_line); + + let entry = OutputLogEntry::new( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0), + output_level, + "frontend", + message, + ); + state.push_output_log(entry); +} + #[tauri::command] /// Reads and returns recent lines from `logs/openvcs.log`. /// diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index e58b5376..29c0f5dd 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -1,10 +1,70 @@ -use crate::plugin_bundles::{InstalledPlugin, InstalledPluginIndex, PluginBundleStore}; -use crate::plugin_runtime::stdio_rpc::{RpcConfig, SpawnConfig, StdioRpcProcess}; +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +use crate::plugin_bundles::{ApprovalState, InstalledPlugin, InstalledPluginIndex, PluginBundleStore}; +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::settings_store; use crate::plugins; +use crate::state::AppState; +use log::{debug, error, info, trace, warn}; +use openvcs_core::settings::{SettingKv, SettingValue}; +use openvcs_core::ui::{Menu, UiElement}; use serde_json::Value; -use tauri::Emitter; -use tauri::Manager; -use tauri::{Runtime, Window}; +use std::sync::Arc; +use tauri::{Runtime, State, Window}; + +/// JSON-friendly plugin setting entry payload. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PluginSettingEntry { + /// Stable setting id. + pub id: String, + /// JSON value persisted by the host. + pub value: Value, +} + +/// Choice item for settings rendered as a select input. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PluginSettingOptionPayload { + /// Persisted value for the option. + pub value: String, + /// User-visible option label. + pub label: String, +} + +/// Plugin setting metadata and current value payload. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PluginSettingFieldPayload { + /// Stable setting id. + pub id: String, + /// Setting value kind (`bool`, `s32`, `u32`, `f64`, `text`). + pub kind: String, + /// User-visible label. + pub label: String, + /// Optional help text. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Default setting value. + pub default_value: Value, + /// Effective current value (persisted override or default). + pub value: Value, + /// Optional options for text-select style inputs. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + /// Origin of this schema (`runtime`). + pub source: String, +} + +/// JSON-friendly plugin menu payload returned to frontend. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PluginMenuPayload { + /// Owning plugin id. + pub plugin_id: String, + /// Menu id. + pub id: String, + /// User-visible label. + pub label: String, + /// Renderable menu elements. + pub elements: Vec, +} #[tauri::command] /// Lists plugin summaries discovered by the backend. @@ -15,6 +75,18 @@ pub fn list_plugins() -> Vec { plugins::list_plugins() } +#[tauri::command] +/// Lists plugin ids whose most recent runtime startup attempt failed. +/// +/// # Parameters +/// - `state`: Application state. +/// +/// # Returns +/// - Sorted plugin id list. +pub fn list_plugin_start_failures(state: State<'_, AppState>) -> Vec { + state.plugin_runtime().failed_plugin_starts() +} + #[tauri::command] /// Loads details for a specific plugin id. /// @@ -32,28 +104,29 @@ pub fn load_plugin(id: String) -> Result { /// Installs an `.ovcsp` plugin bundle. /// /// # Parameters -/// - `window`: Calling window handle used for capability prompt events. +/// - `window`: Calling window handle. /// - `bundle_path`: Filesystem path to the bundle. /// /// # Returns /// - `Ok(InstalledPlugin)` with install metadata. /// - `Err(String)` when installation fails. pub async fn install_ovcsp( - window: Window, + _window: Window, + state: State<'_, AppState>, bundle_path: String, ) -> Result { let store = PluginBundleStore::new_default(); let installed = store.install_ovcsp(std::path::Path::new(bundle_path.trim()))?; - if !installed.requested_capabilities.is_empty() { - let _ = window.app_handle().emit( - "plugins:capabilities-requested", - serde_json::json!({ - "pluginId": installed.plugin_id, - "version": installed.version, - "capabilities": installed.requested_capabilities, - }), - ); + info!( + "plugin: installed '{}' v{}", + installed.plugin_id, installed.version + ); + + if matches!(installed.approval, ApprovalState::Approved { .. }) { + if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { + warn!("plugins: runtime sync after install failed: {}", err); + } } Ok(installed) @@ -78,173 +151,532 @@ pub fn list_installed_bundles() -> Result, String> { /// # Returns /// - `Ok(())` when removal succeeds. /// - `Err(String)` when validation/removal fails. -pub fn uninstall_plugin(plugin_id: String) -> Result<(), String> { - PluginBundleStore::new_default().uninstall_plugin(plugin_id.trim()) +pub fn uninstall_plugin(state: State<'_, AppState>, plugin_id: String) -> Result<(), String> { + let plugin_id = plugin_id.trim().to_string(); + state.plugin_runtime().stop_plugin(&plugin_id)?; + PluginBundleStore::new_default().uninstall_plugin(&plugin_id)?; + info!("plugin: uninstalled '{}'", plugin_id); + Ok(()) +} + +#[tauri::command] +/// Enables or disables a plugin and persists the override. +/// +/// This updates runtime state immediately and writes the corresponding +/// `plugins.enabled`/`plugins.disabled` override in global settings so the +/// toggle remains stable across settings reloads and app restarts. +/// +/// # Parameters +/// - `state`: Application state. +/// - `plugin_id`: Plugin id to toggle. +/// - `enabled`: Whether the plugin should be enabled. +/// +/// # Returns +/// - `Ok(())` when the operation succeeds. +/// - `Err(String)` when the operation fails. +pub async fn set_plugin_enabled( + state: State<'_, AppState>, + plugin_id: String, + enabled: bool, +) -> Result<(), String> { + trace!( + "set_plugin_enabled: entering with plugin_id='{}', enabled={}", + plugin_id, + enabled + ); + + let plugin_id = plugin_id.trim().to_string(); + debug!("set_plugin_enabled: trimmed plugin_id='{}'", plugin_id); + + info!( + "set_plugin_enabled: plugin={}, enabled={}", + plugin_id, enabled + ); + + let plugin_key = plugin_id.trim().to_ascii_lowercase(); + + let runtime = state.plugin_runtime(); + let plugin_id_for_runtime = plugin_id.clone(); + let runtime_result = tauri::async_runtime::spawn_blocking(move || { + runtime + .set_plugin_enabled(&plugin_id_for_runtime, enabled) + .map_err(|e| { + error!( + "set_plugin_enabled failed: plugin={}, error={}", + plugin_id_for_runtime, e + ); + e + }) + }) + .await + .map_err(|e| format!("set_plugin_enabled task join failed: {e}"))?; + + if let Err(err) = runtime_result { + if enabled { + let mut fallback_cfg = state.config(); + fallback_cfg + .plugins + .enabled + .retain(|id| !id.trim().eq_ignore_ascii_case(&plugin_key)); + fallback_cfg + .plugins + .disabled + .retain(|id| !id.trim().eq_ignore_ascii_case(&plugin_key)); + fallback_cfg.plugins.disabled.push(plugin_key.clone()); + if let Err(persist_error) = state.set_config(fallback_cfg) { + warn!( + "set_plugin_enabled: failed to persist disable fallback for {}: {}", + plugin_id, persist_error + ); + } + let _ = state.plugin_runtime().stop_plugin(&plugin_id); + } + return Err(err); + } + + let mut cfg = state.config(); + cfg.plugins + .enabled + .retain(|id| !id.trim().eq_ignore_ascii_case(&plugin_key)); + cfg.plugins + .disabled + .retain(|id| !id.trim().eq_ignore_ascii_case(&plugin_key)); + + if enabled { + cfg.plugins.enabled.push(plugin_key.clone()); + } else { + cfg.plugins.disabled.push(plugin_key.clone()); + } + + state.set_config(cfg).map_err(|e| { + error!( + "set_plugin_enabled: failed to persist plugin override for {}: {}", + plugin_id, e + ); + e + })?; + + Ok(()) } #[tauri::command] -/// Approves or denies requested capabilities for a plugin version. +/// Approves or denies an installed plugin version for runtime startup. /// /// # Parameters +/// - `state`: Application state. /// - `plugin_id`: Plugin id. -/// - `version`: Installed plugin version. +/// - `version`: Installed version string. /// - `approved`: Approval decision. /// /// # Returns -/// - `Ok(())` when state is updated. -/// - `Err(String)` when update fails. -pub fn approve_plugin_capabilities( +/// - `Ok(())` when plugin approval is updated. +/// - `Err(String)` when plugin/version lookup fails. +pub fn set_plugin_approval( + state: State<'_, AppState>, plugin_id: String, version: String, approved: bool, ) -> Result<(), String> { - PluginBundleStore::new_default().approve_capabilities( - plugin_id.trim(), - version.trim(), - approved, - ) + let plugin_id = plugin_id.trim().to_string(); + let version = version.trim().to_string(); + if plugin_id.is_empty() { + return Err("plugin id is empty".to_string()); + } + if version.is_empty() { + return Err("version is empty".to_string()); + } + + let store = PluginBundleStore::new_default(); + store.approve_capabilities(&plugin_id, &version, approved)?; + + if approved { + if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { + warn!( + "plugins: runtime sync after approval failed for {}: {}", + plugin_id, err + ); + } + } else if let Err(err) = state.plugin_runtime().stop_plugin(&plugin_id) { + warn!( + "plugins: stop runtime after denial failed for {}: {}", + plugin_id, err + ); + } + + Ok(()) } -#[tauri::command] -/// Lists callable functions exported by a plugin's functions component. +/// Returns plugin-contributed menus for enabled plugins. /// /// # Parameters -/// - `plugin_id`: Plugin id to inspect. +/// - `state`: Application state. /// /// # Returns -/// - `Ok(Value)` containing function descriptors. -/// - `Err(String)` when plugin lookup or RPC fails. -pub fn list_plugin_functions(plugin_id: String) -> Result { - let store = PluginBundleStore::new_default(); - let Some(components) = store.load_current_components(plugin_id.trim())? else { - return Err("plugin not installed".to_string()); - }; - let Some(functions) = components.functions else { - return Err("plugin has no functions component".to_string()); - }; +/// - `Ok(Vec)` sorted by plugin/menu label. +/// - `Err(String)` when runtime access fails. +#[tauri::command] +pub fn list_plugin_menus(state: State<'_, AppState>) -> Result, String> { + let cfg = state.config(); + let mut collected: Vec<(String, Menu)> = Vec::new(); - let installed = store - .get_current_installed(&components.plugin_id)? - .ok_or_else(|| "plugin is not installed".to_string())?; - - let rpc = StdioRpcProcess::new( - SpawnConfig { - plugin_id: components.plugin_id, - component_label: "functions".into(), - exec_path: functions.exec_path, - args: Vec::new(), - requested_capabilities: installed.requested_capabilities, - approval: installed.approval, - allowed_workspace_root: None, - }, - RpcConfig::default(), - ); + for summary in plugins::list_plugins() { + let plugin_id = summary.id.trim().to_string(); + if plugin_id.is_empty() { + continue; + } + if !cfg.is_plugin_enabled(&plugin_id, summary.default_enabled) { + continue; + } + match state.plugin_runtime().has_module(&plugin_id) { + Ok(Some(true)) => {} + Ok(Some(false)) | Ok(None) => continue, + Err(err) => { + warn!("list_plugin_menus: skip plugin {}: {}", plugin_id, err); + continue; + } + } + + let runtime = match state + .plugin_runtime() + .running_runtime_for_plugin(&plugin_id) + { + Ok(Some(runtime)) => runtime, + Ok(None) => { + debug!( + "list_plugin_menus: skip plugin {} because runtime is not running", + plugin_id + ); + continue; + } + Err(err) => { + warn!("list_plugin_menus: skip plugin {}: {}", plugin_id, err); + continue; + } + }; + + let menus = match runtime.get_menus() { + Ok(menus) => menus, + Err(err) => { + warn!( + "list_plugin_menus: plugin {} menu error: {}", + plugin_id, err + ); + continue; + } + }; + + for menu in menus { + collected.push((plugin_id.clone(), menu)); + } + } + + collected.sort_by(|a, b| { + let a_order = a.1.order; + let b_order = b.1.order; - let v = rpc - .call("functions.list", Value::Null) - .map_err(|e| format!("{}: {}", e.code, e.message))?; - Ok(v) + a_order + .is_none() + .cmp(&b_order.is_none()) + .then_with(|| { + a_order + .unwrap_or(u32::MAX) + .cmp(&b_order.unwrap_or(u32::MAX)) + }) + .then_with(|| { + a.1.label + .to_ascii_lowercase() + .cmp(&b.1.label.to_ascii_lowercase()) + }) + .then_with(|| a.0.cmp(&b.0)) + .then_with(|| a.1.id.cmp(&b.1.id)) + }); + + let out = collected + .into_iter() + .map(|(plugin_id, menu)| menu_to_payload(&plugin_id, menu)) + .collect::>(); + + Ok(out) +} + +/// Converts a plugin menu model to frontend payload. +fn menu_to_payload(plugin_id: &str, menu: Menu) -> PluginMenuPayload { + let elements = menu + .elements + .into_iter() + .map(|element| match element { + UiElement::Text(text) => serde_json::json!({ + "type": "text", + "id": text.id, + "content": text.content, + }), + UiElement::Button(button) => serde_json::json!({ + "type": "button", + "id": button.id, + "label": button.label, + }), + }) + .collect::>(); + + PluginMenuPayload { + plugin_id: plugin_id.to_string(), + id: menu.id, + label: menu.label, + elements, + } } +/// Invokes a plugin-provided action by id. +/// +/// # Parameters +/// - `state`: Application state. +/// - `plugin_id`: Plugin id. +/// - `action_id`: Action id from `get-menus`. +/// +/// # Returns +/// - `Ok(())` when action succeeds. +/// - `Err(String)` when runtime/action invocation fails. #[tauri::command] -/// Invokes a plugin function by id. +pub fn invoke_plugin_action( + state: State<'_, AppState>, + plugin_id: String, + action_id: String, +) -> Result<(), String> { + let cfg = state.config(); + let runtime = + state + .plugin_runtime() + .runtime_for_workspace_with_config(&cfg, plugin_id.trim(), None)?; + runtime.handle_action(action_id.trim()) +} + +/// Returns plugin settings schema with effective values. /// /// # Parameters -/// - `plugin_id`: Plugin id to invoke. -/// - `function_id`: Function id exported by the plugin. -/// - `args`: JSON argument payload. +/// - `state`: Application state. +/// - `plugin_id`: Plugin id. /// /// # Returns -/// - `Ok(Value)` function result payload. -/// - `Err(String)` when invocation fails. -pub fn invoke_plugin_function( +/// - `Ok(Vec)` schema and values. +/// - `Err(String)` when plugin/settings resolution fails. +#[tauri::command] +pub fn get_plugin_settings( + state: State<'_, AppState>, plugin_id: String, - function_id: String, - args: Value, -) -> Result { - let store = PluginBundleStore::new_default(); - let Some(components) = store.load_current_components(plugin_id.trim())? else { - return Err("plugin not installed".to_string()); - }; - let Some(functions) = components.functions else { - return Err("plugin has no functions component".to_string()); - }; +) -> Result, String> { + let plugin_id = plugin_id.trim().to_string(); + if plugin_id.is_empty() { + return Err("plugin id is empty".to_string()); + } - let installed = store - .get_current_installed(&components.plugin_id)? - .ok_or_else(|| "plugin is not installed".to_string())?; - - let rpc = StdioRpcProcess::new( - SpawnConfig { - plugin_id: components.plugin_id, - component_label: "functions".into(), - exec_path: functions.exec_path, - args: Vec::new(), - requested_capabilities: installed.requested_capabilities, - approval: installed.approval, - allowed_workspace_root: None, - }, - RpcConfig::default(), - ); + let cfg = state.config(); + let (defaults, _runtime) = resolve_plugin_settings_defaults(&state, &cfg, &plugin_id)?; + let persisted = settings_store::load_settings(&plugin_id)?; - let v = rpc - .call( - "functions.invoke", - serde_json::json!({ "id": function_id.trim(), "args": args }), - ) - .map_err(|e| format!("{}: {}", e.code, e.message))?; - Ok(v) + Ok(defaults + .into_iter() + .map(|default| { + let id = default.id.trim().to_string(); + let effective_value = persisted + .get(&id) + .and_then(|raw| setting_from_json(&id, raw, &default.value).ok()) + .unwrap_or_else(|| default.value.clone()); + + PluginSettingFieldPayload { + id: id.clone(), + kind: setting_kind_name(&default.value).to_string(), + label: default.label.unwrap_or(id), + description: None, + default_value: setting_value_to_json(&default.value), + value: setting_value_to_json(&effective_value), + options: Vec::new(), + source: "runtime".to_string(), + } + }) + .collect::>()) } -#[tauri::command] -/// Calls an arbitrary method on a plugin module component. +/// Saves plugin settings and applies them immediately. /// /// # Parameters -/// - `plugin_id`: Plugin id to invoke. -/// - `method`: Module RPC method name. -/// - `params`: Optional JSON params payload. +/// - `state`: Application state. +/// - `plugin_id`: Plugin id. +/// - `values`: Setting entries from frontend. /// /// # Returns -/// - `Ok(Value)` method result payload. -/// - `Err(String)` when lookup/validation/RPC fails. -pub fn call_plugin_module_method( +/// - `Ok(())` when save + apply succeeds. +/// - `Err(String)` when validation or runtime calls fail. +#[tauri::command] +pub fn save_plugin_settings( + state: State<'_, AppState>, plugin_id: String, - method: String, - params: Option, -) -> Result { - let store = PluginBundleStore::new_default(); - let Some(components) = store.load_current_components(plugin_id.trim())? else { - return Err("plugin not installed".to_string()); - }; - let Some(module) = components.module else { - return Err("plugin has no module component".to_string()); + values: Vec, +) -> Result<(), String> { + let plugin_id = plugin_id.trim().to_string(); + if plugin_id.is_empty() { + return Err("plugin id is empty".to_string()); + } + + let cfg = state.config(); + let (defaults, runtime) = resolve_plugin_settings_defaults(&state, &cfg, &plugin_id)?; + if defaults.is_empty() { + return Err(format!("plugin `{plugin_id}` does not declare settings")); + } + let incoming = merge_settings_with_defaults(defaults, values)?; + + let normalized = if let Some(runtime) = runtime.as_ref() { + runtime.settings_on_save(incoming)? + } else { + incoming }; - let installed = store - .get_current_installed(&components.plugin_id)? - .ok_or_else(|| "plugin is not installed".to_string())?; - - let method = method.trim(); - if method.is_empty() { - return Err("method is empty".to_string()); - } - - let rpc = StdioRpcProcess::new( - SpawnConfig { - plugin_id: components.plugin_id, - component_label: "module".into(), - exec_path: module.exec_path, - args: Vec::new(), - requested_capabilities: installed.requested_capabilities, - approval: installed.approval, - allowed_workspace_root: None, - }, - RpcConfig::default(), - ); + settings_store::save_settings(&plugin_id, &settings_to_json_map(&normalized))?; + + if let Some(runtime) = runtime { + runtime.settings_on_apply(normalized)?; + } + Ok(()) +} - let params = params.unwrap_or(Value::Null); - let v = rpc - .call(method, params) - .map_err(|e| format!("{}: {}", e.code, e.message))?; - Ok(v) +/// Resets plugin settings to defaults and applies them immediately. +/// +/// # Parameters +/// - `state`: Application state. +/// - `plugin_id`: Plugin id. +/// +/// # Returns +/// - `Ok(())` when reset + apply succeeds. +/// - `Err(String)` when runtime calls fail. +#[tauri::command] +pub fn reset_plugin_settings(state: State<'_, AppState>, plugin_id: String) -> Result<(), String> { + let plugin_id = plugin_id.trim().to_string(); + if plugin_id.is_empty() { + return Err("plugin id is empty".to_string()); + } + + let cfg = state.config(); + let (_defaults, runtime) = resolve_plugin_settings_defaults(&state, &cfg, &plugin_id)?; + + if let Some(runtime) = runtime.as_ref() { + runtime.settings_on_reset()?; + } + + settings_store::reset_settings(&plugin_id)?; + + if let Some(runtime) = runtime { + let defaults = runtime.settings_defaults()?; + runtime.settings_on_apply(defaults)?; + } + + Ok(()) +} + +/// Resolves plugin settings defaults from runtime hooks. +fn resolve_plugin_settings_defaults( + state: &AppState, + cfg: &crate::settings::AppConfig, + plugin_id: &str, +) -> Result<(Vec, Option>), String> { + let mut runtime = state + .plugin_runtime() + .runtime_for_workspace_with_config(cfg, plugin_id, None) + .ok(); + + if runtime.is_none() { + let _ = state.plugin_runtime().start_plugin(plugin_id); + runtime = state + .plugin_runtime() + .runtime_for_workspace_with_config(cfg, plugin_id, None) + .ok(); + } + + if let Some(runtime_ref) = runtime.as_ref() { + let runtime_defaults = runtime_ref.settings_defaults()?; + if !runtime_defaults.is_empty() { + return Ok((runtime_defaults, runtime)); + } + } + + Ok((Vec::new(), runtime)) +} + +/// Returns a stable string kind name for a typed setting. +fn setting_kind_name(value: &SettingValue) -> &'static str { + match value { + SettingValue::Bool(_) => "bool", + SettingValue::S32(_) => "s32", + SettingValue::U32(_) => "u32", + SettingValue::F64(_) => "f64", + SettingValue::String(_) => "text", + } +} + +/// Merges incoming frontend values into typed defaults. +fn merge_settings_with_defaults( + mut defaults: Vec, + incoming: Vec, +) -> Result, String> { + let mut map: std::collections::HashMap = std::collections::HashMap::new(); + for item in incoming { + map.insert(item.id.trim().to_string(), item.value); + } + + for entry in &mut defaults { + if let Some(value) = map.get(&entry.id) { + entry.value = setting_from_json(&entry.id, value, &entry.value)?; + } + } + Ok(defaults) +} + +/// Converts JSON value into expected typed setting variant. +fn setting_from_json( + id: &str, + value: &Value, + expected: &SettingValue, +) -> Result { + match expected { + SettingValue::Bool(_) => value + .as_bool() + .map(SettingValue::Bool) + .ok_or_else(|| format!("setting `{id}` expects bool")), + SettingValue::S32(_) => value + .as_i64() + .and_then(|v| i32::try_from(v).ok()) + .map(SettingValue::S32) + .ok_or_else(|| format!("setting `{id}` expects s32")), + SettingValue::U32(_) => value + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .map(SettingValue::U32) + .ok_or_else(|| format!("setting `{id}` expects u32")), + SettingValue::F64(_) => value + .as_f64() + .map(SettingValue::F64) + .ok_or_else(|| format!("setting `{id}` expects f64")), + SettingValue::String(_) => value + .as_str() + .map(|v| SettingValue::String(v.to_string())) + .ok_or_else(|| format!("setting `{id}` expects string")), + } +} + +/// Converts typed settings to JSON object map. +fn settings_to_json_map(values: &[SettingKv]) -> serde_json::Map { + let mut out = serde_json::Map::new(); + for entry in values { + out.insert(entry.id.clone(), setting_value_to_json(&entry.value)); + } + out +} + +/// Converts one typed setting variant to JSON. +fn setting_value_to_json(value: &SettingValue) -> Value { + match value { + SettingValue::Bool(v) => Value::Bool(*v), + SettingValue::S32(v) => Value::from(*v), + SettingValue::U32(v) => Value::from(*v), + SettingValue::F64(v) => Value::from(*v), + SettingValue::String(v) => Value::String(v.clone()), + } } diff --git a/Backend/src/tauri_commands/remotes.rs b/Backend/src/tauri_commands/remotes.rs index 5fc03491..8437f88c 100644 --- a/Backend/src/tauri_commands/remotes.rs +++ b/Backend/src/tauri_commands/remotes.rs @@ -1,8 +1,9 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use log::{error, info, warn}; use tauri::{Emitter, Manager, Runtime, State, Window}; use openvcs_core::models::{CommitItem, LogQuery, VcsEvent}; -use openvcs_core::FetchOptions; use openvcs_core::Vcs; use openvcs_core::VcsError; @@ -153,18 +154,28 @@ fn emit_ssh_prompt(app: &tauri::AppHandle, remote: &str, url: &st } #[derive(Clone, serde::Serialize)] +/// UI event payload requesting unknown-host-key confirmation. struct SshHostKeyPrompt { + /// Remote host name requiring trust confirmation. host: String, + /// Remote alias involved in the failed operation. remote: String, + /// Remote URL associated with the host. url: String, + /// Raw backend error message. message: String, } #[derive(Clone, serde::Serialize)] +/// UI event payload requesting SSH authentication troubleshooting. struct SshAuthPrompt { + /// Remote host that rejected authentication. host: String, + /// Remote alias involved in the failed operation. remote: String, + /// Remote URL associated with the host. url: String, + /// Raw backend error message. message: String, } @@ -222,9 +233,6 @@ pub async fn git_fetch( ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; let app = window.app_handle().clone(); - let fetch_opts = FetchOptions { - prune: state.with_config(|c| c.git.prune_on_fetch), - }; let current = run_repo_task("git_fetch", repo, move |repo| { info!("git_fetch called"); let on = Some(progress_bridge(app.clone())); @@ -256,10 +264,7 @@ pub async fn git_fetch( } info!("Fetching '{refspec}' from remote '{remote}' (current branch '{current}')"); - if let Err(e) = repo - .inner() - .fetch_with_options(&remote, &refspec, fetch_opts, on) - { + if let Err(e) = repo.inner().fetch(&remote, &refspec, on) { let msg = e.to_string(); let url = remote_url_for(repo.inner(), &remote).unwrap_or_default(); emit_ssh_prompt(&app, &remote, &url, &msg); @@ -297,9 +302,6 @@ pub async fn git_fetch_all( ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; let app = window.app_handle().clone(); - let fetch_opts = FetchOptions { - prune: state.with_config(|c| c.git.prune_on_fetch), - }; run_repo_task("git_fetch_all", repo, move |repo| { info!("git_fetch_all called"); let on = Some(progress_bridge(app.clone())); @@ -320,15 +322,9 @@ pub async fn git_fetch_all( // Some backends/environments can be picky about force-refspec syntax; fall back to a // non-force refspec so we still populate `refs/remotes//*` for the UI. - if let Err(e) = - repo.inner() - .fetch_with_options(&r, &refspec_force, fetch_opts, on.clone()) - { + if let Err(e) = repo.inner().fetch(&r, &refspec_force, on.clone()) { warn!("Fetch (force refspec) failed for remote '{r}': {e}; retrying without '+'"); - if let Err(e2) = - repo.inner() - .fetch_with_options(&r, &refspec, fetch_opts, on.clone()) - { + if let Err(e2) = repo.inner().fetch(&r, &refspec, on.clone()) { let msg = e2.to_string(); emit_ssh_prompt(&app, &r, &url, &msg); error!("Fetch failed for remote '{r}': {msg}"); @@ -475,9 +471,13 @@ pub async fn git_pull( } #[derive(serde::Serialize)] +/// Pull execution result returned to the frontend. pub struct PullResult { + /// Whether a pull operation ran and updated local refs. pub pulled: bool, + /// Branch name evaluated for the pull. pub branch: String, + /// Skip/failure context when no pull was performed. pub reason: Option, } @@ -497,9 +497,6 @@ pub async fn git_push( ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; let app = window.app_handle().clone(); - let fetch_opts = FetchOptions { - prune: state.with_config(|c| c.git.prune_on_fetch), - }; let current = run_repo_task("git_push", repo, move |repo| { info!("git_push called"); let on = Some(progress_bridge(app.clone())); @@ -527,10 +524,7 @@ pub async fn git_push( // Pushing does not update local remote-tracking refs (refs/remotes/origin/*), // which the UI uses for ahead/behind; refresh them best-effort. let on_fetch = Some(progress_bridge(app)); - if let Err(e) = repo - .inner() - .fetch_with_options("origin", ¤t, fetch_opts, on_fetch) - { + if let Err(e) = repo.inner().fetch("origin", ¤t, on_fetch) { warn!("Post-push fetch failed for branch '{current}': {e}"); } diff --git a/Backend/src/tauri_commands/repo_files.rs b/Backend/src/tauri_commands/repo_files.rs index 8d06b9bd..4aa4cbd2 100644 --- a/Backend/src/tauri_commands/repo_files.rs +++ b/Backend/src/tauri_commands/repo_files.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::collections::HashSet; use std::path::{Component, PathBuf}; diff --git a/Backend/src/tauri_commands/settings.rs b/Backend/src/tauri_commands/settings.rs index 88d681b6..86c7c012 100644 --- a/Backend/src/tauri_commands/settings.rs +++ b/Backend/src/tauri_commands/settings.rs @@ -1,4 +1,6 @@ -use log::warn; +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +use log::{info, warn}; use std::collections::{HashMap, HashSet}; use tauri::State; @@ -8,6 +10,49 @@ use crate::state::AppState; use super::run_repo_task; +fn diff_configs(old_cfg: &AppConfig, new_cfg: &AppConfig) -> Vec { + let mut changes = Vec::new(); + + if old_cfg.general != new_cfg.general { + changes.push("general".to_string()); + } + if old_cfg.git != new_cfg.git { + changes.push("git".to_string()); + } + if old_cfg.credentials != new_cfg.credentials { + changes.push("credentials".to_string()); + } + if old_cfg.diff != new_cfg.diff { + changes.push("diff".to_string()); + } + if old_cfg.lfs != new_cfg.lfs { + changes.push("lfs".to_string()); + } + if old_cfg.performance != new_cfg.performance { + changes.push("performance".to_string()); + } + if old_cfg.integrations != new_cfg.integrations { + changes.push("integrations".to_string()); + } + if old_cfg.plugins != new_cfg.plugins { + changes.push("plugins".to_string()); + } + if old_cfg.ux != new_cfg.ux { + changes.push("ux".to_string()); + } + if old_cfg.advanced != new_cfg.advanced { + changes.push("advanced".to_string()); + } + if old_cfg.experimental != new_cfg.experimental { + changes.push("experimental".to_string()); + } + if old_cfg.logging != new_cfg.logging { + changes.push("logging".to_string()); + } + + changes +} + #[tauri::command] /// Returns global application settings. /// @@ -31,7 +76,23 @@ pub fn get_global_settings(state: State<'_, AppState>) -> Result, cfg: AppConfig) -> Result<(), String> { - state.set_config(cfg) + let old_cfg = state.config(); + state.set_config(cfg.clone())?; + state + .plugin_runtime() + .sync_plugin_runtime_with_config(&cfg) + .map_err(|err| format!("settings saved but plugin runtime sync failed: {err}"))?; + + let changes = diff_configs(&old_cfg, &cfg); + if changes.is_empty() { + info!("settings: global config saved (no changes detected)"); + } else { + info!( + "settings: global config updated - changed: {}", + changes.join(", ") + ); + } + Ok(()) } #[tauri::command] @@ -156,5 +217,6 @@ pub async fn set_repo_settings(state: State<'_, AppState>, cfg: RepoConfig) -> R }) .await?; } + info!("settings: repository config updated"); Ok(()) } diff --git a/Backend/src/tauri_commands/shared.rs b/Backend/src/tauri_commands/shared.rs index 001a0505..b72737dd 100644 --- a/Backend/src/tauri_commands/shared.rs +++ b/Backend/src/tauri_commands/shared.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::sync::Arc; use openvcs_core::models::VcsEvent; @@ -10,7 +12,9 @@ use crate::repo::Repo; use crate::state::AppState; #[derive(serde::Serialize, Clone)] +/// Generic progress event payload sent to the UI. pub struct ProgressPayload { + /// Human-readable progress message. pub message: String, } diff --git a/Backend/src/tauri_commands/ssh.rs b/Backend/src/tauri_commands/ssh.rs index 3ca8019b..e080e189 100644 --- a/Backend/src/tauri_commands/ssh.rs +++ b/Backend/src/tauri_commands/ssh.rs @@ -1,5 +1,8 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::{fs, path::PathBuf, process::Command}; +use log::{debug, error, info, trace, warn}; use serde::Serialize; use tauri::command; @@ -9,8 +12,13 @@ use tauri::command; /// - `Ok(PathBuf)` known-hosts path. /// - `Err(String)` when home directory cannot be resolved. fn known_hosts_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())?; - Ok(home.join(".ssh").join("known_hosts")) + let home = dirs::home_dir().ok_or_else(|| { + error!("known_hosts_path: could not determine home directory",); + "Could not determine home directory".to_string() + })?; + let path = home.join(".ssh").join("known_hosts"); + trace!("known_hosts_path: {}", path.display()); + Ok(path) } /// Returns `~/.ssh` directory path. @@ -19,8 +27,13 @@ fn known_hosts_path() -> Result { /// - `Ok(PathBuf)` ssh directory path. /// - `Err(String)` when home directory cannot be resolved. fn ssh_dir_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())?; - Ok(home.join(".ssh")) + let home = dirs::home_dir().ok_or_else(|| { + error!("ssh_dir_path: could not determine home directory",); + "Could not determine home directory".to_string() + })?; + let path = home.join(".ssh"); + trace!("ssh_dir_path: {}", path.display()); + Ok(path) } /// Ensures `~/.ssh` directory exists. @@ -29,16 +42,27 @@ fn ssh_dir_path() -> Result { /// - `Ok(PathBuf)` created/existing ssh directory path. /// - `Err(String)` on resolution or create failure. fn ensure_ssh_dir() -> Result { - let home = dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())?; + let home = dirs::home_dir().ok_or_else(|| { + error!("ensure_ssh_dir: could not determine home directory",); + "Could not determine home directory".to_string() + })?; let dir = home.join(".ssh"); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create ~/.ssh: {e}"))?; + fs::create_dir_all(&dir).map_err(|e| { + error!("ensure_ssh_dir: failed to create {}: {}", dir.display(), e); + format!("Failed to create ~/.ssh: {e}") + })?; + debug!("ensure_ssh_dir: ssh directory ready at {}", dir.display()); Ok(dir) } #[derive(Clone, Serialize)] +/// Process output captured from SSH-related shell commands. pub struct SshCommandOutput { + /// Process exit code, or `-1` when unavailable. pub code: i32, + /// UTF-8-decoded standard output. pub stdout: String, + /// UTF-8-decoded standard error. pub stderr: String, } @@ -52,16 +76,35 @@ pub struct SshCommandOutput { /// - `Ok(SshCommandOutput)` command output. /// - `Err(String)` on spawn failure. fn run_command(cmd: &str, args: &[&str]) -> Result { - let out = Command::new(cmd) - .args(args) - .output() - .map_err(|e| format!("Failed to run {cmd}: {e}"))?; + trace!("run_command: {} {:?}", cmd, args); + let start = std::time::Instant::now(); + + let out = Command::new(cmd).args(args).output().map_err(|e| { + error!("run_command: failed to spawn {}: {}", cmd, e); + format!("Failed to run {cmd}: {e}") + })?; - Ok(SshCommandOutput { + let elapsed = start.elapsed(); + let result = SshCommandOutput { code: out.status.code().unwrap_or(-1), stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(), stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(), - }) + }; + + if out.status.success() { + debug!( + "run_command: {} succeeded in {:?} (code={})", + cmd, elapsed, result.code + ); + trace!("run_command: stdout='{}'", result.stdout); + } else { + warn!( + "run_command: {} failed in {:?} (code={}): {}", + cmd, elapsed, result.code, result.stderr + ); + } + + Ok(result) } #[cfg(not(target_os = "windows"))] @@ -74,24 +117,42 @@ fn run_command(cmd: &str, args: &[&str]) -> Result { /// - `Ok(String)` scanned key lines. /// - `Err(String)` on command failure. fn keyscan(host: &str) -> Result { + trace!("keyscan: scanning host '{}'", host); + let start = std::time::Instant::now(); + let out = Command::new("ssh-keyscan") .arg("-H") .arg(host) .output() - .map_err(|e| format!("Failed to run ssh-keyscan: {e}"))?; + .map_err(|e| { + error!("keyscan: failed to run ssh-keyscan: {}", e); + format!("Failed to run ssh-keyscan: {e}") + })?; + + let elapsed = start.elapsed(); + if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + error!("keyscan: failed for '{}' in {:?}: {}", host, elapsed, err); return Err(if err.is_empty() { format!("ssh-keyscan exited with {}", out.status) } else { err }); } + let s = String::from_utf8_lossy(&out.stdout).to_string(); let s = s.trim().to_string(); if s.is_empty() { + warn!("keyscan: no host keys returned for '{}'", host); return Err("ssh-keyscan returned no host keys".to_string()); } + + debug!("keyscan: got keys for '{}' in {:?}", host, elapsed); + trace!( + "keyscan: keys='{}'", + s.lines().take(3).collect::>().join("\\n") + ); Ok(s) } @@ -104,6 +165,7 @@ fn keyscan(host: &str) -> Result { /// # Returns /// - Always `Err(String)` until implemented. fn keyscan(_host: &str) -> Result { + warn!("keyscan: not implemented for Windows"); Err("SSH host key scanning is not implemented for Windows yet".to_string()) } @@ -118,16 +180,22 @@ fn keyscan(_host: &str) -> Result { /// - `Err(String)` when validation, scanning, or file write fails. pub fn ssh_trust_host(host: String) -> Result<(), String> { let host = host.trim(); + let start = std::time::Instant::now(); + info!("ssh_trust_host: host='{}'", host); + if host.is_empty() { + warn!("ssh_trust_host: empty host provided"); return Err("Host cannot be empty".to_string()); } ensure_ssh_dir()?; let known_hosts = known_hosts_path()?; + debug!("ssh_trust_host: known_hosts path={}", known_hosts.display()); // Avoid duplicating entries if the host is already present. if let Ok(existing) = fs::read_to_string(&known_hosts) { if existing.lines().any(|l| l.contains(host)) { + debug!("ssh_trust_host: host '{}' already in known_hosts", host); return Ok(()); } } @@ -142,8 +210,17 @@ pub fn ssh_trust_host(host: String) -> Result<(), String> { .append(true) .open(&known_hosts) .and_then(|mut f| std::io::Write::write_all(&mut f, to_append.as_bytes())) - .map_err(|e| format!("Failed to update {}: {e}", known_hosts.display()))?; + .map_err(|e| { + error!( + "ssh_trust_host: failed to update {}: {}", + known_hosts.display(), + e + ); + format!("Failed to update {}: {e}", known_hosts.display()) + })?; + let elapsed = start.elapsed(); + info!("ssh_trust_host: host '{}' trusted in {:?}", host, elapsed); Ok(()) } @@ -154,14 +231,33 @@ pub fn ssh_trust_host(host: String) -> Result<(), String> { /// - `Ok(SshCommandOutput)` with command exit/status output. /// - `Err(String)` when command execution fails. pub fn ssh_agent_list_keys() -> Result { - // Exit codes: - // 0 = keys listed, 1 = agent has no keys, 2 = agent not running/unreachable (platform dependent). - run_command("ssh-add", &["-l"]) + info!("ssh_agent_list_keys: listing SSH agent keys"); + let result = run_command("ssh-add", &["-l"])?; + + match result.code { + 0 => { + debug!( + "ssh_agent_list_keys: agent has {} keys", + result.stdout.lines().count() + ); + } + 1 => { + warn!("ssh_agent_list_keys: agent has no identities"); + } + code => { + warn!("ssh_agent_list_keys: agent returned code {}", code); + } + } + + Ok(result) } #[derive(Clone, Serialize)] +/// Candidate private-key file discovered in `~/.ssh`. pub struct SshKeyCandidate { + /// Absolute path to the candidate key file. pub path: String, + /// File name shown in the UI. pub name: String, } @@ -172,8 +268,11 @@ pub struct SshKeyCandidate { /// - `Ok(Vec)` sorted candidate list. /// - `Err(String)` when home/ssh directory resolution fails. pub fn ssh_key_candidates() -> Result, String> { + info!("ssh_key_candidates: scanning for SSH key candidates",); let dir = ssh_dir_path()?; + let Ok(read_dir) = fs::read_dir(&dir) else { + debug!("ssh_key_candidates: ssh directory does not exist or is not readable",); return Ok(vec![]); }; @@ -194,6 +293,7 @@ pub fn ssh_key_candidates() -> Result, String> { || name.ends_with(".log") || name.ends_with(".old") { + trace!("ssh_key_candidates: skipping non-key file: {}", name); continue; } @@ -208,6 +308,7 @@ pub fn ssh_key_candidates() -> Result, String> { continue; } + trace!("ssh_key_candidates: found candidate: {}", name); keys.push(SshKeyCandidate { path: path.display().to_string(), name: name.to_string(), @@ -215,6 +316,7 @@ pub fn ssh_key_candidates() -> Result, String> { } keys.sort_by(|a, b| a.name.cmp(&b.name)); + debug!("ssh_key_candidates: found {} candidate keys", keys.len()); Ok(keys) } @@ -229,8 +331,20 @@ pub fn ssh_key_candidates() -> Result, String> { /// - `Err(String)` when validation or command execution fails. pub fn ssh_add_key(path: String) -> Result { let p = path.trim(); + info!("ssh_add_key: path='{}'", p); + if p.is_empty() { + warn!("ssh_add_key: empty path provided"); return Err("Path cannot be empty".to_string()); } - run_command("ssh-add", &[p]) + + let result = run_command("ssh-add", &[p])?; + + if result.code == 0 { + debug!("ssh_add_key: key added successfully"); + } else { + warn!("ssh_add_key: failed to add key: {}", result.stderr); + } + + Ok(result) } diff --git a/Backend/src/tauri_commands/stash.rs b/Backend/src/tauri_commands/stash.rs index fd3bd217..73374a22 100644 --- a/Backend/src/tauri_commands/stash.rs +++ b/Backend/src/tauri_commands/stash.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::path::PathBuf; use log::{error, info}; diff --git a/Backend/src/tauri_commands/status.rs b/Backend/src/tauri_commands/status.rs index b83567d3..87af2897 100644 --- a/Backend/src/tauri_commands/status.rs +++ b/Backend/src/tauri_commands/status.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::path::PathBuf; use log::{debug, error, info}; diff --git a/Backend/src/tauri_commands/themes.rs b/Backend/src/tauri_commands/themes.rs index 8fc4039f..2d85fe5e 100644 --- a/Backend/src/tauri_commands/themes.rs +++ b/Backend/src/tauri_commands/themes.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::{plugins, settings, state::AppState, themes}; use std::collections::HashSet; use tauri::State; @@ -10,29 +12,13 @@ use tauri::State; /// # Returns /// - Lowercase set of enabled plugin ids. fn enabled_plugins(cfg: &settings::AppConfig) -> HashSet { - let disabled: HashSet = cfg - .plugins - .disabled - .iter() - .map(|s| s.trim().to_ascii_lowercase()) - .collect(); - let enabled: HashSet = cfg - .plugins - .enabled - .iter() - .map(|s| s.trim().to_ascii_lowercase()) - .collect(); - let mut out = HashSet::new(); for p in plugins::list_plugins() { let id = p.id.trim().to_ascii_lowercase(); if id.is_empty() { continue; } - if disabled.contains(&id) { - continue; - } - if enabled.contains(&id) || p.default_enabled { + if cfg.is_plugin_enabled(&id, p.default_enabled) { out.insert(id); } } diff --git a/Backend/src/tauri_commands/updater.rs b/Backend/src/tauri_commands/updater.rs index 0e1fa7e0..1b5dfaa3 100644 --- a/Backend/src/tauri_commands/updater.rs +++ b/Backend/src/tauri_commands/updater.rs @@ -1,3 +1,6 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +use log::{debug, error, info, trace}; use tauri::{Emitter, Manager, Runtime, Window}; use tauri_plugin_updater::UpdaterExt; @@ -12,22 +15,66 @@ use tauri_plugin_updater::UpdaterExt; /// - `Ok(())` when no update exists or installation succeeds. /// - `Err(String)` when updater operations fail. pub async fn updater_install_now(window: Window) -> Result<(), String> { + let start = std::time::Instant::now(); + info!("updater_install_now: starting update check"); + let app = window.app_handle(); - let updater = app.updater().map_err(|e| e.to_string())?; - match updater.check().await.map_err(|e| e.to_string())? { + let updater = app.updater().map_err(|e| { + error!("updater_install_now: failed to get updater: {}", e); + e.to_string() + })?; + + debug!("updater_install_now: checking for updates"); + let check_result = updater.check().await.map_err(|e| { + error!("updater_install_now: update check failed: {}", e); + e.to_string() + })?; + + match check_result { Some(update) => { + let version = &update.version; + let current_version = &update.current_version; + info!( + "updater_install_now: update available: {} -> {}", + current_version, version + ); + debug!( + "updater_install_now: update date={:?}, body_len={}", + update.date, + update.body.as_ref().map(|b| b.len()).unwrap_or(0) + ); + let app2 = app.clone(); + let download_start = std::time::Instant::now(); + update .download_and_install( |received, total| { + let total_val = total.unwrap_or(0); + let percent = if total_val > 0 { + (received as f64 / total_val as f64 * 100.0) as u32 + } else { + 0 + }; + trace!( + "updater_install_now: download progress {}/{} bytes ({}%)", + received, + total_val, + percent + ); let payload = serde_json::json!({ "kind": "progress", "received": received, - "total": total + "total": total_val }); let _ = app2.emit("update:progress", payload); }, || { + let download_elapsed = download_start.elapsed(); + info!( + "updater_install_now: download completed in {:?}", + download_elapsed + ); let _ = app2.emit( "update:progress", serde_json::json!({ "kind": "downloaded" }), @@ -35,9 +82,25 @@ pub async fn updater_install_now(window: Window) -> Result<(), St }, ) .await - .map_err(|e| e.to_string())?; + .map_err(|e| { + error!("updater_install_now: download/install failed: {}", e); + e.to_string() + })?; + + let elapsed = start.elapsed(); + info!( + "updater_install_now: update installed successfully in {:?}", + elapsed + ); + Ok(()) + } + None => { + let elapsed = start.elapsed(); + debug!( + "updater_install_now: no update available (checked in {:?})", + elapsed + ); Ok(()) } - None => Ok(()), } } diff --git a/Backend/src/themes.rs b/Backend/src/themes.rs index c11cb563..deb0ddf8 100644 --- a/Backend/src/themes.rs +++ b/Backend/src/themes.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use log::warn; use serde::{Deserialize, Serialize}; use std::{collections::HashSet, fs, path::Path}; diff --git a/Backend/src/utilities/inner.rs b/Backend/src/utilities/inner.rs index beac64d9..eda4570c 100644 --- a/Backend/src/utilities/inner.rs +++ b/Backend/src/utilities/inner.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use serde::Serialize; #[derive(Serialize)] diff --git a/Backend/src/utilities/mod.rs b/Backend/src/utilities/mod.rs index b73c13df..6bfab2d3 100644 --- a/Backend/src/utilities/mod.rs +++ b/Backend/src/utilities/mod.rs @@ -1,2 +1,8 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +//! Utility helpers exposed by the backend crate. + +/// Internal utility helper module. pub mod inner; +/// Back-compat re-export of utility helper module. pub use inner as utilities; diff --git a/Backend/src/utilities/utilities.rs b/Backend/src/utilities/utilities.rs index beac64d9..43835d31 100644 --- a/Backend/src/utilities/utilities.rs +++ b/Backend/src/utilities/utilities.rs @@ -1,5 +1,9 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +use log::{debug, info, trace, warn}; use serde::Serialize; + #[derive(Serialize)] pub struct AboutInfo { pub name: String, @@ -19,6 +23,8 @@ impl AboutInfo { /// # Returns /// - A populated [`AboutInfo`] record. pub fn gather() -> Self { + trace!("AboutInfo::gather: collecting application metadata"); + // Compile-time package metadata from Cargo let name = env!("CARGO_PKG_NAME").to_string(); let version = env!("OPENVCS_VERSION").to_string(); @@ -36,6 +42,10 @@ impl AboutInfo { let os = std::env::consts::OS.to_string(); let arch = std::env::consts::ARCH.to_string(); + debug!( + "AboutInfo::gather: {} v{} on {}-{}", name, version, os, arch + ); + Self { name, version, @@ -63,6 +73,9 @@ pub async fn browse_directory_async( app: tauri::AppHandle, title: &str, ) -> Option { + let start = std::time::Instant::now(); + info!("browse_directory_async: opening folder picker (title='{}')", title); + let dialog = tauri_plugin_dialog::DialogExt::dialog(&app).clone(); // OWNED Dialog let (tx, rx) = tokio::sync::oneshot::channel::>(); @@ -72,7 +85,23 @@ pub async fn browse_directory_async( let _ = tx.send(res.map(|p| p.to_string())); }); - rx.await.unwrap_or(None) + let result = rx.await.unwrap_or(None); + let elapsed = start.elapsed(); + + match &result { + Some(path) => { + debug!( + "browse_directory_async: selected '{}' in {:?}", path, elapsed + ); + } + None => { + debug!( + "browse_directory_async: canceled in {:?}", elapsed + ); + } + } + + result } /// Opens a native file picker and returns the selected file path. @@ -90,6 +119,11 @@ pub async fn browse_file_async( title: &str, extensions: &[&str], ) -> Option { + let start = std::time::Instant::now(); + info!( + "browse_file_async: opening file picker (title='{}', extensions={:?})", title, extensions + ); + let dialog = tauri_plugin_dialog::DialogExt::dialog(&app).clone(); let (tx, rx) = tokio::sync::oneshot::channel::>(); @@ -101,5 +135,21 @@ pub async fn browse_file_async( let _ = tx.send(res.map(|p| p.to_string())); }); - rx.await.unwrap_or(None) + let result = rx.await.unwrap_or(None); + let elapsed = start.elapsed(); + + match &result { + Some(path) => { + debug!( + "browse_file_async: selected '{}' in {:?}", path, elapsed + ); + } + None => { + debug!( + "browse_file_async: canceled in {:?}", elapsed + ); + } + } + + result } diff --git a/Backend/src/validate.rs b/Backend/src/validate.rs index e4a17c11..32863a20 100644 --- a/Backend/src/validate.rs +++ b/Backend/src/validate.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::path::Path; #[derive(serde::Serialize)] diff --git a/Backend/src/workarounds.rs b/Backend/src/workarounds.rs index 554dceee..47afe18e 100644 --- a/Backend/src/workarounds.rs +++ b/Backend/src/workarounds.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later #[cfg(target_os = "linux")] /// Applies a runtime workaround for NVIDIA + Wayland rendering issues. /// diff --git a/Backend/tauri.conf.json b/Backend/tauri.conf.json index 1b5e2e45..e93f1799 100644 --- a/Backend/tauri.conf.json +++ b/Backend/tauri.conf.json @@ -3,8 +3,8 @@ "productName": "OpenVCS", "identifier": "dev.jordon.openvcs", "build": { - "beforeDevCommand": "node ../Backend/scripts/run-tauri-before-command.js dev || node Backend/scripts/run-tauri-before-command.js dev || node scripts/run-tauri-before-command.js dev", - "beforeBuildCommand": "node Backend/scripts/run-tauri-before-command.js build || node ../Backend/scripts/run-tauri-before-command.js build || node scripts/run-tauri-before-command.js build", + "beforeDevCommand": "node ../Backend/scripts/run-tauri-before-command.js dev", + "beforeBuildCommand": "node ../Backend/scripts/run-tauri-before-command.js build", "devUrl": "http://localhost:1420", "frontendDist": "../Frontend/dist" }, @@ -41,7 +41,8 @@ "icons/icon.ico" ], "resources": [ - "../target/openvcs/built-in-plugins" + "../target/openvcs/built-in-plugins", + "../target/openvcs/node-runtime" ], "licenseFile": "../LICENSE", "publisher": "Jordon Brooks", diff --git a/Cargo.lock b/Cargo.lock index c2965778..e946a986 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -52,18 +43,6 @@ dependencies = [ "alloc-no-stdlib", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "ambient-authority" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -189,7 +168,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.3", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -220,7 +199,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.3", + "rustix", ] [[package]] @@ -246,7 +225,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -331,15 +310,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitmaps" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" -dependencies = [ - "typenum", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -397,9 +367,6 @@ name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" -dependencies = [ - "allocator-api2", -] [[package]] name = "bytemuck" @@ -465,84 +432,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "cap-fs-ext" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" -dependencies = [ - "cap-primitives", - "cap-std", - "io-lifetimes", - "windows-sys 0.59.0", -] - -[[package]] -name = "cap-net-ext" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" -dependencies = [ - "cap-primitives", - "cap-std", - "rustix 1.1.3", - "smallvec", -] - -[[package]] -name = "cap-primitives" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" -dependencies = [ - "ambient-authority", - "fs-set-times", - "io-extras", - "io-lifetimes", - "ipnet", - "maybe-owned", - "rustix 1.1.3", - "rustix-linux-procfs", - "windows-sys 0.59.0", - "winx", -] - -[[package]] -name = "cap-rand" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" -dependencies = [ - "ambient-authority", - "rand 0.8.5", -] - -[[package]] -name = "cap-std" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" -dependencies = [ - "cap-primitives", - "io-extras", - "io-lifetimes", - "rustix 1.1.3", -] - -[[package]] -name = "cap-time-ext" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" -dependencies = [ - "ambient-authority", - "cap-primitives", - "iana-time-zone", - "once_cell", - "rustix 1.1.3", - "winx", -] - [[package]] name = "cargo-platform" version = "0.1.9" @@ -612,7 +501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", - "target-lexicon 0.12.16", + "target-lexicon", ] [[package]] @@ -643,15 +532,6 @@ dependencies = [ "inout", ] -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.18", -] - [[package]] name = "colorchoice" version = "1.0.4" @@ -739,15 +619,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpp_demangle" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" -dependencies = [ - "cfg-if", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -757,144 +628,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cranelift-assembler-x64" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0377b13bf002a0774fcccac4f1102a10f04893d24060cf4b7350c87e4cbb647c" -dependencies = [ - "cranelift-assembler-x64-meta", -] - -[[package]] -name = "cranelift-assembler-x64-meta" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfa027979140d023b25bf7509fb7ede3a54c3d3871fb5ead4673c4b633f671a2" -dependencies = [ - "cranelift-srcgen", -] - -[[package]] -name = "cranelift-bforest" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "618e4da87d9179a70b3c2f664451ca8898987aa6eb9f487d16988588b5d8cc40" -dependencies = [ - "cranelift-entity", -] - -[[package]] -name = "cranelift-bitset" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db53764b5dad233b37b8f5dc54d3caa9900c54579195e00f17ea21f03f71aaa7" -dependencies = [ - "serde", - "serde_derive", -] - -[[package]] -name = "cranelift-codegen" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae927f1d8c0abddaa863acd201471d56e7fc6c3925104f4861ed4dc3e28b421" -dependencies = [ - "bumpalo", - "cranelift-assembler-x64", - "cranelift-bforest", - "cranelift-bitset", - "cranelift-codegen-meta", - "cranelift-codegen-shared", - "cranelift-control", - "cranelift-entity", - "cranelift-isle", - "gimli", - "hashbrown 0.15.5", - "log", - "pulley-interpreter", - "regalloc2", - "rustc-hash", - "serde", - "smallvec", - "target-lexicon 0.13.4", - "wasmtime-internal-math", -] - -[[package]] -name = "cranelift-codegen-meta" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fcf1e3e6757834bd2584f4cbff023fcc198e9279dcb5d684b4bb27a9b19f54" -dependencies = [ - "cranelift-assembler-x64-meta", - "cranelift-codegen-shared", - "cranelift-srcgen", - "heck 0.5.0", - "pulley-interpreter", -] - -[[package]] -name = "cranelift-codegen-shared" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "205dcb9e6ccf9d368b7466be675ff6ee54a63e36da6fe20e72d45169cf6fd254" - -[[package]] -name = "cranelift-control" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "108eca9fcfe86026054f931eceaf57b722c1b97464bf8265323a9b5877238817" -dependencies = [ - "arbitrary", -] - -[[package]] -name = "cranelift-entity" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d96496910065d3165f84ff8e1e393916f4c086f88ac8e1b407678bc78735aa" -dependencies = [ - "cranelift-bitset", - "serde", - "serde_derive", -] - -[[package]] -name = "cranelift-frontend" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e303983ad7e23c850f24d9c41fc3cb346e1b930f066d3966545e4c98dac5c9fb" -dependencies = [ - "cranelift-codegen", - "log", - "smallvec", - "target-lexicon 0.13.4", -] - -[[package]] -name = "cranelift-isle" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24b0cf8d867d891245836cac7abafb0a5b0ea040a019d720702b3b8bcba40bfa" - -[[package]] -name = "cranelift-native" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24b641e315443e27807b69c440fe766737d7e718c68beb665a2d69259c77bf3" -dependencies = [ - "cranelift-codegen", - "libc", - "target-lexicon 0.13.4", -] - -[[package]] -name = "cranelift-srcgen" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e378a54e7168a689486d67ee1f818b7e5356e54ae51a1d7a53f4f13f7f8b7a" - [[package]] name = "crc" version = "3.3.0" @@ -928,25 +661,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1035,15 +749,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "debugid" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" -dependencies = [ - "uuid", -] - [[package]] name = "deflate64" version = "0.1.10" @@ -1104,16 +809,6 @@ dependencies = [ "dirs-sys", ] -[[package]] -name = "directories-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - [[package]] name = "dirs" version = "6.0.0" @@ -1131,21 +826,10 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", -] - [[package]] name = "dispatch" version = "0.2.0" @@ -1234,12 +918,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "embed-resource" version = "3.0.6" @@ -1260,27 +938,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "endi" version = "1.1.1" @@ -1379,29 +1036,12 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix 1.1.3", - "windows-sys 0.59.0", -] - [[package]] name = "fdeflate" version = "0.3.7" @@ -1438,12 +1078,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "flate2" version = "1.1.9" @@ -1504,14 +1138,12 @@ dependencies = [ ] [[package]] -name = "fs-set-times" -version = "0.20.3" +name = "fsevent-sys" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" dependencies = [ - "io-lifetimes", - "rustix 1.1.3", - "windows-sys 0.59.0", + "libc", ] [[package]] @@ -1524,20 +1156,6 @@ dependencies = [ "new_debug_unreachable", ] -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -1545,7 +1163,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1613,7 +1230,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1634,20 +1250,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "fxprof-processed-profile" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" -dependencies = [ - "bitflags 2.10.0", - "debugid", - "rustc-hash", - "serde", - "serde_derive", - "serde_json", -] - [[package]] name = "gdk" version = "0.18.2" @@ -1806,17 +1408,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -dependencies = [ - "fallible-iterator", - "indexmap 2.13.0", - "stable_deref_trait", -] - [[package]] name = "gio" version = "0.18.4" @@ -1849,21 +1440,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "git2" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" -dependencies = [ - "bitflags 2.10.0", - "libc", - "libgit2-sys", - "log", - "openssl-probe 0.1.6", - "openssl-sys", - "url", -] - [[package]] name = "glib" version = "0.18.5" @@ -1993,7 +1569,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash", - "serde", ] [[package]] @@ -2295,24 +1870,10 @@ dependencies = [ ] [[package]] -name = "im-rc" -version = "15.1.0" +name = "indexmap" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe" -dependencies = [ - "bitmaps", - "rand_core 0.6.4", - "rand_xoshiro", - "sized-chunks", - "typenum", - "version_check", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", @@ -2341,29 +1902,33 @@ dependencies = [ ] [[package]] -name = "inout" -version = "0.1.4" +name = "inotify" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" dependencies = [ - "generic-array", + "bitflags 1.3.2", + "inotify-sys", + "libc", ] [[package]] -name = "io-extras" -version = "0.18.4" +name = "inotify-sys" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" dependencies = [ - "io-lifetimes", - "windows-sys 0.59.0", + "libc", ] [[package]] -name = "io-lifetimes" -version = "2.0.4" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] [[package]] name = "ipnet" @@ -2406,41 +1971,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "ittapi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" -dependencies = [ - "anyhow", - "ittapi-sys", - "log", -] - -[[package]] -name = "ittapi-sys" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" -dependencies = [ - "cc", -] - [[package]] name = "javascriptcore-rs" version = "1.1.2" @@ -2563,6 +2099,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2581,12 +2137,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -2629,20 +2179,6 @@ version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" -[[package]] -name = "libgit2-sys" -version = "0.18.3+1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" -dependencies = [ - "cc", - "libc", - "libssh2-sys", - "libz-sys", - "openssl-sys", - "pkg-config", -] - [[package]] name = "libloading" version = "0.7.4" @@ -2653,12 +2189,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.12" @@ -2670,58 +2200,6 @@ dependencies = [ "redox_syscall 0.7.0", ] -[[package]] -name = "libssh2-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "libz-sys" -version = "1.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linkme" -version = "0.3.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e3283ed2d0e50c06dd8602e0ab319bb048b6325d0bba739db64ed8205179898" -dependencies = [ - "linkme-impl", -] - -[[package]] -name = "linkme-impl" -version = "0.3.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2776,15 +2254,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" -[[package]] -name = "mach2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] - [[package]] name = "markup5ever" version = "0.14.1" @@ -2816,27 +2285,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" -[[package]] -name = "maybe-owned" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memfd" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" -dependencies = [ - "rustix 1.1.3", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -2868,6 +2322,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.1" @@ -2942,6 +2408,25 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.10.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -3212,18 +2697,6 @@ dependencies = [ "objc2-security", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "crc32fast", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -3248,39 +2721,23 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "openvcs" version = "0.1.1" dependencies = [ + "base64 0.22.1", "directories", "dirs", "env_logger", "hex", "log", + "notify", "openvcs-core", "os_pipe", "parking_lot", @@ -3299,8 +2756,6 @@ dependencies = [ "time", "tokio", "toml 0.9.12+spec-1.1.0", - "wasmtime", - "wasmtime-wasi", "xz2", "zip 7.4.0", ] @@ -3308,27 +2763,11 @@ dependencies = [ [[package]] name = "openvcs-core" version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "706c4aa8b72515997a4b518b6e5372bd39213a841774a6675123ad19d3c99d1d" -dependencies = [ - "log", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "openvcs-plugin-git" -version = "0.2.0" dependencies = [ - "git2", - "linkme", "log", - "openvcs-core", "serde", "serde_json", "thiserror 2.0.18", - "time", ] [[package]] @@ -3447,16 +2886,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset", - "indexmap 2.13.0", -] - [[package]] name = "phf" version = "0.8.0" @@ -3656,7 +3085,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -3675,18 +3104,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "serde", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -3801,29 +3218,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pulley-interpreter" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01051a5b172e07f9197b85060e6583b942aec679dac08416647bf7e7dc916b65" -dependencies = [ - "cranelift-bitset", - "log", - "pulley-macros", - "wasmtime-internal-math", -] - -[[package]] -name = "pulley-macros" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cf194f5b1a415ef3a44ee35056f4009092cc4038a9f7e3c7c1e392f48ee7dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "quick-xml" version = "0.38.4" @@ -3929,41 +3323,12 @@ dependencies = [ "rand_core 0.5.1", ] -[[package]] -name = "rand_xoshiro" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -3982,17 +3347,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.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -4024,20 +3378,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "regalloc2" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08effbc1fa53aaebff69521a5c05640523fab037b34a4a2c109506bc938246fa" -dependencies = [ - "allocator-api2", - "bumpalo", - "hashbrown 0.15.5", - "log", - "rustc-hash", - "smallvec", -] - [[package]] name = "regex" version = "1.12.3" @@ -4144,18 +3484,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -4167,40 +3495,17 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.44" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] -[[package]] -name = "rustix-linux-procfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" -dependencies = [ - "once_cell", - "rustix 1.1.3", -] - [[package]] name = "rustls" version = "0.23.36" @@ -4221,7 +3526,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", "security-framework", @@ -4280,12 +3585,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "same-file" version = "1.0.6" @@ -4538,19 +3837,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.13.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4639,16 +3925,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" -[[package]] -name = "sized-chunks" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" -dependencies = [ - "bitmaps", - "typenum", -] - [[package]] name = "slab" version = "0.4.12" @@ -4660,9 +3936,6 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] [[package]] name = "socket2" @@ -4831,22 +4104,6 @@ dependencies = [ "version-compare", ] -[[package]] -name = "system-interface" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" -dependencies = [ - "bitflags 2.10.0", - "cap-fs-ext", - "cap-std", - "fd-lock", - "io-lifetimes", - "rustix 0.38.44", - "windows-sys 0.59.0", - "winx", -] - [[package]] name = "tao" version = "0.34.5" @@ -4915,12 +4172,6 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" -[[package]] -name = "target-lexicon" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1dd07eb858a2067e2f3c7155d54e929265c264e6f37efe3ee7a8d1b5a1dd0ba" - [[package]] name = "tauri" version = "2.10.2" @@ -5257,7 +4508,7 @@ dependencies = [ "fastrand", "getrandom 0.4.1", "once_cell", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -5272,15 +4523,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -5373,7 +4615,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "pin-project-lite", "signal-hook-registry", "socket2", @@ -5685,24 +4927,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[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" @@ -5764,12 +4994,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version-compare" version = "0.2.1" @@ -5910,37 +5134,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-compose" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af801b6f36459023eaec63fdbaedad2fd5a4ab7dc74ecc110a8b5d375c5775e4" -dependencies = [ - "anyhow", - "heck 0.5.0", - "im-rc", - "indexmap 2.13.0", - "log", - "petgraph", - "serde", - "serde_derive", - "serde_yaml", - "smallvec", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", - "wat", -] - -[[package]] -name = "wasm-encoder" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c55db9c896d70bd9fa535ce83cd4e1f2ec3726b0edd2142079f594fc3be1cb35" -dependencies = [ - "leb128fmt", - "wasmparser 0.243.0", -] - [[package]] name = "wasm-encoder" version = "0.244.0" @@ -5948,17 +5141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser 0.244.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.245.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95d568e113f706ee7a7df9b33547bb80721f55abffc79b3dc4d09c368690e662" -dependencies = [ - "leb128fmt", - "wasmparser 0.245.0", + "wasmparser", ] [[package]] @@ -5969,8 +5152,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap 2.13.0", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", + "wasm-encoder", + "wasmparser", ] [[package]] @@ -5986,19 +5169,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d8db401b0528ec316dfbe579e6ab4152d61739cfe076706d2009127970159d" -dependencies = [ - "bitflags 2.10.0", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", - "serde", -] - [[package]] name = "wasmparser" version = "0.244.0" @@ -6011,363 +5181,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wasmparser" -version = "0.245.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48a767a48974f0c8b66f211b96e01aa77feed58b8ccce4e7f0cff0ae55b174d4" -dependencies = [ - "bitflags 2.10.0", - "indexmap 2.13.0", - "semver", -] - -[[package]] -name = "wasmprinter" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2b6035559e146114c29a909a3232928ee488d6507a1504d8934e8607b36d7b" -dependencies = [ - "anyhow", - "termcolor", - "wasmparser 0.243.0", -] - -[[package]] -name = "wasmtime" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19f56cece843fa95dd929f5568ff8739c7e3873b530ceea9eda2aa02a0b4142" -dependencies = [ - "addr2line", - "anyhow", - "async-trait", - "bitflags 2.10.0", - "bumpalo", - "cc", - "cfg-if", - "encoding_rs", - "futures", - "fxprof-processed-profile", - "gimli", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "ittapi", - "libc", - "log", - "mach2", - "memfd", - "object", - "once_cell", - "postcard", - "pulley-interpreter", - "rayon", - "rustix 1.1.3", - "semver", - "serde", - "serde_derive", - "serde_json", - "smallvec", - "target-lexicon 0.13.4", - "tempfile", - "wasm-compose", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-cache", - "wasmtime-internal-component-macro", - "wasmtime-internal-component-util", - "wasmtime-internal-cranelift", - "wasmtime-internal-fiber", - "wasmtime-internal-jit-debug", - "wasmtime-internal-jit-icache-coherence", - "wasmtime-internal-math", - "wasmtime-internal-slab", - "wasmtime-internal-unwinder", - "wasmtime-internal-versioned-export-macros", - "wasmtime-internal-winch", - "wat", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-environ" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf9dff572c950258548cbbaf39033f68f8dcd0b43b22e80def9fe12d532d3e5" -dependencies = [ - "anyhow", - "cpp_demangle", - "cranelift-bitset", - "cranelift-entity", - "gimli", - "indexmap 2.13.0", - "log", - "object", - "postcard", - "rustc-demangle", - "semver", - "serde", - "serde_derive", - "smallvec", - "target-lexicon 0.13.4", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", - "wasmprinter", - "wasmtime-internal-component-util", -] - -[[package]] -name = "wasmtime-internal-cache" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f52a985f5b5dae53147fc596f3a313c334e2c24fd1ba708634e1382f6ecd727" -dependencies = [ - "base64 0.22.1", - "directories-next", - "log", - "postcard", - "rustix 1.1.3", - "serde", - "serde_derive", - "sha2", - "toml 0.9.12+spec-1.1.0", - "wasmtime-environ", - "windows-sys 0.61.2", - "zstd", -] - -[[package]] -name = "wasmtime-internal-component-macro" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7920dc7dcb608352f5fe93c52582e65075b7643efc5dac3fc717c1645a8d29a0" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "syn 2.0.114", - "wasmtime-internal-component-util", - "wasmtime-internal-wit-bindgen", - "wit-parser 0.243.0", -] - -[[package]] -name = "wasmtime-internal-component-util" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "066f5aed35aa60580a2ac0df145c0f0d4b04319862fee1d6036693e1cca43a12" - -[[package]] -name = "wasmtime-internal-cranelift" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afb8002dc415b7773d7949ee360c05ee8f91627ec25a7a0b01ee03831bdfdda1" -dependencies = [ - "cfg-if", - "cranelift-codegen", - "cranelift-control", - "cranelift-entity", - "cranelift-frontend", - "cranelift-native", - "gimli", - "itertools", - "log", - "object", - "pulley-interpreter", - "smallvec", - "target-lexicon 0.13.4", - "thiserror 2.0.18", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-math", - "wasmtime-internal-unwinder", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-fiber" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9c562c5a272bc9f615d8f0c085a4360bafa28eef9aa5947e63d204b1129b22" -dependencies = [ - "cc", - "cfg-if", - "libc", - "rustix 1.1.3", - "wasmtime-environ", - "wasmtime-internal-versioned-export-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-internal-jit-debug" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db673148f26e1211db3913c12c75594be9e3858a71fa297561e9162b1a49cfb0" -dependencies = [ - "cc", - "object", - "rustix 1.1.3", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-jit-icache-coherence" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bada5ca1cc47df7d14100e2254e187c2486b426df813cea2dd2553a7469f7674" -dependencies = [ - "anyhow", - "cfg-if", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-internal-math" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf6f615d528eda9adc6eefb062135f831b5215c348f4c3ec3e143690c730605b" -dependencies = [ - "libm", -] - -[[package]] -name = "wasmtime-internal-slab" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da169d4f789b586e1b2612ba8399c653ed5763edf3e678884ba785bb151d018f" - -[[package]] -name = "wasmtime-internal-unwinder" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4888301f3393e4e8c75c938cce427293fade300fee3fc8fd466fdf3e54ae068e" -dependencies = [ - "cfg-if", - "cranelift-codegen", - "log", - "object", - "wasmtime-environ", -] - -[[package]] -name = "wasmtime-internal-versioned-export-macros" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63ba3124cc2cbcd362672f9f077303ccc4cd61daa908f73447b7fdaece75ff9f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "wasmtime-internal-winch" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90a4182515dabba776656de4ebd62efad03399e261cf937ecccb838ce8823534" -dependencies = [ - "cranelift-codegen", - "gimli", - "log", - "object", - "target-lexicon 0.13.4", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-cranelift", - "winch-codegen", -] - -[[package]] -name = "wasmtime-internal-wit-bindgen" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87acbd416227cdd279565ba49e57cf7f08d112657c3b3f39b70250acdfd094fe" -dependencies = [ - "anyhow", - "bitflags 2.10.0", - "heck 0.5.0", - "indexmap 2.13.0", - "wit-parser 0.243.0", -] - -[[package]] -name = "wasmtime-wasi" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a1bdb4948463ed22559a640e687fed0df50b66353144aa6a9496c041ecd927" -dependencies = [ - "anyhow", - "async-trait", - "bitflags 2.10.0", - "bytes", - "cap-fs-ext", - "cap-net-ext", - "cap-rand", - "cap-std", - "cap-time-ext", - "fs-set-times", - "futures", - "io-extras", - "io-lifetimes", - "rustix 1.1.3", - "system-interface", - "thiserror 2.0.18", - "tokio", - "tracing", - "url", - "wasmtime", - "wasmtime-wasi-io", - "wiggle", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-wasi-io" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7873d8b990d3cf1105ef491abf2b3cf1e19ff6722d24d5ca662026ea082cdff" -dependencies = [ - "anyhow", - "async-trait", - "bytes", - "futures", - "wasmtime", -] - -[[package]] -name = "wast" -version = "35.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" -dependencies = [ - "leb128", -] - -[[package]] -name = "wast" -version = "245.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ffc7471e16a6f3c7a3c3a230314915b5dcd158e5ef13ccda2f43358a9df00c" -dependencies = [ - "bumpalo", - "leb128fmt", - "memchr", - "unicode-width", - "wasm-encoder 0.245.0", -] - -[[package]] -name = "wat" -version = "1.245.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bcac6f915e2a84a4c0d9df9d41ad7518d99cda13f3bb83e3b8c22bf8726ab6" -dependencies = [ - "wast 245.0.0", -] - [[package]] name = "web-sys" version = "0.3.85" @@ -6467,46 +5280,6 @@ dependencies = [ "windows-core 0.61.2", ] -[[package]] -name = "wiggle" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f05d2a9932ca235984248dc98471ae83d1985e095682d049af4c296f54f0fb4" -dependencies = [ - "anyhow", - "bitflags 2.10.0", - "thiserror 2.0.18", - "tracing", - "wasmtime", - "wiggle-macro", -] - -[[package]] -name = "wiggle-generate" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f773d51c1696bd7d028aa35c884d9fc58f48d79a1176dfbad6c908de314235" -dependencies = [ - "anyhow", - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.114", - "witx", -] - -[[package]] -name = "wiggle-macro" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e976fe0cecd60041f66b15ad45ebc997952af13da9bf9d90261c7b025057edc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", - "wiggle-generate", -] - [[package]] name = "winapi" version = "0.3.9" @@ -6538,26 +5311,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "winch-codegen" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f31dcfdfaf9d6df9e1124d7c8ee6fc29af5b99b89d11ae731c138e0f5bd77b" -dependencies = [ - "anyhow", - "cranelift-assembler-x64", - "cranelift-codegen", - "gimli", - "regalloc2", - "smallvec", - "target-lexicon 0.13.4", - "thiserror 2.0.18", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-cranelift", - "wasmtime-internal-math", -] - [[package]] name = "window-vibrancy" version = "0.6.0" @@ -6721,6 +5474,15 @@ 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" @@ -6772,6 +5534,21 @@ 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" @@ -6829,6 +5606,12 @@ 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" @@ -6847,6 +5630,12 @@ 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" @@ -6865,6 +5654,12 @@ 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" @@ -6895,6 +5690,12 @@ 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" @@ -6913,6 +5714,12 @@ 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" @@ -6931,6 +5738,12 @@ 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" @@ -6949,6 +5762,12 @@ 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" @@ -6989,16 +5808,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "winx" -version = "0.36.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" -dependencies = [ - "bitflags 2.10.0", - "windows-sys 0.59.0", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -7016,7 +5825,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck 0.5.0", - "wit-parser 0.244.0", + "wit-parser", ] [[package]] @@ -7063,28 +5872,10 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder 0.244.0", + "wasm-encoder", "wasm-metadata", - "wasmparser 0.244.0", - "wit-parser 0.244.0", -] - -[[package]] -name = "wit-parser" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df983a8608e513d8997f435bb74207bf0933d0e49ca97aa9d8a6157164b9b7fc" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.243.0", + "wasmparser", + "wit-parser", ] [[package]] @@ -7102,19 +5893,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.244.0", -] - -[[package]] -name = "witx" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" -dependencies = [ - "anyhow", - "log", - "thiserror 1.0.69", - "wast 35.0.2", + "wasmparser", ] [[package]] @@ -7196,7 +5975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.3", + "rustix", ] [[package]] @@ -7253,7 +6032,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.3", + "rustix", "serde", "serde_repr", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 24fbd624..1c7a1663 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] members = [ "Backend", - "Backend/built-in-plugins/Git", ] resolver = "2" diff --git a/DESIGN.md b/DESIGN.md index 36c403b1..9096f774 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -27,7 +27,7 @@ OpenVCS client is split into three main runtime concerns: 1. UI layer (`Frontend/src/scripts/`): renders state and invokes backend commands. 2. Host layer (`Backend/src/`): owns app state, command handling, and orchestration. -3. Plugin components (`.ovcsp` bundles): out-of-process modules used by backend/plugin runtime. +3. Plugin modules (`.ovcsp` bundles): out-of-process Node.js modules used by backend/plugin runtime. Primary request flow: @@ -48,7 +48,7 @@ UI code under `Frontend/src/scripts/features/` delegates repository operations t ### 3) Backend to plugin communication is process-isolated -Plugin backend/function components communicate over stdio JSON-RPC (`Backend/src/plugin_runtime/stdio_rpc.rs` and `Backend/src/plugin_runtime/vcs_proxy.rs`), not in-process calls. +Plugin backend/function modules communicate over JSON-RPC over stdio (`Backend/src/plugin_runtime/node_instance.rs` and `Backend/src/plugin_runtime/vcs_proxy.rs`), not in-process calls. ### 4) Safety checks are centralized @@ -76,8 +76,7 @@ Current repo/backend validity checks and progress forwarding are centralized in ## Plugin Lifecycle -- Plugin discovery, load, installation, uninstall, capability approval, and function invocation are handled in `Backend/src/tauri_commands/plugins.rs` plus plugin store/runtime modules. -- UI plugin loading is coordinated in `Frontend/src/scripts/plugins.ts`. +- Plugin discovery, load, installation, uninstall, approval gating, and function invocation are handled in `Backend/src/tauri_commands/plugins.rs` plus plugin store/runtime modules. ## State Model @@ -114,7 +113,7 @@ Frontend listeners are attached through `TAURI.listen(...)` in feature modules. - Command handlers return stringified errors at the Tauri boundary for predictable UI handling. - Plugin module execution has timeout/restart/backoff behavior in runtime helpers. -- Bundle install/runtime path and capability checks are enforced in backend plugin subsystems. +- Bundle install/runtime path and approval checks are enforced in backend plugin subsystems. - Output logging is centralized so operations can be inspected in the output-log UI. ## Contributor Guidance @@ -134,11 +133,10 @@ When adding or changing behavior: - `Backend/src/lib.rs` - `Backend/src/tauri_commands/mod.rs` - `Backend/src/tauri_commands/shared.rs` -- `Backend/src/plugin_runtime/stdio_rpc.rs` +- `Backend/src/plugin_runtime/node_instance.rs` - `Backend/src/plugin_runtime/vcs_proxy.rs` - `Backend/src/plugin_vcs_backends.rs` - `Frontend/src/scripts/lib/tauri.ts` - `Frontend/src/scripts/main.ts` -- `Frontend/src/scripts/plugins.ts` - `docs/plugin architecture.md` - `docs/plugins.md` diff --git a/Frontend/src/modals/settings.html b/Frontend/src/modals/settings.html index e37b286a..85845634 100644 --- a/Frontend/src/modals/settings.html +++ b/Frontend/src/modals/settings.html @@ -242,12 +242,6 @@

Installed Plugins

Plugins can add themes, UI actions, and hooks. Disabling a plugin prevents its code from running and hides any themes it provides. -
-

Installed Bundles (.ovcsp)

-
-
-
-
diff --git a/Frontend/src/scripts/features/about.ts b/Frontend/src/scripts/features/about.ts index 97250486..c0d8f26f 100644 --- a/Frontend/src/scripts/features/about.ts +++ b/Frontend/src/scripts/features/about.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/about.ts import { openModal } from "@scripts/ui/modals"; import { TAURI } from "../lib/tauri"; diff --git a/Frontend/src/scripts/features/branches.ts b/Frontend/src/scripts/features/branches.ts index 3ef2b7e0..3a0fe065 100644 --- a/Frontend/src/scripts/features/branches.ts +++ b/Frontend/src/scripts/features/branches.ts @@ -1,6 +1,9 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/branches.ts import { qs } from '../lib/dom'; import { TAURI } from '../lib/tauri'; +import { confirmBool } from '../lib/confirm'; import { notify } from '../lib/notify'; import { refreshOverlayScrollbarsFor } from '../lib/scrollbars'; import { state } from '../state/state'; @@ -202,7 +205,7 @@ export function bindBranchUI() { }}); items.push({ label: 'Merge into current…', action: async () => { if (name === cur) { notify('Cannot merge a branch into itself'); return; } - const ok = window.confirm(`Merge '${name}' into '${cur}'?`); + const ok = await confirmBool(`Merge '${name}' into '${cur}'?`); if (!ok) return; try { if (TAURI.has) await TAURI.invoke('git_merge_branch', { name }); @@ -282,7 +285,7 @@ export function bindBranchUI() { notify(`Force-deleted '${name}'`); await loadBranches(); await runHook('postBranchDelete', hookData); - } catch { notify('Force delete failed'); } + } catch (e) { console.error('Force delete failed:', e); notify('Force delete failed'); } } }}); } diff --git a/Frontend/src/scripts/features/cherryPick.ts b/Frontend/src/scripts/features/cherryPick.ts index eae62dd2..9c315de1 100644 --- a/Frontend/src/scripts/features/cherryPick.ts +++ b/Frontend/src/scripts/features/cherryPick.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; import { closeModal, hydrate, openModal } from '../ui/modals'; diff --git a/Frontend/src/scripts/features/commandSheet.ts b/Frontend/src/scripts/features/commandSheet.ts index c5f9806d..2376beb8 100644 --- a/Frontend/src/scripts/features/commandSheet.ts +++ b/Frontend/src/scripts/features/commandSheet.ts @@ -1,9 +1,12 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/commandSheet.ts import { TAURI } from "../lib/tauri"; import { notify } from "../lib/notify"; import { openModal, closeModal, hydrate } from "../ui/modals"; import { refreshRepoSummary } from "./repoSelection"; +/** Available command-sheet tabs. */ type Which = "clone" | "add"; // Elements inside the modal @@ -105,6 +108,7 @@ function setSheet(which: Which) { positionIndicator(); } +/** Opens the command sheet and selects an initial tab. */ export function openSheet(which: Which = "clone") { openModal("command-modal"); if (!root) bindCommandSheet(); @@ -118,10 +122,12 @@ export function openSheet(which: Which = "clone") { requestAnimationFrame(positionIndicator); } +/** Closes the command sheet modal. */ export function closeSheet() { closeModal("command-modal"); } +/** Wires command-sheet controls and action handlers once. */ export function bindCommandSheet() { // Inject the fragment if not already present hydrate("command-modal"); diff --git a/Frontend/src/scripts/features/conflicts.ts b/Frontend/src/scripts/features/conflicts.ts index df9c999a..2316e44f 100644 --- a/Frontend/src/scripts/features/conflicts.ts +++ b/Frontend/src/scripts/features/conflicts.ts @@ -1,4 +1,7 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; +import { confirmBool } from '../lib/confirm'; import { notify } from '../lib/notify'; import { hydrate, openModal, closeModal } from '../ui/modals'; import { hydrateStatus } from './repo'; @@ -83,7 +86,7 @@ async function ensureSummaryModal() { abortBtn?.addEventListener('click', async () => { if (!TAURI.has) return; - const ok = window.confirm('Abort the merge? This will discard merge progress.'); + const ok = await confirmBool('Abort the merge? This will discard merge progress.'); if (!ok) return; try { await TAURI.invoke('git_merge_abort'); diff --git a/Frontend/src/scripts/features/deleteBranchConfirm.ts b/Frontend/src/scripts/features/deleteBranchConfirm.ts index 3506c445..ed4caf1d 100644 --- a/Frontend/src/scripts/features/deleteBranchConfirm.ts +++ b/Frontend/src/scripts/features/deleteBranchConfirm.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/deleteBranchConfirm.ts import { closeModal, hydrate, openModal } from "../ui/modals"; diff --git a/Frontend/src/scripts/features/diff.ts b/Frontend/src/scripts/features/diff.ts index c6207cb9..f0bea069 100644 --- a/Frontend/src/scripts/features/diff.ts +++ b/Frontend/src/scripts/features/diff.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { qs } from '../lib/dom'; import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; @@ -35,19 +37,6 @@ export function bindCommit() { ...Object.keys(linesMap).filter(p => linesMap[p] && Object.keys(linesMap[p] || {}).length > 0), ])); - // Guard: libgit2 backend does not support partial-hunk commit (stage_patch) - if (TAURI.has && partialFiles.length > 0) { - try { - const cfg = await TAURI.invoke('get_global_settings'); - const backend = String(cfg?.git?.backend || 'system'); - if (backend === 'libgit2') { - notify('Partial-hunk commits are not supported with the Libgit2 backend. Commit full files or switch to System backend in Settings.'); - clearBusy('Ready'); - return; - } - } catch {} - } - // Build patch only from hunks; ignore selectedFiles for commit content per latest request let combinedPatch = ''; for (const path of partialFiles) { @@ -97,7 +86,7 @@ export function bindCommit() { await Promise.allSettled([hydrateStatus(), hydrateCommits()]); await runHook('postCommit', { summary, description, branch: state.branch, files: fullFiles, partialFiles }); clearBusy('Ready'); - } catch { notify('Commit failed'); } + } catch (e) { console.error('Commit failed:', e); notify('Commit failed'); } finally { clearBusy('Ready'); } diff --git a/Frontend/src/scripts/features/newBranch.ts b/Frontend/src/scripts/features/newBranch.ts index 59b329b1..8bebe4e2 100644 --- a/Frontend/src/scripts/features/newBranch.ts +++ b/Frontend/src/scripts/features/newBranch.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/newBranch.ts import { TAURI } from "../lib/tauri"; import { notify } from "../lib/notify"; diff --git a/Frontend/src/scripts/features/outputLog.ts b/Frontend/src/scripts/features/outputLog.ts index 3a1b37f0..fad10af6 100644 --- a/Frontend/src/scripts/features/outputLog.ts +++ b/Frontend/src/scripts/features/outputLog.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from '../lib/scrollbars'; diff --git a/Frontend/src/scripts/features/renameBranch.ts b/Frontend/src/scripts/features/renameBranch.ts index 7ceb07fa..b86dd0be 100644 --- a/Frontend/src/scripts/features/renameBranch.ts +++ b/Frontend/src/scripts/features/renameBranch.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/renameBranch.ts import { TAURI } from "../lib/tauri"; import { notify } from "../lib/notify"; diff --git a/Frontend/src/scripts/features/repo/commit.ts b/Frontend/src/scripts/features/repo/commit.ts index 24e1defa..c73b84fb 100644 --- a/Frontend/src/scripts/features/repo/commit.ts +++ b/Frontend/src/scripts/features/repo/commit.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { state } from '../../state/state'; export function updateCommitButton() { diff --git a/Frontend/src/scripts/features/repo/context.ts b/Frontend/src/scripts/features/repo/context.ts index 6fdf0a11..04dcfcb2 100644 --- a/Frontend/src/scripts/features/repo/context.ts +++ b/Frontend/src/scripts/features/repo/context.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { qs } from '../../lib/dom'; export const filterInput = qs('#filter'); diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index fda8587c..407fb9de 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -1,6 +1,9 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { qsa, escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; +import { confirmBool } from '../../lib/confirm'; import { notify } from '../../lib/notify'; import { state, prefs, disableDefaultSelectAll, DiffMeta, HunkNodeRefs } from '../../state/state'; import type { FileStatus, ConflictDetails } from '../../types'; @@ -11,6 +14,7 @@ import { hydrateStatus } from './hydrate'; import { getVisibleFiles, updateSelectAllState } from './selectionState'; import { openMergeModal, hasExternalMergeTool, launchExternalMergeTool } from '../conflicts'; +/** Scrolls the current diff viewport back to the origin. */ function scrollDiffToTop() { if (!diffEl) return; const host = diffEl.closest('.diff-scroll') as HTMLElement | null; @@ -21,12 +25,14 @@ function scrollDiffToTop() { } } +/** Regex markers that identify non-textual Git patches. */ const BINARY_DIFF_INDICATORS = [ /^binary files /i, /^git binary patch/i, /^literal /i, ]; +/** Returns true when the diff payload should be treated as binary. */ function detectBinaryDiff(lines: string[] = []) { if (!Array.isArray(lines) || lines.length === 0) { return true; @@ -40,11 +46,13 @@ function detectBinaryDiff(lines: string[] = []) { ); } +/** Renders a placeholder hunk for binary or unsupported file types. */ function renderBinaryDiffPlaceholder(path?: string) { const label = path ? ` (${escapeHtml(path)})` : ''; return `
Diff not supported on this file type${label}.
`; } +/** Builds a synthetic unified diff for an untracked text file. */ function buildUntrackedTextPatch(path: string, text: string): string[] { const normalized = String(text || '').replace(/\r\n/g, '\n'); const body = normalized.length ? normalized.split('\n') : []; @@ -60,11 +68,13 @@ function buildUntrackedTextPatch(path: string, text: string): string[] { return out; } +/** Highlights a row in the left list for the current tab. */ export function highlightRow(index: number) { const rows = qsa((prefs.tab === 'history' ? '.row.commit' : '.row'), listEl || (undefined as any)); rows.forEach((el, i) => el.classList.toggle('active', i === index)); } +/** Loads and renders the selected file diff with selection state restored. */ export async function selectFile(file: FileStatus, index: number) { if (!diffHeadPath || !diffEl) return; if (!state.diffDirty && state.currentFile === file.path) { @@ -133,7 +143,7 @@ export async function selectFile(file: FileStatus, index: number) { const items: CtxItem[] = []; items.push({ label: 'Discard hunk', action: async () => { if (!TAURI.has) return; - const ok = window.confirm('Discard this hunk? This cannot be undone.'); + const ok = await confirmBool('Discard this hunk? This cannot be undone.'); if (!ok) return; try { const patch = buildPatchForSelectedHunks(file.path, state.currentDiff, [hi]); @@ -141,13 +151,13 @@ export async function selectFile(file: FileStatus, index: number) { await TAURI.invoke('git_discard_patch', { patch }); await Promise.allSettled([hydrateStatus()]); } - } catch { notify('Discard failed'); } + } catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } }}); const selected = (state as any).selectedHunksByFile?.[file.path] as number[] | undefined; if (Array.isArray(selected) && selected.length > 0) { items.push({ label: 'Discard selected hunks (this file)', action: async () => { if (!TAURI.has) return; - const ok = window.confirm(`Discard ${selected.length} selected hunk(s) in this file? This cannot be undone.`); + const ok = await confirmBool(`Discard ${selected.length} selected hunk(s) in this file? This cannot be undone.`); if (!ok) return; try { const patch = buildPatchForSelectedHunks(file.path, state.currentDiff, selected); @@ -155,7 +165,7 @@ export async function selectFile(file: FileStatus, index: number) { await TAURI.invoke('git_discard_patch', { patch }); await Promise.allSettled([hydrateStatus()]); } - } catch { notify('Discard failed'); } + } catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } }}); } const hunksMap: Record = (state as any).selectedHunksByFile || {}; @@ -163,7 +173,7 @@ export async function selectFile(file: FileStatus, index: number) { if (filesWithSel.length > 0) { items.push({ label: 'Discard selected hunks (all files)', action: async () => { if (!TAURI.has) return; - const ok = window.confirm(`Discard selected hunks across ${filesWithSel.length} file(s)? This cannot be undone.`); + const ok = await confirmBool(`Discard selected hunks across ${filesWithSel.length} file(s)? This cannot be undone.`); if (!ok) return; try { let patch = ''; @@ -177,7 +187,7 @@ export async function selectFile(file: FileStatus, index: number) { await TAURI.invoke('git_discard_patch', { patch }); await Promise.allSettled([hydrateStatus()]); } - } catch { notify('Discard failed'); } + } catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } }}); } buildCtxMenu(items, x, y); @@ -233,6 +243,7 @@ export async function selectFile(file: FileStatus, index: number) { } } +/** Loads and renders a stash diff in read-only mode. */ export async function selectStashDiff(selector: string) { if (!diffHeadPath || !diffEl) return; diffEl.innerHTML = '
Loading…
'; @@ -252,6 +263,7 @@ export async function selectStashDiff(selector: string) { } } +/** Renders diffs for multiple files in a single combined view. */ export async function renderCombinedDiff(paths: string[]) { if (!diffHeadPath || !diffEl) return; clearActiveRows(); @@ -278,6 +290,7 @@ export async function renderCombinedDiff(paths: string[]) { scrollDiffToTop(); } +/** Clears multi-file diff selection state from the list. */ export function clearDiffSelection() { if (!listEl) return; if (state.diffSelectedFiles && state.diffSelectedFiles.size > 0) { @@ -287,12 +300,14 @@ export function clearDiffSelection() { } } +/** Removes active styling from all rows in the file list. */ export function clearActiveRows() { if (!listEl) return; const rows = listEl.querySelectorAll('li.row.active'); rows.forEach((r) => r.classList.remove('active')); } +/** Loads and renders conflict details and resolution actions. */ async function renderConflictView(file: FileStatus) { if (!diffEl) return; state.currentFile = file.path; @@ -320,6 +335,7 @@ async function renderConflictView(file: FileStatus) { } } +/** Builds conflict view markup for text or binary conflicts. */ function renderConflictMarkup(details: ConflictDetails) { const binary = !!details.binary; const header = `
Merge conflict
${renderConflictActions(binary)}
`; @@ -328,6 +344,7 @@ function renderConflictMarkup(details: ConflictDetails) { return `
${header}${body}
`; } +/** Renders conflict action buttons based on conflict content type. */ function renderConflictActions(binary: boolean) { const mergeBtn = binary ? '' : ''; return `
@@ -337,11 +354,13 @@ function renderConflictActions(binary: boolean) {
`; } +/** Renders a compact binary-conflict explanation panel. */ function renderBinaryConflictBody(details: ConflictDetails) { const note = 'This file is binary. Choose which version to keep.'; return `
${escapeHtml(note)}
`; } +/** Renders side-by-side panes for textual conflict content. */ function renderTextConflictBody(details: ConflictDetails) { return `
${renderConflictPane('Mine', details.ours)} @@ -349,6 +368,7 @@ function renderTextConflictBody(details: ConflictDetails) {
`; } +/** Renders one labeled conflict pane section. */ function renderConflictPane(label: string, value?: string | null) { const safeLabel = escapeHtml(label); const hasText = typeof value === 'string' && value.length > 0; @@ -358,6 +378,7 @@ function renderConflictPane(label: string, value?: string | null) { return `
${safeLabel}
${body}
`; } +/** Wires conflict action buttons to backend commands and UI refreshes. */ function bindConflictActions(root: HTMLElement, file: FileStatus, details: ConflictDetails) { const container = root.querySelector('.conflict-view') as HTMLElement | null; if (!container) return; @@ -402,6 +423,7 @@ function bindConflictActions(root: HTMLElement, file: FileStatus, details: Confl } } +/** Clears picked styling and checkboxes for all visible rows. */ function clearAllFileSelections() { if (!listEl) return; const rows = listEl.querySelectorAll('li.row'); @@ -412,6 +434,7 @@ function clearAllFileSelections() { }); } +/** Toggles commit inclusion for a file and syncs current hunk selection. */ export function toggleFilePick(path: string, on: boolean) { if (!path) return; disableDefaultSelectAll(); @@ -455,6 +478,7 @@ export function toggleFilePick(path: string, on: boolean) { updateCommitButton(); } +/** Reconciles hunk and line checkbox UI with in-memory selection state. */ export function updateHunkCheckboxes() { const nodes = state.currentDiffHunkNodes; if (!nodes || nodes.size === 0) return; @@ -488,14 +512,17 @@ export function updateHunkCheckboxes() { }); } +/** Tracks whether delegated diff checkbox handlers are already bound. */ let diffToggleHandlerBound = false; +/** Binds delegated change handling for hunk and line toggles once. */ function bindHunkToggles(root: HTMLElement) { if (!root || diffToggleHandlerBound) return; root.addEventListener('change', handleDiffInputChange); diffToggleHandlerBound = true; } +/** Routes checkbox changes to hunk-level or line-level handlers. */ function handleDiffInputChange(ev: Event) { const target = ev.target as HTMLInputElement | null; if (!target || !(target instanceof HTMLInputElement)) return; @@ -506,6 +533,7 @@ function handleDiffInputChange(ev: Event) { } } +/** Applies selection updates for a single hunk toggle interaction. */ function handleHunkToggle(input: HTMLInputElement) { const idx = Number(input.dataset.hunk || -1); if (!state.currentFile || idx < 0) return; @@ -544,6 +572,7 @@ function handleHunkToggle(input: HTMLInputElement) { updateCommitButton(); } +/** Applies selection updates for a single changed line toggle. */ function handleLineToggle(input: HTMLInputElement) { const hunk = Number(input.dataset.hunk || -1); const line = Number(input.dataset.line || -1); @@ -585,6 +614,7 @@ function handleLineToggle(input: HTMLInputElement) { updateCommitButton(); } +/** Syncs the file-level checkbox to current hunk and line selections. */ function syncFileCheckboxWithHunks() { if (!state.currentFile) return; if (state.currentDiffBinary) { @@ -620,6 +650,7 @@ function syncFileCheckboxWithHunks() { } } +/** Returns contiguous hunk indices derived from unified diff lines. */ export function allHunkIndices(lines: string[]) { const meta = state.currentDiffMeta; if (meta && meta.totalHunks > 0) { @@ -633,6 +664,7 @@ export function allHunkIndices(lines: string[]) { return starts.map((_, i) => i); } +/** Parses diff lines into reusable metadata for hunk rendering. */ function buildDiffMeta(lines: string[]): DiffMeta { const idx = lines.findIndex((l) => (l || '').startsWith('@@')); const rest = idx >= 0 ? lines.slice(idx) : []; @@ -666,6 +698,7 @@ function buildDiffMeta(lines: string[]): DiffMeta { }; } +/** Builds a DOM fragment for diff hunks and caches node references. */ function buildDiffFragment(lines: string[]): DocumentFragment { const meta = buildDiffMeta(lines); state.currentDiffMeta = meta; @@ -757,6 +790,7 @@ function buildDiffFragment(lines: string[]): DocumentFragment { return fragment; } +/** Renders diff hunks as HTML with selectable hunk and line checkboxes. */ export function renderHunksWithSelection(lines: string[]) { if (!lines || !lines.length) return ''; let idx = lines.findIndex((l) => l.startsWith('@@')); @@ -785,6 +819,7 @@ export function renderHunksWithSelection(lines: string[]) { return html; } +/** Renders diff hunks as static, read-only HTML. */ export function renderHunksReadonly(lines: string[]) { if (!lines || !lines.length) return ''; let idx = lines.findIndex((l) => l.startsWith('@@')); @@ -806,12 +841,14 @@ export function renderHunksReadonly(lines: string[]) { return html; } +/** Renders one read-only diff line row. */ function hline(ln: string, n: number) { const first = (typeof ln === 'string' ? ln[0] : ' ') || ' '; const t = first === '+' ? 'add' : first === '-' ? 'del' : ''; return `
${n}
${escapeHtml(String(ln))}
`; } +/** Updates a list row checkbox for a specific file path. */ export function updateListCheckboxForPath(path: string, checked: boolean, indeterminate: boolean) { if (!listEl || !path) return; const selector = `li.row[data-path="${path.replace(/(["\\])/g, '\\$1')}"] input.pick`; diff --git a/Frontend/src/scripts/features/repo/filter.ts b/Frontend/src/scripts/features/repo/filter.ts index d0cd5309..9879bb06 100644 --- a/Frontend/src/scripts/features/repo/filter.ts +++ b/Frontend/src/scripts/features/repo/filter.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { state, prefs, disableDefaultSelectAll } from '../../state/state'; import { filterInput, selectAllBox } from './context'; import { renderList } from './list'; diff --git a/Frontend/src/scripts/features/repo/history.test.ts b/Frontend/src/scripts/features/repo/history.test.ts index 2e1ecb19..b4dc0919 100644 --- a/Frontend/src/scripts/features/repo/history.test.ts +++ b/Frontend/src/scripts/features/repo/history.test.ts @@ -1,8 +1,14 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { describe, it, expect } from 'vitest' -// Provide matchMedia to avoid jsdom environment errors in modules that access it -// Set it on global before importing modules that may use it. -(globalThis as any).matchMedia = (query: string) => ({ matches: false, media: query, addListener: () => {}, removeListener: () => {} }) +/** Provides a minimal `matchMedia` test shim used by history imports. */ +function createMatchMediaMock(query: string) { + return { matches: false, media: query, addListener: () => {}, removeListener: () => {} } +} + +// Set matchMedia before importing modules that touch browser media APIs. +(globalThis as any).matchMedia = createMatchMediaMock import { parseCommitDiffByFile, formatTimeAgo } from './history' diff --git a/Frontend/src/scripts/features/repo/history.ts b/Frontend/src/scripts/features/repo/history.ts index 62f02f91..c0686a89 100644 --- a/Frontend/src/scripts/features/repo/history.ts +++ b/Frontend/src/scripts/features/repo/history.ts @@ -1,6 +1,9 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; +import { confirmBool } from '../../lib/confirm'; import { notify } from '../../lib/notify'; import { getPluginContextMenuItems, runPluginAction } from '../../plugins'; import { prefs, state, statusClass, statusLabel } from '../../state/state'; @@ -10,8 +13,22 @@ import { hydrateStatus, hydrateCommits } from './hydrate'; import { updateCommitButton } from './commit'; import { openCherryPick } from '../cherryPick'; +/** Optional toolbar button that opens the selected commit actions menu. */ const historyActionsBtn = document.getElementById('history-actions-btn') as HTMLButtonElement | null; +/** Optional flags that customize commit actions menu contents. */ +type CommitActionsMenuOptions = { + isAhead?: boolean; +}; + +/** Parsed commit diff block grouped by file path. */ +export type CommitDiffFile = { + path: string; + status: string; + lines: string[]; +}; + +/** Toggles visibility of commit actions UI in history mode. */ function updateHistoryActionsVisibility() { if (!historyActionsBtn) return; const on = prefs.tab === 'history' && !!(state as any)?.selectedCommit?.id; @@ -19,7 +36,8 @@ function updateHistoryActionsVisibility() { historyActionsBtn.disabled = !on; } -async function openCommitActionsMenu(commit: any, x: number, y: number, opts?: { isAhead?: boolean }) { +/** Builds and shows the commit context menu at screen coordinates. */ +async function openCommitActionsMenu(commit: any, x: number, y: number, opts?: CommitActionsMenuOptions) { const items: CtxItem[] = []; items.push({ label: 'Copy hash', action: async () => { @@ -49,7 +67,7 @@ async function openCommitActionsMenu(commit: any, x: number, y: number, opts?: { items.push({ label: 'Revert (reverse) commit…', action: async () => { const short = String(commit.id || '').slice(0, 7); - const ok = window.confirm(`Revert commit ${short}? This will create a new commit that undoes its changes.`); + const ok = await confirmBool(`Revert commit ${short}? This will create a new commit that undoes its changes.`); if (!ok) return; try { await TAURI.invoke('git_revert_commit', { id: commit.id }); @@ -71,7 +89,7 @@ async function openCommitActionsMenu(commit: any, x: number, y: number, opts?: { try { await TAURI.invoke('git_undo_to_commit', { id: commit.id }); await Promise.allSettled([hydrateStatus(), hydrateCommits()]); - } catch { notify('Undo failed'); } + } catch (e) { console.error('Undo failed:', e); notify('Undo failed'); } }, }); } @@ -104,6 +122,7 @@ if (historyActionsBtn && !(historyActionsBtn as any).__wired) { window.addEventListener('app:tab-changed', () => updateHistoryActionsVisibility()); } +/** Renders commit rows filtered by search text and selects the first entry. */ export function renderHistoryList(query: string): boolean { const list = listEl; const count = countEl; @@ -179,6 +198,7 @@ export function renderHistoryList(query: string): boolean { return true; } +/** Loads metadata and per-file diff details for the selected commit. */ export async function selectHistory(commit: any, index: number) { if (!diffHeadPath || !diffEl) return; (state as any).selectedCommit = commit || null; @@ -234,6 +254,7 @@ export async function selectHistory(commit: any, index: number) { const sideEl = diffEl.querySelector('.commit-files'); const contentEl = diffEl.querySelector('.commit-content'); if (sideEl && contentEl) { + /** Switches the right panel to the selected file diff block. */ const selectCommitFile = (idx: number) => { if (idx < 0 || idx >= files.length) return; sideEl.querySelectorAll('.row').forEach((r) => r.classList.remove('active')); @@ -282,7 +303,7 @@ export async function selectHistory(commit: any, index: number) { } const short = String(commit?.id || '').slice(0, 7) || '(unknown)'; - const ok = window.confirm(`Revert changes from commit ${short} for:\n${file?.path || '(unknown file)'}\n\nThis applies a reverse patch to your working tree and index.`); + const ok = await confirmBool(`Revert changes from commit ${short} for:\n${file?.path || '(unknown file)'}\n\nThis applies a reverse patch to your working tree and index.`); if (!ok) return; let patch = block.join('\n'); @@ -309,9 +330,10 @@ export async function selectHistory(commit: any, index: number) { } } -export function parseCommitDiffByFile(lines: string[]): { path: string; status: string; lines: string[] }[] { +/** Splits a full commit diff payload into file-scoped diff blocks. */ +export function parseCommitDiffByFile(lines: string[]): CommitDiffFile[] { if (!Array.isArray(lines) || lines.length === 0) return []; - const files: { path: string; status: string; lines: string[] }[] = []; + const files: CommitDiffFile[] = []; let i = 0; while (i < lines.length) { const l = lines[i] || ''; @@ -338,6 +360,7 @@ export function parseCommitDiffByFile(lines: string[]): { path: string; status: return files; } +/** Formats a timestamp-like value into a compact relative time string. */ export function formatTimeAgo(isoMaybe: string): string { try { const d = new Date(String(isoMaybe || '').trim()); diff --git a/Frontend/src/scripts/features/repo/hotkeys.ts b/Frontend/src/scripts/features/repo/hotkeys.ts index dec1d06d..c76c0c0b 100644 --- a/Frontend/src/scripts/features/repo/hotkeys.ts +++ b/Frontend/src/scripts/features/repo/hotkeys.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { filterInput } from './context'; import { disableDefaultSelectAll, prefs, state } from '../../state/state'; import { getVisibleFiles } from './selectionState'; diff --git a/Frontend/src/scripts/features/repo/hydrate.ts b/Frontend/src/scripts/features/repo/hydrate.ts index 7316da94..096baca1 100644 --- a/Frontend/src/scripts/features/repo/hydrate.ts +++ b/Frontend/src/scripts/features/repo/hydrate.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../../lib/tauri'; import { state, prefs } from '../../state/state'; import { renderList } from './list'; diff --git a/Frontend/src/scripts/features/repo/index.ts b/Frontend/src/scripts/features/repo/index.ts index 29f84127..38391e5a 100644 --- a/Frontend/src/scripts/features/repo/index.ts +++ b/Frontend/src/scripts/features/repo/index.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later export { bindRepoHotkeys } from './hotkeys'; export { bindFilter } from './filter'; export { renderList, wireRenderListCallbacks } from './list'; diff --git a/Frontend/src/scripts/features/repo/interactions.ts b/Frontend/src/scripts/features/repo/interactions.ts index 45054fdf..0bd25670 100644 --- a/Frontend/src/scripts/features/repo/interactions.ts +++ b/Frontend/src/scripts/features/repo/interactions.ts @@ -1,4 +1,7 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { buildCtxMenu, CtxItem } from '../../lib/menu'; +import { confirmBool } from '../../lib/confirm'; import { notify } from '../../lib/notify'; import { TAURI } from '../../lib/tauri'; import { getPluginContextMenuItems, runPluginAction } from '../../plugins'; @@ -11,6 +14,7 @@ import { selectFile, renderCombinedDiff, clearDiffSelection, clearActiveRows, to import { hydrateStatus, hydrateStash } from './hydrate'; import { getVisibleFiles, updateSelectAllState } from './selectionState'; +/** Handles click selection behavior for a file row. */ export function onFileClick(e: MouseEvent, file: FileStatus, index: number, visible: FileStatus[]) { if (dragState.suppressNextClick) { dragState.suppressNextClick = false; @@ -61,6 +65,7 @@ export function onFileClick(e: MouseEvent, file: FileStatus, index: number, visi updateCommitButton(); } +/** Starts drag-selection for diff or commit selection gestures. */ export function onFileMouseDown(e: MouseEvent, file: FileStatus, index: number, visible: FileStatus[], li: HTMLElement) { if (e.button !== 0) return; const mode = e.shiftKey ? 'diff' : (e.ctrlKey || e.metaKey) ? 'commit' : null; @@ -124,6 +129,7 @@ export function onFileMouseDown(e: MouseEvent, file: FileStatus, index: number, document.addEventListener('mouseup', onUp, { once: true }); } +/** Applies one row selection change for the active drag mode. */ export function applySelect(path: string, on: boolean, rowEl: HTMLElement | null, visible: FileStatus[], mode: 'diff' | 'commit') { disableDefaultSelectAll(); if (mode === 'commit') { @@ -138,6 +144,7 @@ export function applySelect(path: string, on: boolean, rowEl: HTMLElement | null } } +/** Recomputes drag selection state for the current cursor range. */ export function updateDragRange(visible: FileStatus[]) { if (!dragState.isDragSelecting || dragState.dragMode === null) return; const list = listEl; @@ -184,6 +191,7 @@ export function updateDragRange(visible: FileStatus[]) { } } +/** Toggles commit selection for all visible files. */ export function toggleSelectAll(on: boolean, visible: FileStatus[]) { if (on) { visible.forEach((f) => { if (f.path) toggleFilePick(f.path, true); }); @@ -192,6 +200,7 @@ export function toggleSelectAll(on: boolean, visible: FileStatus[]) { } } +/** Opens the context menu for one file row and current selection. */ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { ev.preventDefault(); const x = ev.clientX, y = ev.clientY; @@ -206,6 +215,7 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { selectedPaths.length === 1 && !state.selectionImplicitAll; const items: CtxItem[] = []; + /** Opens the stash modal pre-filled for the provided paths. */ const openStashForPaths = (paths: string[], defaultMessage: string) => { if (!paths.length) return; openStashConfirm({ @@ -253,7 +263,7 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { const targets = (explicitMultiSelection ? selectedPaths.slice() : [f.path]).filter(Boolean); if (!targets.length) return; const label = targets.length > 1 ? `${targets.length} files` : targets[0]; - const ok = window.confirm(`Add ${label} to .gitignore?`); + const ok = await confirmBool(`Add ${label} to .gitignore?`); if (!ok) return; try { await TAURI.invoke('git_add_to_gitignore_paths', { paths: targets }); @@ -264,23 +274,22 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { } }}); items.push({ label: '---' }); - items.push({ label: '---' }); if (explicitMultiSelection) { items.push({ label: 'Discard all selected', action: async () => { if (!TAURI.has) return; const paths = selectedPaths.slice(); - const ok = window.confirm(`Discard all changes in ${paths.length} selected file(s)? This cannot be undone.`); + const ok = await confirmBool(`Discard all changes in ${paths.length} selected file(s)? This cannot be undone.`); if (!ok) return; try { await TAURI.invoke('git_discard_paths', { paths }); await Promise.allSettled([hydrateStatus()]); } - catch { notify('Discard failed'); } + catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } }}); } items.push({ label: 'Discard changes', action: async () => { if (!TAURI.has) return; - const ok = window.confirm(`Discard all changes in \n${f.path}? This cannot be undone.`); + const ok = await confirmBool(`Discard all changes in \n${f.path}? This cannot be undone.`); if (!ok) return; try { await TAURI.invoke('git_discard_paths', { paths: [f.path] }); await Promise.allSettled([hydrateStatus()]); } - catch { notify('Discard failed'); } + catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } }}); const pluginTargets = (explicitMultiSelection ? selectedPaths.slice() : [singleTarget]).filter(Boolean); @@ -299,19 +308,25 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { buildCtxMenu(items, x, y); } +/** Optional callback that re-renders the left list. */ let renderListCallback: (() => void) | null = null; + +/** Registers a callback used after operations that refresh list state. */ export function setRenderListCallback(fn: () => void) { renderListCallback = fn; } +/** Returns true while drag selection is currently active. */ export function isDragSelecting() { return dragState.isDragSelecting; } +/** Stores the latest drag cursor index for range updates. */ export function setDragCurrentIndex(index: number) { dragState.dragCurrentIndex = index; } +/** Re-renders list state after shift-range toggling and reselects target row. */ function renderListAfterRangeSelect(file: FileStatus) { renderListCallback?.(); const refreshed = getVisibleFiles(); @@ -320,6 +335,7 @@ function renderListAfterRangeSelect(file: FileStatus) { updateCommitButton(); } +/** Applies active-row styling by index in the current list. */ function highlightRow(index: number) { const rows = listEl?.querySelectorAll('li.row'); rows?.forEach((el, i) => el.classList.toggle('active', i === index)); diff --git a/Frontend/src/scripts/features/repo/list.ts b/Frontend/src/scripts/features/repo/list.ts index 94b7ef03..d0adfc19 100644 --- a/Frontend/src/scripts/features/repo/list.ts +++ b/Frontend/src/scripts/features/repo/list.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { escapeHtml } from '../../lib/dom'; import { state, prefs, statusClass, statusLabel } from '../../state/state'; import { refreshRepoActions } from '../../ui/layout'; diff --git a/Frontend/src/scripts/features/repo/selectionState.ts b/Frontend/src/scripts/features/repo/selectionState.ts index a20158ac..83111b7c 100644 --- a/Frontend/src/scripts/features/repo/selectionState.ts +++ b/Frontend/src/scripts/features/repo/selectionState.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { prefs, state } from '../../state/state'; import { filterInput, selectAllBox } from './context'; import type { FileStatus } from '../../types'; diff --git a/Frontend/src/scripts/features/repo/stash.ts b/Frontend/src/scripts/features/repo/stash.ts index edb11343..acc3750f 100644 --- a/Frontend/src/scripts/features/repo/stash.ts +++ b/Frontend/src/scripts/features/repo/stash.ts @@ -1,6 +1,9 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; +import { confirmBool } from '../../lib/confirm'; import { notify } from '../../lib/notify'; import { state } from '../../state/state'; import { openStashConfirm } from '../stashConfirm'; @@ -8,14 +11,26 @@ import { diffEl, diffHeadPath, listEl, countEl, leftFootEl, undoLeftBtn } from ' import { highlightRow, selectStashDiff } from './diffView'; import { hydrateStatus, hydrateStash } from './hydrate'; +/** Lazily created footer container for stash actions. */ let stashFootEl: HTMLElement | null = null; +/** True once stash footer button handlers are wired. */ let stashFootBound = false; +/** Optional callback used to refresh the stash list. */ let renderListRef: (() => void) | null = null; +/** Minimal stash list item shape used by selection helpers. */ +type StashListItem = { + selector: string; + msg?: string; + meta?: string; +}; + +/** Registers a list render callback used after stash mutations. */ export function setRenderListRef(fn: () => void) { renderListRef = fn; } +/** Renders stash entries filtered by the provided query. */ export function renderStashList(query: string): boolean { const list = listEl; const count = countEl; @@ -27,6 +42,7 @@ export function renderStashList(query: string): boolean { const items = stash.filter((s) => !query || (s.msg || '').toLowerCase().includes(query) || (s.selector || '').includes(query)); count.textContent = `${items.length} stash${items.length === 1 ? '' : 'es'}`; + /** Enables or disables footer action buttons for stash operations. */ const enableActionButtons = (enabled: boolean) => { const a = document.querySelector('#stash-apply-btn'); if (a) a.disabled = !enabled; const p = document.querySelector('#stash-pop-btn'); if (p) p.disabled = !enabled; @@ -66,10 +82,10 @@ export function renderStashList(query: string): boolean { notify('Applied stash'); await Promise.allSettled([hydrateStatus(), hydrateStash()]); renderListRef?.(); - } catch { notify('Failed to apply stash'); } + } catch (e) { console.error('Failed to apply stash:', e); notify('Failed to apply stash'); } }}); items.push({ label: 'Delete stash', action: async () => { - const ok = window.confirm(`Delete ${target}? This cannot be undone.`); + const ok = await confirmBool(`Delete ${target}? This cannot be undone.`); if (!ok) return; try { if (!TAURI.has) return; @@ -78,7 +94,7 @@ export function renderStashList(query: string): boolean { if (state.currentStash === target) state.currentStash = ''; await Promise.allSettled([hydrateStash()]); renderListRef?.(); - } catch { notify('Failed to delete stash'); } + } catch (e) { console.error('Failed to delete stash:', e); notify('Failed to delete stash'); } }}); buildCtxMenu(items, x, y); }); @@ -88,7 +104,8 @@ export function renderStashList(query: string): boolean { return true; } -export async function selectStash(item: { selector: string; msg?: string; meta?: string }, index: number) { +/** Selects a stash entry and loads its diff preview. */ +export async function selectStash(item: StashListItem, index: number) { if (!diffHeadPath || !diffEl) return; highlightRow(index); state.currentStash = item.selector; @@ -106,6 +123,7 @@ export async function selectStash(item: { selector: string; msg?: string; meta?: } } +/** Shows the stash-specific footer controls and hides undo UI. */ export function showStashFooter() { if (!leftFootEl) return; const foot = ensureStashFooterControls(); @@ -116,6 +134,7 @@ export function showStashFooter() { foot.classList.add('show'); } +/** Hides stash footer controls and restores default footer state. */ export function hideStashFooter() { if (!leftFootEl) return; if (leftFootEl.dataset.mode === 'stash') { @@ -126,6 +145,7 @@ export function hideStashFooter() { if (stashFootEl) stashFootEl.classList.remove('show'); } +/** Returns the currently active stash selector from state or list row. */ export function getActiveStashSelector(): string { if (state.currentStash) return state.currentStash; const active = listEl?.querySelector('li.row.commit.active'); @@ -134,6 +154,7 @@ export function getActiveStashSelector(): string { return sel; } +/** Creates stash footer controls on demand and wires handlers once. */ function ensureStashFooterControls(): HTMLElement | null { if (!leftFootEl) return null; if (!stashFootEl) { @@ -155,6 +176,7 @@ function ensureStashFooterControls(): HTMLElement | null { return stashFootEl; } +/** Binds click handlers for create/apply/pop/drop stash actions. */ function wireStashFooterButtons(container: HTMLElement) { const createBtn = container.querySelector('#stash-create-btn'); createBtn?.addEventListener('click', () => { @@ -176,7 +198,7 @@ function wireStashFooterButtons(container: HTMLElement) { notify('Applied stash'); await Promise.allSettled([hydrateStatus(), hydrateStash()]); renderListRef?.(); - } catch (e) { console.warn('git_stash_apply failed', e); notify('Failed to apply stash'); } + } catch (e) { console.error('git_stash_apply failed:', e); notify('Failed to apply stash'); } }); const popBtn = container.querySelector('#stash-pop-btn'); @@ -189,14 +211,14 @@ function wireStashFooterButtons(container: HTMLElement) { notify('Popped stash'); await Promise.allSettled([hydrateStatus(), hydrateStash()]); renderListRef?.(); - } catch (e) { console.warn('git_stash_pop failed', e); notify('Failed to pop stash'); } + } catch (e) { console.error('git_stash_pop failed:', e); notify('Failed to pop stash'); } }); const dropBtn = container.querySelector('#stash-drop-btn'); dropBtn?.addEventListener('click', async () => { const selector = getActiveStashSelector(); if (!selector) return; - const ok = window.confirm(`Drop ${selector}? This cannot be undone.`); + const ok = await confirmBool(`Drop ${selector}? This cannot be undone.`); if (!ok) return; try { if (!TAURI.has) return; @@ -205,6 +227,6 @@ function wireStashFooterButtons(container: HTMLElement) { state.currentStash = ''; await Promise.allSettled([hydrateStash()]); renderListRef?.(); - } catch (e) { console.warn('git_stash_drop failed', e); notify('Failed to drop stash'); } + } catch (e) { console.error('git_stash_drop failed:', e); notify('Failed to drop stash'); } }); } diff --git a/Frontend/src/scripts/features/repoSelection.ts b/Frontend/src/scripts/features/repoSelection.ts index 9157c1a0..1d51dd72 100644 --- a/Frontend/src/scripts/features/repoSelection.ts +++ b/Frontend/src/scripts/features/repoSelection.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/repoSelection.ts import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; diff --git a/Frontend/src/scripts/features/repoSettings.ts b/Frontend/src/scripts/features/repoSettings.ts index 65f95aa2..9016825d 100644 --- a/Frontend/src/scripts/features/repoSettings.ts +++ b/Frontend/src/scripts/features/repoSettings.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { openModal, closeModal } from '../ui/modals'; import { notify } from '../lib/notify'; @@ -18,6 +20,12 @@ export async function wireRepoSettings() { const addRemoteBtn = modal.querySelector('#git-remote-add') as HTMLButtonElement | null; const saveBtn = modal.querySelector('#repo-settings-save') as HTMLButtonElement | null; + if (saveBtn) { + saveBtn.style.width = '5rem'; + saveBtn.style.textAlign = 'center'; + saveBtn.style.boxSizing = 'border-box'; + } + const rows: RemoteRow[] = []; let initialRemotesKey = ''; @@ -120,7 +128,12 @@ export async function wireRepoSettings() { // Remote-tracking branches only exist after a fetch; do it once after remotes are modified. try { await TAURI.invoke('git_fetch_all', {}); } catch { /* ignore */ } } - closeModal('repo-settings-modal'); + saveBtn.classList.add('saved-state'); + saveBtn.textContent = 'Saved!'; + setTimeout(() => { + saveBtn.textContent = 'Save'; + saveBtn.classList.remove('saved-state'); + }, 2000); } catch { notify('Failed to save repository settings'); } diff --git a/Frontend/src/scripts/features/repoSwitchDrawer.ts b/Frontend/src/scripts/features/repoSwitchDrawer.ts index 4cf8236d..63877a6c 100644 --- a/Frontend/src/scripts/features/repoSwitchDrawer.ts +++ b/Frontend/src/scripts/features/repoSwitchDrawer.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/repoSwitchDrawer.ts import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; diff --git a/Frontend/src/scripts/features/setUpstream.ts b/Frontend/src/scripts/features/setUpstream.ts index 9ffef405..8d845d7b 100644 --- a/Frontend/src/scripts/features/setUpstream.ts +++ b/Frontend/src/scripts/features/setUpstream.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from "../lib/tauri"; import { notify } from "../lib/notify"; import { closeModal, hydrate, openModal } from "../ui/modals"; diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 43db9de5..1122016f 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1,6 +1,9 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { openModal, closeModal } from '../ui/modals'; import { toKebab } from '../lib/dom'; +import { confirmBool } from '../lib/confirm'; import { notify } from '../lib/notify'; import { setTheme } from '../ui/layout'; import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, DEFAULT_THEME_ID, getActiveThemeId, getAvailableThemes, refreshAvailableThemes, selectThemePack } from '../themes'; @@ -12,6 +15,37 @@ import type { GlobalSettings, ThemeSummary } from '../types'; const THEME_PACK_HINT = 'Install a theme ZIP into the themes folder, or install a plugin that provides themes.'; const SYSTEM_DARK_MQ = matchMedia('(prefers-color-scheme: dark)'); +interface PluginMenuPayload { + plugin_id: string; + id: string; + label: string; + elements: Array<{ + type: 'text' | 'button' | string; + id?: string; + content?: string; + label?: string; + }>; +} + +interface PluginSettingOptionPayload { + value: string; + label: string; +} + +interface PluginSettingFieldPayload { + id: string; + kind: 'bool' | 's32' | 'u32' | 'f64' | 'text' | string; + label: string; + description?: string | null; + default_value: unknown; + value: unknown; + options?: PluginSettingOptionPayload[]; +} + +function pluginSectionId(pluginId: string, menuId: string): string { + return `plugin-${toKebab(`${pluginId}-${menuId}`)}`; +} + export function applyAnimationPreference(enabled: boolean | undefined | null) { document.documentElement.dataset.animations = enabled === false ? 'off' : 'on'; } @@ -46,6 +80,234 @@ function themeTooltip(id: string): string { return details.join('\n') || THEME_PACK_HINT; } +async function renderPluginMenus(modal: HTMLElement): Promise { + const nav = modal.querySelector('#settings-nav'); + const panelsScroll = modal.querySelector('#settings-panels-scroll'); + if (!nav || !panelsScroll) return; + + nav.querySelectorAll('[data-plugin-menu="true"]').forEach((node) => node.remove()); + nav.querySelectorAll('[data-plugin-menus-wrap="true"]').forEach((node) => node.remove()); + panelsScroll + .querySelectorAll('.panel-form[data-plugin-menu="true"]') + .forEach((node) => node.remove()); + + if (!TAURI.has) return; + let menus: PluginMenuPayload[] = []; + let pluginSummaries: PluginSummary[] = []; + try { + menus = await TAURI.invoke('list_plugin_menus'); + } catch { + return; + } + try { + pluginSummaries = await TAURI.invoke('list_plugins'); + } catch { + pluginSummaries = []; + } + + const pluginSources = new Map(); + const pluginNames = new Map(); + for (const summary of Array.isArray(pluginSummaries) ? pluginSummaries : []) { + const id = String(summary?.id || '').trim().toLowerCase(); + if (!id) continue; + pluginSources.set(id, String(summary?.source || '').trim().toLowerCase()); + pluginNames.set(id, String(summary?.name || summary?.id || '').trim() || id); + } + + const pluginsNavBtn = nav.querySelector('[data-section="plugins"]'); + const pluginsNavLi = pluginsNavBtn?.closest('li') || null; + + let thirdPartySublist: HTMLElement | null = null; + const ensureThirdPartySublist = (): HTMLElement => { + if (thirdPartySublist) return thirdPartySublist; + + const wrap = document.createElement('div'); + wrap.setAttribute('data-plugin-menus-wrap', 'true'); + + const heading = document.createElement('div'); + heading.className = 'settings-plugin-subhead'; + heading.textContent = 'Plugin Settings'; + wrap.appendChild(heading); + + const list = document.createElement('ul'); + list.className = 'settings-plugin-sublist'; + list.setAttribute('data-plugin-menus', 'true'); + wrap.appendChild(list); + + if (pluginsNavLi) { + pluginsNavLi.appendChild(wrap); + } else { + nav.appendChild(wrap); + } + + thirdPartySublist = list; + return list; + }; + + for (const menu of menus) { + const section = pluginSectionId(menu.plugin_id, menu.id); + const navLi = document.createElement('li'); + navLi.dataset.pluginMenu = 'true'; + const navBtn = document.createElement('button'); + const source = pluginSources.get(String(menu.plugin_id || '').trim().toLowerCase()) || ''; + const isBuiltIn = source === 'built-in'; + navBtn.className = 'seg-btn'; + navBtn.setAttribute('data-section', section); + navBtn.textContent = menu.label || menu.id; + navLi.appendChild(navBtn); + + if (isBuiltIn) { + if (pluginsNavLi?.parentElement) { + pluginsNavLi.parentElement.insertBefore(navLi, pluginsNavLi); + } else { + nav.appendChild(navLi); + } + } else { + ensureThirdPartySublist().appendChild(navLi); + } + + const panel = document.createElement('form'); + panel.className = 'panel-form hidden'; + panel.setAttribute('data-panel', section); + panel.setAttribute('data-plugin-menu', 'true'); + panel.dataset.pluginId = menu.plugin_id; + panel.dataset.menuId = menu.id; + + for (const element of menu.elements || []) { + const group = document.createElement('div'); + group.className = 'group'; + if (element.type === 'text') { + const text = document.createElement('div'); + text.textContent = String(element.content || ''); + group.appendChild(text); + } else if (element.type === 'button') { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'tbtn'; + button.textContent = String(element.label || 'Action'); + button.dataset.pluginAction = String(element.id || ''); + button.dataset.pluginId = menu.plugin_id; + group.appendChild(button); + } + panel.appendChild(group); + } + panelsScroll.appendChild(panel); + } + + for (const summary of Array.isArray(pluginSummaries) ? pluginSummaries : []) { + const pluginId = String(summary?.id || '').trim(); + const pluginKey = pluginId.toLowerCase(); + if (!pluginId) continue; + + let fields: PluginSettingFieldPayload[] = []; + try { + fields = await TAURI.invoke('get_plugin_settings', { pluginId }); + } catch { + continue; + } + if (!Array.isArray(fields) || fields.length === 0) continue; + + const section = `plugin-settings-${toKebab(pluginId)}`; + const navLi = document.createElement('li'); + navLi.dataset.pluginMenu = 'true'; + const navBtn = document.createElement('button'); + navBtn.className = 'seg-btn'; + navBtn.setAttribute('data-section', section); + navBtn.textContent = pluginNames.get(pluginKey) || pluginId; + navLi.appendChild(navBtn); + ensureThirdPartySublist().appendChild(navLi); + + const panel = document.createElement('form'); + panel.className = 'panel-form hidden'; + panel.setAttribute('data-panel', section); + panel.setAttribute('data-plugin-menu', 'true'); + panel.dataset.pluginId = pluginId; + panel.dataset.pluginSettings = 'true'; + + const settingsWrap = document.createElement('div'); + settingsWrap.className = 'group'; + const heading = document.createElement('h4'); + heading.className = 'settings-section-title'; + heading.textContent = 'Settings'; + settingsWrap.appendChild(heading); + + const controls = new Map(); + for (const field of fields) { + const settingId = String(field?.id || '').trim(); + if (!settingId) continue; + const kind = String(field?.kind || '').trim().toLowerCase(); + + const row = document.createElement('div'); + row.className = 'group'; + + const hasOptions = Array.isArray(field.options) && field.options.length > 0; + let control: HTMLInputElement | HTMLSelectElement; + + if (kind === 'bool') { + const labelEl = document.createElement('label'); + labelEl.className = 'checkbox'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = Boolean(field.value); + labelEl.appendChild(input); + labelEl.append(` ${String(field?.label || settingId).trim() || settingId}`); + control = input; + row.appendChild(labelEl); + } else if (kind === 'text' && hasOptions) { + const labelEl = document.createElement('label'); + labelEl.textContent = String(field?.label || settingId).trim() || settingId; + row.appendChild(labelEl); + const select = document.createElement('select'); + for (const option of field.options || []) { + const opt = document.createElement('option'); + opt.value = String(option?.value || ''); + opt.textContent = String(option?.label || option?.value || '').trim() || opt.value; + select.appendChild(opt); + } + const value = String(field?.value ?? ''); + if (value && Array.from(select.options).some((opt) => opt.value === value)) { + select.value = value; + } + control = select; + row.appendChild(control); + } else { + const labelEl = document.createElement('label'); + labelEl.textContent = String(field?.label || settingId).trim() || settingId; + row.appendChild(labelEl); + const input = document.createElement('input'); + if (kind === 's32' || kind === 'u32' || kind === 'f64') { + input.type = 'number'; + input.step = kind === 'f64' ? 'any' : '1'; + if (kind === 'u32') input.min = '0'; + const n = Number(field?.value ?? field?.default_value ?? 0); + input.value = Number.isFinite(n) ? String(n) : '0'; + } else { + input.type = 'text'; + input.value = String(field?.value ?? field?.default_value ?? ''); + } + control = input; + row.appendChild(control); + } + + control.setAttribute('data-setting-id', settingId); + control.setAttribute('data-setting-kind', kind); + controls.set(settingId, control); + + const description = String(field?.description || '').trim(); + if (description) { + const hint = document.createElement('small'); + hint.textContent = description; + row.appendChild(hint); + } + + settingsWrap.appendChild(row); + } + + panel.appendChild(settingsWrap); + panelsScroll.appendChild(panel); + } +} + async function rebuildThemePackOptions( selectEl: HTMLSelectElement, opts: { desiredId?: string | null; forceReload?: boolean } = {}, @@ -77,11 +339,16 @@ async function rebuildThemePackOptions( } export function openSettings(section?: string){ + console.log('Opening settings modal', section ? `section: ${section}` : ''); openModal('settings-modal'); const modal = document.getElementById('settings-modal') as HTMLElement | null; if (!modal) return; applyPluginSettingsSections(modal); - if (section) activateSection(modal, section); + renderPluginMenus(modal) + .catch(() => {}) + .finally(() => { + if (section) activateSection(modal, section); + }); // Prevent a "double-click to refresh" feel where the user opens the Theme dropdown // before the async settings/theme list has finished loading. @@ -128,9 +395,55 @@ function activateSection(modal: HTMLElement, section: string) { p.classList.toggle('hidden', p.getAttribute('data-panel') !== safeSection); }); - // Plugins are applied immediately (no Save/Cancel). + // Keep footer actions hidden for action-only plugin menu panels. const actions = modal.querySelector('.sheet-actions'); - if (actions) actions.classList.toggle('hidden', safeSection === 'plugins'); + const activePanel = panels.querySelector( + `.panel-form[data-panel="${CSS.escape(safeSection)}"]`, + ); + const isPluginMenuPanel = activePanel?.getAttribute('data-plugin-menu') === 'true'; + const isPluginSettingsPanel = activePanel?.getAttribute('data-plugin-settings') === 'true'; + const hideActions = safeSection === 'plugins' || (isPluginMenuPanel && !isPluginSettingsPanel); + if (actions) actions.classList.toggle('hidden', hideActions); +} + +/** Collects typed plugin setting values from a plugin-settings panel. */ +function collectPluginSettingsFromPanel( + panel: HTMLElement, +): Array<{ id: string; value: unknown }> { + const entries: Array<{ id: string; value: unknown }> = []; + const controls = panel.querySelectorAll( + '[data-setting-id][data-setting-kind]', + ); + + for (const control of controls) { + const settingId = String(control.getAttribute('data-setting-id') || '').trim(); + const kind = String(control.getAttribute('data-setting-kind') || '') + .trim() + .toLowerCase(); + if (!settingId || !kind) continue; + + let value: unknown; + if (kind === 'bool' && control instanceof HTMLInputElement) { + value = control.checked; + } else if (kind === 's32' || kind === 'u32' || kind === 'f64') { + const n = Number(control.value); + if (!Number.isFinite(n)) { + value = 0; + } else if (kind === 's32') { + value = Math.trunc(n); + } else if (kind === 'u32') { + value = Math.max(0, Math.trunc(n)); + } else { + value = n; + } + } else { + value = control.value ?? ''; + } + + entries.push({ id: settingId, value }); + } + + return entries; } export function wireSettings() { @@ -159,6 +472,20 @@ export function wireSettings() { if (!target) return; activateSection(modal, target); }); + + panels.addEventListener('click', async (e) => { + const btn = (e.target as HTMLElement).closest('button[data-plugin-action][data-plugin-id]'); + if (!btn || !TAURI.has) return; + const pluginId = btn.dataset.pluginId || ''; + const actionId = btn.dataset.pluginAction || ''; + if (!pluginId || !actionId) return; + try { + await TAURI.invoke('invoke_plugin_action', { pluginId, actionId }); + } catch (err) { + console.error('Failed to invoke plugin action', err); + notify('Plugin action failed'); + } + }); } const lfsToggle = modal.querySelector('#set-lfs-enabled'); @@ -252,21 +579,42 @@ export function wireSettings() { const settingsSave = modal.querySelector('#settings-save') as HTMLButtonElement | null; const settingsReset = modal.querySelector('#settings-reset') as HTMLButtonElement | null; + if (settingsSave) { + settingsSave.style.width = '5rem'; + settingsSave.style.textAlign = 'center'; + } + settingsSave?.addEventListener('click', async () => { + if (!settingsSave || settingsSave.classList.contains('saved-state')) return; + settingsSave.classList.add('saved-state'); + settingsSave.textContent = 'Saved!'; try { - const baseRaw = (modal as HTMLElement).dataset.currentCfg || '{}'; - const base = JSON.parse(baseRaw || '{}'); - const prevBackend: string = String(base?.git?.backend || 'system'); + const activePanel = modal.querySelector('#settings-panels .panel-form:not(.hidden)'); + if (activePanel?.getAttribute('data-plugin-settings') === 'true') { + if (!TAURI.has) return; + const pluginId = String(activePanel.dataset.pluginId || '').trim(); + if (!pluginId) { + notify('Failed to save plugin settings'); + return; + } + await TAURI.invoke('save_plugin_settings', { + pluginId, + values: collectPluginSettingsFromPanel(activePanel), + }); + notify('Plugin settings saved'); + settingsSave.classList.add('saved-state'); + settingsSave.textContent = 'Saved!'; + setTimeout(() => { + settingsSave.textContent = 'Save'; + settingsSave.classList.remove('saved-state'); + }, 2000); + return; + } + const next = collectSettingsFromForm(modal); if (TAURI.has) { await TAURI.invoke('set_global_settings', { cfg: next }); - - // If Git engine changed, reopen the current repo so the plugin can reconfigure. - const newBackend: string = String(next?.git?.backend || 'system'); - if (newBackend && newBackend !== prevBackend) { - try { await TAURI.invoke('reopen_current_repo_cmd'); } catch {} - } } modal.dataset.currentCfg = JSON.stringify(next); @@ -289,12 +637,33 @@ export function wireSettings() { } catch {} notify('Settings saved'); - closeModal('settings-modal'); - } catch { notify('Failed to save settings'); } + settingsSave.classList.add('saved-state'); + settingsSave.textContent = 'Saved!'; + setTimeout(() => { + settingsSave.textContent = 'Save'; + settingsSave.classList.remove('saved-state'); + }, 2000); + } catch (e) { console.error('Failed to save settings:', e); notify('Failed to save settings'); } }); settingsReset?.addEventListener('click', async () => { try { + const activePanel = modal.querySelector('#settings-panels .panel-form:not(.hidden)'); + if (activePanel?.getAttribute('data-plugin-settings') === 'true') { + if (!TAURI.has) return; + const pluginId = String(activePanel.dataset.pluginId || '').trim(); + const section = String(activePanel.getAttribute('data-panel') || '').trim(); + if (!pluginId) { + notify('Failed to reset plugin settings'); + return; + } + await TAURI.invoke('reset_plugin_settings', { pluginId }); + notify('Plugin settings reset'); + await renderPluginMenus(modal); + if (section) activateSection(modal, section); + return; + } + if (!TAURI.has) return; const cur = await TAURI.invoke('get_global_settings'); @@ -309,7 +678,6 @@ export function wireSettings() { telemetry: false, crash_reports: false, }; - cur.git = { backend: 'system', default_branch: 'main', prune_on_fetch: true, fetch_on_focus: true, allow_hooks: 'ask', respect_core_autocrlf: true, merge_commit_message_template: "Merged branch '{branch:source}' into '{branch:target}'" }; cur.diff = { tab_width: 4, ignore_whitespace: 'none', max_file_size_mb: 10, intraline: true, show_binary_placeholders: true, external_diff: {enabled:false,path:'',args:''}, external_merge: {enabled:false,path:'',args:''}, binary_exts: ['png','jpg','dds','uasset'] }; cur.lfs = { enabled: true, concurrency: 4, require_lock_before_edit: false, background_fetch_on_checkout: true }; cur.performance = { progressive_render: true, gpu_accel: true, animations: true }; @@ -323,7 +691,7 @@ export function wireSettings() { setTheme('system'); try { await selectThemePack(DEFAULT_LIGHT_THEME_ID, { silent: true, mode: 'system' }); } catch {} notify('Defaults restored'); - } catch { notify('Failed to restore defaults'); } + } catch (e) { console.error('Failed to restore defaults:', e); notify('Failed to restore defaults'); } }); // Settings are loaded by `openSettings()` on open. @@ -351,20 +719,6 @@ function collectSettingsFromForm(root: HTMLElement): GlobalSettings { checks_on_launch: !!get('#set-checks-on-launch')?.checked, }; - if (get('#set-git-backend') || get('#set-merge-message-template') || get('#set-git-ssh-binary')) { - o.git = { - ...o.git, - backend: get('#set-git-backend')?.value as any, - merge_commit_message_template: get('#set-merge-message-template')?.value ?? '', - ssh_binary: (get('#set-git-ssh-binary')?.value || 'auto') as any, - ssh_path: (get('#set-git-ssh-path')?.value || '').trim(), - prune_on_fetch: !!get('#set-prune-on-fetch')?.checked, - fetch_on_focus: !!get('#set-fetch-on-focus')?.checked, - allow_hooks: get('#set-hook-policy')?.value, - respect_core_autocrlf: !!get('#set-respect-autocrlf')?.checked, - }; - } - o.diff = { ...o.diff, tab_width: Number(get('#set-tab-width')?.value ?? 0), @@ -504,24 +858,6 @@ export async function loadSettingsIntoForm(root?: HTMLElement) { const elChk = get('#set-checks-on-launch'); if (elChk) elChk.checked = !!cfg.general?.checks_on_launch; const elRl = get('#set-recents-limit'); if (elRl) elRl.value = String(cfg.ux?.recents_limit ?? 10); - await refreshGitBackendOptions(m, cfg); - const elMmt = get('#set-merge-message-template'); - if (elMmt) elMmt.value = cfg.git?.merge_commit_message_template ?? ''; - const elSshBin = get('#set-git-ssh-binary'); - if (elSshBin) elSshBin.value = toKebab(cfg.git?.ssh_binary) || 'auto'; - const elSshPath = get('#set-git-ssh-path'); - if (elSshPath) elSshPath.value = cfg.git?.ssh_path ?? ''; - if (elSshPath) { - const enabled = (elSshBin?.value || 'auto') === 'custom'; - elSshPath.disabled = !enabled; - if (!enabled) elSshPath.value = ''; - } - const elPr = get('#set-prune-on-fetch'); if (elPr) elPr.checked = !!cfg.git?.prune_on_fetch; - const elFoF = get('#set-fetch-on-focus'); if (elFoF) elFoF.checked = !!cfg.git?.fetch_on_focus; - - const elHp = get('#set-hook-policy'); if (elHp) elHp.value = toKebab(cfg.git?.allow_hooks); - const elRc = get('#set-respect-autocrlf'); if (elRc) elRc.checked = !!cfg.git?.respect_core_autocrlf; - const elTw = get('#set-tab-width'); if (elTw) elTw.value = String(cfg.diff?.tab_width ?? 0); const elIw = get('#set-ignore-whitespace'); if (elIw) elIw.value = toKebab(cfg.diff?.ignore_whitespace); const elMx = get('#set-max-file-size-mb'); if (elMx) elMx.value = String(cfg.diff?.max_file_size_mb ?? 0); @@ -559,28 +895,6 @@ export async function loadSettingsIntoForm(root?: HTMLElement) { const elKeep= get('#set-log-keep'); if (elKeep) elKeep.value = String(cfg.logging?.retain_archives ?? 10); } -async function refreshGitBackendOptions(modal: HTMLElement, cfg: GlobalSettings) { - const elGb = modal.querySelector('#set-git-backend'); - if (!elGb) return; - - const backend = String(cfg.git?.backend || '').trim(); - const options: Array<[string, string]> = [ - ['system', 'System'], - ['libgit2', 'Libgit2'], - ]; - - elGb.innerHTML = ''; - for (const [id, label] of options) { - const opt = document.createElement('option'); - opt.value = id; - opt.textContent = label; - elGb.appendChild(opt); - } - - elGb.disabled = false; - elGb.value = (backend === 'libgit2') ? 'libgit2' : 'system'; -} - async function refreshDefaultBackendOptions(modal: HTMLElement, cfg: GlobalSettings) { const el = modal.querySelector('#set-default-backend'); if (!el) return; @@ -624,125 +938,8 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const installBundleBtn = modal.querySelector('#plugins-install-bundle'); const enableAllBtn = modal.querySelector('#plugins-enable-all'); const disableAllBtn = modal.querySelector('#plugins-disable-all'); - const bundleListEl = modal.querySelector('#plugin-bundles-list'); - - if (!pane || !listEl || !detailEl || !groupLabelEl || !searchEl || !installBundleBtn || !enableAllBtn || !disableAllBtn || !bundleListEl) return; - - const renderBundles = async () => { - bundleListEl.innerHTML = ''; - if (!TAURI.has) { - bundleListEl.textContent = 'Bundles are only available in the desktop app.'; - return; - } - - let bundles: any[] = []; - try { - bundles = await TAURI.invoke('list_installed_bundles'); - } catch (err) { - bundleListEl.textContent = 'Failed to load installed bundles.'; - return; - } - - if (!Array.isArray(bundles) || bundles.length === 0) { - bundleListEl.textContent = 'No bundles installed.'; - return; - } - - const wrap = document.createElement('div'); - wrap.style.display = 'grid'; - wrap.style.gap = '.5rem'; - - for (const b of bundles) { - const pluginId = String(b?.plugin_id || '').trim(); - const current = String(b?.current || '').trim(); - const versions = b?.versions && typeof b.versions === 'object' ? b.versions : {}; - const cur = current && versions[current] ? versions[current] : null; - const approval = cur?.approval?.Pending ? 'Pending' : (cur?.approval?.Denied ? 'Denied' : (cur?.approval?.Approved ? 'Approved' : 'Pending')); - const requestedCaps: string[] = Array.isArray(cur?.requested_capabilities) ? cur.requested_capabilities : []; - - const row = document.createElement('div'); - row.className = 'card'; - (row.style as any).padding = '.6rem .7rem'; - (row.style as any).display = 'flex'; - (row.style as any).gap = '.75rem'; - (row.style as any).alignItems = 'center'; - - const left = document.createElement('div'); - left.style.flex = '1'; - const title = document.createElement('div'); - title.textContent = pluginId || '(unknown plugin)'; - const sub = document.createElement('div'); - sub.className = 'muted'; - sub.style.fontSize = '.85rem'; - sub.textContent = current ? `version ${current} • ${approval}` : `no current version • ${approval}`; - left.appendChild(title); - left.appendChild(sub); - - const actions = document.createElement('div'); - actions.style.display = 'flex'; - actions.style.gap = '.4rem'; - - const approveBtn = document.createElement('button'); - approveBtn.type = 'button'; - approveBtn.className = 'tbtn'; - approveBtn.textContent = 'Approve'; - approveBtn.disabled = !pluginId || !current; - approveBtn.addEventListener('click', async () => { - try { - await TAURI.invoke('approve_plugin_capabilities', { - pluginId, - version: current, - approved: true, - }); - notify('Capabilities approved'); - await renderBundles(); - } catch (err) { - const msg = String(err || '').trim(); - notify(msg ? `Approve failed: ${msg}` : 'Approve failed'); - } - }); - - const denyBtn = document.createElement('button'); - denyBtn.type = 'button'; - denyBtn.className = 'tbtn'; - denyBtn.textContent = 'Deny'; - denyBtn.disabled = !pluginId || !current; - denyBtn.addEventListener('click', async () => { - try { - await TAURI.invoke('approve_plugin_capabilities', { - pluginId, - version: current, - approved: false, - }); - notify('Capabilities denied'); - await renderBundles(); - } catch (err) { - const msg = String(err || '').trim(); - notify(msg ? `Deny failed: ${msg}` : 'Deny failed'); - } - }); - - actions.appendChild(approveBtn); - actions.appendChild(denyBtn); - - if (requestedCaps.length) { - const caps = document.createElement('div'); - caps.className = 'muted'; - caps.style.fontSize = '.8rem'; - caps.style.marginTop = '.2rem'; - caps.textContent = `requested: ${requestedCaps.join(', ')}`; - left.appendChild(caps); - } - - row.appendChild(left); - row.appendChild(actions); - wrap.appendChild(row); - } - bundleListEl.appendChild(wrap); - }; - - await renderBundles(); + if (!pane || !listEl || !detailEl || !groupLabelEl || !searchEl || !installBundleBtn || !enableAllBtn || !disableAllBtn) return; // This settings pane can be initialized multiple times during navigation/rerender. // Avoid stacking duplicate click handlers which would open many dialogs. @@ -757,20 +954,25 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const installed = await TAURI.invoke('install_ovcsp', { bundlePath }); notify(`Installed ${installed?.plugin_id || 'plugin'} ${installed?.version || ''}`.trim()); - const caps = Array.isArray(installed?.requested_capabilities) ? installed.requested_capabilities : []; - if (caps.length) { - const ok = window.confirm( - `Plugin requests capabilities:\n\n- ${caps.join('\n- ')}\n\nApprove and allow it to run?` + const pluginId = String(installed?.plugin_id || '').trim(); + const version = String(installed?.version || '').trim(); + if (pluginId && version) { + const trusted = await confirmBool( + 'Trust this plugin and allow it to run?\n\n' + + 'Only approve plugins from sources you trust.' ); - await TAURI.invoke('approve_plugin_capabilities', { - pluginId: String(installed?.plugin_id || '').trim(), - version: String(installed?.version || '').trim(), - approved: ok, + await TAURI.invoke('set_plugin_approval', { + pluginId, + version, + approved: trusted, }); - notify(ok ? 'Capabilities approved' : 'Capabilities denied'); + if (trusted) { + notify('Plugin approved'); + } else { + notify('Plugin installed but not approved to run'); + } } - await renderBundles(); await reloadPluginSummaries(); } catch (err) { const msg = String(err || '').trim(); @@ -987,6 +1189,10 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { list: PluginSummary[]; disabled: Set; enabled: Set; + pendingToggleById: Map; + errorToggleById: Set; + buttonErrorToggleById: Set; + buttonErrorTimerById: Map; query: string; selectedId: string | null; }; @@ -994,6 +1200,10 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { list: [], disabled: new Set(), enabled: new Set(), + pendingToggleById: new Map(), + errorToggleById: new Set(), + buttonErrorToggleById: new Set(), + buttonErrorTimerById: new Map(), query: '', selectedId: null, }; @@ -1002,6 +1212,23 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { state.enabled = enabled; state.query = String(searchEl.value || '').trim(); + const syncStartFailures = async (): Promise => { + if (!TAURI.has) { + state.errorToggleById.clear(); + return; + } + try { + const failed = await TAURI.invoke('list_plugin_start_failures'); + state.errorToggleById = new Set( + (Array.isArray(failed) ? failed : []) + .map((id) => String(id || '').trim().toLowerCase()) + .filter(Boolean), + ); + } catch (err) { + console.warn('list_plugin_start_failures failed', err); + } + }; + const pluginIsEnabled = (p: PluginSummary): boolean => { const id = String(p?.id || '').trim().toLowerCase(); if (!id) return false; @@ -1053,7 +1280,11 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { detailEl.classList.remove('empty'); const id = String(plugin.id).trim(); + const idLower = id.toLowerCase(); const isEnabledNow = pluginIsEnabled(plugin); + const pendingToggle = state.pendingToggleById.get(idLower); + const hasButtonError = state.buttonErrorToggleById.has(idLower); + const isDisablingAction = typeof pendingToggle === 'boolean' ? pendingToggle === false : isEnabledNow; const version = String(plugin.version || '').trim(); const author = String(plugin.author || '').trim(); const category = String(plugin.category || '').trim(); @@ -1083,9 +1314,18 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { actions.className = 'plugin-detail-actions'; const toggle = document.createElement('button'); toggle.type = 'button'; - toggle.className = 'tbtn'; + toggle.className = `tbtn plugin-toggle-btn ${(hasButtonError || isDisablingAction) ? 'plugin-toggle-btn-disable' : 'plugin-toggle-btn-enable'}`; toggle.id = 'plugins-toggle-selected'; - toggle.textContent = isEnabledNow ? 'Disable' : 'Enable'; + toggle.disabled = typeof pendingToggle === 'boolean' || hasButtonError; + toggle.textContent = hasButtonError + ? 'Error' + : pendingToggle === true + ? 'Enabling...' + : pendingToggle === false + ? 'Disabling...' + : isEnabledNow + ? 'Disable' + : 'Enable'; toggle.dataset.pluginToggle = id; actions.appendChild(toggle); @@ -1126,6 +1366,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { detailEl.appendChild(head); detailEl.appendChild(body); + }; const renderList = () => { @@ -1153,7 +1394,10 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { for (const plugin of filtered) { const id = String(plugin.id).trim(); + const idLower = id.toLowerCase(); const isEnabledNow = pluginIsEnabled(plugin); + const pendingToggle = state.pendingToggleById.get(idLower); + const hasToggleError = state.errorToggleById.has(idLower); const li = document.createElement('li'); li.className = 'plugin-row'; @@ -1205,14 +1449,33 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { main.appendChild(icon); main.appendChild(text); + const checkboxWrap = document.createElement('label'); + checkboxWrap.className = 'plugin-check'; + checkboxWrap.dataset.state = hasToggleError + ? 'error' + : pendingToggle === true + ? 'enabling' + : isEnabledNow + ? 'enabled' + : 'disabled'; + const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; + checkbox.className = 'plugin-check-input'; checkbox.checked = isEnabledNow; + checkbox.disabled = typeof pendingToggle === 'boolean'; checkbox.dataset.pluginId = id; checkbox.setAttribute('aria-label', `Enable ${String(plugin.name || '').trim() || 'plugin'}`); + const checkboxUi = document.createElement('span'); + checkboxUi.className = 'plugin-check-ui'; + checkboxUi.setAttribute('aria-hidden', 'true'); + + checkboxWrap.appendChild(checkbox); + checkboxWrap.appendChild(checkboxUi); + li.appendChild(main); - li.appendChild(checkbox); + li.appendChild(checkboxWrap); listEl.appendChild(li); } @@ -1235,11 +1498,13 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { } state.list = Array.isArray(list) ? list : []; + await syncStartFailures(); ensureSelection(getFiltered()); renderList(); updateCounts(); } + await syncStartFailures(); ensureSelection(getFiltered()); (modal as any)[stateKey] = state; renderList(); @@ -1255,7 +1520,6 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { await TAURI.invoke('set_global_settings', { cfg: next }); modal.dataset.currentCfg = JSON.stringify(next); await reloadPlugins(); - await refreshGitBackendOptions(modal, next); try { await refreshAvailableThemes(); const themeSel = modal.querySelector('#set-theme'); @@ -1281,11 +1545,74 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { } } } catch {} - } catch { - notify('Failed to update plugins'); + } catch (e) { console.error('Failed to update plugins:', e); notify('Failed to update plugins'); } + }; + + const persistSinglePluginToggle = async (pluginId: string, enabled: boolean) => { + if (!TAURI.has) return; + const idLower = pluginId.trim().toLowerCase(); + try { + const activeSection = String( + modal + .querySelector('#settings-nav .seg-btn.active') + ?.getAttribute('data-section') || '', + ).trim(); + await TAURI.invoke('set_plugin_enabled', { pluginId, enabled }); + if (enabled) { + state.disabled.delete(idLower); + state.enabled.add(idLower); + } else { + state.enabled.delete(idLower); + state.disabled.add(idLower); + } + console.log(`Plugin '${pluginId}' ${enabled ? 'enabled' : 'disabled'}`); + await reloadPlugins(); + await renderPluginMenus(modal); + if (activeSection) activateSection(modal, activeSection); + try { + await refreshAvailableThemes(); + } catch (e) { console.warn('refreshAvailableThemes failed:', e); } + } catch (e) { + state.errorToggleById.add(idLower); + const existingTimer = state.buttonErrorTimerById.get(idLower); + if (typeof existingTimer === 'number') { + window.clearTimeout(existingTimer); + } + state.buttonErrorToggleById.add(idLower); + const timer = window.setTimeout(() => { + state.buttonErrorToggleById.delete(idLower); + state.buttonErrorTimerById.delete(idLower); + renderDetails(getFiltered()); + }, 2000); + state.buttonErrorTimerById.set(idLower, timer); + console.error('Failed to toggle plugin:', e); + notify('Failed to toggle plugin'); + } finally { + state.pendingToggleById.delete(idLower); + updateCounts(); + renderList(); } }; + const queuePluginToggle = (pluginIdRaw: string, enabled: boolean) => { + const id = String(pluginIdRaw || '').trim().toLowerCase(); + if (!id) return; + if (state.pendingToggleById.has(id)) return; + const existingTimer = state.buttonErrorTimerById.get(id); + if (typeof existingTimer === 'number') { + window.clearTimeout(existingTimer); + state.buttonErrorTimerById.delete(id); + } + state.buttonErrorToggleById.delete(id); + state.errorToggleById.delete(id); + state.pendingToggleById.set(id, enabled); + renderList(); + void (async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await persistSinglePluginToggle(id, enabled); + })(); + }; + if (!(pane as any).__wired) { (pane as any).__wired = true; @@ -1373,7 +1700,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const plugin = state.list.find((p) => String(p?.id || '').trim() === id) || null; if (!plugin) return; const label = String(plugin.name || plugin.id || 'plugin'); - if (!window.confirm(`Remove ${label}? This will delete the plugin bundle.`)) return; + if (!(await confirmBool(`Remove ${label}? This will delete the plugin bundle.`))) return; if (!TAURI.has) { notify('Plugin removal is only available in the desktop app.'); return; @@ -1428,11 +1755,16 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const toggleBtn = target?.closest('[data-plugin-toggle]') || null; if (toggleBtn) { const id = String(toggleBtn.dataset.pluginToggle || '').trim(); - const checkbox = pane.querySelector(`input[type="checkbox"][data-plugin-id="${CSS.escape(id)}"]`); - if (checkbox) { - checkbox.checked = !checkbox.checked; - checkbox.dispatchEvent(new Event('change', { bubbles: true })); - } + if (!id) return; + const plugin = state.list.find( + (p) => String(p?.id || '').trim().toLowerCase() === id.toLowerCase(), + ); + const desiredEnabled = plugin ? !pluginIsEnabled(plugin) : false; + const checkbox = pane.querySelector( + `input[type="checkbox"][data-plugin-id="${CSS.escape(id)}"]`, + ); + if (checkbox) checkbox.checked = plugin ? pluginIsEnabled(plugin) : false; + queuePluginToggle(id, desiredEnabled); return; } @@ -1441,7 +1773,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const id = String(row.dataset.plugin || '').trim(); if (!id) return; - const isCheckbox = !!target?.closest('input[type="checkbox"]'); + const isCheckbox = !!target?.closest('.plugin-check'); if (!isCheckbox) { const now = Date.now(); const idKey = id.toLowerCase(); @@ -1474,16 +1806,13 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { if (!el || el.type !== 'checkbox' || !el.dataset.pluginId) return; const id = String(el.dataset.pluginId).trim().toLowerCase(); if (!id) return; - if (el.checked) { - state.disabled.delete(id); - state.enabled.add(id); - } else { - state.enabled.delete(id); - state.disabled.add(id); - } - updateCounts(); - renderDetails(getFiltered()); - persistPluginsDisabled().catch(() => {}); + const plugin = state.list.find( + (p) => String(p?.id || '').trim().toLowerCase() === id, + ); + const currentEnabled = plugin ? pluginIsEnabled(plugin) : false; + const desiredEnabled = !currentEnabled; + el.checked = currentEnabled; + queuePluginToggle(id, desiredEnabled); }); searchEl.addEventListener('input', () => { diff --git a/Frontend/src/scripts/features/sshAuth.ts b/Frontend/src/scripts/features/sshAuth.ts index 5cd1ec05..bd198bb4 100644 --- a/Frontend/src/scripts/features/sshAuth.ts +++ b/Frontend/src/scripts/features/sshAuth.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; import { openModal, closeModal } from '../ui/modals'; diff --git a/Frontend/src/scripts/features/sshHostkey.ts b/Frontend/src/scripts/features/sshHostkey.ts index a38cc965..f32c2a25 100644 --- a/Frontend/src/scripts/features/sshHostkey.ts +++ b/Frontend/src/scripts/features/sshHostkey.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; import { openModal, closeModal } from '../ui/modals'; diff --git a/Frontend/src/scripts/features/sshKeys.ts b/Frontend/src/scripts/features/sshKeys.ts index 11f66af3..a29428f5 100644 --- a/Frontend/src/scripts/features/sshKeys.ts +++ b/Frontend/src/scripts/features/sshKeys.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; import { openModal } from '../ui/modals'; diff --git a/Frontend/src/scripts/features/stashConfirm.ts b/Frontend/src/scripts/features/stashConfirm.ts index 17de644f..4d54cc29 100644 --- a/Frontend/src/scripts/features/stashConfirm.ts +++ b/Frontend/src/scripts/features/stashConfirm.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/stashConfirm.ts import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; diff --git a/Frontend/src/scripts/features/update.ts b/Frontend/src/scripts/features/update.ts index 57967e2c..9a1f69b8 100644 --- a/Frontend/src/scripts/features/update.ts +++ b/Frontend/src/scripts/features/update.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { openModal, closeModal } from '../ui/modals'; import { notify } from '../lib/notify'; diff --git a/Frontend/src/scripts/lib/confirm.ts b/Frontend/src/scripts/lib/confirm.ts new file mode 100644 index 00000000..9d7febed --- /dev/null +++ b/Frontend/src/scripts/lib/confirm.ts @@ -0,0 +1,52 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +/** + * Attempts to coerce arbitrary confirm-return payloads into a boolean. + * + * @param value - Value returned by a confirm implementation. + * @returns A normalized confirmation decision. + */ +function coerceConfirmResult(value: unknown): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (!normalized) return false; + if (normalized === 'true' || normalized === 'yes' || normalized === 'ok') return true; + if (normalized === 'false' || normalized === 'no' || normalized === 'cancel') return false; + } + if (value && typeof value === 'object') { + const record = value as Record; + const candidates = ['approved', 'ok', 'value', 'confirmed', 'result']; + for (const key of candidates) { + if (key in record) return coerceConfirmResult(record[key]); + } + } + return false; +} + +/** + * Shows a confirmation prompt and returns a normalized boolean result. + * + * Supports both synchronous browser `window.confirm` and Promise-returning + * confirm implementations exposed by embedded runtimes. + * + * @param message - Prompt text shown to the user. + * @returns `true` when the user confirms; otherwise `false`. + */ +export async function confirmBool(message: string): Promise { + const confirmFn = (window as any).confirm; + if (typeof confirmFn !== 'function') return false; + + try { + const maybe = confirmFn(message) as unknown; + if (maybe && typeof (maybe as PromiseLike).then === 'function') { + const resolved = await (maybe as PromiseLike); + return coerceConfirmResult(resolved); + } + return coerceConfirmResult(maybe); + } catch { + return false; + } +} diff --git a/Frontend/src/scripts/lib/dom.test.ts b/Frontend/src/scripts/lib/dom.test.ts index 16bc4cab..5150f37f 100644 --- a/Frontend/src/scripts/lib/dom.test.ts +++ b/Frontend/src/scripts/lib/dom.test.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { describe, it, expect, beforeEach } from 'vitest' import { qs, qsa, setText, setValue, setChecked, escapeHtml, toKebab } from './dom' diff --git a/Frontend/src/scripts/lib/dom.ts b/Frontend/src/scripts/lib/dom.ts index f1ea4ee9..5f7c0307 100644 --- a/Frontend/src/scripts/lib/dom.ts +++ b/Frontend/src/scripts/lib/dom.ts @@ -1,21 +1,66 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +/** + * Query a single element by CSS selector. + * @param sel - CSS selector string + * @param root - Root element to search within (defaults to document) + * @returns First matching element or null + */ export const qs = (sel: string, root: Document | Element = document): T | null => root.querySelector(sel) as T | null; +/** + * Query all elements matching a CSS selector. + * @param sel - CSS selector string + * @param root - Root element to search within (defaults to document) + * @returns Array of matching elements + */ export const qsa = (sel: string, root: Document | Element = document): T[] => Array.from(root.querySelectorAll(sel)) as T[]; +/** + * Set the text content of an element. + * @param el - Element to modify + * @param text - Text content to set + */ export const setText = (el: Element | null | undefined, text: string) => { if (el) (el as HTMLElement).textContent = text; }; +/** + * Set the value of an input or select element. + * @param el - Form element to modify + * @param value - Value to set + */ export const setValue = (el: HTMLInputElement | HTMLSelectElement | null | undefined, value: string | number) => { if (!el) return; (el as HTMLInputElement | HTMLSelectElement).value = String(value); }; +/** + * Set the checked state of a checkbox or radio input. + * @param el - Input element to modify + * @param on - Whether to check the element + */ export const setChecked = (el: HTMLInputElement | null | undefined, on: boolean) => { if (el) el.checked = on; }; +/** + * Escape HTML special characters in a string. + * @param s - Value to escape + * @returns HTML-escaped string + */ export const escapeHtml = (s: any) => String(s) .replace(/&/g,'&') .replace(/(target: Document | HTMLElement | Window, type: K, fn: (ev: DocumentEventMap[K]) => any) => target.addEventListener(type, fn as any); +/** + * Convert a value to kebab-case. + * @param v - Value to convert + * @returns Kebab-case string + */ export const toKebab = (v: unknown) => String(v ?? '').toLowerCase().replace(/_/g, '-'); diff --git a/Frontend/src/scripts/lib/logger.ts b/Frontend/src/scripts/lib/logger.ts new file mode 100644 index 00000000..36bac15e --- /dev/null +++ b/Frontend/src/scripts/lib/logger.ts @@ -0,0 +1,110 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { TAURI } from "./tauri"; + +type LogLevel = "trace" | "debug" | "info" | "warn" | "error"; + +interface Logger { + trace: (...args: unknown[]) => void; + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +function formatMessage(...args: unknown[]): string { + return args + .map((a) => { + if (a instanceof Error) { + return `${a.name}: ${a.message}${a.stack ? `\n${a.stack}` : ""}`; + } + if (typeof a === "object") { + try { + return JSON.stringify(a, null, 2); + } catch { + return String(a); + } + } + return String(a); + }) + .join(" "); +} + +function sendToBackend(level: LogLevel, message: string): void { + if (TAURI.has) { + TAURI.invoke("log_frontend_message", { level, message }).catch(() => {}); + } +} + +function createLogger(module: string): Logger { + const prefix = `[${module}]`; + + return { + trace: (...args: unknown[]) => { + const msg = `${prefix} ${formatMessage(...args)}`; + sendToBackend("trace", msg); + }, + debug: (...args: unknown[]) => { + const msg = `${prefix} ${formatMessage(...args)}`; + sendToBackend("debug", msg); + }, + info: (...args: unknown[]) => { + const msg = `${prefix} ${formatMessage(...args)}`; + sendToBackend("info", msg); + }, + warn: (...args: unknown[]) => { + const msg = `${prefix} ${formatMessage(...args)}`; + sendToBackend("warn", msg); + }, + error: (...args: unknown[]) => { + const msg = `${prefix} ${formatMessage(...args)}`; + sendToBackend("error", msg); + }, + }; +} + +function installFrontendLogger(): void { + const originalConsole = { + debug: console.debug, + log: console.log, + warn: console.warn, + error: console.error, + }; + + console.debug = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("debug", msg); + }; + + console.log = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("info", msg); + }; + + console.warn = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("warn", msg); + }; + + console.error = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("error", msg); + }; + + console.trace = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("trace", msg); + }; +} + +installFrontendLogger(); + +export const logger = { + create: createLogger, + trace: (...args: unknown[]) => sendToBackend("trace", formatMessage(...args)), + debug: (...args: unknown[]) => sendToBackend("debug", formatMessage(...args)), + info: (...args: unknown[]) => sendToBackend("info", formatMessage(...args)), + warn: (...args: unknown[]) => sendToBackend("warn", formatMessage(...args)), + error: (...args: unknown[]) => sendToBackend("error", formatMessage(...args)), +}; diff --git a/Frontend/src/scripts/lib/menu.ts b/Frontend/src/scripts/lib/menu.ts index 41356cef..09375df0 100644 --- a/Frontend/src/scripts/lib/menu.ts +++ b/Frontend/src/scripts/lib/menu.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later export type CtxItem = { label: string; action?: () => void | Promise }; /** @@ -12,7 +14,23 @@ export function buildCtxMenu(items: CtxItem[], x: number, y: number) { // Position gets clamped to viewport after measuring. m.style.left = `${Math.round(x)}px`; m.style.top = `${Math.round(y)}px`; - items.forEach((it) => { + // Normalize separators: remove leading/trailing, collapse consecutive. + const normalized: CtxItem[] = []; + let lastWasSep = true; // start as true to drop leading separators + for (const it of items) { + const isSep = it.label === '---'; + if (isSep) { + if (!lastWasSep) { normalized.push(it); } + } else { + normalized.push(it); + } + lastWasSep = isSep; + } + // Drop trailing separator if present + if (normalized.length > 0 && normalized[normalized.length - 1].label === '---') { + normalized.pop(); + } + normalized.forEach((it) => { if (it.label === '---') { const sep = document.createElement('div'); sep.className = 'sep'; diff --git a/Frontend/src/scripts/lib/notify.ts b/Frontend/src/scripts/lib/notify.ts index f01eb2cf..d6c2e661 100644 --- a/Frontend/src/scripts/lib/notify.ts +++ b/Frontend/src/scripts/lib/notify.ts @@ -1,8 +1,15 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { qs, setText } from './dom'; const statusEl = qs('#status'); +/** + * Display a notification message in the status bar. + * @param text - Message to display + */ export function notify(text: string) { + console.log(`[notify] ${text}`); if (!statusEl) return; setText(statusEl, text); setTimeout(() => { diff --git a/Frontend/src/scripts/lib/scrollbars.ts b/Frontend/src/scripts/lib/scrollbars.ts index d766131e..af5919b4 100644 --- a/Frontend/src/scripts/lib/scrollbars.ts +++ b/Frontend/src/scripts/lib/scrollbars.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { OverlayScrollbars } from 'overlayscrollbars'; // Use the official attribute name so OverlayScrollbars can hide native scrollbars @@ -115,14 +117,26 @@ function queryScrollableElements(root: ParentNode, includeHidden = false): HTMLE return includeHidden ? all : all.filter(isVisibleForInit); } +/** + * Initialize OverlayScrollbars for scrollable elements within a root. + * @param root - Root element to search for scrollable elements (defaults to document) + */ export function initOverlayScrollbarsFor(root: ParentNode = document) { queryScrollableElements(root).forEach(initOne); } +/** + * Refresh OverlayScrollbars for scrollable elements within a root. + * @param root - Root element to search for scrollable elements (defaults to document) + */ export function refreshOverlayScrollbarsFor(root: ParentNode = document) { queryScrollableElements(root).forEach(refreshOne); } +/** + * Destroy OverlayScrollbars instances for matching elements. + * @param target - CSS selector string or root element + */ export function destroyOverlayScrollbarsFor(target: string | ParentNode = document) { if (typeof target === 'string') { let els: HTMLElement[] = []; diff --git a/Frontend/src/scripts/lib/status.ts b/Frontend/src/scripts/lib/status.ts new file mode 100644 index 00000000..63299a5e --- /dev/null +++ b/Frontend/src/scripts/lib/status.ts @@ -0,0 +1,14 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +import { qs, setText } from './dom'; + +const statusEl = qs('#status'); + +/** + * Updates the footer status text. + * @param text - Status text to display. + */ +export function setStatus(text: string) { + if (!statusEl) return; + setText(statusEl, text); +} diff --git a/Frontend/src/scripts/lib/tauri.ts b/Frontend/src/scripts/lib/tauri.ts index 831614bb..7041e1c2 100644 --- a/Frontend/src/scripts/lib/tauri.ts +++ b/Frontend/src/scripts/lib/tauri.ts @@ -1,12 +1,16 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/lib/tauri.ts import type { Json } from "../types"; type Unlisten = () => void; type Listener = (evt: { payload: T }) => void; +/** Interface for Tauri core functionality. */ interface TauriCore { invoke(cmd: string, args?: Json): Promise; } +/** Interface for Tauri event system. */ interface TauriEvent { listen(event: string, cb: Listener): Promise<{ unlisten: Unlisten }>; } @@ -20,11 +24,15 @@ declare global { const core: TauriCore | null = typeof window !== "undefined" && window.__TAURI__?.core ? window.__TAURI__.core : null; const tEvent: TauriEvent | null = typeof window !== "undefined" && window.__TAURI__?.event ? window.__TAURI__.event : null; +/** Tauri API wrapper providing invoke and event listening capabilities. */ export const TAURI = { + /** Whether Tauri runtime is available. */ has: !!core, + /** Invoke a Tauri command. */ invoke(cmd: string, args?: Json): Promise { return core ? core.invoke(cmd, args) : Promise.resolve(undefined as unknown as T); }, + /** Listen for Tauri events. */ listen(event: string, cb: Listener): Promise<{ unlisten: Unlisten }> { return tEvent ? tEvent.listen(event, cb) : Promise.resolve({ unlisten() {} }); }, diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index fe5186de..80bc2056 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -1,6 +1,10 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +import './lib/logger'; import { TAURI } from './lib/tauri'; import { qs } from './lib/dom'; import { notify } from './lib/notify'; +import { setStatus } from './lib/status'; import { destroyOverlayScrollbarsFor, initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from './lib/scrollbars'; import { prefs, state, hasRepo } from './state/state'; import { @@ -39,6 +43,7 @@ const undoLeftBtn = qs('#undo-left-btn'); let fetchCloseTimer: number | null = null; const FETCH_CLOSE_MS = 130; +/** Closes the Fetch/Pull popover, optionally with a short close animation. */ function closeFetchPopover() { if (!fetchPop || !fetchCaret) return; const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -56,6 +61,7 @@ function closeFetchPopover() { }, FETCH_CLOSE_MS); } +/** Closes transient UI surfaces before a repo switch or hard refresh. */ function forceCloseTransientUi() { closeAllModals(); closeSheet(); @@ -74,6 +80,7 @@ function forceCloseTransientUi() { window.dispatchEvent(new CustomEvent('app:repo-will-switch')); } +/** Boots the frontend shell, wires handlers, and hydrates initial state. */ async function boot() { // If launched as the Output Log window, render that view and skip the main app UI. if (await initOutputLogViewIfRequested()) return; @@ -315,39 +322,41 @@ async function boot() { notify('Pushed'); await Promise.allSettled([hydrateStatus(), hydrateCommits()]); await runHook('postPush', hookData); - } catch { notify('Push failed'); } finally { clearBusy(); } + } catch (e) { console.error('Push failed:', e); notify('Push failed'); } finally { clearBusy(); } } async function openDocs() { if (TAURI.has) { try { await TAURI.invoke('open_docs', {}); return; } catch { /* fall back */ } } - try { window.open(WIKI_URL, '_blank', 'noopener'); } catch { notify('Unable to open docs'); } + try { window.open(WIKI_URL, '_blank', 'noopener'); } catch (e) { console.error('Unable to open docs:', e); notify('Unable to open docs'); } } async function runMenuAction(id?: string | null) { switch (id) { - case 'clone_repo': openSheet('clone'); break; - case 'add_repo': openSheet('add'); break; - case 'open_repo': openSwitchDrawer(); break; - case 'fetch': await defaultFetchAction(); break; - case 'push': await pushChanges(); break; - case 'commit': commitBtn?.click(); break; - case 'docs': await openDocs(); break; + case 'clone_repo': console.log('Action: clone_repo'); openSheet('clone'); break; + case 'add_repo': console.log('Action: add_repo'); openSheet('add'); break; + case 'open_repo': console.log('Action: open_repo'); openSwitchDrawer(); break; + case 'fetch': console.log('Action: fetch'); await defaultFetchAction(); break; + case 'push': console.log('Action: push'); await pushChanges(); break; + case 'commit': console.log('Action: commit'); commitBtn?.click(); break; + case 'docs': console.log('Action: docs'); await openDocs(); break; case 'show-output-log': + console.log('Action: show-output-log'); if (!TAURI.has) { notify('Output Log is available in the desktop app'); break; } try { await TAURI.invoke('open_output_log_window', {}); } - catch { notify('Failed to open Output Log'); } + catch (e) { console.error('Failed to open Output Log:', e); notify('Failed to open Output Log'); } break; - case 'about': openAbout(); break; - case 'settings': openSettings(); break; - case 'repo-settings': openRepoSettings(); break; + case 'about': console.log('Action: about'); openAbout(); break; + case 'settings': console.log('Action: settings'); openSettings(); break; + case 'repo-settings': console.log('Action: repo-settings'); openRepoSettings(); break; case 'repo-edit-gitignore': case 'repo-edit-gitattributes': { + console.log('Action:', id); if (!TAURI.has) { notify('Open this in the desktop app to edit repository files'); break; } const name = id === 'repo-edit-gitignore' ? '.gitignore' : '.gitattributes'; try { await TAURI.invoke('open_repo_dotfile', { name }); } - catch { notify(`Could not open ${name}`); } + catch (e) { console.error(`Could not open ${name}:`, e); notify(`Could not open ${name}`); } break; } case 'lfs-settings': openSettings('lfs'); break; @@ -356,7 +365,7 @@ async function boot() { try { const hasUpdate = await TAURI.invoke('check_for_updates', {}); if (!hasUpdate) notify('Already up to date'); - } catch { notify('Update check failed'); } + } catch (e) { console.error('Update check failed:', e); notify('Update check failed'); } break; case 'exit': if (TAURI.has) { TAURI.invoke('exit_app', {}).catch(() => {}); } break; default: { @@ -398,7 +407,7 @@ async function boot() { await TAURI.invoke('git_undo_since_push', {}); notify('Undid unpushed commits'); await Promise.allSettled([hydrateStatus(), hydrateCommits()]); - } catch { notify('Undo failed'); } finally { clearBusy(); } + } catch (e) { console.error('Undo failed:', e); notify('Undo failed'); } finally { clearBusy(); } }); @@ -485,9 +494,9 @@ async function boot() { .catch(() => {}); } - // generic notifications from backend - TAURI.listen?.('ui:notify', ({ payload }) => { - try { notify(String((payload as any) ?? '')); } catch {} + // backend status updates (footer) + TAURI.listen?.('status:set', ({ payload }) => { + try { setStatus(String((payload as any) ?? '')); } catch {} }); // update available payload from backend -> open modal with notes @@ -500,11 +509,16 @@ async function boot() { async function onFocus() { if (focusInFlight) return focusInFlight; focusInFlight = (async () => { - let doFetch = false; + let doFetch = true; if (TAURI.has) { try { - const cfg = await TAURI.invoke('get_global_settings'); - doFetch = cfg?.git?.fetch_on_focus !== false; // default true when unset + const fields = await TAURI.invoke>('get_plugin_settings', { + pluginId: 'openvcs.git', + }); + const fetchSetting = (Array.isArray(fields) ? fields : []).find((field) => String(field?.id || '').trim() === 'fetch_on_focus'); + if (fetchSetting && typeof fetchSetting.value === 'boolean') { + doFetch = fetchSetting.value; + } } catch {} } if (doFetch) { diff --git a/Frontend/src/scripts/plugins.ts b/Frontend/src/scripts/plugins.ts index 8d00324a..3383f1ec 100644 --- a/Frontend/src/scripts/plugins.ts +++ b/Frontend/src/scripts/plugins.ts @@ -1,8 +1,11 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from './lib/tauri'; import { notify } from './lib/notify'; import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from './lib/scrollbars'; import type { GlobalSettings, Json, ThemePayload, ThemeSummary } from './types'; +/** Describes plugin metadata returned by discovery endpoints. */ export interface PluginSummary { id: string; name: string; @@ -18,11 +21,13 @@ export interface PluginSummary { icon_data_url?: string; } +/** Holds a plugin manifest summary with optional UI module code. */ export interface PluginPayload { summary: PluginSummary; entry?: string | null; } +/** Enumerates supported lifecycle hook names for plugin callbacks. */ export type HookName = | 'preCommit' | 'onCommit' | 'postCommit' | 'prePush' | 'onPush' | 'postPush' @@ -30,6 +35,7 @@ export type HookName = | 'preBranchCreate' | 'onBranchCreate' | 'postBranchCreate' | 'preBranchDelete' | 'onBranchDelete' | 'postBranchDelete'; +/** Carries hook execution data and cancellation controls. */ export interface HookContext { name: HookName; data: T; @@ -38,21 +44,26 @@ export interface HookContext { reason?: string; } +/** Defines a hook callback signature used by plugin registrations. */ export type HookHandler = (ctx: HookContext) => void | Promise; +/** Defines a generic plugin action callback signature. */ export type PluginAction = (payload?: unknown) => void | Promise; +/** Represents a plugin-provided menu entry. */ export interface PluginMenuItem { label: string; action: string; title?: string; } +/** Represents a plugin-provided titlebar action button. */ export interface PluginTitleButton { label: string; action: string; title?: string; } +/** Represents a plugin-provided settings section descriptor. */ export interface PluginSettingsSection { id: string; label: string; @@ -62,6 +73,7 @@ export interface PluginSettingsSection { onMount?: (ctx: { modal: HTMLElement; panel: HTMLElement }) => void; } +/** Represents a plugin-provided menubar menu contribution. */ export interface PluginMenubarMenu { id: string; html: string; @@ -69,20 +81,24 @@ export interface PluginMenubarMenu { after?: string; } +/** Lists context menu targets supported by plugin contributions. */ export type PluginContextMenuTarget = 'files' | 'commits' | 'branches'; +/** Represents a single context menu command contributed by a plugin. */ export interface PluginContextMenuItem { label: string; action: string; title?: string; } +/** Groups context menu contributions by target surface. */ export interface PluginContextMenus { files?: PluginContextMenuItem[]; commits?: PluginContextMenuItem[]; branches?: PluginContextMenuItem[]; } +/** Defines the full plugin registration payload accepted by the host. */ export interface PluginRegistration { id?: string; name?: string; @@ -111,9 +127,7 @@ declare global { invoke(cmd: string, args?: Json): Promise; listen(event: string, cb: (evt: { payload: T }) => void): Promise<{ unlisten: () => void }>; notify(msg: string): void; - callPlugin?(pluginId: string, method: string, params?: Json): Promise; }; - callPluginMethod?: (pluginId: string, method: string, params?: Json) => Promise; __openvcsPluginContext?: { id: string } | null; } } @@ -133,10 +147,12 @@ let initialized = false; let disabledPlugins = new Set(); let enabledPlugins = new Set(); +/** Normalizes ids for case-insensitive map keys. */ function normalizeId(value: string): string { return String(value || '').trim().toLowerCase(); } +/** Checks whether a plugin is enabled after overrides are applied. */ function isPluginEnabled(summary: PluginSummary): boolean { const id = normalizeId(summary?.id || ''); if (!id) return false; @@ -145,6 +161,7 @@ function isPluginEnabled(summary: PluginSummary): boolean { return !!summary?.default_enabled; } +/** Removes all injected plugin script nodes. */ function clearPluginScripts() { while (PLUGIN_SCRIPT_NODES.length) { const node = PLUGIN_SCRIPT_NODES.pop(); @@ -152,6 +169,7 @@ function clearPluginScripts() { } } +/** Clears all plugin runtime registries and injected UI. */ function resetPluginRuntime() { clearPluginScripts(); @@ -174,6 +192,7 @@ function resetPluginRuntime() { if (host) host.replaceChildren(); } +/** Injects a plugin module into the document head. */ function injectPluginModule(code: string, pluginId: string) { const head = document.head; if (!head) return; @@ -188,6 +207,7 @@ function injectPluginModule(code: string, pluginId: string) { PLUGIN_SCRIPT_NODES.push(script); } +/** Removes tracked UI nodes that belong to a plugin. */ function clearPluginUi(pluginId: string) { const nodes = pluginUiNodes.get(pluginId) || []; for (const node of nodes) { @@ -196,20 +216,24 @@ function clearPluginUi(pluginId: string) { pluginUiNodes.delete(pluginId); } +/** Tracks host-inserted UI nodes for plugin cleanup. */ function trackUiNode(pluginId: string, node: HTMLElement) { const list = pluginUiNodes.get(pluginId) || []; list.push(node); pluginUiNodes.set(pluginId, list); } +/** Returns the plugins menu list element. */ function pluginsMenuList(): HTMLElement | null { return document.getElementById('plugins-menu-list'); } +/** Returns the titlebar plugin action host element. */ function pluginActionsHost(): HTMLElement | null { return document.getElementById('plugin-title-actions'); } +/** Ensures a disabled placeholder row exists when no plugin menu items exist. */ function ensurePluginsMenuPlaceholder() { const list = pluginsMenuList(); if (!list) return; @@ -225,24 +249,28 @@ function ensurePluginsMenuPlaceholder() { list.appendChild(btn); } +/** Removes the plugin menu placeholder row. */ function removePluginsMenuPlaceholder() { pluginsMenuList() ?.querySelector('[data-openvcs-plugin-placeholder="true"]') ?.remove(); } +/** Registers a plugin action handler by id. */ function registerAction(id: string, handler: PluginAction) { const key = String(id || '').trim(); if (!key) return; actionHandlers.set(key, handler); } +/** Registers a lifecycle hook handler for a plugin. */ function registerHook(pluginId: string, name: HookName, handler: HookHandler) { const list = hookHandlers.get(name) || []; list.push({ pluginId, handler }); hookHandlers.set(name, list); } +/** Registers a theme payload exposed by a plugin. */ function registerTheme(theme: ThemePayload) { const id = normalizeId(theme?.summary?.id || ''); if (!id) return; @@ -252,12 +280,14 @@ function registerTheme(theme: ThemePayload) { } } +/** Registers theme summary metadata exposed by a plugin. */ function registerThemeSummary(summary: ThemeSummary) { const id = normalizeId(summary?.id || ''); if (!id) return; registeredThemeSummaries.set(id, summary); } +/** Adds a plugin item to the plugins menu list. */ function addMenuItem(pluginId: string, item: PluginMenuItem) { const list = pluginsMenuList(); if (!list) return; @@ -277,6 +307,7 @@ function addMenuItem(pluginId: string, item: PluginMenuItem) { trackUiNode(pluginId, btn); } +/** Adds a plugin action button to the titlebar host. */ function addTitlebarButton(pluginId: string, btn: PluginTitleButton) { const host = pluginActionsHost(); if (!host) return; @@ -293,6 +324,7 @@ function addTitlebarButton(pluginId: string, btn: PluginTitleButton) { trackUiNode(pluginId, el); } +/** Inserts or updates a plugin-provided settings section. */ function upsertSettingsSection(pluginId: string, section: PluginSettingsSection) { const id = String(section?.id || '').trim(); const label = String(section?.label || '').trim(); @@ -309,6 +341,7 @@ function upsertSettingsSection(pluginId: string, section: PluginSettingsSection) if (modal) applyPluginSettingsSections(modal); } +/** Inserts or updates a plugin-provided menubar menu. */ function applyMenubarMenu(pluginId: string, menu: PluginMenubarMenu) { const id = String(menu?.id || '').trim(); const html = String(menu?.html || ''); @@ -339,6 +372,7 @@ function applyMenubarMenu(pluginId: string, menu: PluginMenubarMenu) { trackUiNode(pluginId, node); } +/** Resolves plugin id during global API registration callbacks. */ function currentPluginIdForRegistration(explicit?: string): string | null { const id = String(explicit || '').trim(); if (id) return id; @@ -346,6 +380,7 @@ function currentPluginIdForRegistration(explicit?: string): string | null { return ctxId || null; } +/** Registers plugin hooks, actions, menus, and theme contributions. */ function registerPlugin(reg: PluginRegistration) { const pluginId = currentPluginIdForRegistration(reg?.id) || null; if (!pluginId) return; @@ -424,19 +459,9 @@ function registerPlugin(reg: PluginRegistration) { } } +/** Installs the `window.OpenVCS` plugin registration API once. */ function installGlobalApi() { if (window.OpenVCS) return; - const callPluginMethod = ( - pluginId: string, - method: string, - params?: Json, - ) => { - return TAURI.invoke('call_plugin_module_method', { - pluginId, - method, - params: params ?? null, - }); - }; window.OpenVCS = { registerPlugin, registerTheme, @@ -467,16 +492,10 @@ function installGlobalApi() { notify(msg: string) { notify(msg); }, - callPlugin(pluginId: string, method: string, params?: Json) { - return callPluginMethod(pluginId, method, params); - }, }; - if (!window.callPluginMethod) { - window.callPluginMethod = (pluginId: string, method: string, params?: Json) => - callPluginMethod(pluginId, method, params); - } } +/** Renders plugin-provided settings sections inside the settings modal. */ export function applyPluginSettingsSections(modal?: HTMLElement | null): void { const m = modal || (document.getElementById('settings-modal') as HTMLElement | null); if (!m) return; @@ -561,15 +580,18 @@ export function applyPluginSettingsSections(modal?: HTMLElement | null): void { } } +/** Returns registered theme summaries from loaded plugins. */ export function getRegisteredThemeSummaries(): ThemeSummary[] { return Array.from(registeredThemeSummaries.values()); } +/** Returns a registered theme payload by id, if available. */ export function getRegisteredThemePayload(id: string): ThemePayload | null { const key = normalizeId(id); return registeredThemePayloads.get(key) || null; } +/** Executes all handlers registered for a lifecycle hook. */ export async function runHook(name: HookName, data: T): Promise> { const ctx: HookContext = { name, @@ -596,6 +618,7 @@ export async function runHook(name: HookName, data: T): Promise { const id = String(actionId || '').trim(); if (!id) return false; @@ -610,12 +633,14 @@ export async function runPluginAction(actionId: string, payload?: unknown): Prom return true; } +/** Returns plugin-contributed context menu items for a target surface. */ export function getPluginContextMenuItems( target: PluginContextMenuTarget, ): PluginContextMenuItem[] { return (contextMenuItems.get(target) || []).slice(); } +/** Loads plugin manifests and installs plugin UI/runtime state. */ export async function initPlugins(): Promise { if (initialized) return; initialized = true; @@ -650,22 +675,13 @@ export async function initPlugins(): Promise { for (const summary of Array.isArray(list) ? list : []) { const pluginId = String(summary?.id || '').trim(); if (!pluginId) continue; - if (!summary.entry) continue; if (!isPluginEnabled(summary)) continue; - - try { - const payload = await TAURI.invoke('load_plugin', { id: pluginId }); - const code = typeof payload?.entry === 'string' ? payload.entry : ''; - if (!code.trim()) continue; - injectPluginModule(code, pluginId); - } catch (err) { - console.warn(`load_plugin failed (${pluginId})`, err); - } } ensurePluginsMenuPlaceholder(); } +/** Reloads plugins by resetting and reinitializing the plugin runtime. */ export async function reloadPlugins(): Promise { installGlobalApi(); if (!TAURI.has) return; diff --git a/Frontend/src/scripts/state/state.ts b/Frontend/src/scripts/state/state.ts index b4cd193a..88d9fa67 100644 --- a/Frontend/src/scripts/state/state.ts +++ b/Frontend/src/scripts/state/state.ts @@ -1,6 +1,9 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/state/state.ts import type { AppPrefs, Branch, CommitItem, FileStatus, StashItem } from '../types'; +/** Default application preferences. */ export const defaultPrefs: AppPrefs = { theme: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', leftW: 0, @@ -8,11 +11,17 @@ export const defaultPrefs: AppPrefs = { }; // In-memory-only UI prefs. Persisted preferences now live in native Rust config. +/** Current application preferences. */ export let prefs: AppPrefs = { ...defaultPrefs }; +/** + * Save preferences to storage. + * @deprecated Web prefs are not persisted; native settings handle persistence + */ export function savePrefs() { // no-op: web prefs are not persisted; native settings handle persistence } +/** Metadata for diff view rendering. */ export type DiffMeta = { offset: number; rest: string[]; @@ -21,12 +30,14 @@ export type DiffMeta = { totalHunks: number; }; +/** References to DOM elements for a hunk. */ export type HunkNodeRefs = { hunkEl: HTMLElement; hunkCheckbox: HTMLInputElement | null; lineCheckboxes: Record; }; +/** Global application state. */ export const state = { hasRepo: false, // backend truth (set after open/clone/add) branch: '' as string, // current branch name @@ -60,13 +71,18 @@ export const state = { // repoPath: '' as string, }; -/** True iff a repo is selected AND we know the current branch. Always boolean. */ -export const hasRepo = (): boolean => Boolean(state.hasRepo && state.branch); +/** True iff a repository is selected. Always boolean. */ +export const hasRepo = (): boolean => Boolean(state.hasRepo); /** True iff there are staged/unstaged changes. Always boolean. */ export const hasChanges = (): boolean => Array.isArray(state.files) && state.files.length > 0; +/** + * Get display label for a file status code. + * @param s - Status code character + * @returns Human-readable status label + */ export const statusLabel = (s: string) => s === 'A' ? 'Added' : s === '?' ? 'Untracked' : @@ -78,6 +94,11 @@ export const statusLabel = (s: string) => s === 'M' ? 'Modified' : s === 'D' ? 'Deleted' : 'Changed'; +/** + * Get CSS class token for a file status code. + * @param s - Status code character + * @returns CSS class suffix used by status badges + */ export const statusClass = (s: string) => s === 'A' ? 'add' : s === '?' ? 'untracked' : @@ -89,8 +110,11 @@ export const statusClass = (s: string) => s === 'M' ? 'mod' : s === 'D' ? 'del' : 'mod'; -// Disable the implicit "select all" mode. When clearImplicit is true, drop the -// auto-filled selection set so later logic only sees explicit user picks. +/** + * Disables implicit select-all behavior. + * @param clearImplicit - Whether to clear auto-filled file selections + * @returns True when implicit selections were cleared + */ export function disableDefaultSelectAll(clearImplicit = false): boolean { const hadImplicit = state.defaultSelectAll && state.selectionImplicitAll; if (clearImplicit && hadImplicit) { diff --git a/Frontend/src/scripts/themes.ts b/Frontend/src/scripts/themes.ts index b3939253..b9357162 100644 --- a/Frontend/src/scripts/themes.ts +++ b/Frontend/src/scripts/themes.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from './lib/tauri'; import { notify } from './lib/notify'; import { getRegisteredThemePayload, getRegisteredThemeSummaries } from './plugins'; @@ -26,10 +28,12 @@ let activeScripts: string[] = []; let currentMode: 'system' | 'light' | 'dark' = 'system'; let systemListenerInstalled = false; +/** Resolves the current system appearance mode from media query state. */ function effectiveSystemMode(): 'light' | 'dark' { return SYSTEM_DARK_MQ.matches ? 'dark' : 'light'; } +/** Checks whether an id refers to one of the built-in defaults. */ function isBuiltInDefaultThemeId(id: string): boolean { const desired = String(id ?? '').trim().toLowerCase(); return ( @@ -39,22 +43,26 @@ function isBuiltInDefaultThemeId(id: string): boolean { ); } +/** Resolves the built-in default theme id for a display mode. */ function defaultThemeIdForMode(mode: 'system' | 'light' | 'dark'): string { const target = mode === 'system' ? effectiveSystemMode() : mode; return target === 'dark' ? DEFAULT_DARK_THEME_ID : DEFAULT_LIGHT_THEME_ID; } +/** Normalizes theme appearance metadata from external sources. */ function normalizeAppearance(value: unknown): 'light' | 'dark' | 'both' | null { const raw = String(value ?? '').trim().toLowerCase(); if (raw === 'light' || raw === 'dark' || raw === 'both') return raw; return null; } +/** Finds a loaded theme summary by id. */ function getThemeSummary(id: string): ThemeSummary | null { const desired = String(id || DEFAULT_THEME_ID).trim().toLowerCase() || DEFAULT_THEME_ID; return availableThemes.find((t) => (t.id || '').toLowerCase() === desired) ?? null; } +/** Returns a paired theme id when switching between light and dark variants. */ function resolvePairedThemeId(id: string): string | null { const desired = String(id || DEFAULT_THEME_ID).trim() || DEFAULT_THEME_ID; const summary = getThemeSummary(desired); @@ -82,6 +90,7 @@ function resolvePairedThemeId(id: string): string | null { return null; } +/** Broadcasts a theme-pack change event to the UI. */ function dispatchThemeChanged() { try { window.dispatchEvent(new CustomEvent('openvcs:theme-pack-changed', { detail: { id: activeThemeId } })); @@ -90,6 +99,7 @@ function dispatchThemeChanged() { } } +/** Installs a system color-scheme listener once. */ function ensureSystemListener() { if (systemListenerInstalled) return; systemListenerInstalled = true; @@ -104,6 +114,7 @@ function ensureSystemListener() { }); } +/** Builds the fallback generic default theme summary. */ function defaultSummary(): ThemeSummary { return { id: DEFAULT_THEME_ID, @@ -113,6 +124,7 @@ function defaultSummary(): ThemeSummary { }; } +/** Builds the fallback built-in light theme summary. */ function defaultLightSummary(): ThemeSummary { return { id: DEFAULT_LIGHT_THEME_ID, @@ -124,6 +136,7 @@ function defaultLightSummary(): ThemeSummary { }; } +/** Builds the fallback built-in dark theme summary. */ function defaultDarkSummary(): ThemeSummary { return { id: DEFAULT_DARK_THEME_ID, @@ -135,6 +148,7 @@ function defaultDarkSummary(): ThemeSummary { }; } +/** Sanitizes externally provided theme summary fields. */ function sanitizeSummary(raw: ThemeSummary): ThemeSummary { const id = String(raw?.id ?? '').trim() || DEFAULT_THEME_ID; const base: ThemeSummary = { @@ -150,6 +164,7 @@ function sanitizeSummary(raw: ThemeSummary): ThemeSummary { return base; } +/** Creates, updates, or removes a style tag by id. */ function setStyleContent(id: string, css: string | null | undefined) { const existing = document.getElementById(id) as HTMLStyleElement | null; const text = typeof css === 'string' ? css : ''; @@ -170,6 +185,7 @@ function setStyleContent(id: string, css: string | null | undefined) { target.textContent = text; } +/** Syncs the active theme-pack id onto the document root attribute. */ function syncThemePackAttr() { const root = document.documentElement; if (!root) return; @@ -185,6 +201,7 @@ function syncThemePackAttr() { root.setAttribute(THEME_PACK_ATTR, current); } +/** Applies active markup snippets to head and body. */ function applyMarkupNodes() { const markup = activeMarkup ?? null; const headHtml = markup?.head ?? null; @@ -193,6 +210,7 @@ function applyMarkupNodes() { setMarkupForTarget(document.body, BODY_MARKUP_NODES, bodyHtml); } +/** Replaces tracked markup nodes for a target container. */ function setMarkupForTarget(target: ParentNode | null, store: ChildNode[], html: string | null | undefined) { const parent = target ?? null; if (!parent) return; @@ -208,6 +226,7 @@ function setMarkupForTarget(target: ParentNode | null, store: ChildNode[], html: store.push(...nodes); } +/** Rebuilds theme-provided script nodes from the active payload. */ function applyScriptNodes() { while (THEME_SCRIPT_NODES.length) { const node = THEME_SCRIPT_NODES.pop(); @@ -231,6 +250,7 @@ function applyScriptNodes() { }); } +/** Removes tracked DOM nodes from the document. */ function clearNodes(store: ChildNode[]) { while (store.length) { const node = store.pop(); @@ -238,6 +258,7 @@ function clearNodes(store: ChildNode[]) { } } +/** Applies currently selected theme assets for the requested appearance mode. */ function applyModeStyles(mode: 'system' | 'light' | 'dark') { currentMode = mode; ensureSystemListener(); @@ -250,6 +271,7 @@ function applyModeStyles(mode: 'system' | 'light' | 'dark') { dispatchThemeChanged(); } +/** Resolves the id written to the root theme-pack attribute. */ function resolveThemePackAttrId(summary: ThemeSummary | null | undefined, themeId: string): string { const rawId = String(summary?.id ?? themeId ?? DEFAULT_THEME_ID).trim() || DEFAULT_THEME_ID; const pluginId = String(summary?.plugin_id ?? '').trim(); @@ -261,18 +283,22 @@ function resolveThemePackAttrId(summary: ThemeSummary | null | undefined, themeI return rawId; } +/** Returns the current list of available theme summaries. */ export function getAvailableThemes(): ThemeSummary[] { return [...availableThemes]; } +/** Returns the currently active theme id. */ export function getActiveThemeId(): string { return activeThemeId; } +/** Returns the current appearance mode used by theme rendering. */ export function getCurrentMode(): 'system' | 'light' | 'dark' { return currentMode; } +/** Refreshes available themes from backend and plugin registries. */ export async function refreshAvailableThemes(): Promise { const pluginSummaries = getRegisteredThemeSummaries(); if (!TAURI.has) { @@ -338,6 +364,7 @@ export async function refreshAvailableThemes(): Promise { return availableThemes; } +/** Ensures theme metadata has been loaded at least once. */ export async function ensureThemesLoaded(force?: boolean): Promise { if (!fetchedThemes || force) { return refreshAvailableThemes(); @@ -345,6 +372,7 @@ export async function ensureThemesLoaded(force?: boolean): Promise; +/** Represents branch kind metadata reported by the backend. */ export interface BranchKind { type?: 'Local' | 'Remote' | string; remote?: string; } +/** Represents a Git branch entry used in branch pickers. */ export interface Branch { name: string; full_ref?: string; @@ -11,6 +16,7 @@ export interface Branch { kind?: BranchKind; } +/** Represents a file status row in the Changes view. */ export interface FileStatus { path: string; old_path?: string; @@ -20,6 +26,7 @@ export interface FileStatus { hunks?: string[]; } +/** Represents merge conflict payload data for a file. */ export interface ConflictDetails { path: string; ours?: string | null; @@ -28,6 +35,7 @@ export interface ConflictDetails { binary?: boolean; } +/** Represents a commit list item for History. */ export interface CommitItem { id: string; msg?: string; @@ -37,18 +45,21 @@ export interface CommitItem { remoteRef?: string; } +/** Represents a stash list item for the Stash tab. */ export interface StashItem { selector: string; // e.g., "stash@{0}" msg?: string; meta?: string; // date string } +/** Represents persisted local UI preferences. */ export interface AppPrefs { theme: 'dark' | 'light'; leftW: number; // px tab: 'changes' | 'history' | 'stash'; } +/** Represents global settings loaded from the backend. */ export interface GlobalSettings { general?: { theme?: 'system'|'dark'|'light'; @@ -111,6 +122,7 @@ export interface GlobalSettings { }; } +/** Represents theme metadata shown in settings. */ export interface ThemeSummary { id: string; name: string; @@ -123,6 +135,7 @@ export interface ThemeSummary { plugin_id?: string; } +/** Represents the full theme package payload. */ export interface ThemePayload { summary: ThemeSummary; styles?: string | null; @@ -133,6 +146,7 @@ export interface ThemePayload { scripts?: string[]; } +/** Represents repository-local identity and remote settings. */ export interface RepoSettings { user_name?: string; user_email?: string; diff --git a/Frontend/src/scripts/ui/layout.ts b/Frontend/src/scripts/ui/layout.ts index 6be2427f..8452bede 100644 --- a/Frontend/src/scripts/ui/layout.ts +++ b/Frontend/src/scripts/ui/layout.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { qs, qsa, setText } from '../lib/dom'; import { prefs, savePrefs, state, hasRepo, hasChanges } from '../state/state'; import { TAURI } from '../lib/tauri'; @@ -10,6 +12,7 @@ const SYSTEM_DARK_MQ = matchMedia('(prefers-color-scheme: dark)'); let systemSyncActive = false; let systemSyncWired = false; +/** Wires a single listener that keeps system-theme mode in sync. */ function ensureSystemSyncListener() { if (systemSyncWired) return; systemSyncWired = true; @@ -32,6 +35,7 @@ const repoTitleEl = qs('#repo-title'); const repoBranchEl = qs('#repo-branch'); const aheadBehindEl = qs('#ahead-behind'); +/** Applies a requested appearance mode to the document and persisted prefs. */ export function setTheme(theme: 'dark'|'light'|'system') { const root = document.documentElement; ensureSystemSyncListener(); @@ -50,6 +54,7 @@ export function setTheme(theme: 'dark'|'light'|'system') { savePrefs(); } +/** Toggles between light and dark appearance modes. */ export function toggleTheme() { const next = (prefs.theme === 'dark' ? 'light' : 'dark'); // Persist to native settings when available, then apply to UI @@ -69,6 +74,7 @@ export function toggleTheme() { } } +/** Switches the active center tab and updates related UI state. */ export function setTab(tab: 'changes'|'history'|'stash') { const prevTab = prefs.tab; prefs.tab = tab; savePrefs(); @@ -110,10 +116,12 @@ export function setTab(tab: 'changes'|'history'|'stash') { window.dispatchEvent(new CustomEvent('app:tab-changed', { detail: tab })); } +/** Binds tab button clicks to an external change handler. */ export function bindTabs(onChange: (t: 'changes'|'history'|'stash') => void) { tabs.forEach(btn => btn.addEventListener('click', () => onChange((btn.dataset.tab as any) ?? 'changes'))); } +/** Enables drag-resizing for the work grid split view. */ export function initResizer() { if (!workGrid || !resizer) return; @@ -172,6 +180,7 @@ export function initResizer() { }); } +/** Recomputes enablement and labels for repo-scoped UI actions. */ export function refreshRepoActions() { const repoOn = hasRepo(); const changesOn = hasChanges(); @@ -238,6 +247,7 @@ export function refreshRepoActions() { } } +/** Binds layout action refresh handlers to app lifecycle events. */ export function bindLayoutActionState() { // Recompute on repo selection, status refresh, branch changes, and typing (when enabled) window.addEventListener('app:repo-selected', refreshRepoActions); diff --git a/Frontend/src/scripts/ui/menubar.ts b/Frontend/src/scripts/ui/menubar.ts index 3b4c8cc5..6be704d7 100644 --- a/Frontend/src/scripts/ui/menubar.ts +++ b/Frontend/src/scripts/ui/menubar.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later type MenuAction = (id: string) => void | Promise; const MENU_CLOSE_MS = 130; @@ -58,9 +60,13 @@ export function initMenubar(onAction: MenuAction) { if (!list || !trigger) return; const isOpen = !list.hasAttribute('hidden'); if (isOpen) { + const menuName = trigger.textContent || 'menu'; + console.log(`UI: Close ${menuName} menu`); closeMenus(); return; } + const menuName = trigger.textContent || 'menu'; + console.log(`UI: Open ${menuName} menu`); list.classList.remove('is-closing'); list.removeAttribute('hidden'); trigger.setAttribute('aria-expanded', 'true'); diff --git a/Frontend/src/scripts/ui/modals.ts b/Frontend/src/scripts/ui/modals.ts index e08752c0..3f8a9a60 100644 --- a/Frontend/src/scripts/ui/modals.ts +++ b/Frontend/src/scripts/ui/modals.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/ui/modals.ts import { qs } from "@scripts/lib/dom"; import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from "../lib/scrollbars"; diff --git a/Frontend/src/scripts/vite-raw.d.ts b/Frontend/src/scripts/vite-raw.d.ts index 8e0ff041..4d53f9c5 100644 --- a/Frontend/src/scripts/vite-raw.d.ts +++ b/Frontend/src/scripts/vite-raw.d.ts @@ -1,4 +1,6 @@ +/** Declares Vite raw-loader imports for modal HTML templates. */ declare module '*.html?raw' { + /** Contains raw file contents as a string. */ const content: string; export default content; } diff --git a/Frontend/src/setupTests.ts b/Frontend/src/setupTests.ts index 9d29c1e9..d63222ad 100644 --- a/Frontend/src/setupTests.ts +++ b/Frontend/src/setupTests.ts @@ -1,5 +1,9 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // Test setup: provide browser shims used by frontend modules -(globalThis as any).matchMedia = (query: string) => ({ +/** Provides a deterministic `matchMedia` mock for Vitest. */ +function createMatchMediaMock(query: string) { + return { matches: false, media: query, addListener: () => {}, @@ -8,5 +12,7 @@ addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, -}) + } +} +(globalThis as any).matchMedia = createMatchMediaMock diff --git a/Frontend/src/styles/components.css b/Frontend/src/styles/components.css index 146fc287..a74eee4a 100644 --- a/Frontend/src/styles/components.css +++ b/Frontend/src/styles/components.css @@ -54,6 +54,12 @@ button:disabled,.btn:disabled,.btn.primary:disabled,.tbtn:disabled,.pick:disable background:var(--surface-2); border-color:var(--border); color:var(--muted); } +/* Saved state - keep same dimensions as normal button */ +button.saved-state,.btn.saved-state,.tbtn.saved-state{ + opacity:1; cursor:default; pointer-events:auto; transform:none!important; + background:var(--accent); border-color:var(--accent); color:#fff; +} + /* Split button (main action + caret) */ .split-btn{ display:inline-flex; diff --git a/Frontend/src/styles/modal/settings.css b/Frontend/src/styles/modal/settings.css index 3315d5b4..166eaa5a 100644 --- a/Frontend/src/styles/modal/settings.css +++ b/Frontend/src/styles/modal/settings.css @@ -58,6 +58,29 @@ box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 45%, transparent); } +#settings-modal nav [data-plugin-menus-wrap="true"]{ + margin-top: 0; + padding-top: 0; +} + +#settings-modal nav .settings-plugin-subhead{ + margin: 4rem 0 .35rem; + font-size: .74rem; + line-height: 1.2; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--muted); +} + +#settings-modal nav .settings-plugin-sublist{ + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: .5rem; +} + /* Make the right-side content a flex column so the footer can hug the content when there's extra vertical space, but still stick while scrolling */ #settings-panels{ @@ -191,9 +214,89 @@ overflow: hidden; text-overflow: ellipsis; } -.plugin-row input[type="checkbox"]{ +.plugin-check{ + position: relative; width: 1.1rem; height: 1.1rem; + flex: 0 0 1.1rem; + display: inline-grid; + place-items: center; +} + +.plugin-check-input{ + -webkit-appearance: none; + appearance: none; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + background: transparent; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.plugin-check-ui{ + position: relative; + width: 1.1rem; + height: 1.1rem; + border: 0; + background: transparent; + box-shadow: none; + border-radius: .3rem; + transition: color .15s ease; +} + +.plugin-check-input:focus-visible + .plugin-check-ui{ + outline: none; + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 45%, transparent); +} + +.plugin-check[data-state="enabled"] .plugin-check-ui::after{ + content: ""; + position: absolute; + left: 5px; + top: 1px; + width: 4px; + height: 8px; + border: solid color-mix(in oklab, #2aa84a 85%, white 15%); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.plugin-check[data-state="enabling"] .plugin-check-ui::after{ + content: ""; + position: absolute; + inset: 3px; + border-radius: 999px; + border: 2px solid color-mix(in oklab, #2aa84a 50%, transparent); + border-top-color: color-mix(in oklab, #2aa84a 95%, white 5%); + animation: plugin-check-spin .75s linear infinite; +} + +.plugin-check[data-state="enabling"] .plugin-check-input{ + cursor: progress; +} + +.plugin-check[data-state="error"] .plugin-check-ui::after{ + content: "!"; + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #e35353; + font-size: .95rem; + font-weight: 800; + line-height: 1; +} + +.plugin-check-input:disabled{ + cursor: not-allowed; +} + +@keyframes plugin-check-spin { + to { transform: rotate(360deg); } } .plugins-context-menu{ @@ -242,6 +345,8 @@ .plugins-detail{ padding: .85rem .9rem; min-height: 0; + display: flex; + flex-direction: column; } .plugins-detail.empty{ color: var(--muted); @@ -273,11 +378,41 @@ gap: .5rem; align-items: center; } +.plugin-toggle-btn{ + min-width: 7.6rem; + text-align: center; +} +.plugin-toggle-btn.plugin-toggle-btn-enable{ + border-color: color-mix(in oklab, #2aa84a 55%, var(--border)); + background: color-mix(in oklab, #2aa84a 20%, transparent); + color: color-mix(in oklab, #2aa84a 82%, white 18%); +} +.plugin-toggle-btn.plugin-toggle-btn-enable:hover{ + border-color: #2aa84a; + background: color-mix(in oklab, #2aa84a 28%, transparent); + color: #eaf9ef; +} +.plugin-toggle-btn.plugin-toggle-btn-disable{ + border-color: color-mix(in oklab, #d64545 55%, var(--border)); + background: color-mix(in oklab, #d64545 18%, transparent); + color: color-mix(in oklab, #d64545 80%, white 20%); +} +.plugin-toggle-btn.plugin-toggle-btn-disable:hover{ + border-color: #d64545; + background: color-mix(in oklab, #d64545 26%, transparent); + color: #fdeeee; +} .plugin-detail-body{ margin-top: .85rem; display: grid; gap: .75rem; } +.plugin-detail-footer{ + margin-top: auto; + padding-top: .9rem; + display: flex; + justify-content: flex-end; +} .plugin-detail-body .desc{ color: var(--muted); white-space: pre-wrap; diff --git a/PLANS.md b/PLANS.md index c38d4cbd..fd8a61f0 100644 --- a/PLANS.md +++ b/PLANS.md @@ -139,7 +139,7 @@ Prefer additive code changes followed by subtractions that keep tests passing. P Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `crate::module::function` or `package.submodule.Interface`. E.g.: - In crates/foo/planner.rs, define: + In src/foo/planner.rs, define: pub trait Planner { fn plan(&self, observed: &Observed) -> Vec; diff --git a/README.md b/README.md index 4d0f6ae2..a0ab0f64 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Swap `stable` for `dev` in the URL if you want the bleeding-edge installer. ## Features (Current) -- 🔗 **Git support** with a selectable backend (**system Git** by default; **libgit2** optional). +- 🔗 **Git support** via the built-in `openvcs.git` plugin (System Git execution). - 📁 **Repo workflows:** clone, open existing repos, recent repos list, optional reopen of last repo on launch. - ✅ **Status & diffs:** working tree status, per-file diff, commit diff, discard changes. - 🧩 **Staging & commits:** stage files, partial staging/commits via patch, commit from index. @@ -70,7 +70,7 @@ Swap `stable` for `dev` in the URL if you want the bleeding-edge installer. - 🗃 **Git LFS helpers:** fetch/pull/prune, track/untrack, inspect tracked paths. - 🔐 **SSH helpers:** trust host keys, list/add SSH agent keys, key discovery. - 🎨 **Themes:** built-in light/dark themes, plus plugin-provided themes (standalone theme `.zip` packs are not supported). -- 🧩 **Plugins (early):** local plugins with manifests, hooks/actions, and UI contributions (no store yet). +- 🧩 **Plugins (early):** installable `.ovcsp` bundles (theme packs and/or Node.js modules). - 🔄 **Updater & logs:** update check/install, VCS output log window, app log tail/clear. ## Planned / Exploratory @@ -89,10 +89,6 @@ Swap `stable` for `dev` in the URL if you want the bleeding-edge installer. . ├── Backend/ # Rust + Tauri backend (native logic, app entry) ├── Frontend/ # TypeScript + Vite frontend (UI layer) -├── crates/ # Rust crates for modular OpenVCS components -│ ├── openvcs-core # Core traits and abstractions -│ ├── openvcs-git # Git implementation -│ └── openvcs-git-libgit2 # Alternative Git backend (libgit2) ├── Cargo.toml # Workspace manifest ├── LICENSE └── README.md @@ -131,7 +127,7 @@ A Flatpak manifest exists under `packaging/flatpak/`, but Flatpak support is cur Known issues/limitations: -- The sandbox does not provide `git`, but OpenVCS currently defaults to the **system Git** backend; in Flatpak you may need to switch to the **libgit2** backend in settings. +- The sandbox does not provide `git`, and OpenVCS currently relies on **system Git** via plugin execution. - If the frontend assets are not included correctly, the app can show a blank window / “could not connect to localhost” (dev server) instead of loading `Frontend/dist`. For local build notes see `packaging/flatpak/README.md`. @@ -155,6 +151,7 @@ npm install **Run in development mode (dev server):** ```bash +cd Backend cargo tauri dev ``` @@ -181,7 +178,6 @@ cargo build - **Frontend:** TypeScript + Vite for a fast iteration loop. - **Backend:** Rust + Tauri commands for native operations. -- **Crates:** All modular logic (e.g., Git backend, core abstractions) lives under `crates/`. - **Bridge:** Tauri `invoke` is used to call Rust from the UI; events are used for progress/streaming. --- @@ -189,7 +185,7 @@ cargo build ## Testing - Use `just test` to run the full project test/check flow (runs `cargo test --workspace`, then frontend typecheck and tests). -- Use `just fix` to run formatting and quick fixes; it now also builds the frontend and typechecks (`npm run build` and `npm exec tsc -- -p tsconfig.json --noEmit`). +- Use `just fix` to run formatting and clippy fixes plus a frontend typecheck. - Frontend-only commands (from `Frontend/`): - `npm exec tsc -- -p tsconfig.json --noEmit` — TypeScript typecheck for the frontend. - `npm test` — run Vitest unit tests (added to the frontend devDependencies). diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index 63e8a180..36830412 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -1,147 +1,95 @@ -# OpenVCS Plugin Bundles (.ovcsp) — Architecture +# OpenVCS Plugin Architecture -This document describes OpenVCS’s **secure, out-of-process** plugin system for distributing plugins as **`.ovcsp`** bundles. +OpenVCS plugins run as long-lived Node.js processes and are authored in TypeScript. -## Goals (non-negotiables) - -- The OpenVCS-Client process **never loads third-party dynamic libraries** and **never runs third-party plugin code in-process**. -- Every plugin component executes **out-of-process** and communicates over **JSON-RPC (line-delimited JSON) via stdio**. -- Plugins are **installed (unpacked) before execution**; nothing executes directly from inside a tar.xz archive. -- The bundle manifest uses the existing `openvcs.plugin.json` format and extends it minimally. - -## Bundle format - -An `.ovcsp` is a tar.xz archive containing exactly one top-level plugin folder named by plugin id: +## Architecture +```text +Client (Frontend) -> Client (Backend host) <-> Plugin (Node.js process) ``` -/ - openvcs.plugin.json - bin/ - .wasm - assets/... (optional) - themes/... (optional; existing `theme.json` packs) -``` - -Notes: -- Bundles are **WASM-only**; OpenVCS will reject native binaries. -- Bundle entry paths must be relative and use `/` separators (the installer normalizes and validates). - -## Manifest (`openvcs.plugin.json`) +- The frontend talks to the backend via Tauri commands/events. +- The backend starts each plugin module as a persistent Node.js process. +- Host and plugin communicate through JSON-RPC 2.0 over stdio with `Content-Length` framing. -Existing fields like `id`, `name`, `version`, etc. remain unchanged. - -This system uses the `module` section (used by `OpenVCS-Plugin-Git`) and adds: - -- `capabilities`: string array of requested capabilities. -- `functions`: optional function component descriptor. - -Example: - -```json -{ - "id": "openvcs.git", - "name": "Git", - "version": "0.1.0", - "capabilities": ["workspace.read", "vcs.read", "vcs.write"], - "module": { - "exec": "openvcs-git-plugin.wasm", - "vcs_backends": [ - { "id": "git", "name": "Git" } - ] - }, - "functions": { - "exec": "openvcs-hello-functions.wasm" - } -} -``` +## Runtime contract -### Component types +- Method names and framing live in `Client/Backend/src/plugin_runtime/protocol.rs`. +- Runtime process implementation lives in: + - `Client/Backend/src/plugin_runtime/node_instance.rs` + - `Client/Backend/src/plugin_runtime/runtime_select.rs` -- **Module component** (`module`): a plugin-executed WASI module. It is spawned with: - - module: `bin/` (must end in `.wasm`) - - arguments: `--backend ` for each id in `module.vcs_backends` - - protocol: stdio JSON-RPC using `openvcs_core::plugin_protocol` message types -- **Function component** (`functions`): a WASI module exposing callable functions/hooks/commands over the same stdio JSON-RPC transport. +Core groups of host->plugin methods: -## Installation locations and layout +- `plugin.*`: lifecycle, menus, and settings hooks +- `vcs.*`: backend operations for repository workflows -OpenVCS installs bundles into the user config directory (via `directories::ProjectDirs`): +Core plugin->host notifications: -- Config root: - - Linux: `$XDG_CONFIG_HOME/OpenVCS` (or `~/.config/OpenVCS`) - - Windows: `%APPDATA%\\OpenVCS` - - macOS: `~/Library/Application Support/OpenVCS` +- `host.log` +- `host.ui_notify` +- `host.status_set` +- `host.event_emit` +- `vcs.event` +- Plugin runtime requires the app-bundled Node binary (`node-runtime/node` or `node.exe`); no system `node` fallback. In dev runs, the backend also probes the generated bundled path under `target/openvcs/node-runtime/`. -Installed bundle layout: +## Plugin types -``` -plugins/ - / - index.json (metadata, including SHA-256 + approvals) - current.json (pointer: {"version": "..."}; used instead of symlinks) - / - openvcs.plugin.json - bin/... - ... -``` +- Theme pack plugin + - Ships `themes/` assets only. -The runtime discovers plugins **only** from this installed directory and resolves `/current.json` to a concrete version folder. +- Module plugin (lifecycle + optional settings/UI hooks) + - Exposes `plugin.*` methods over JSON-RPC. -## Security model +- VCS backend plugin + - Exposes both `plugin.*` and `vcs.*` methods. -### Secure ZIP extraction (install-time) +## Bundle format (`.ovcsp`) -The installer enforces: +Plugins are installed from `.ovcsp` tar.xz archives. Layout: -- **ZipSlip/path traversal prevention** - - reject absolute paths and Windows drive prefixes - - normalize separators and reject any `..` components - - canonicalize and ensure every extracted path stays within the install directory -- **Symlink rejection** - - reject any archive entries that are symlinks (Unix mode `0120000`) -- **Resource limits** - - cap total uncompressed size per bundle - - cap per-file size - - cap file count - - reject suspicious compression ratios (zip-bomb heuristics) -- **Required file validation** - - `openvcs.plugin.json` must exist at `/openvcs.plugin.json` - - declared component entrypoints must exist under `bin/` after extraction and be valid `.wasm` modules -- **Integrity** - - compute and store SHA-256 of the `.ovcsp` bundle in `/index.json` +```text +/ + openvcs.plugin.json + icon. (optional) + themes/ (optional; may coexist with a module) + bin/ + .mjs|.js|.cjs + ...other runtime files + node_modules/ (optional; pre-bundled npm dependencies) +``` -### Trust + capabilities +## Manifest (`openvcs.plugin.json`) -Plugins are **untrusted by default**. +The host currently consumes: -- Capabilities are declared in the manifest (`capabilities`). -- Capabilities must be **approved by the user** at install-time (or on first run). -- The host enforces capabilities for **plugin → host** JSON-RPC calls; denied calls return structured errors. +- `id` (required) +- `name`, `version` (optional but recommended) +- `default_enabled` (optional) +- `module.exec` (optional Node entry filename under `bin/`) +- `module.vcs_backends` (optional VCS backend ids the module provides) -Capability strings: +Dependency notes: -- `workspace.read`, `workspace.write` -- `vcs.read`, `vcs.write` -- `network.http` -- `credentials.request` -- `ui.commands`, `ui.notifications` +- Plugin dependencies are expected to be pre-bundled in `.ovcsp`. +- The host does not run npm/yarn/pnpm during plugin install/update. -### Process isolation (best-effort) +## Security model -Each plugin component is spawned with: +Plugins are trust-model based: -- sanitized environment (allowlist) -- controlled working directory -- restricted `PATH` and no implicit shell execution +- No per-capability permission prompts. +- Plugins have full system access within their own Node process. +- Plugin module startup is gated by installed-version approval state. +- Install only plugins from authors you trust. -Runtime hardening: +## Runtime lifecycle -- per-request timeouts and cancellation best-effort -- stdout/stderr captured into per-plugin logs (rotation + size limits) -- crash restart with exponential backoff; auto-disable after repeated crashes +- Module runtimes are started/stopped by lifecycle operations (startup sync and plugin enable/disable toggles). +- VCS backend plugin runtimes are repo-scoped and started when opening a repository through that backend. +- Backend plugin command calls do not implicitly start stopped plugin runtimes. -OS-level sandboxing is optional and best-effort: +## Plugin settings persistence -- Linux: supports wrappers (e.g. `bwrap`) if configured; otherwise runs unprivileged. -- Windows/macOS: no large dependencies; relies on install validation + capability gating + process isolation. +- Plugin settings are persisted by the host in the user config directory under: + - `plugin-data//settings.json` diff --git a/docs/plugins.md b/docs/plugins.md index a5fbb3b8..f0b10b93 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,164 +1,102 @@ -# Plugins (WIP) +# Plugins -OpenVCS plugins are local, user-installed extensions that can: +OpenVCS plugins are local extensions installed as `.ovcsp` bundles. -- Register one or more themes (using the existing `theme.json` theme-pack format) -- Run JavaScript/TypeScript-authored code in the UI -- Hook into app actions like commit/push/branch switch -- Add basic UI contributions (menu items + titlebar buttons) -- Add context menu items (right-click actions) in lists like files/commits/branches +Plugins may include themes, a Node.js module, or both. -## Plugin Location +## Where plugins live -Plugins are discovered from: +OpenVCS discovers plugins from two places: -- User plugins directory: OpenVCS config `plugins/` folder -- Built-in plugins directory (for packaged/bundled plugins): `built-in-plugins/` +- User plugins directory (in the OpenVCS config dir): `plugins/` +- Built-in plugins directory (bundled with the app): `built-in-plugins/` -## Plugin Format +## Bundle format (`.ovcsp`) -A plugin is a folder containing: +An `.ovcsp` is a tar.xz archive with this layout: -- `openvcs.plugin.json` (manifest) -- An optional JavaScript ESM entry file (referenced by `entry`) -- An optional `themes/` folder containing one or more theme packs (each containing a `theme.json`) +```text +/ + openvcs.plugin.json + icon. (optional) + themes/ (optional) + bin/ + .mjs|.js|.cjs + ...other runtime files + node_modules/ (optional; pre-bundled npm dependencies) +``` -### `openvcs.plugin.json` +## Manifest (`openvcs.plugin.json`) -Minimal example: +Minimal theme-only plugin: ```json { - "id": "example.hello", - "name": "Hello Plugin", - "category": "Examples", - "tags": ["example", "demo"], - "version": "0.1.0", - "author": "You", - "description": "Demonstrates OpenVCS plugins.", - "entry": "entry.js" + "id": "example.theme-pack", + "name": "Example Theme Pack", + "version": "0.1.0" } ``` -Fields: - -- `id` (string, required): stable unique plugin id -- `name` (string, required): display name -- `category` (string, optional): broad grouping for UI (e.g. `Themes`, `Integrations`, `Examples`) -- `tags` (string[], optional): searchable keywords (e.g. `["git", "theme", "hooks"]`) -- `entry` (string, optional): relative path to a JS ESM module to run -- Theme packs are auto-detected under `themes/` within the plugin folder (no manifest field required) - - Theme ids are namespaced at runtime as `.` to avoid collisions - -## Running Code (TypeScript / JavaScript) - -Plugins run as JavaScript ESM modules in the UI. - -- Author in TypeScript if you want, but compile to a single `.js` file for `entry`. -- Keep it single-file for now; inline modules can’t reliably `import` sibling files by relative path. - -## Plugin API (UI Runtime) - -Plugin entry code can call the global `window.OpenVCS` API: +Minimal module plugin: -- `window.OpenVCS.registerPlugin({ ... })` -- `window.OpenVCS.registerAction(id, handler)` -- `window.OpenVCS.addMenuItem({ label, action })` -- `window.OpenVCS.addTitlebarButton({ label, action })` -- `window.OpenVCS.notify(message)` -- `window.OpenVCS.invoke(cmd, args)` / `window.OpenVCS.listen(event, cb)` - -Menu items appear under the `Plugins` menu. Titlebar buttons appear next to `Push`. - -### Context menus - -Plugins can add items to existing right-click menus by including `contextMenus` in `registerPlugin(...)`. - -```js -window.OpenVCS?.registerPlugin({ - contextMenus: { - files: [{ label: 'Copy selected paths', action: 'my.plugin:copyPaths' }], - commits: [{ label: 'Copy commit hash', action: 'my.plugin:copyHash' }], - branches: [{ label: 'Copy branch name', action: 'my.plugin:copyBranch' }], - }, -}); +```json +{ + "id": "example.plugin", + "name": "Example Plugin", + "version": "0.1.0", + "module": { "exec": "example-plugin.mjs" } +} ``` -When the user clicks one of these items, OpenVCS runs the referenced action with a payload that describes the clicked object (e.g. `payload.paths` / `payload.commit` / `payload.branch`). - -### Calling plugin module RPC from the console +Notes: -Plugin modules register RPC methods (e.g. `example.notify.ping`) via `openvcs_core::plugin_runtime::register_delegate`, and you can hit those endpoints from the dev console using the new `window.callPluginMethod` helper (it wraps `call_plugin_module_method` so you don’t have to go through `window.OpenVCS.invoke` manually). Example: - -```js -await window.callPluginMethod( - 'example.notify', - 'example.notify.ping', - { message: 'hello from the console' }, -); -``` +- `module.exec` must end with `.js`, `.mjs`, or `.cjs`. +- The runtime loads only Node entry files from `bin/`. +- If `themes/` exists, it is packaged and discovered automatically. +- Dependency installation is a packaging concern (SDK), not an app install concern. +- OpenVCS does not run npm during plugin installation or updates. -The helper spawns the plugin’s module process, passes the JSON-RPC request, waits for the response, and enforces any requested capabilities (you must have approved them in Settings → Plugins). There’s also a matching API on `window.OpenVCS` (`window.OpenVCS.callPlugin(...)`) if you breezily interact through that object. If you prefer a named shortcut, register a simple global after loading the plugin: +## Plugin UI menus and settings -```js -window.example = window.example || {}; -window.example.notify = (message) => - window.callPluginMethod('example.notify', 'example.notify.ping', { message }); -``` +- Plugins can contribute typed menus/elements (text and buttons today) that the client renders. +- Enabling/disabling a plugin from the Settings > Plugins pane refreshes plugin-contributed menus. +- Plugin list checkboxes are tri-state in the UI: disabled, enabled (green check), and enabling (animated pending indicator). +- If plugin runtime startup fails, the plugin list shows a persistent red `!` marker for that plugin until retry. +- Plugin menus are fetched only from plugins with a currently running module runtime. +- If enabling a plugin fails during runtime startup, the host keeps that plugin disabled and returns an error to the UI. +- Plugin settings persistence is automatic in the host under: + - `plugin-data//settings.json` -After that you can call `example.notify('hi')` in the console, and the helper will forward the call to the module. +## Security model -## Logging from plugin modules (Rust/WASI) +Plugins are trust-model based and do not use per-capability permission prompts. +Plugins run with full system access in their own Node process. -Plugin modules should write logs to **stderr** (never stdout) so they don’t interfere with the JSON message protocol. +Before a plugin module can start, the installed version must be marked +`approved` in plugin installation metadata. -To keep plugin crates lightweight, `openvcs-core` provides logging macros so you don’t need to add a logging crate dependency: +Plugin modules run only with the app-bundled Node runtime; OpenVCS does not +fall back to `node` from system PATH. -```rs -openvcs_core::trace!("trace details"); -openvcs_core::debug!("debug details"); -openvcs_core::info!("hello from a plugin"); -openvcs_core::warn!("something looks off"); -openvcs_core::error!("something failed"); -``` +Install only plugins you trust. -If you prefer calling them without a prefix, import the macros you use: +## Building bundles -```rs -use openvcs_core::{debug, info, trace}; +Install the SDK from crates.io: -info!("hello"); -debug!("details"); -trace!("very verbose"); +```bash +cargo install openvcs-sdk ``` -## Hooks - -Plugins can register hook handlers via `registerPlugin({ hooks: { ... } })`. - -Supported hook names: +Then build plugin bundles with: -- `preCommit` / `onCommit` / `postCommit` -- `prePush` / `onPush` / `postPush` -- `preSwitchBranch` / `onSwitchBranch` / `postSwitchBranch` -- `preBranchCreate` / `onBranchCreate` / `postBranchCreate` -- `preBranchDelete` / `onBranchDelete` / `postBranchDelete` +```bash +# From a plugin directory +cargo openvcs dist -Pre-hooks can cancel the operation: - -- Call `ctx.cancel("reason")`, or -- Throw an error (the error message becomes the reason) - -Hook `ctx.data` is a plain object describing the operation (e.g. commit summary/description, branch names). For `preCommit`, mutating `ctx.data.summary` / `ctx.data.description` updates what gets sent to the backend. - -## Example Plugin - -See `docs/examples/hello-plugin/` for a minimal plugin with: - -- A menu item -- A titlebar button -- A `preCommit` hook that blocks commits starting with `WIP` - -## Managing Plugins +# Or explicitly +cargo openvcs dist --plugin-dir /path/to/plugin --out /path/to/dist +``` -Open **Settings → Plugins** to view installed plugins and enable/disable them. +See `Client/docs/plugin architecture.md` for the runtime model and `SDK/README.md` for packager details.