From a292dfe39f55cf2c6fd6f4c52ffb9fff50c1a1bf Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 15 Jan 2026 14:15:59 +0000 Subject: [PATCH 01/50] Revamped VCS trait --- Backend/built-in-plugins/openvcs.git.ovcsp | 4 +- Backend/src/lib.rs | 37 ++++++------- Backend/src/plugin_runtime/vcs_proxy.rs | 43 +++++++++++++++- Backend/src/settings.rs | 16 +++--- Backend/src/tauri_commands/backends.rs | 23 +++++++++ Backend/src/tauri_commands/general.rs | 33 ++++++++++-- Frontend/src/modals/settings.html | 2 +- Frontend/src/scripts/features/settings.ts | 60 +++++++++++++--------- docs/plugins/architecture.md | 5 +- 9 files changed, 158 insertions(+), 65 deletions(-) diff --git a/Backend/built-in-plugins/openvcs.git.ovcsp b/Backend/built-in-plugins/openvcs.git.ovcsp index b5f4b51..22a10af 100644 --- a/Backend/built-in-plugins/openvcs.git.ovcsp +++ b/Backend/built-in-plugins/openvcs.git.ovcsp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2b4b53d4a3888c1d9b38ce744879bcda9115ac0c905913406130900447e3e725 -size 174388 +oid sha256:ca22ffed335b5dd684dac3ac0444a6495b6aa0203ef05208d6bc3306e8141ccf +size 176020 diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 2b01651..573cb0d 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -1,5 +1,5 @@ use log::warn; -use openvcs_core::{backend_id, BackendId}; +use openvcs_core::BackendId; use std::sync::Arc; use tauri::path::BaseDirectory; use tauri::WindowEvent; @@ -23,27 +23,19 @@ mod utilities; mod validate; mod workarounds; -pub const GIT_SYSTEM_ID: BackendId = backend_id!("git-system"); - -fn preferred_git_backend_id(cfg: &settings::AppConfig) -> Option { - let configured = cfg.git.backend.trim(); - if !configured.is_empty() { - return Some(BackendId::from(configured.to_string())); - } - - // No configured backend: pick a git backend from enabled plugins (if any exist). - let mut git_ids: Vec = Vec::new(); - if let Ok(plugin_bes) = crate::plugin_vcs_backends::list_plugin_vcs_backends() { - for p in plugin_bes { - let id = p.backend_id.as_ref(); - if id.starts_with("git-") { - git_ids.push(id.to_string()); - } +fn preferred_vcs_backend_id(_cfg: &settings::AppConfig) -> Option { + let desired = _cfg.general.default_backend.trim().to_string(); + if !desired.is_empty() { + let desired = BackendId::from(desired); + if crate::plugin_vcs_backends::has_plugin_vcs_backend(&desired) { + return Some(desired); } } - git_ids.sort(); - git_ids.dedup(); - git_ids.into_iter().next().map(BackendId::from) + + crate::plugin_vcs_backends::list_plugin_vcs_backends().ok().and_then(|mut backends| { + backends.sort_by(|a, b| a.backend_id.as_ref().cmp(b.backend_id.as_ref())); + backends.into_iter().next().map(|b| b.backend_id) + }) } /// Attempt to reopen the most recent repository at startup if the @@ -60,8 +52,8 @@ fn try_reopen_last_repo(app_handle: &tauri::AppHandle) { let recents = state.recents(); if let Some(path) = recents.into_iter().find(|p| p.exists()) { - let Some(backend) = preferred_git_backend_id(&app_config) else { - log::warn!("startup reopen: no git backend available"); + let Some(backend) = preferred_vcs_backend_id(&app_config) else { + log::warn!("startup reopen: no VCS backend available"); return; }; @@ -175,6 +167,7 @@ fn build_invoke_handler( tauri_commands::add_repo, tauri_commands::list_vcs_backends_cmd, tauri_commands::set_vcs_backend_cmd, + tauri_commands::reopen_current_repo_cmd, tauri_commands::validate_git_url, tauri_commands::validate_add_path, tauri_commands::validate_clone_input, diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index c7ac01a..ceed4ee 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -1,5 +1,6 @@ use crate::plugin_bundles::ApprovalState; use crate::plugin_runtime::stdio_rpc::{RpcConfig, RpcError, SpawnConfig, StdioRpcProcess}; +use crate::settings::AppConfig; use openvcs_core::models::{ Capabilities, ConflictDetails, ConflictSide, FetchOptions, LogQuery, StashItem, StatusPayload, StatusSummary, VcsEvent, @@ -26,6 +27,11 @@ impl PluginVcsProxy { repo_path: &Path, ) -> 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()), @@ -42,7 +48,10 @@ impl PluginVcsProxy { rpc, }; p.rpc - .call("open", json!({ "path": path_to_utf8(repo_path)? })) + .call( + "open", + json!({ "path": path_to_utf8(repo_path)?, "config": cfg }), + ) .map_err(map_rpc_err)?; Ok(Arc::new(p)) } @@ -349,6 +358,38 @@ impl Vcs for PluginVcsProxy { fn stash_show(&self, selector: &str) -> VcsResult> { self.call_json("stash_show", json!({ "selector": selector })) } + + fn lfs_fetch(&self) -> VcsResult<()> { + self.call_unit("lfs_fetch", Value::Null) + } + + fn lfs_pull(&self) -> VcsResult<()> { + self.call_unit("lfs_pull", Value::Null) + } + + fn lfs_prune(&self) -> VcsResult<()> { + self.call_unit("lfs_prune", Value::Null) + } + + fn lfs_track(&self, paths: &[PathBuf]) -> VcsResult<()> { + let paths: Vec = paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + self.call_unit("lfs_track", json!({ "paths": paths })) + } + + fn lfs_untrack(&self, paths: &[PathBuf]) -> VcsResult<()> { + let paths: Vec = paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + self.call_unit("lfs_untrack", json!({ "paths": paths })) + } + + fn lfs_is_tracked(&self, path: &Path) -> VcsResult { + self.call_json("lfs_is_tracked", json!({ "path": path_to_utf8(path)? })) + } } fn map_rpc_err(err: RpcError) -> VcsError { diff --git a/Backend/src/settings.rs b/Backend/src/settings.rs index b2ea503..cdf0ca1 100644 --- a/Backend/src/settings.rs +++ b/Backend/src/settings.rs @@ -61,7 +61,7 @@ pub struct General { #[serde(default)] pub language: Language, #[serde(default)] - pub default_backend: DefaultBackend, + pub default_backend: String, #[serde(default)] pub update_channel: UpdateChannel, #[serde(default)] @@ -79,7 +79,7 @@ impl Default for General { theme: Theme::System, theme_pack: default_theme_pack(), language: Language::System, - default_backend: DefaultBackend::Git, + default_backend: "git".into(), update_channel: UpdateChannel::Stable, reopen_last_repos: true, checks_on_launch: true, @@ -398,14 +398,6 @@ pub enum GitSshBinary { Custom, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -#[derive(Default)] -pub enum DefaultBackend { - #[default] - Git, -} - #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] #[derive(Default)] @@ -609,6 +601,10 @@ impl AppConfig { if self.general.theme_pack.trim().is_empty() { self.general.theme_pack = default_theme_pack(); } + self.general.default_backend = self.general.default_backend.trim().to_string(); + if self.general.default_backend.is_empty() { + self.general.default_backend = "git".into(); + } // Git self.git.backend = self.git.backend.trim().to_string(); diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index 117e4b4..f5c5a60 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -96,3 +96,26 @@ pub async fn set_vcs_backend_cmd( Ok(()) } + +#[tauri::command] +pub async fn reopen_current_repo_cmd(state: State<'_, AppState>) -> Result<(), String> { + let Some(repo) = state.current_repo() else { + return Ok(()); + }; + + let backend_id = repo.id(); + let path = repo.inner().workdir().to_path_buf(); + + let backend_label = backend_id.as_ref().to_string(); + let open_path = path.clone(); + let handle = async_runtime::spawn_blocking(move || { + plugin_vcs_backends::open_repo_via_plugin_vcs_backend(backend_id, Path::new(&open_path)) + }) + .await + .map_err(|e| format!("reopen_current_repo_cmd task failed: {e}"))? + .map_err(|e| format!("Failed to reopen repo with `{backend_label}`: {e}"))?; + + let new_repo = Arc::new(Repo::new(handle)); + state.set_current_repo(new_repo); + Ok(()) +} diff --git a/Backend/src/tauri_commands/general.rs b/Backend/src/tauri_commands/general.rs index 83ca9c8..d493398 100644 --- a/Backend/src/tauri_commands/general.rs +++ b/Backend/src/tauri_commands/general.rs @@ -68,10 +68,29 @@ pub async fn add_repo( path: String, backend_id: Option, ) -> Result<(), String> { - let be = backend_id.unwrap_or_else(|| BackendId::from("git-system")); + let be = backend_id + .or_else(|| default_backend_id(&state)) + .ok_or_else(|| { + "No VCS backend is available (install/enable a backend plugin)".to_string() + })?; add_repo_internal(window, state, path, be).await } +fn default_backend_id(state: &AppState) -> Option { + let desired = state.config().general.default_backend.trim().to_string(); + if !desired.is_empty() { + let desired = BackendId::from(desired); + if crate::plugin_vcs_backends::has_plugin_vcs_backend(&desired) { + return Some(desired); + } + } + + crate::plugin_vcs_backends::list_plugin_vcs_backends().ok().and_then(|mut backends| { + backends.sort_by(|a, b| a.backend_id.as_ref().cmp(b.backend_id.as_ref())); + backends.into_iter().next().map(|b| b.backend_id) + }) +} + pub async fn add_repo_internal( window: Window, state: State<'_, AppState>, @@ -137,7 +156,11 @@ pub async fn clone_repo( dest: String, backend_id: Option, ) -> Result<(), String> { - let be = backend_id.unwrap_or_else(|| BackendId::from("git-system")); + let be = backend_id + .or_else(|| default_backend_id(&state)) + .ok_or_else(|| { + "No VCS backend is available (install/enable a backend plugin)".to_string() + })?; let _prefer_plugin = plugin_vcs_backends::has_plugin_vcs_backend(&be); let folder = infer_repo_dir_from_url(&url); @@ -226,7 +249,11 @@ pub async fn open_repo( path: String, backend_id: Option, ) -> Result<(), String> { - let be = backend_id.unwrap_or_else(|| BackendId::from("git-system")); + let be = backend_id + .or_else(|| default_backend_id(&state)) + .ok_or_else(|| { + "No VCS backend is available (install/enable a backend plugin)".to_string() + })?; add_repo_internal(window, state, path, be).await } diff --git a/Frontend/src/modals/settings.html b/Frontend/src/modals/settings.html index dd14c27..14eb213 100644 --- a/Frontend/src/modals/settings.html +++ b/Frontend/src/modals/settings.html @@ -57,7 +57,7 @@

Settings

- diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 4380a75..29bd1ce 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -258,11 +258,10 @@ export function wireSettings() { if (TAURI.has) { await TAURI.invoke('set_global_settings', { cfg: next }); - // If backend changed, request a backend swap (reopens repo if open) + // 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) { - const backend_id = (newBackend === 'libgit2') ? 'git-libgit2' : 'git-system'; - try { await TAURI.invoke('set_vcs_backend_cmd', { backend_id }); } catch {} + try { await TAURI.invoke('reopen_current_repo_cmd'); } catch {} } } @@ -489,7 +488,7 @@ export async function loadSettingsIntoForm(root?: HTMLElement) { } const elLang = get('#set-language'); if (elLang) elLang.value = toKebab(cfg.general?.language); - const elDefBe = get('#set-default-backend'); if (elDefBe) elDefBe.value = toKebab(cfg.general?.default_backend || 'git'); + await refreshDefaultBackendOptions(m, cfg); const elChan = get('#set-update-channel'); if (elChan) { const v = toKebab(cfg.general?.update_channel); elChan.value = (v === 'beta') ? 'nightly' : v; @@ -557,6 +556,28 @@ async function refreshGitBackendOptions(modal: HTMLElement, cfg: GlobalSettings) 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; + + const desired = String(cfg.general?.default_backend || '').trim(); let available: Array<[string, string]> = []; if (TAURI.has) { @@ -565,31 +586,24 @@ async function refreshGitBackendOptions(modal: HTMLElement, cfg: GlobalSettings) } catch {} } - const gitBackends = (Array.isArray(available) ? available : []) + const backends = (Array.isArray(available) ? available : []) .map(([id, name]) => [String(id || '').trim(), String(name || '').trim()] as const) - .filter(([id]) => id.startsWith('git-')); - - const labelCounts = new Map(); - for (const [, name] of gitBackends) { - const label = name || ''; - if (!label) continue; - labelCounts.set(label, (labelCounts.get(label) || 0) + 1); - } + .filter(([id]) => id.length > 0); - elGb.innerHTML = ''; - for (const [id, name] of gitBackends) { + el.innerHTML = ''; + for (const [id, name] of backends) { const opt = document.createElement('option'); opt.value = id; - const base = name || id; - opt.textContent = (name && (labelCounts.get(name) || 0) > 1) ? `${base} — ${id}` : base; - elGb.appendChild(opt); + opt.textContent = name || id; + el.appendChild(opt); } - elGb.disabled = gitBackends.length === 0; - if (backend && gitBackends.some(([id]) => id === backend)) { - elGb.value = backend; - } else if (gitBackends.length) { - elGb.value = gitBackends[0][0]; + el.disabled = backends.length === 0; + if (!backends.length) return; + if (desired && backends.some(([id]) => id === desired)) { + el.value = desired; + } else { + el.value = backends[0][0]; } } diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 9d347af..63e8a18 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -41,14 +41,13 @@ Example: ```json { "id": "openvcs.git", - "name": "Git Backends", + "name": "Git", "version": "0.1.0", "capabilities": ["workspace.read", "vcs.read", "vcs.write"], "module": { "exec": "openvcs-git-plugin.wasm", "vcs_backends": [ - { "id": "git-system", "name": "System" }, - { "id": "git-libgit2", "name": "Libgit2" } + { "id": "git", "name": "Git" } ] }, "functions": { From b60bcd41ee8ece209f5dc10858b952eec79f59e8 Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 15 Jan 2026 15:23:27 +0000 Subject: [PATCH 02/50] Improved --- Backend/src/lib.rs | 8 +- Backend/src/plugin_runtime/vcs_proxy.rs | 32 ---- Backend/src/state.rs | 32 ++-- Backend/src/tauri_commands/backends.rs | 49 ++++++ Backend/src/tauri_commands/lfs.rs | 141 ------------------ Backend/src/tauri_commands/mod.rs | 4 +- Backend/src/tauri_commands/shared.rs | 69 --------- Backend/src/workarounds.rs | 5 +- Frontend/index.html | 1 - Frontend/src/scripts/features/repo/context.ts | 1 - .../src/scripts/features/repo/diffView.ts | 20 +-- Frontend/src/scripts/features/repo/history.ts | 21 +-- .../src/scripts/features/repo/interactions.ts | 53 ------- Frontend/src/scripts/main.ts | 21 --- Frontend/src/scripts/types.d.ts | 1 - 15 files changed, 80 insertions(+), 378 deletions(-) delete mode 100644 Backend/src/tauri_commands/lfs.rs diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 573cb0d..69c6fa4 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -168,6 +168,7 @@ 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, @@ -217,13 +218,6 @@ fn build_invoke_handler( tauri_commands::git_push, tauri_commands::git_undo_since_push, tauri_commands::git_undo_to_commit, - tauri_commands::git_lfs_fetch_all, - tauri_commands::git_lfs_pull, - tauri_commands::git_lfs_prune, - tauri_commands::git_lfs_track_paths, - tauri_commands::git_lfs_untrack_paths, - tauri_commands::git_lfs_is_tracked, - tauri_commands::git_lfs_tracked_paths, tauri_commands::git_add_to_gitignore_paths, tauri_commands::open_repo_file, tauri_commands::list_themes, diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index ceed4ee..2680403 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -358,38 +358,6 @@ impl Vcs for PluginVcsProxy { fn stash_show(&self, selector: &str) -> VcsResult> { self.call_json("stash_show", json!({ "selector": selector })) } - - fn lfs_fetch(&self) -> VcsResult<()> { - self.call_unit("lfs_fetch", Value::Null) - } - - fn lfs_pull(&self) -> VcsResult<()> { - self.call_unit("lfs_pull", Value::Null) - } - - fn lfs_prune(&self) -> VcsResult<()> { - self.call_unit("lfs_prune", Value::Null) - } - - fn lfs_track(&self, paths: &[PathBuf]) -> VcsResult<()> { - let paths: Vec = paths - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - self.call_unit("lfs_track", json!({ "paths": paths })) - } - - fn lfs_untrack(&self, paths: &[PathBuf]) -> VcsResult<()> { - let paths: Vec = paths - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - self.call_unit("lfs_untrack", json!({ "paths": paths })) - } - - fn lfs_is_tracked(&self, path: &Path) -> VcsResult { - self.call_json("lfs_is_tracked", json!({ "path": path_to_utf8(path)? })) - } } fn map_rpc_err(err: RpcError) -> VcsError { diff --git a/Backend/src/state.rs b/Backend/src/state.rs index 0bd1aa6..1465c5b 100644 --- a/Backend/src/state.rs +++ b/Backend/src/state.rs @@ -17,21 +17,31 @@ pub const MAX_RECENTS: usize = 10; fn apply_git_ssh_env(cfg: &AppConfig) { // Prefer config-driven runtime env so the VCS backend (in another crate) can read it. // Keep env var names stable for packaging and troubleshooting. - std::env::set_var( - "OPENVCS_SSH_MODE", - match cfg.git.ssh_binary { - crate::settings::GitSshBinary::Auto => "auto", - crate::settings::GitSshBinary::Host => "host", - crate::settings::GitSshBinary::Bundled => "bundled", - crate::settings::GitSshBinary::Custom => "custom", - }, - ); + unsafe { + // Safety: OpenVCS sets these env vars during startup/config updates and treats them as + // process-wide configuration for child processes (e.g. `git`). + std::env::set_var( + "OPENVCS_SSH_MODE", + match cfg.git.ssh_binary { + crate::settings::GitSshBinary::Auto => "auto", + crate::settings::GitSshBinary::Host => "host", + crate::settings::GitSshBinary::Bundled => "bundled", + crate::settings::GitSshBinary::Custom => "custom", + }, + ); + } if cfg.git.ssh_binary == crate::settings::GitSshBinary::Custom && !cfg.git.ssh_path.trim().is_empty() { - std::env::set_var("OPENVCS_SSH", cfg.git.ssh_path.trim()); + unsafe { + // Safety: see comment above. + std::env::set_var("OPENVCS_SSH", cfg.git.ssh_path.trim()); + } } else { - std::env::remove_var("OPENVCS_SSH"); + unsafe { + // Safety: see comment above. + std::env::remove_var("OPENVCS_SSH"); + } } } diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index f5c5a60..8448878 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -2,12 +2,14 @@ use std::path::Path; use std::sync::Arc; use log::{error, info, warn}; +use serde_json::Value; use tauri::{async_runtime, State}; use openvcs_core::BackendId; use std::collections::BTreeMap; use crate::plugin_vcs_backends; +use crate::plugin_runtime::stdio_rpc::{RpcConfig, SpawnConfig, StdioRpcProcess}; use crate::repo::Repo; use crate::state::AppState; @@ -119,3 +121,50 @@ 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. +#[tauri::command] +pub fn call_vcs_backend_method( + 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 list = plugin_vcs_backends::list_plugin_vcs_backends()?; + let desc = list + .into_iter() + .find(|d| d.backend_id.as_ref() == backend_id.as_ref()) + .ok_or_else(|| format!("Unknown VCS backend: {backend_id_str}"))?; + + // If params contain a `path`, use it as the workspace root for capability checks. + let allowed_workspace_root = params + .get("path") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(std::path::PathBuf::from); + + let rpc = StdioRpcProcess::new( + SpawnConfig { + plugin_id: desc.plugin_id, + component_label: format!("vcs-backend-{}", backend_id_str), + exec_path: desc.exec_path, + args: vec!["--backend".into(), backend_id_str.clone()], + requested_capabilities: desc.requested_capabilities, + approval: desc.approval, + allowed_workspace_root, + }, + RpcConfig::default(), + ); + + rpc.call(&method, params) + .map_err(|e| format!("{}: {}", e.code, e.message)) +} diff --git a/Backend/src/tauri_commands/lfs.rs b/Backend/src/tauri_commands/lfs.rs deleted file mode 100644 index acf13fe..0000000 --- a/Backend/src/tauri_commands/lfs.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::path::PathBuf; - -use log::info; -use tauri::State; - -use crate::state::AppState; - -use super::{current_repo_or_err, lfs_config, run_repo_task, LfsEnvGuard}; - -#[tauri::command] -pub async fn git_lfs_fetch_all(state: State<'_, AppState>) -> Result<(), String> { - info!("git_lfs_fetch_all called"); - let cfg = lfs_config(&state); - if !cfg.enabled { - return Err("Git LFS integration is disabled".into()); - } - - let repo = current_repo_or_err(&state)?; - run_repo_task("git_lfs_fetch_all", repo, move |repo| { - let _guard = LfsEnvGuard::apply(&cfg); - repo.inner().lfs_fetch().map_err(|e| e.to_string()) - }) - .await -} - -#[tauri::command] -pub async fn git_lfs_pull(state: State<'_, AppState>) -> Result<(), String> { - info!("git_lfs_pull called"); - let cfg = lfs_config(&state); - if !cfg.enabled { - return Err("Git LFS integration is disabled".into()); - } - - let repo = current_repo_or_err(&state)?; - run_repo_task("git_lfs_pull", repo, move |repo| { - let _guard = LfsEnvGuard::apply(&cfg); - repo.inner().lfs_pull().map_err(|e| e.to_string()) - }) - .await -} - -#[tauri::command] -pub async fn git_lfs_prune(state: State<'_, AppState>) -> Result<(), String> { - info!("git_lfs_prune called"); - let cfg = lfs_config(&state); - if !cfg.enabled { - return Err("Git LFS integration is disabled".into()); - } - - let repo = current_repo_or_err(&state)?; - run_repo_task("git_lfs_prune", repo, move |repo| { - let _guard = LfsEnvGuard::apply(&cfg); - repo.inner().lfs_prune().map_err(|e| e.to_string()) - }) - .await -} - -#[tauri::command] -pub async fn git_lfs_track_paths( - state: State<'_, AppState>, - paths: Vec, -) -> Result<(), String> { - info!("git_lfs_track_paths called (count={})", paths.len()); - if paths.is_empty() { - return Ok(()); - } - - let cfg = lfs_config(&state); - if !cfg.enabled { - return Err("Git LFS integration is disabled".into()); - } - - let repo = current_repo_or_err(&state)?; - run_repo_task("git_lfs_track_paths", repo, move |repo| { - let _guard = LfsEnvGuard::apply(&cfg); - let list: Vec = paths.into_iter().map(PathBuf::from).collect(); - repo.inner().lfs_track(&list).map_err(|e| e.to_string()) - }) - .await -} - -#[tauri::command] -pub async fn git_lfs_untrack_paths( - state: State<'_, AppState>, - paths: Vec, -) -> Result<(), String> { - info!("git_lfs_untrack_paths called (count={})", paths.len()); - if paths.is_empty() { - return Ok(()); - } - - let cfg = lfs_config(&state); - if !cfg.enabled { - return Err("Git LFS integration is disabled".into()); - } - - let repo = current_repo_or_err(&state)?; - run_repo_task("git_lfs_untrack_paths", repo, move |repo| { - let _guard = LfsEnvGuard::apply(&cfg); - let list: Vec = paths.into_iter().map(PathBuf::from).collect(); - repo.inner().lfs_untrack(&list).map_err(|e| e.to_string()) - }) - .await -} - -#[tauri::command] -pub async fn git_lfs_is_tracked(state: State<'_, AppState>, path: String) -> Result { - let repo = current_repo_or_err(&state)?; - run_repo_task("git_lfs_is_tracked", repo, move |repo| { - repo.inner() - .lfs_is_tracked(&PathBuf::from(path)) - .map_err(|e| e.to_string()) - }) - .await -} - -#[tauri::command] -pub async fn git_lfs_tracked_paths( - state: State<'_, AppState>, - paths: Vec, -) -> Result, String> { - if paths.is_empty() { - return Ok(vec![]); - } - - let repo = current_repo_or_err(&state)?; - run_repo_task("git_lfs_tracked_paths", repo, move |repo| { - let mut tracked = Vec::new(); - for p in paths { - let is_tracked = repo - .inner() - .lfs_is_tracked(&PathBuf::from(&p)) - .map_err(|e| e.to_string())?; - if is_tracked { - tracked.push(p); - } - } - Ok(tracked) - }) - .await -} diff --git a/Backend/src/tauri_commands/mod.rs b/Backend/src/tauri_commands/mod.rs index e6a993e..d4812e5 100644 --- a/Backend/src/tauri_commands/mod.rs +++ b/Backend/src/tauri_commands/mod.rs @@ -3,7 +3,6 @@ mod branches; mod commit; mod conflicts; mod general; -mod lfs; mod output_log; mod plugins; mod remotes; @@ -21,7 +20,6 @@ pub use branches::*; pub use commit::*; pub use conflicts::*; pub use general::*; -pub use lfs::*; pub use output_log::*; pub use plugins::*; pub use remotes::*; @@ -34,5 +32,5 @@ pub use themes::*; pub use updater::*; pub(crate) use shared::{ - current_repo_or_err, lfs_config, progress_bridge, run_repo_task, LfsEnvGuard, ProgressPayload, + current_repo_or_err, progress_bridge, run_repo_task, ProgressPayload, }; diff --git a/Backend/src/tauri_commands/shared.rs b/Backend/src/tauri_commands/shared.rs index 073ddbe..b9e2b27 100644 --- a/Backend/src/tauri_commands/shared.rs +++ b/Backend/src/tauri_commands/shared.rs @@ -7,7 +7,6 @@ use tauri::{async_runtime, AppHandle, Emitter, Manager, Runtime, State}; use crate::output_log::{OutputLevel, OutputLogEntry}; use crate::plugin_vcs_backends; use crate::repo::Repo; -use crate::settings::Lfs; use crate::state::AppState; #[derive(serde::Serialize, Clone)] @@ -77,71 +76,3 @@ where .await .map_err(|e| format!("{label} task failed: {e}"))? } - -pub(crate) fn lfs_config(state: &State<'_, AppState>) -> Lfs { - state.with_config(|cfg| cfg.lfs.clone()) -} - -pub(crate) struct LfsEnvGuard { - originals: Vec<(&'static str, Option)>, -} - -impl LfsEnvGuard { - fn capture(key: &'static str) -> Option { - std::env::var(key).ok() - } - - fn set( - key: &'static str, - value: Option, - originals: &mut Vec<(&'static str, Option)>, - ) { - originals.push((key, Self::capture(key))); - match value { - Some(v) => std::env::set_var(key, v), - None => std::env::remove_var(key), - } - } - - pub(crate) fn apply(cfg: &Lfs) -> Self { - let mut originals = Vec::new(); - - Self::set( - "GIT_LFS_CONCURRENCY", - Some(cfg.concurrency.clamp(1, 16).to_string()), - &mut originals, - ); - - let lock_env = if cfg.require_lock_before_edit { - Some("1".to_string()) - } else { - None - }; - Self::set( - "GIT_LFS_SET_LOCKED_FILES_READONLY", - lock_env, - &mut originals, - ); - - let skip_smudge = if cfg.background_fetch_on_checkout { - None - } else { - Some("1".to_string()) - }; - Self::set("GIT_LFS_SKIP_SMUDGE", skip_smudge, &mut originals); - - Self { originals } - } -} - -impl Drop for LfsEnvGuard { - fn drop(&mut self) { - for (key, val) in self.originals.drain(..).rev() { - if let Some(v) = val { - std::env::set_var(key, v); - } else { - std::env::remove_var(key); - } - } - } -} diff --git a/Backend/src/workarounds.rs b/Backend/src/workarounds.rs index 2c485d9..f503618 100644 --- a/Backend/src/workarounds.rs +++ b/Backend/src/workarounds.rs @@ -23,7 +23,10 @@ pub fn apply_linux_nvidia_workaround() { const KEY: &str = "WEBKIT_DISABLE_DMABUF_RENDERER"; if std::env::var_os(KEY).is_none() { eprintln!("Applying NVIDIA Wayland workaround: {KEY}=1"); - std::env::set_var(KEY, "1"); + unsafe { + // Safety: set once at process startup to work around driver issues. + std::env::set_var(KEY, "1"); + } } } } diff --git a/Frontend/index.html b/Frontend/index.html index bed48b8..83fee5f 100644 --- a/Frontend/index.html +++ b/Frontend/index.html @@ -111,7 +111,6 @@
Select an item
- LFUTF-8Unified
diff --git a/Frontend/src/scripts/features/repo/context.ts b/Frontend/src/scripts/features/repo/context.ts index 7a8bfc5..6fdf0a1 100644 --- a/Frontend/src/scripts/features/repo/context.ts +++ b/Frontend/src/scripts/features/repo/context.ts @@ -7,7 +7,6 @@ export const countEl = qs('#changes-count'); export const leftFootEl = qs('#left-foot'); export const undoLeftBtn = leftFootEl?.querySelector('#undo-left-btn') ?? null; export const diffHeadPath = qs('#diff-path'); -export const diffMetaLfs = qs('#diff-meta-lfs'); export const diffEl = qs('#diff'); export const dragState = { diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index 3dd4d52..a725cf1 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -5,17 +5,12 @@ import { notify } from '../../lib/notify'; import { state, prefs, disableDefaultSelectAll } from '../../state/state'; import type { FileStatus, ConflictDetails } from '../../types'; import { buildPatchForSelectedHunks } from '../diff'; -import { diffEl, diffHeadPath, diffMetaLfs, listEl } from './context'; +import { diffEl, diffHeadPath, listEl } from './context'; import { updateCommitButton } from './commit'; import { hydrateStatus } from './hydrate'; import { getVisibleFiles, updateSelectAllState } from './selectionState'; import { openMergeModal, hasExternalMergeTool, launchExternalMergeTool } from '../conflicts'; -function setLfsBadge(isLfs: boolean) { - if (!diffMetaLfs) return; - diffMetaLfs.hidden = !isLfs; -} - function scrollDiffToTop() { if (!diffEl) return; const host = diffEl.closest('.diff-scroll') as HTMLElement | null; @@ -58,7 +53,6 @@ export function highlightRow(index: number) { export async function selectFile(file: FileStatus, index: number) { if (!diffHeadPath || !diffEl) return; highlightRow(index); - setLfsBadge(false); const status = String(file.status || '').toUpperCase(); if (status === 'U') { diffHeadPath.textContent = `${file.path || '(unknown file)'} (conflicted)`; @@ -72,11 +66,6 @@ export async function selectFile(file: FileStatus, index: number) { try { - if (TAURI.has && file.path) { - TAURI.invoke('git_lfs_is_tracked', { path: file.path }) - .then((isLfs) => setLfsBadge(!!isLfs)) - .catch(() => setLfsBadge(false)); - } let lines: string[] = []; if (TAURI.has && file.path) { lines = await TAURI.invoke('git_diff_file', { path: file.path }); @@ -277,7 +266,6 @@ async function renderConflictView(file: FileStatus) { } try { const details = await TAURI.invoke('git_conflict_details', { path: file.path }); - setLfsBadge(!!details?.lfs_pointer); diffEl.innerHTML = renderConflictMarkup(details); bindConflictActions(diffEl, file, details); scrollDiffToTop(); @@ -289,7 +277,7 @@ async function renderConflictView(file: FileStatus) { } function renderConflictMarkup(details: ConflictDetails) { - const binary = !!details.binary || !!details.lfs_pointer; + const binary = !!details.binary; const header = `
Merge conflict
${renderConflictActions(binary)}
`; const body = binary ? renderBinaryConflictBody(details) : renderTextConflictBody(details); const pathAttr = escapeHtml(details.path || ''); @@ -306,9 +294,7 @@ function renderConflictActions(binary: boolean) { } function renderBinaryConflictBody(details: ConflictDetails) { - const note = details.lfs_pointer - ? 'This file is managed by Git LFS. Choose which version to keep.' - : 'This file is binary. Choose which version to keep.'; + const note = 'This file is binary. Choose which version to keep.'; return `
${escapeHtml(note)}
`; } diff --git a/Frontend/src/scripts/features/repo/history.ts b/Frontend/src/scripts/features/repo/history.ts index 32c43bd..62f02f9 100644 --- a/Frontend/src/scripts/features/repo/history.ts +++ b/Frontend/src/scripts/features/repo/history.ts @@ -4,7 +4,7 @@ import { TAURI } from '../../lib/tauri'; import { notify } from '../../lib/notify'; import { getPluginContextMenuItems, runPluginAction } from '../../plugins'; import { prefs, state, statusClass, statusLabel } from '../../state/state'; -import { diffEl, diffHeadPath, diffMetaLfs, listEl, countEl } from './context'; +import { diffEl, diffHeadPath, listEl, countEl } from './context'; import { renderHunksReadonly, highlightRow } from './diffView'; import { hydrateStatus, hydrateCommits } from './hydrate'; import { updateCommitButton } from './commit'; @@ -104,21 +104,6 @@ if (historyActionsBtn && !(historyActionsBtn as any).__wired) { window.addEventListener('app:tab-changed', () => updateHistoryActionsVisibility()); } -function setLfsBadge(isLfs: boolean) { - if (!diffMetaLfs) return; - diffMetaLfs.hidden = !isLfs; -} - -function hydrateLfsBadgeForPath(path: string) { - if (!TAURI.has || !path) { - setLfsBadge(false); - return; - } - TAURI.invoke('git_lfs_is_tracked', { path }) - .then((isLfs) => setLfsBadge(!!isLfs)) - .catch(() => setLfsBadge(false)); -} - export function renderHistoryList(query: string): boolean { const list = listEl; const count = countEl; @@ -199,7 +184,6 @@ export async function selectHistory(commit: any, index: number) { (state as any).selectedCommit = commit || null; updateHistoryActionsVisibility(); highlightRow(index); - setLfsBadge(false); const id = (commit.id || '').slice(0, 7); diffHeadPath.textContent = `Commit ${id || '(unknown)'}`; diffEl.innerHTML = ` @@ -247,8 +231,6 @@ export async function selectHistory(commit: any, index: number) {
${files.length} file${files.length === 1 ? '' : 's'} changed
${sidebar}${right}
`; - hydrateLfsBadgeForPath(files[0]?.path || ''); - const sideEl = diffEl.querySelector('.commit-files'); const contentEl = diffEl.querySelector('.commit-content'); if (sideEl && contentEl) { @@ -257,7 +239,6 @@ export async function selectHistory(commit: any, index: number) { sideEl.querySelectorAll('.row').forEach((r) => r.classList.remove('active')); const row = sideEl.querySelector(`.row[data-idx="${idx}"]`); row?.classList.add('active'); - hydrateLfsBadgeForPath(files[idx]?.path || ''); (contentEl as HTMLElement).innerHTML = renderHunksReadonly(files[idx].lines); }; diff --git a/Frontend/src/scripts/features/repo/interactions.ts b/Frontend/src/scripts/features/repo/interactions.ts index 7399aa5..45054fd 100644 --- a/Frontend/src/scripts/features/repo/interactions.ts +++ b/Frontend/src/scripts/features/repo/interactions.ts @@ -264,59 +264,6 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { } }}); items.push({ label: '---' }); - const lfsTargets = (explicitMultiSelection ? selectedPaths.slice() : [f.path]).filter(Boolean); - let lfsTracked: string[] = []; - if (TAURI.has && lfsTargets.length > 0) { - try { - lfsTracked = await TAURI.invoke('git_lfs_tracked_paths', { paths: lfsTargets }); - } catch { - lfsTracked = []; - } - } - const trackedSet = new Set(lfsTracked); - const lfsToAdd = lfsTargets.filter((p) => !trackedSet.has(p)); - const lfsToRemove = lfsTargets.filter((p) => trackedSet.has(p)); - - if (lfsToAdd.length > 0) { - items.push({ label: 'Add to Git LFS', action: () => { - if (!TAURI.has) { - notify('Git LFS is available in the desktop app'); - return; - } - const targets = lfsToAdd.slice(); - if (!targets.length) return; - (async () => { - try { - await TAURI.invoke('git_lfs_track_paths', { paths: targets }); - notify(targets.length > 1 ? 'Tracked files with Git LFS' : 'Tracked file with Git LFS'); - await Promise.allSettled([hydrateStatus()]); - } catch { - notify('Git LFS track failed'); - } - })(); - }}); - } - - if (lfsToRemove.length > 0) { - items.push({ label: 'Remove from Git LFS', action: () => { - if (!TAURI.has) { - notify('Git LFS is available in the desktop app'); - return; - } - const targets = lfsToRemove.slice(); - if (!targets.length) return; - (async () => { - try { - await TAURI.invoke('git_lfs_untrack_paths', { paths: targets }); - notify(targets.length > 1 ? 'Removed from Git LFS' : 'Removed from Git LFS'); - await Promise.allSettled([hydrateStatus()]); - } catch { - notify('Git LFS untrack failed'); - } - })(); - }}); - } - items.push({ label: '---' }); if (explicitMultiSelection) { items.push({ label: 'Discard all selected', action: async () => { diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index 8d92098..3af3be9 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -303,24 +303,6 @@ async function boot() { try { window.open(WIKI_URL, '_blank', 'noopener'); } catch { notify('Unable to open docs'); } } - async function runLfsCommand(cmd: string, okMsg: string, errMsg: string) { - if (!TAURI.has) { - notify('Git LFS actions require the desktop app'); - return; - } - try { - await TAURI.invoke(cmd); - notify(okMsg); - await Promise.allSettled([hydrateStatus(), hydrateCommits()]); - } catch (err) { - const msg = String(err || '').trim(); - const friendly = msg.includes('unsupported backend') - ? 'The current backend does not support Git LFS' - : (msg || errMsg); - notify(friendly); - } - } - async function runMenuAction(id?: string | null) { switch (id) { case 'clone_repo': openSheet('clone'); break; @@ -347,9 +329,6 @@ async function boot() { break; } case 'lfs-settings': openSettings('lfs'); break; - case 'lfs-fetch-all': await runLfsCommand('git_lfs_fetch_all', 'Fetched Git LFS objects', 'Git LFS fetch failed'); break; - case 'lfs-pull-all': await runLfsCommand('git_lfs_pull', 'Pulled Git LFS objects', 'Git LFS pull failed'); break; - case 'lfs-prune': await runLfsCommand('git_lfs_prune', 'Pruned Git LFS cache', 'Git LFS prune failed'); break; case 'check_updates': if (!TAURI.has) { notify('Update checks are available in the desktop app'); break; } try { diff --git a/Frontend/src/scripts/types.d.ts b/Frontend/src/scripts/types.d.ts index 31ce7a3..0f10aae 100644 --- a/Frontend/src/scripts/types.d.ts +++ b/Frontend/src/scripts/types.d.ts @@ -26,7 +26,6 @@ export interface ConflictDetails { theirs?: string | null; base?: string | null; binary?: boolean; - lfs_pointer?: boolean; } export interface CommitItem { From ec3035e5109b8f299b6f3611b25fea8f437c060b Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 15 Jan 2026 15:27:34 +0000 Subject: [PATCH 03/50] Update openvcs.git.ovcsp --- Backend/built-in-plugins/openvcs.git.ovcsp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Backend/built-in-plugins/openvcs.git.ovcsp b/Backend/built-in-plugins/openvcs.git.ovcsp index 22a10af..14a29ec 100644 --- a/Backend/built-in-plugins/openvcs.git.ovcsp +++ b/Backend/built-in-plugins/openvcs.git.ovcsp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca22ffed335b5dd684dac3ac0444a6495b6aa0203ef05208d6bc3306e8141ccf -size 176020 +oid sha256:0a1787b36a64fc11f8c0386c50897457df6f0278a1d1f292be8eb72f9f22c091 +size 183024 From 3b22e6de66771b76ef9daf507fca3fb30be76394 Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 15 Jan 2026 15:32:34 +0000 Subject: [PATCH 04/50] Made backend call async --- Backend/built-in-plugins/openvcs.git.ovcsp | 4 +- Backend/src/tauri_commands/backends.rs | 44 ++++++++++++++-------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/Backend/built-in-plugins/openvcs.git.ovcsp b/Backend/built-in-plugins/openvcs.git.ovcsp index 14a29ec..b0da2f4 100644 --- a/Backend/built-in-plugins/openvcs.git.ovcsp +++ b/Backend/built-in-plugins/openvcs.git.ovcsp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a1787b36a64fc11f8c0386c50897457df6f0278a1d1f292be8eb72f9f22c091 -size 183024 +oid sha256:e5e64f86f364f1b8d919ff1a3476961a2123ea527887e354112f04248e71a876 +size 183060 diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index 8448878..c6f40d4 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -127,7 +127,7 @@ pub async fn reopen_current_repo_cmd(state: State<'_, AppState>) -> Result<(), S /// 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. #[tauri::command] -pub fn call_vcs_backend_method( +pub async fn call_vcs_backend_method( backend_id: BackendId, method: String, params: Value, @@ -152,19 +152,33 @@ pub fn call_vcs_backend_method( .filter(|s| !s.is_empty()) .map(std::path::PathBuf::from); - let rpc = StdioRpcProcess::new( - SpawnConfig { - plugin_id: desc.plugin_id, - component_label: format!("vcs-backend-{}", backend_id_str), - exec_path: desc.exec_path, - args: vec!["--backend".into(), backend_id_str.clone()], - requested_capabilities: desc.requested_capabilities, - approval: desc.approval, - allowed_workspace_root, - }, - RpcConfig::default(), - ); + // 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 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.call(&method_clone, params_clone) + }); + + let call_res = call_task + .await + .map_err(|e| format!("call_vcs_backend_method task failed: {e}"))?; - rpc.call(&method, params) - .map_err(|e| format!("{}: {}", e.code, e.message)) + call_res.map_err(|e| format!("{}: {}", e.code, e.message)) } From 83688a0445febbf44a4e5def84560b9ae1a2cbb9 Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 22 Jan 2026 20:41:56 +0000 Subject: [PATCH 05/50] Update client --- Backend/built-in-plugins/openvcs.git.ovcsp | 4 ++-- Backend/src/tauri_commands/backends.rs | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Backend/built-in-plugins/openvcs.git.ovcsp b/Backend/built-in-plugins/openvcs.git.ovcsp index b0da2f4..4ab921d 100644 --- a/Backend/built-in-plugins/openvcs.git.ovcsp +++ b/Backend/built-in-plugins/openvcs.git.ovcsp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e5e64f86f364f1b8d919ff1a3476961a2123ea527887e354112f04248e71a876 -size 183060 +oid sha256:9abf73e1e25adb252e3e12efa007cc675fcefeebd63e0650d61bac80a3d62340 +size 204176 diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index c6f40d4..6085741 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use log::{error, info, warn}; use serde_json::Value; -use tauri::{async_runtime, State}; +use tauri::{async_runtime, Manager, Runtime, State, Window}; use openvcs_core::BackendId; use std::collections::BTreeMap; @@ -12,6 +12,7 @@ use crate::plugin_vcs_backends; use crate::plugin_runtime::stdio_rpc::{RpcConfig, SpawnConfig, StdioRpcProcess}; use crate::repo::Repo; use crate::state::AppState; +use crate::tauri_commands::shared::progress_bridge; #[tauri::command] pub fn list_vcs_backends_cmd() -> Vec<(String, String)> { @@ -127,7 +128,8 @@ pub async fn reopen_current_repo_cmd(state: State<'_, AppState>) -> Result<(), S /// 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. #[tauri::command] -pub async fn call_vcs_backend_method( +pub async fn call_vcs_backend_method( + window: Window, backend_id: BackendId, method: String, params: Value, @@ -158,6 +160,7 @@ pub async fn call_vcs_backend_method( 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( @@ -172,6 +175,7 @@ pub async fn call_vcs_backend_method( }, RpcConfig::default(), ); + rpc.set_event_sink(Some(on_event)); rpc.call(&method_clone, params_clone) }); From 6f0abab124be368d80b9626596bc679336417960 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 11:32:52 +0000 Subject: [PATCH 06/50] Update Cargo.lock --- Cargo.lock | 817 +++++++++++++++++++++++++++++------------------------ 1 file changed, 440 insertions(+), 377 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d9558bd..6ab4617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,9 +125,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "arbitrary" @@ -403,9 +403,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -563,7 +563,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -578,9 +578,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.52" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", "jobserver", @@ -621,12 +621,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.43" @@ -655,7 +649,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -685,9 +679,9 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "convert_case" @@ -765,36 +759,36 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d32b9105ce689b3e79ae288f62e9c2d0de66e4869176a11829e5c696da0f018f" +checksum = "0377b13bf002a0774fcccac4f1102a10f04893d24060cf4b7350c87e4cbb647c" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e950e8dd96c1760f1c3a2b06d3d35584a3617239d034e73593ec096a1f3ea69" +checksum = "cfa027979140d023b25bf7509fb7ede3a54c3d3871fb5ead4673c4b633f671a2" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d769576bc48246fccf7f07173739e5f7a7fb3270eb9ac363c0792cad8963c034" +checksum = "618e4da87d9179a70b3c2f664451ca8898987aa6eb9f487d16988588b5d8cc40" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94d37c4589e52def48bd745c3b28b523d66ade8b074644ed3a366144c225f212" +checksum = "db53764b5dad233b37b8f5dc54d3caa9900c54579195e00f17ea21f03f71aaa7" dependencies = [ "serde", "serde_derive", @@ -802,9 +796,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c23b5ab93367eba82bddf49b63d841d8a0b8b39fb89d82829de6647b3a747108" +checksum = "4ae927f1d8c0abddaa863acd201471d56e7fc6c3925104f4861ed4dc3e28b421" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -829,9 +823,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c6118d26dd046455d31374b9432947ea2ba445c21fd8724370edd072f51f3bd" +checksum = "d3fcf1e3e6757834bd2584f4cbff023fcc198e9279dcb5d684b4bb27a9b19f54" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -842,24 +836,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a068c67f04f37de835fda87a10491e266eea9f9283d0887d8bd0a2c0726588a9" +checksum = "205dcb9e6ccf9d368b7466be675ff6ee54a63e36da6fe20e72d45169cf6fd254" [[package]] name = "cranelift-control" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35ceb830549fcd7f05493a3b6d3d2bcfa4d43588b099e8c2393d2d140d6f7951" +checksum = "108eca9fcfe86026054f931eceaf57b722c1b97464bf8265323a9b5877238817" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b130f0edd119e7665f1875b8d686bd3fccefd9d74d10e9005cbcd76392e1831" +checksum = "a0d96496910065d3165f84ff8e1e393916f4c086f88ac8e1b407678bc78735aa" dependencies = [ "cranelift-bitset", "serde", @@ -868,9 +862,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626a46aa207183bae011de3411a40951c494cea3fb2ef223d3118f75e13b23ca" +checksum = "e303983ad7e23c850f24d9c41fc3cb346e1b930f066d3966545e4c98dac5c9fb" dependencies = [ "cranelift-codegen", "log", @@ -880,15 +874,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d09dab08a5129cf59919fdd4567e599ea955de62191a852982150ac42ce4ab21" +checksum = "24b0cf8d867d891245836cac7abafb0a5b0ea040a019d720702b3b8bcba40bfa" [[package]] name = "cranelift-native" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "847b8eaef0f7095b401d3ce80587036495b94e7a051904df9e28d6cd14e69b94" +checksum = "e24b641e315443e27807b69c440fe766737d7e718c68beb665a2d69259c77bf3" dependencies = [ "cranelift-codegen", "libc", @@ -897,9 +891,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.128.1" +version = "0.128.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15a4849e90e778f2fcc9fd1b93bd074dbf6b8b6f420951f9617c4774fe71e7fc" +checksum = "a4e378a54e7168a689486d67ee1f818b7e5356e54ae51a1d7a53f4f13f7f8b7a" [[package]] name = "crc" @@ -1429,21 +1423,20 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -1453,9 +1446,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1782,10 +1775,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", ] [[package]] @@ -1793,12 +1784,25 @@ name = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", "wasip2", + "wasip3", "wasm-bindgen", ] @@ -2102,19 +2106,17 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -2131,9 +2133,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2155,9 +2157,9 @@ dependencies = [ [[package]] name = "ico" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", "png", @@ -2449,9 +2451,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +checksum = "d89a5b5e10d5a9ad6e5d1f4bd58225f655d6fe9767575a5e8ac5a6fe64e04495" dependencies = [ "jiff-static", "log", @@ -2462,9 +2464,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +checksum = "ff7a39c8862fc1369215ccf0a8f12dd4598c7f6484704359f0351bd617034dbf" dependencies = [ "proc-macro2", "quote", @@ -2505,9 +2507,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.84" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "992dc2f5318945507d390b324ab307c7e7ef69da0002cd14f178a5f37e289dc5" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -2624,9 +2626,9 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -2692,12 +2694,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lzma-rust2" version = "0.15.7" @@ -2773,9 +2769,9 @@ checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memfd" @@ -2845,7 +2841,7 @@ dependencies = [ "once_cell", "png", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -3197,6 +3193,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openvcs" version = "0.1.0-rc.6" @@ -3227,7 +3229,7 @@ dependencies = [ "wasmtime", "wasmtime-wasi", "xz2", - "zip 7.2.0", + "zip 7.4.0", ] [[package]] @@ -3240,7 +3242,7 @@ dependencies = [ "log", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3280,7 +3282,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3500,7 +3502,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher 1.0.1", + "siphasher 1.0.2", ] [[package]] @@ -3574,15 +3576,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -3616,9 +3618,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppmd-rust" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d558c559f0450f16f2a27a1f017ef38468c1090c9ce63c8e51366232d53717b4" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" [[package]] name = "ppv-lite86" @@ -3635,6 +3637,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3696,18 +3708,18 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "pulley-interpreter" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45b733bc861727077314d961c926e41f4a2f366c9bf1c2b29caf8182b979e9fd" +checksum = "01051a5b172e07f9197b85060e6583b942aec679dac08416647bf7e7dc916b65" dependencies = [ "cranelift-bitset", "log", @@ -3717,9 +3729,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "591c2768539dc694548d3aec1460b5afeb6bdeccb3ca1fbeac4d81a381fedc05" +checksum = "2cf194f5b1a415ef3a44ee35056f4009092cc4038a9f7e3c7c1e392f48ee7dbb" dependencies = [ "proc-macro2", "quote", @@ -3735,66 +3747,11 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.17", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.17", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -3830,16 +3787,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand_chacha" version = "0.2.2" @@ -3860,16 +3807,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.5.1" @@ -3888,15 +3825,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_hc" version = "0.2.0" @@ -3987,7 +3915,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -4026,9 +3954,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -4038,9 +3966,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -4049,15 +3977,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", @@ -4073,12 +4001,11 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -4091,7 +4018,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", ] [[package]] @@ -4134,9 +4060,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" @@ -4203,21 +4129,59 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ - "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "ring", "rustls-pki-types", @@ -4245,6 +4209,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -4274,9 +4247,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -4302,6 +4275,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -4425,18 +4421,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "serde_with" version = "3.16.1" @@ -4449,7 +4433,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.13.0", "schemars 0.9.0", - "schemars 1.2.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -4565,9 +4549,9 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "sized-chunks" @@ -4581,9 +4565,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -4596,9 +4580,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -4853,9 +4837,9 @@ checksum = "b1dd07eb858a2067e2f3c7155d54e929265c264e6f37efe3ee7a8d1b5a1dd0ba" [[package]] name = "tauri" -version = "2.9.5" +version = "2.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a3868da5508446a7cd08956d523ac3edf0a8bc20bf7e4038f9a95c2800d2033" +checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" dependencies = [ "anyhow", "bytes", @@ -4892,7 +4876,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tray-icon", "url", @@ -4904,9 +4888,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.3" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" +checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" dependencies = [ "anyhow", "cargo_toml", @@ -4926,9 +4910,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.5.2" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa9844cefcf99554a16e0a278156ae73b0d8680bbc0e2ad1e4287aadd8489cf" +checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" dependencies = [ "base64 0.22.1", "brotli", @@ -4944,7 +4928,7 @@ dependencies = [ "sha2", "syn 2.0.114", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "url", "uuid", @@ -4953,9 +4937,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.2" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3764a12f886d8245e66b7ee9b43ccc47883399be2019a61d80cf0f4117446fde" +checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4967,9 +4951,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.5.2" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1d0a4860b7ff570c891e1d2a586bf1ede205ff858fbc305e0b5ae5d14c1377" +checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f" dependencies = [ "anyhow", "glob", @@ -4996,7 +4980,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", ] @@ -5017,7 +5001,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "toml 0.9.11+spec-1.1.0", "url", ] @@ -5038,7 +5022,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "windows", "zbus", @@ -5046,9 +5030,9 @@ dependencies = [ [[package]] name = "tauri-plugin-updater" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" +checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" dependencies = [ "base64 0.22.1", "dirs", @@ -5061,6 +5045,7 @@ dependencies = [ "osakit", "percent-encoding", "reqwest", + "rustls", "semver", "serde", "serde_json", @@ -5068,7 +5053,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tokio", "url", @@ -5078,9 +5063,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.9.2" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f766fe9f3d1efc4b59b17e7a891ad5ed195fa8d23582abb02e6c9a01137892" +checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" dependencies = [ "cookie", "dpi", @@ -5094,7 +5079,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "webkit2gtk", "webview2-com", @@ -5103,9 +5088,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.9.3" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065" +checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" dependencies = [ "gtk", "http", @@ -5130,9 +5115,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a423c51176eb3616ee9b516a9fa67fed5f0e78baaba680e44eb5dd2cc37490" +checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" dependencies = [ "anyhow", "brotli", @@ -5158,7 +5143,7 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.17", + "thiserror 2.0.18", "toml 0.9.11+spec-1.1.0", "url", "urlpattern", @@ -5221,11 +5206,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -5241,9 +5226,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -5294,21 +5279,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.49.0" @@ -5537,7 +5507,7 @@ dependencies = [ "once_cell", "png", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -5549,9 +5519,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typed-path" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e43ffa54726cdc9ea78392023ffe9fe9cf9ac779e1c6fcb0d23f9862e3879d20" +checksum = "3015e6ce46d5ad8751e4a772543a30c7511468070e98e64e20165f8f81155b64" [[package]] name = "typeid" @@ -5698,9 +5668,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", @@ -5773,18 +5743,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.107" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1310980282a2842658e512a8bd683c962bbf9395e0544fa7bc0509343b8f7d10" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -5795,9 +5774,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.57" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de050049980fd9bee908eebfcdc8fa78dddb59acdbe7cbcc5b523a93c9fe0a4e" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", "futures-util", @@ -5809,9 +5788,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.107" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83321b348310f762bebefa30cd9504f673f3b554a53755eaa93af8272d28f7b" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5819,9 +5798,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.107" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6971fd7d06a3063afaaf6b843a2b2b16c3d84b42f4e2ec4e0c8deafbcb179708" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -5832,9 +5811,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.107" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54d2e1dc11b30bef0c334a34e7c7a1ed57cff1b602ad7eb6e5595e2e1e60bd62" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -5880,11 +5859,23 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -5913,6 +5904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.10.0", + "hashbrown 0.15.5", "indexmap 2.13.0", "semver", ] @@ -5930,9 +5922,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a1198409bd281650c097b95ac1d20a82e5b403a5ca7223ea607fe1272125d5a" +checksum = "a19f56cece843fa95dd929f5568ff8739c7e3873b530ceea9eda2aa02a0b4142" dependencies = [ "addr2line", "anyhow", @@ -5987,9 +5979,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b9af430b11ff3cd63fbef54cf38e26154089c179316b8a5e400b8ba2d0ebf1" +checksum = "3bf9dff572c950258548cbbaf39033f68f8dcd0b43b22e80def9fe12d532d3e5" dependencies = [ "anyhow", "cpp_demangle", @@ -6014,9 +6006,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cache" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37f09527993e5d3ab68857fa8b4cddfb300ec89d8bbe6ba33e279f0234367e73" +checksum = "7f52a985f5b5dae53147fc596f3a313c334e2c24fd1ba708634e1382f6ecd727" dependencies = [ "base64 0.22.1", "directories-next", @@ -6034,9 +6026,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5c69a6d1514ee5bcae494f69f3fee7a20528a38048fc9e847e0833af71071b" +checksum = "7920dc7dcb608352f5fe93c52582e65075b7643efc5dac3fc717c1645a8d29a0" dependencies = [ "anyhow", "proc-macro2", @@ -6044,20 +6036,20 @@ dependencies = [ "syn 2.0.114", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", - "wit-parser", + "wit-parser 0.243.0", ] [[package]] name = "wasmtime-internal-component-util" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa29030e4457259121400fa9043e9af3bb29e004e2f56b5e26caf1a2728fc5f" +checksum = "066f5aed35aa60580a2ac0df145c0f0d4b04319862fee1d6036693e1cca43a12" [[package]] name = "wasmtime-internal-cranelift" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "452397e623732c58fd9ce0545c62210965c0446155667fbd59c380642ce6df1b" +checksum = "afb8002dc415b7773d7949ee360c05ee8f91627ec25a7a0b01ee03831bdfdda1" dependencies = [ "cfg-if", "cranelift-codegen", @@ -6072,7 +6064,7 @@ dependencies = [ "pulley-interpreter", "smallvec", "target-lexicon 0.13.4", - "thiserror 2.0.17", + "thiserror 2.0.18", "wasmparser 0.243.0", "wasmtime-environ", "wasmtime-internal-math", @@ -6082,9 +6074,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa94737a693a38227edca24aaa995d3a3a80b2fe88a7de029345bd35c0d19b13" +checksum = "7f9c562c5a272bc9f615d8f0c085a4360bafa28eef9aa5947e63d204b1129b22" dependencies = [ "cc", "cfg-if", @@ -6097,9 +6089,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2d760a8909786674007cc1a65fd999d280502437c73b2eb4fab2fe6b714effe" +checksum = "db673148f26e1211db3913c12c75594be9e3858a71fa297561e9162b1a49cfb0" dependencies = [ "cc", "object", @@ -6109,36 +6101,36 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b46da671c07242b5f5eab491b12d6c25dd26929f1693c055fcca94489ef8f5" +checksum = "bada5ca1cc47df7d14100e2254e187c2486b426df813cea2dd2553a7469f7674" dependencies = [ + "anyhow", "cfg-if", "libc", - "wasmtime-environ", "windows-sys 0.61.2", ] [[package]] name = "wasmtime-internal-math" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d1f0763c6f6f78e410f964db9f53d9b84ab4cc336945e81f0b78717b0a9934e" +checksum = "cf6f615d528eda9adc6eefb062135f831b5215c348f4c3ec3e143690c730605b" dependencies = [ "libm", ] [[package]] name = "wasmtime-internal-slab" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24f641abc8d6c6d5464615222b0617c85317f391c14aaa60b13183e4e2a63462" +checksum = "da169d4f789b586e1b2612ba8399c653ed5763edf3e678884ba785bb151d018f" [[package]] name = "wasmtime-internal-unwinder" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6916a23c8369d3caf04630f55598b5c326782817faa318c5e9355ed7dea8f172" +checksum = "4888301f3393e4e8c75c938cce427293fade300fee3fc8fd466fdf3e54ae068e" dependencies = [ "cfg-if", "cranelift-codegen", @@ -6149,9 +6141,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a724908757d1b5c174984f4215e377183de1d4fe789f3755f6b4fd7928274fb" +checksum = "63ba3124cc2cbcd362672f9f077303ccc4cd61daa908f73447b7fdaece75ff9f" dependencies = [ "proc-macro2", "quote", @@ -6160,9 +6152,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa86f52a53d2bfcb60673b039a0e07bbcc2dd3e5a6459df1dcc195e563045479" +checksum = "90a4182515dabba776656de4ebd62efad03399e261cf937ecccb838ce8823534" dependencies = [ "cranelift-codegen", "gimli", @@ -6177,22 +6169,22 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c0d0892239910953c6f3e9ff5cf418c29eb964470ea855b64b2c0af67f2b8a" +checksum = "87acbd416227cdd279565ba49e57cf7f08d112657c3b3f39b70250acdfd094fe" dependencies = [ "anyhow", "bitflags 2.10.0", "heck 0.5.0", "indexmap 2.13.0", - "wit-parser", + "wit-parser 0.243.0", ] [[package]] name = "wasmtime-wasi" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b48028f5a86dc62c4d23b4769f5a59dcafb572c172b7b94a53619820a2727f3d" +checksum = "d9a1bdb4948463ed22559a640e687fed0df50b66353144aa6a9496c041ecd927" dependencies = [ "anyhow", "async-trait", @@ -6209,7 +6201,7 @@ dependencies = [ "io-lifetimes", "rustix 1.1.3", "system-interface", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -6221,9 +6213,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-io" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb53401d473beef46b530a5d6394f3ea9ccdbabc1b66456c72b8ad6015060697" +checksum = "d7873d8b990d3cf1105ef491abf2b3cf1e19ff6722d24d5ca662026ea082cdff" dependencies = [ "anyhow", "async-trait", @@ -6265,19 +6257,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1803a5757552f43190297bc8351e32442341c064b940983d29ac94a0b957577" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -6285,9 +6267,9 @@ dependencies = [ [[package]] name = "webkit2gtk" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" dependencies = [ "bitflags 1.3.2", "cairo-rs", @@ -6309,9 +6291,9 @@ dependencies = [ [[package]] name = "webkit2gtk-sys" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" dependencies = [ "bitflags 1.3.2", "cairo-sys-rs", @@ -6328,10 +6310,10 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.5" +name = "webpki-root-certs" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -6367,20 +6349,20 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", "windows", "windows-core 0.61.2", ] [[package]] name = "wiggle" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6ae01b30b9f18d138161960031656929f85d747b3dd4bcfad7ee34fe097a65" +checksum = "1f05d2a9932ca235984248dc98471ae83d1985e095682d049af4c296f54f0fb4" dependencies = [ "anyhow", "bitflags 2.10.0", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "wasmtime", "wiggle-macro", @@ -6388,9 +6370,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9938a7719a726027b28bfc435bd162004a73a31410615894a920a80ee8119216" +checksum = "57f773d51c1696bd7d028aa35c884d9fc58f48d79a1176dfbad6c908de314235" dependencies = [ "anyhow", "heck 0.5.0", @@ -6402,9 +6384,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b933908b084f69998d6a3d1072a32e534d1f9888d04b4ffd0fe179c7759af239" +checksum = "0e976fe0cecd60041f66b15ad45ebc997952af13da9bf9d90261c7b025057edc" dependencies = [ "proc-macro2", "quote", @@ -6445,9 +6427,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "41.0.1" +version = "41.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37f6bea231cd5a9b4e70f30172556c6793dedf4308dcb45902e6be3e1cb0448d" +checksum = "a4f31dcfdfaf9d6df9e1124d7c8ee6fc29af5b99b89d11ae731c138e0f5bd77b" dependencies = [ "anyhow", "cranelift-assembler-x64", @@ -6456,7 +6438,7 @@ dependencies = [ "regalloc2", "smallvec", "target-lexicon 0.13.4", - "thiserror 2.0.17", + "thiserror 2.0.18", "wasmparser 0.243.0", "wasmtime-environ", "wasmtime-internal-cranelift", @@ -6906,9 +6888,73 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] [[package]] name = "wit-parser" @@ -6928,6 +6974,24 @@ dependencies = [ "wasmparser 0.243.0", ] +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + [[package]] name = "witx" version = "0.9.1" @@ -6948,9 +7012,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.53.5" +version = "0.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0" dependencies = [ "base64 0.22.1", "block2", @@ -6980,7 +7044,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "webkit2gtk", "webkit2gtk-sys", @@ -7056,9 +7120,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.13.1" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f79257df967b6779afa536788657777a0001f5b42524fcaf5038d4344df40b" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" dependencies = [ "async-broadcast", "async-executor", @@ -7091,9 +7155,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.13.1" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aad23e2d2f91cae771c7af7a630a49e755f1eb74f8a46e9f6d5f7a146edf5a37" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", @@ -7117,18 +7181,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", @@ -7223,9 +7287,9 @@ dependencies = [ [[package]] name = "zip" -version = "7.2.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" dependencies = [ "aes", "bzip2", @@ -7233,8 +7297,7 @@ dependencies = [ "crc32fast", "deflate64", "flate2", - "generic-array", - "getrandom 0.3.4", + "getrandom 0.4.1", "hmac", "indexmap 2.13.0", "lzma-rust2", @@ -7251,15 +7314,15 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" +checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" [[package]] name = "zmij" -version = "1.0.14" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" [[package]] name = "zopfli" @@ -7303,9 +7366,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.9.1" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "326aaed414f04fe839777b4c443d4e94c74e7b3621093bd9c5e649ac8aa96543" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" dependencies = [ "endi", "enumflags2", @@ -7317,9 +7380,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.9.1" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba44e1f8f4da9e6e2d25d2a60b116ef8b9d0be174a7685e55bb12a99866279a7" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", From a3b76135da03cab6ef35385b43b16aed4e4e7b01 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 12:17:01 +0000 Subject: [PATCH 07/50] client: add untracked text diff fallback path --- Backend/built-in-plugins/openvcs.git.ovcsp | 4 +- Backend/src/lib.rs | 1 + Backend/src/tauri_commands/repo_files.rs | 57 +++++++++++++++++++ .../src/scripts/features/repo/diffView.ts | 29 ++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/Backend/built-in-plugins/openvcs.git.ovcsp b/Backend/built-in-plugins/openvcs.git.ovcsp index 4ab921d..09217d9 100644 --- a/Backend/built-in-plugins/openvcs.git.ovcsp +++ b/Backend/built-in-plugins/openvcs.git.ovcsp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9abf73e1e25adb252e3e12efa007cc675fcefeebd63e0650d61bac80a3d62340 -size 204176 +oid sha256:246405cca22ed9f649a54061417b23dd5696c0d503b631167553ab236fdcd038 +size 205028 diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 69c6fa4..8bed33f 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -220,6 +220,7 @@ fn build_invoke_handler( tauri_commands::git_undo_to_commit, tauri_commands::git_add_to_gitignore_paths, tauri_commands::open_repo_file, + tauri_commands::read_repo_file_text, tauri_commands::list_themes, tauri_commands::load_theme, tauri_commands::list_plugins, diff --git a/Backend/src/tauri_commands/repo_files.rs b/Backend/src/tauri_commands/repo_files.rs index 4f3606a..283afe5 100644 --- a/Backend/src/tauri_commands/repo_files.rs +++ b/Backend/src/tauri_commands/repo_files.rs @@ -120,3 +120,60 @@ pub fn open_repo_file( .open_path(abs.to_string_lossy().to_string(), None::<&str>) .map_err(|e| format!("Failed to open file: {e}")) } + +fn decode_repo_text(bytes: &[u8]) -> String { + if bytes.len() >= 2 && bytes.len().is_multiple_of(2) && bytes.contains(&0) { + let (endianness, start) = if bytes.starts_with(&[0xFF, 0xFE]) { + ("le", 2usize) + } else if bytes.starts_with(&[0xFE, 0xFF]) { + ("be", 2usize) + } else { + let even_zeros = bytes.iter().step_by(2).filter(|b| **b == 0).count(); + let odd_zeros = bytes.iter().skip(1).step_by(2).filter(|b| **b == 0).count(); + if odd_zeros > even_zeros { + ("le", 0usize) + } else if even_zeros > odd_zeros { + ("be", 0usize) + } else { + return String::from_utf8_lossy(bytes).to_string(); + } + }; + + let mut u16s = Vec::with_capacity((bytes.len() - start) / 2); + let mut i = start; + while i + 1 < bytes.len() { + let a = bytes[i]; + let b = bytes[i + 1]; + let u = if endianness == "le" { + u16::from_le_bytes([a, b]) + } else { + u16::from_be_bytes([a, b]) + }; + u16s.push(u); + i += 2; + } + let mut out = String::new(); + for ch in std::char::decode_utf16(u16s.into_iter()) { + match ch { + Ok(c) => out.push(c), + Err(_) => return String::from_utf8_lossy(bytes).to_string(), + } + } + return out; + } + String::from_utf8_lossy(bytes).to_string() +} + +#[tauri::command] +pub fn read_repo_file_text(state: State<'_, AppState>, path: String) -> Result { + let repo = state + .current_repo() + .ok_or_else(|| "No repository selected".to_string())?; + let rel = safe_relative_path(&path)?; + let abs = repo.inner().workdir().join(rel); + if !abs.exists() { + return Err(format!("Path does not exist: {}", abs.display())); + } + let bytes = std::fs::read(&abs).map_err(|e| format!("Failed to read file: {e}"))?; + Ok(decode_repo_text(&bytes)) +} diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index a725cf1..d738ab4 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -45,6 +45,21 @@ function renderBinaryDiffPlaceholder(path?: string) { return `
Diff not supported on this file type${label}.
`; } +function buildUntrackedTextPatch(path: string, text: string): string[] { + const normalized = String(text || '').replace(/\r\n/g, '\n'); + const body = normalized.length ? normalized.split('\n') : []; + if (body.length > 0 && body[body.length - 1] === '') body.pop(); + const out = [ + `diff --git a/${path} b/${path}`, + 'new file mode 100644', + '--- /dev/null', + `+++ b/${path}`, + `@@ -0,0 +1,${body.length} @@`, + ]; + for (const line of body) out.push(`+${line}`); + return out; +} + 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)); @@ -70,6 +85,20 @@ export async function selectFile(file: FileStatus, index: number) { if (TAURI.has && file.path) { lines = await TAURI.invoke('git_diff_file', { path: file.path }); } + if (status === '?' && file.path && (!Array.isArray(lines) || lines.length === 0)) { + try { + const text = TAURI.has ? await TAURI.invoke('read_repo_file_text', { path: file.path }) : ''; + lines = buildUntrackedTextPatch(file.path, text || ''); + } catch { + lines = [ + `diff --git a/${file.path} b/${file.path}`, + 'new file mode 100644', + '--- /dev/null', + `+++ b/${file.path}`, + '@@ -0,0 +1,0 @@', + ]; + } + } state.currentFile = file.path; state.currentDiff = lines || []; const isBinary = detectBinaryDiff(state.currentDiff); From 4bc7074f081e142139ebc6c1c099a1f7d35a66c5 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 13:12:36 +0000 Subject: [PATCH 08/50] Improve development workflow --- .gitmodules | 6 + Backend/build.rs | 15 ++ Backend/built-in-plugins/Git | 1 + Backend/built-in-plugins/OfficialThemes | 1 + Backend/built-in-plugins/openvcs.git.ovcsp | 3 - .../openvcs.official-themes.ovcsp | 3 - Backend/src/plugin_paths.rs | 4 + Backend/tauri.conf.json | 4 +- Cargo.lock | 188 ++++++++++++++---- Cargo.toml | 1 + Justfile | 18 +- 11 files changed, 198 insertions(+), 46 deletions(-) create mode 100644 .gitmodules create mode 160000 Backend/built-in-plugins/Git create mode 160000 Backend/built-in-plugins/OfficialThemes delete mode 100644 Backend/built-in-plugins/openvcs.git.ovcsp delete mode 100644 Backend/built-in-plugins/openvcs.official-themes.ovcsp diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..45568d4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "Backend/built-in-plugins/Git"] + path = Backend/built-in-plugins/Git + url = git@github.com:Open-VCS/OpenVCS-Plugin-Git.git +[submodule "Backend/built-in-plugins/OfficialThemes"] + path = Backend/built-in-plugins/OfficialThemes + url = git@github.com:Open-VCS/OpenVCS-Plugin-Themes.git diff --git a/Backend/build.rs b/Backend/build.rs index d48e2c0..43eb718 100644 --- a/Backend/build.rs +++ b/Backend/build.rs @@ -86,6 +86,19 @@ fn sanitize_semver_ident(s: &str) -> String { } } +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. + let generated = manifest_dir.join("../target/openvcs/built-in-plugins"); + if let Err(err) = fs::create_dir_all(&generated) { + panic!( + "failed to create generated built-in plugins resource dir {}: {}", + generated.display(), + err + ); + } +} + fn main() { // Base config path (in the Backend crate) let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); @@ -229,6 +242,8 @@ fn main() { println!("cargo:rustc-env=OPENVCS_VERSION={}", version); println!("cargo:rustc-env=OPENVCS_BUILD={}", build_id); + ensure_generated_builtins_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 new file mode 160000 index 0000000..d6d305c --- /dev/null +++ b/Backend/built-in-plugins/Git @@ -0,0 +1 @@ +Subproject commit d6d305ca15d98a3d389d9cf64aff63a543f49d3c diff --git a/Backend/built-in-plugins/OfficialThemes b/Backend/built-in-plugins/OfficialThemes new file mode 160000 index 0000000..19d1a79 --- /dev/null +++ b/Backend/built-in-plugins/OfficialThemes @@ -0,0 +1 @@ +Subproject commit 19d1a79c846323c3ec361e449dc1fadcb05e9c0a diff --git a/Backend/built-in-plugins/openvcs.git.ovcsp b/Backend/built-in-plugins/openvcs.git.ovcsp deleted file mode 100644 index 09217d9..0000000 --- a/Backend/built-in-plugins/openvcs.git.ovcsp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:246405cca22ed9f649a54061417b23dd5696c0d503b631167553ab236fdcd038 -size 205028 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 1acd326..0000000 --- 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:43f3a36b66ccf3e1e35a0efc19b31f6a59c681f3f1eb954b07b1b649515a7492 -size 17724 diff --git a/Backend/src/plugin_paths.rs b/Backend/src/plugin_paths.rs index 7057143..674925f 100644 --- a/Backend/src/plugin_paths.rs +++ b/Backend/src/plugin_paths.rs @@ -37,6 +37,10 @@ pub fn built_in_plugin_dirs() -> Vec { // in a `resources` subdirectory or directly alongside the exe. candidates.push(dir.join("resources").join(BUILT_IN_PLUGINS_DIR_NAME)); candidates.push(dir.join(BUILT_IN_PLUGINS_DIR_NAME)); + // Dev builds can generate built-in bundles under `target/openvcs/`. + if let Some(target_dir) = dir.parent() { + candidates.push(target_dir.join("openvcs").join(BUILT_IN_PLUGINS_DIR_NAME)); + } #[cfg(target_os = "macos")] if let Some(parent) = dir.parent() { candidates.push(parent.join("Resources").join(BUILT_IN_PLUGINS_DIR_NAME)); diff --git a/Backend/tauri.conf.json b/Backend/tauri.conf.json index 89399c7..d5e804a 100644 --- a/Backend/tauri.conf.json +++ b/Backend/tauri.conf.json @@ -3,7 +3,7 @@ "productName": "OpenVCS", "identifier": "dev.jordon.openvcs", "build": { - "beforeDevCommand": "npm run dev --prefix ../Frontend", + "beforeDevCommand": "cargo openvcs dist --all --plugin-dir ../Backend/built-in-plugins --out ../target/openvcs/built-in-plugins && npm run dev --prefix ../Frontend", "devUrl": "http://localhost:1420", "frontendDist": "../Frontend/dist" }, @@ -40,7 +40,7 @@ "icons/icon.ico" ], "resources": [ - "built-in-plugins" + "../target/openvcs/built-in-plugins" ], "licenseFile": "../LICENSE", "publisher": "Jordon Brooks", diff --git a/Cargo.lock b/Cargo.lock index 6ab4617..8c0e155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -403,9 +403,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "byteorder" @@ -1849,6 +1849,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "git2" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2b37e2f62729cdada11f0e6b3b6fe383c69c29fc619e391223e12856af308c" +dependencies = [ + "bitflags 2.10.0", + "libc", + "libgit2-sys", + "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", +] + [[package]] name = "glib" version = "0.18.5" @@ -2143,7 +2158,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -2614,6 +2629,20 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[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" @@ -2641,6 +2670,32 @@ 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" @@ -3193,12 +3248,30 @@ 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.0-rc.6" @@ -3245,6 +3318,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "openvcs-plugin-git" +version = "0.1.0" +dependencies = [ + "git2", + "linkme", + "log", + "openvcs-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3576,9 +3663,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "portable-atomic-util" @@ -4135,7 +4222,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", "security-framework", @@ -5678,6 +5765,12 @@ 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" @@ -6311,9 +6404,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b" dependencies = [ "rustls-pki-types", ] @@ -6328,8 +6421,8 @@ dependencies = [ "webview2-com-sys", "windows", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -6484,28 +6577,28 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.61.2" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", ] [[package]] name = "windows-core" -version = "0.62.2" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -6519,6 +6612,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6530,6 +6634,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -6565,38 +6680,39 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.3.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" dependencies = [ - "windows-link 0.1.3", + "windows-targets 0.52.6", ] [[package]] name = "windows-result" -version = "0.4.1" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link 0.2.1", + "windows-link 0.1.3", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-link 0.1.3", + "windows-result 0.2.0", + "windows-targets 0.52.6", ] [[package]] name = "windows-strings" -version = "0.5.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link 0.2.1", + "windows-link 0.1.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1c7a166..24fbd62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "Backend", + "Backend/built-in-plugins/Git", ] resolver = "2" diff --git a/Justfile b/Justfile index b9e702f..1d44120 100644 --- a/Justfile +++ b/Justfile @@ -1,8 +1,22 @@ -set shell := ["bash", "-eu", "-o", "pipefail", "-c"] - default: @just --list +build target="all": + @just {{ if target == "plugins" { "_build_plugins" } else if target == "client" { "_build_client" } else if target == "all" { "_build_all" } else { "_build_usage" } }} + +_build_all: _build_client + +_build_plugins: + cargo openvcs dist --all --plugin-dir Backend/built-in-plugins --out target/openvcs/built-in-plugins + +_build_client: _build_plugins + npm --prefix Frontend run build + cargo build + +_build_usage: + @echo "Unknown build target. Use: just build, just build plugins, or just build client" + @exit 2 + test: cargo test --workspace || true cd Frontend && npm exec tsc -- -p tsconfig.json --noEmit || true From 8626771433a280e4a3cb80fc52f37f352cff8e29 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 13:20:36 +0000 Subject: [PATCH 09/50] backend: update git plugin submodule for wasm dist path Track the plugin commit that sets Cargo target-dir in Backend/built-in-plugins/Git/.cargo/config.toml so cargo openvcs dist finds openvcs-git-plugin.wasm. --- Backend/built-in-plugins/Git | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index d6d305c..5d625f6 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit d6d305ca15d98a3d389d9cf64aff63a543f49d3c +Subproject commit 5d625f6ddd7afdff13d841fd33face8088bf7fe3 From 6530a9f7f314a1b703210f25e749bc761d0c71bf Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 13:26:59 +0000 Subject: [PATCH 10/50] backend: reuse workspace target for plugin dist Create a symlink from Backend/built-in-plugins/Git/target to the workspace target before plugin bundling in both tauri dev and just build plugins flows. --- Backend/tauri.conf.json | 2 +- Justfile | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Backend/tauri.conf.json b/Backend/tauri.conf.json index d5e804a..8cb510a 100644 --- a/Backend/tauri.conf.json +++ b/Backend/tauri.conf.json @@ -3,7 +3,7 @@ "productName": "OpenVCS", "identifier": "dev.jordon.openvcs", "build": { - "beforeDevCommand": "cargo openvcs dist --all --plugin-dir ../Backend/built-in-plugins --out ../target/openvcs/built-in-plugins && npm run dev --prefix ../Frontend", + "beforeDevCommand": "rm -rf ../Backend/built-in-plugins/Git/target && ln -s ../../../target ../Backend/built-in-plugins/Git/target && cargo openvcs dist --all --plugin-dir ../Backend/built-in-plugins --out ../target/openvcs/built-in-plugins && npm run dev --prefix ../Frontend", "devUrl": "http://localhost:1420", "frontendDist": "../Frontend/dist" }, diff --git a/Justfile b/Justfile index 1d44120..aa5af86 100644 --- a/Justfile +++ b/Justfile @@ -7,6 +7,8 @@ build target="all": _build_all: _build_client _build_plugins: + rm -rf Backend/built-in-plugins/Git/target + ln -s ../../../target Backend/built-in-plugins/Git/target cargo openvcs dist --all --plugin-dir Backend/built-in-plugins --out target/openvcs/built-in-plugins _build_client: _build_plugins From 6d86bcc31824a85f5c2e10ee620475b22f1c158c Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 13:34:07 +0000 Subject: [PATCH 11/50] backend: remove plugin target symlink workaround Restore direct cargo openvcs dist invocations now that the dist tool is updated to use workspace target resolution. --- Backend/tauri.conf.json | 2 +- Justfile | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Backend/tauri.conf.json b/Backend/tauri.conf.json index 8cb510a..d5e804a 100644 --- a/Backend/tauri.conf.json +++ b/Backend/tauri.conf.json @@ -3,7 +3,7 @@ "productName": "OpenVCS", "identifier": "dev.jordon.openvcs", "build": { - "beforeDevCommand": "rm -rf ../Backend/built-in-plugins/Git/target && ln -s ../../../target ../Backend/built-in-plugins/Git/target && cargo openvcs dist --all --plugin-dir ../Backend/built-in-plugins --out ../target/openvcs/built-in-plugins && npm run dev --prefix ../Frontend", + "beforeDevCommand": "cargo openvcs dist --all --plugin-dir ../Backend/built-in-plugins --out ../target/openvcs/built-in-plugins && npm run dev --prefix ../Frontend", "devUrl": "http://localhost:1420", "frontendDist": "../Frontend/dist" }, diff --git a/Justfile b/Justfile index aa5af86..1d44120 100644 --- a/Justfile +++ b/Justfile @@ -7,8 +7,6 @@ build target="all": _build_all: _build_client _build_plugins: - rm -rf Backend/built-in-plugins/Git/target - ln -s ../../../target Backend/built-in-plugins/Git/target cargo openvcs dist --all --plugin-dir Backend/built-in-plugins --out target/openvcs/built-in-plugins _build_client: _build_plugins From c0a2ac93686e2367fde2e0efb278dbbcbf42ba24 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 13:36:34 +0000 Subject: [PATCH 12/50] Fix issues --- Backend/src/lib.rs | 10 ++++++---- Backend/src/tauri_commands/backends.rs | 2 +- Backend/src/tauri_commands/general.rs | 22 ++++++++++++---------- Backend/src/tauri_commands/mod.rs | 4 +--- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 8bed33f..c7555c1 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -32,10 +32,12 @@ fn preferred_vcs_backend_id(_cfg: &settings::AppConfig) -> Option { } } - crate::plugin_vcs_backends::list_plugin_vcs_backends().ok().and_then(|mut backends| { - backends.sort_by(|a, b| a.backend_id.as_ref().cmp(b.backend_id.as_ref())); - backends.into_iter().next().map(|b| b.backend_id) - }) + crate::plugin_vcs_backends::list_plugin_vcs_backends() + .ok() + .and_then(|mut backends| { + backends.sort_by(|a, b| a.backend_id.as_ref().cmp(b.backend_id.as_ref())); + backends.into_iter().next().map(|b| b.backend_id) + }) } /// Attempt to reopen the most recent repository at startup if the diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index 6085741..4a059dd 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -8,8 +8,8 @@ use tauri::{async_runtime, Manager, Runtime, State, Window}; use openvcs_core::BackendId; use std::collections::BTreeMap; -use crate::plugin_vcs_backends; 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; diff --git a/Backend/src/tauri_commands/general.rs b/Backend/src/tauri_commands/general.rs index d493398..ea705f3 100644 --- a/Backend/src/tauri_commands/general.rs +++ b/Backend/src/tauri_commands/general.rs @@ -71,8 +71,8 @@ pub async fn add_repo( let be = backend_id .or_else(|| default_backend_id(&state)) .ok_or_else(|| { - "No VCS backend is available (install/enable a backend plugin)".to_string() - })?; + "No VCS backend is available (install/enable a backend plugin)".to_string() + })?; add_repo_internal(window, state, path, be).await } @@ -85,10 +85,12 @@ fn default_backend_id(state: &AppState) -> Option { } } - crate::plugin_vcs_backends::list_plugin_vcs_backends().ok().and_then(|mut backends| { - backends.sort_by(|a, b| a.backend_id.as_ref().cmp(b.backend_id.as_ref())); - backends.into_iter().next().map(|b| b.backend_id) - }) + crate::plugin_vcs_backends::list_plugin_vcs_backends() + .ok() + .and_then(|mut backends| { + backends.sort_by(|a, b| a.backend_id.as_ref().cmp(b.backend_id.as_ref())); + backends.into_iter().next().map(|b| b.backend_id) + }) } pub async fn add_repo_internal( @@ -159,8 +161,8 @@ pub async fn clone_repo( let be = backend_id .or_else(|| default_backend_id(&state)) .ok_or_else(|| { - "No VCS backend is available (install/enable a backend plugin)".to_string() - })?; + "No VCS backend is available (install/enable a backend plugin)".to_string() + })?; let _prefer_plugin = plugin_vcs_backends::has_plugin_vcs_backend(&be); let folder = infer_repo_dir_from_url(&url); @@ -252,8 +254,8 @@ pub async fn open_repo( let be = backend_id .or_else(|| default_backend_id(&state)) .ok_or_else(|| { - "No VCS backend is available (install/enable a backend plugin)".to_string() - })?; + "No VCS backend is available (install/enable a backend plugin)".to_string() + })?; add_repo_internal(window, state, path, be).await } diff --git a/Backend/src/tauri_commands/mod.rs b/Backend/src/tauri_commands/mod.rs index d4812e5..a502e0b 100644 --- a/Backend/src/tauri_commands/mod.rs +++ b/Backend/src/tauri_commands/mod.rs @@ -31,6 +31,4 @@ pub use status::*; pub use themes::*; pub use updater::*; -pub(crate) use shared::{ - current_repo_or_err, progress_bridge, run_repo_task, ProgressPayload, -}; +pub(crate) use shared::{current_repo_or_err, progress_bridge, run_repo_task, ProgressPayload}; From 003008ef2cc219c699fde5d708b7bf90acfff2b0 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 13:36:37 +0000 Subject: [PATCH 13/50] Update Git --- Backend/built-in-plugins/Git | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index 5d625f6..5e269c4 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit 5d625f6ddd7afdff13d841fd33face8088bf7fe3 +Subproject commit 5e269c42da8ff3d9ee61f6a51a8e783ae67103c1 From 84b379c4f1bba02e7daae01061c639f42a80e41f Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 14:51:33 +0000 Subject: [PATCH 14/50] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 35f5cce..899c9c3 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist-ssr /target /Backend/icons/android /Backend/icons/ios +/Core From 198a3f15298c4d6d931023728b44339d3818ae62 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 14:52:37 +0000 Subject: [PATCH 15/50] Update Cargo.lock --- Cargo.lock | 97 +++++++++++++++++++++--------------------------------- 1 file changed, 37 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c0e155..981061f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -403,9 +403,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -1851,9 +1851,9 @@ dependencies = [ [[package]] name = "git2" -version = "0.20.3" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2b37e2f62729cdada11f0e6b3b6fe383c69c29fc619e391223e12856af308c" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ "bitflags 2.10.0", "libc", @@ -2158,7 +2158,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -3663,9 +3663,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" @@ -6404,9 +6404,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.4" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -6421,8 +6421,8 @@ dependencies = [ "webview2-com-sys", "windows", "windows-core 0.61.2", - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", ] [[package]] @@ -6577,28 +6577,28 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.58.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -6612,17 +6612,6 @@ dependencies = [ "windows-threading", ] -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -6634,17 +6623,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -6680,39 +6658,38 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.2.0" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-targets 0.52.6", + "windows-link 0.1.3", ] [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.1.3", + "windows-link 0.2.1", ] [[package]] name = "windows-strings" -version = "0.1.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", + "windows-link 0.1.3", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.1.3", + "windows-link 0.2.1", ] [[package]] From 4fa19366b2ecac93acda31170e4c80a7a5a2500b Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 15:05:30 +0000 Subject: [PATCH 16/50] frontend: reduce UI lag in diff and menus Remove background HEAD polling, make diff selection lazy, and switch hunk/line toggle handling to delegated events. Narrow and batch scrollbar observer scanning to only relevant containers. Add frontend tests for patch selection semantics and scrollbar observer filtering. --- Frontend/src/scripts/features/diff.test.ts | 43 ++++++++++++++ Frontend/src/scripts/features/diff.ts | 2 +- .../src/scripts/features/repo/diffView.ts | 59 ++++++------------- Frontend/src/scripts/lib/scrollbars.test.ts | 49 +++++++++++++++ Frontend/src/scripts/lib/scrollbars.ts | 32 +++++++--- Frontend/src/scripts/main.ts | 28 --------- 6 files changed, 134 insertions(+), 79 deletions(-) create mode 100644 Frontend/src/scripts/features/diff.test.ts create mode 100644 Frontend/src/scripts/lib/scrollbars.test.ts diff --git a/Frontend/src/scripts/features/diff.test.ts b/Frontend/src/scripts/features/diff.test.ts new file mode 100644 index 0000000..6320102 --- /dev/null +++ b/Frontend/src/scripts/features/diff.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { buildPatchForSelected } from './diff' + +describe('buildPatchForSelected', () => { + const lines = [ + 'diff --git a/a.txt b/a.txt', + '--- a/a.txt', + '+++ b/a.txt', + '@@ -1,2 +1,2 @@', + '-old', + '+new', + ' keep', + '@@ -10,2 +10,2 @@', + '-x', + '+y', + ] + + it('includes whole selected hunks without requiring per-line selections', () => { + const patch = buildPatchForSelected('a.txt', lines, [0], {}) + + expect(patch).toContain('@@ -1,2 +1,2 @@') + expect(patch).toContain('-old') + expect(patch).toContain('+new') + expect(patch).not.toContain('@@ -10,2 +10,2 @@') + }) + + it('builds a partial mini-hunk when only line selections are present', () => { + const patch = buildPatchForSelected('a.txt', lines, [], { 0: [1] }) + + expect(patch).toContain('@@ -1,1 +1,0 @@') + expect(patch).toContain('-old') + expect(patch).not.toContain('+new') + }) + + it('combines whole-hunk and partial selections across hunks', () => { + const patch = buildPatchForSelected('a.txt', lines, [0], { 1: [1] }) + + expect(patch).toContain('@@ -1,2 +1,2 @@') + expect(patch).toContain('@@ -10,1 +10,0 @@') + expect(patch).toContain('-x') + expect(patch).not.toContain('+y') + }) +}) diff --git a/Frontend/src/scripts/features/diff.ts b/Frontend/src/scripts/features/diff.ts index c6207cb..a6e7e8f 100644 --- a/Frontend/src/scripts/features/diff.ts +++ b/Frontend/src/scripts/features/diff.ts @@ -155,7 +155,7 @@ export function buildPatchForSelectedHunks(path: string, lines: string[], hunkIn } // Build a patch combining whole selected hunks and per-line selections (unidiff-zero mini-hunks). -function buildPatchForSelected(path: string, lines: string[], hunkIndices: number[] = [], selLines: Record = {}): string { +export function buildPatchForSelected(path: string, lines: string[], hunkIndices: number[] = [], selLines: Record = {}): string { const normPath = String(path).replace(/\\/g, '/'); const firstHunk = lines.findIndex(l => (l || '').startsWith('@@')); const prelude = firstHunk >= 0 ? lines.slice(0, firstHunk) : []; diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index d738ab4..e7d32ff 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -183,21 +183,8 @@ export async function selectFile(file: FileStatus, index: number) { updateHunkCheckboxes(); } else if (state.selectedFiles.has(file.path) || state.defaultSelectAll) { state.selectedHunks = allHunkIndices(state.currentDiff); - updateHunkCheckboxes(); - const recExisting: Record = (state as any).selectedLinesByFile[state.currentFile] || {}; - const root = diffEl as HTMLElement; - const rec: Record = { ...recExisting }; - state.selectedHunks.forEach((h) => { - if (rec[h] && rec[h].length > 0) return; - const boxes = root.querySelectorAll(`input.pick-line[data-hunk="${h}"]`); - const picked: number[] = []; - boxes.forEach((b) => { - b.checked = true; - picked.push(Number(b.dataset.line || -1)); - }); - if (picked.length > 0) rec[h] = Array.from(new Set(picked)).sort((a, b) => a - b); - }); - (state as any).selectedLinesByFile[state.currentFile] = rec; + (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); + delete (state as any).selectedLinesByFile[state.currentFile]; updateHunkCheckboxes(); } else { state.selectedHunks = []; @@ -406,26 +393,10 @@ export function toggleFilePick(path: string, on: boolean) { if (on) { state.selectedHunks = allHunkIndices(state.currentDiff); (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); - const root = diffEl as HTMLElement; - const rec: Record = {}; - const lineBoxes = root.querySelectorAll('input.pick-line'); - lineBoxes.forEach((b) => { - const h = Number(b.dataset.hunk || -1); - const l = Number(b.dataset.line || -1); - if (h < 0 || l < 0) return; - (rec[h] ||= []).push(l); - b.checked = true; - }); - Object.keys(rec).forEach((k) => { - rec[Number(k)] = Array.from(new Set(rec[Number(k)])).sort((a, b) => a - b); - }); - (state as any).selectedLinesByFile[state.currentFile] = rec; + delete (state as any).selectedLinesByFile[state.currentFile]; } else { state.selectedHunks = []; delete (state as any).selectedHunksByFile[state.currentFile]; - const root = diffEl as HTMLElement; - const lineBoxes = root.querySelectorAll('input.pick-line'); - lineBoxes.forEach((b) => { b.checked = false; }); delete (state as any).selectedLinesByFile[state.currentFile]; } updateHunkCheckboxes(); @@ -457,9 +428,15 @@ export function updateHunkCheckboxes() { } function bindHunkToggles(root: HTMLElement) { - const boxes = root.querySelectorAll('input.pick-hunk'); - boxes.forEach((b) => { - b.addEventListener('change', () => { + if ((root as any).__openvcsHunkTogglesBound) return; + (root as any).__openvcsHunkTogglesBound = true; + + root.addEventListener('change', (ev) => { + const target = ev.target as HTMLInputElement | null; + if (!target) return; + + if (target.matches('input.pick-hunk')) { + const b = target; const clearedImplicit = disableDefaultSelectAll(true); if (clearedImplicit) clearAllFileSelections(); const idx = Number(b.dataset.hunk || -1); @@ -487,12 +464,11 @@ function bindHunkToggles(root: HTMLElement) { updateCommitButton(); const hk = b.closest('.hunk') as HTMLElement | null; if (hk) hk.classList.toggle('picked', b.checked); - }); - }); + return; + } - const lineBoxes = root.querySelectorAll('input.pick-line'); - lineBoxes.forEach((b) => { - b.addEventListener('change', () => { + if (target.matches('input.pick-line')) { + const b = target; const clearedImplicit = disableDefaultSelectAll(true); if (clearedImplicit) clearAllFileSelections(); const hunk = Number(b.dataset.hunk || -1); @@ -525,7 +501,8 @@ function bindHunkToggles(root: HTMLElement) { syncFileCheckboxWithHunks(); updateSelectAllState(getVisibleFiles()); updateCommitButton(); - }); + return; + } }); } diff --git a/Frontend/src/scripts/lib/scrollbars.test.ts b/Frontend/src/scripts/lib/scrollbars.test.ts new file mode 100644 index 0000000..5a6c948 --- /dev/null +++ b/Frontend/src/scripts/lib/scrollbars.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const overlayMock = vi.hoisted(() => vi.fn(() => ({}))) +vi.mock('overlayscrollbars', () => ({ + OverlayScrollbars: overlayMock, +})) + +import { initOverlayScrollbars, observeOverlayScrollbars } from './scrollbars' + +describe('scrollbars observer', () => { + beforeEach(() => { + document.body.innerHTML = '' + overlayMock.mockClear() + }) + + it('initializes only known scroll containers', () => { + document.body.innerHTML = ` +
+
+
+ ` + + initOverlayScrollbars() + expect(overlayMock).toHaveBeenCalledTimes(2) + }) + + it('ignores unrelated added nodes and batches matching subtree init', async () => { + const stop = observeOverlayScrollbars() + try { + const noop = document.createElement('div') + noop.className = 'no-scroll-target' + document.body.appendChild(noop) + await Promise.resolve() + expect(overlayMock).toHaveBeenCalledTimes(0) + + const wrapper = document.createElement('section') + wrapper.innerHTML = ` +
+
+ ` + document.body.appendChild(wrapper) + await Promise.resolve() + await Promise.resolve() + expect(overlayMock).toHaveBeenCalledTimes(1) + } finally { + stop() + } + }) +}) diff --git a/Frontend/src/scripts/lib/scrollbars.ts b/Frontend/src/scripts/lib/scrollbars.ts index fddd05e..6a22e1c 100644 --- a/Frontend/src/scripts/lib/scrollbars.ts +++ b/Frontend/src/scripts/lib/scrollbars.ts @@ -3,6 +3,7 @@ import { OverlayScrollbars } from 'overlayscrollbars'; // Use the official attribute name so OverlayScrollbars can hide native scrollbars // during initialization to reduce flicker. const OS_ATTR = 'data-overlayscrollbars-initialize'; +const SCROLL_TARGETS = '.list-scroll, .pop-list-scroll'; function initOne(el: HTMLElement) { if (el.hasAttribute(OS_ATTR)) return; @@ -28,14 +29,7 @@ function initOne(el: HTMLElement) { function queryScrollableElements(root: ParentNode): HTMLElement[] { return Array.from( - root.querySelectorAll( - [ - // Main panes (use wrappers so re-rendering content doesn't destroy OS structure) - '.list-scroll', - // Popovers/menus that scroll (use wrappers) - '.pop-list-scroll', - ].join(','), - ), + root.querySelectorAll(SCROLL_TARGETS), ); } @@ -66,11 +60,31 @@ export function destroyOverlayScrollbarsFor(selector: string) { export function observeOverlayScrollbars() { initOverlayScrollbars(); + let flushQueued = false; + const pending = new Set(); + + const queueInit = (el: HTMLElement) => { + pending.add(el); + if (flushQueued) return; + flushQueued = true; + queueMicrotask(() => { + flushQueued = false; + pending.forEach((node) => initOne(node)); + pending.clear(); + }); + }; + + const collect = (node: HTMLElement) => { + if (node.matches(SCROLL_TARGETS)) queueInit(node); + const matches = node.querySelectorAll(SCROLL_TARGETS); + matches.forEach((el) => queueInit(el)); + }; + const obs = new MutationObserver((records) => { for (const r of records) { r.addedNodes.forEach((n) => { if (!(n instanceof HTMLElement)) return; - if (n.matches?.('[class], [id]')) initOverlayScrollbars(n); + collect(n); }); } }); diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index 3af3be9..5a46138 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -498,34 +498,6 @@ async function boot() { if (document.visibilityState === 'visible') onFocus().catch(() => {}); }); - // Poll HEAD so external checkouts (CLI/other apps) update the UI while focused. - // This is intentionally lightweight: only re-hydrate when HEAD changes. - let headPollInFlight: Promise | null = null; - let lastHeadKey = ''; - const headPollMs = 2000; - setInterval(() => { - if (!TAURI.has) return; - if (!state.hasRepo) return; - if (document.visibilityState !== 'visible') return; - if (headPollInFlight) return; - headPollInFlight = (async () => { - try { - const head = await TAURI.invoke<{ detached: boolean; branch?: string; commit?: string }>('git_head_status'); - const key = `${head?.detached ? 1 : 0}:${String(head?.branch || '')}:${String(head?.commit || '')}`; - if (key === lastHeadKey) return; - - const ok = await hydrateBranches(); - if (!ok) return; - setRepoHeader(); - await Promise.allSettled([hydrateStatus(), hydrateCommits()]); - updateFetchUI(); - lastHeadKey = key; - } catch { - // ignore transient failures (e.g. repo switching / git busy) - } - })().finally(() => { headPollInFlight = null; }); - }, headPollMs); - // open settings via event TAURI.listen?.('ui:open-settings', ({ payload }) => { const section = typeof payload === 'string' From 5f0887aaf01d543164776cbf36ca117d9f7a5dcd Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 15:32:56 +0000 Subject: [PATCH 17/50] Revert "frontend: reduce UI lag in diff and menus" This reverts commit 4fa19366b2ecac93acda31170e4c80a7a5a2500b. --- Frontend/src/scripts/features/diff.test.ts | 43 -------------- Frontend/src/scripts/features/diff.ts | 2 +- .../src/scripts/features/repo/diffView.ts | 59 +++++++++++++------ Frontend/src/scripts/lib/scrollbars.test.ts | 49 --------------- Frontend/src/scripts/lib/scrollbars.ts | 32 +++------- Frontend/src/scripts/main.ts | 28 +++++++++ 6 files changed, 79 insertions(+), 134 deletions(-) delete mode 100644 Frontend/src/scripts/features/diff.test.ts delete mode 100644 Frontend/src/scripts/lib/scrollbars.test.ts diff --git a/Frontend/src/scripts/features/diff.test.ts b/Frontend/src/scripts/features/diff.test.ts deleted file mode 100644 index 6320102..0000000 --- a/Frontend/src/scripts/features/diff.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildPatchForSelected } from './diff' - -describe('buildPatchForSelected', () => { - const lines = [ - 'diff --git a/a.txt b/a.txt', - '--- a/a.txt', - '+++ b/a.txt', - '@@ -1,2 +1,2 @@', - '-old', - '+new', - ' keep', - '@@ -10,2 +10,2 @@', - '-x', - '+y', - ] - - it('includes whole selected hunks without requiring per-line selections', () => { - const patch = buildPatchForSelected('a.txt', lines, [0], {}) - - expect(patch).toContain('@@ -1,2 +1,2 @@') - expect(patch).toContain('-old') - expect(patch).toContain('+new') - expect(patch).not.toContain('@@ -10,2 +10,2 @@') - }) - - it('builds a partial mini-hunk when only line selections are present', () => { - const patch = buildPatchForSelected('a.txt', lines, [], { 0: [1] }) - - expect(patch).toContain('@@ -1,1 +1,0 @@') - expect(patch).toContain('-old') - expect(patch).not.toContain('+new') - }) - - it('combines whole-hunk and partial selections across hunks', () => { - const patch = buildPatchForSelected('a.txt', lines, [0], { 1: [1] }) - - expect(patch).toContain('@@ -1,2 +1,2 @@') - expect(patch).toContain('@@ -10,1 +10,0 @@') - expect(patch).toContain('-x') - expect(patch).not.toContain('+y') - }) -}) diff --git a/Frontend/src/scripts/features/diff.ts b/Frontend/src/scripts/features/diff.ts index a6e7e8f..c6207cb 100644 --- a/Frontend/src/scripts/features/diff.ts +++ b/Frontend/src/scripts/features/diff.ts @@ -155,7 +155,7 @@ export function buildPatchForSelectedHunks(path: string, lines: string[], hunkIn } // Build a patch combining whole selected hunks and per-line selections (unidiff-zero mini-hunks). -export function buildPatchForSelected(path: string, lines: string[], hunkIndices: number[] = [], selLines: Record = {}): string { +function buildPatchForSelected(path: string, lines: string[], hunkIndices: number[] = [], selLines: Record = {}): string { const normPath = String(path).replace(/\\/g, '/'); const firstHunk = lines.findIndex(l => (l || '').startsWith('@@')); const prelude = firstHunk >= 0 ? lines.slice(0, firstHunk) : []; diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index e7d32ff..d738ab4 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -183,8 +183,21 @@ export async function selectFile(file: FileStatus, index: number) { updateHunkCheckboxes(); } else if (state.selectedFiles.has(file.path) || state.defaultSelectAll) { state.selectedHunks = allHunkIndices(state.currentDiff); - (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); - delete (state as any).selectedLinesByFile[state.currentFile]; + updateHunkCheckboxes(); + const recExisting: Record = (state as any).selectedLinesByFile[state.currentFile] || {}; + const root = diffEl as HTMLElement; + const rec: Record = { ...recExisting }; + state.selectedHunks.forEach((h) => { + if (rec[h] && rec[h].length > 0) return; + const boxes = root.querySelectorAll(`input.pick-line[data-hunk="${h}"]`); + const picked: number[] = []; + boxes.forEach((b) => { + b.checked = true; + picked.push(Number(b.dataset.line || -1)); + }); + if (picked.length > 0) rec[h] = Array.from(new Set(picked)).sort((a, b) => a - b); + }); + (state as any).selectedLinesByFile[state.currentFile] = rec; updateHunkCheckboxes(); } else { state.selectedHunks = []; @@ -393,10 +406,26 @@ export function toggleFilePick(path: string, on: boolean) { if (on) { state.selectedHunks = allHunkIndices(state.currentDiff); (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); - delete (state as any).selectedLinesByFile[state.currentFile]; + const root = diffEl as HTMLElement; + const rec: Record = {}; + const lineBoxes = root.querySelectorAll('input.pick-line'); + lineBoxes.forEach((b) => { + const h = Number(b.dataset.hunk || -1); + const l = Number(b.dataset.line || -1); + if (h < 0 || l < 0) return; + (rec[h] ||= []).push(l); + b.checked = true; + }); + Object.keys(rec).forEach((k) => { + rec[Number(k)] = Array.from(new Set(rec[Number(k)])).sort((a, b) => a - b); + }); + (state as any).selectedLinesByFile[state.currentFile] = rec; } else { state.selectedHunks = []; delete (state as any).selectedHunksByFile[state.currentFile]; + const root = diffEl as HTMLElement; + const lineBoxes = root.querySelectorAll('input.pick-line'); + lineBoxes.forEach((b) => { b.checked = false; }); delete (state as any).selectedLinesByFile[state.currentFile]; } updateHunkCheckboxes(); @@ -428,15 +457,9 @@ export function updateHunkCheckboxes() { } function bindHunkToggles(root: HTMLElement) { - if ((root as any).__openvcsHunkTogglesBound) return; - (root as any).__openvcsHunkTogglesBound = true; - - root.addEventListener('change', (ev) => { - const target = ev.target as HTMLInputElement | null; - if (!target) return; - - if (target.matches('input.pick-hunk')) { - const b = target; + const boxes = root.querySelectorAll('input.pick-hunk'); + boxes.forEach((b) => { + b.addEventListener('change', () => { const clearedImplicit = disableDefaultSelectAll(true); if (clearedImplicit) clearAllFileSelections(); const idx = Number(b.dataset.hunk || -1); @@ -464,11 +487,12 @@ function bindHunkToggles(root: HTMLElement) { updateCommitButton(); const hk = b.closest('.hunk') as HTMLElement | null; if (hk) hk.classList.toggle('picked', b.checked); - return; - } + }); + }); - if (target.matches('input.pick-line')) { - const b = target; + const lineBoxes = root.querySelectorAll('input.pick-line'); + lineBoxes.forEach((b) => { + b.addEventListener('change', () => { const clearedImplicit = disableDefaultSelectAll(true); if (clearedImplicit) clearAllFileSelections(); const hunk = Number(b.dataset.hunk || -1); @@ -501,8 +525,7 @@ function bindHunkToggles(root: HTMLElement) { syncFileCheckboxWithHunks(); updateSelectAllState(getVisibleFiles()); updateCommitButton(); - return; - } + }); }); } diff --git a/Frontend/src/scripts/lib/scrollbars.test.ts b/Frontend/src/scripts/lib/scrollbars.test.ts deleted file mode 100644 index 5a6c948..0000000 --- a/Frontend/src/scripts/lib/scrollbars.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const overlayMock = vi.hoisted(() => vi.fn(() => ({}))) -vi.mock('overlayscrollbars', () => ({ - OverlayScrollbars: overlayMock, -})) - -import { initOverlayScrollbars, observeOverlayScrollbars } from './scrollbars' - -describe('scrollbars observer', () => { - beforeEach(() => { - document.body.innerHTML = '' - overlayMock.mockClear() - }) - - it('initializes only known scroll containers', () => { - document.body.innerHTML = ` -
-
-
- ` - - initOverlayScrollbars() - expect(overlayMock).toHaveBeenCalledTimes(2) - }) - - it('ignores unrelated added nodes and batches matching subtree init', async () => { - const stop = observeOverlayScrollbars() - try { - const noop = document.createElement('div') - noop.className = 'no-scroll-target' - document.body.appendChild(noop) - await Promise.resolve() - expect(overlayMock).toHaveBeenCalledTimes(0) - - const wrapper = document.createElement('section') - wrapper.innerHTML = ` -
-
- ` - document.body.appendChild(wrapper) - await Promise.resolve() - await Promise.resolve() - expect(overlayMock).toHaveBeenCalledTimes(1) - } finally { - stop() - } - }) -}) diff --git a/Frontend/src/scripts/lib/scrollbars.ts b/Frontend/src/scripts/lib/scrollbars.ts index 6a22e1c..fddd05e 100644 --- a/Frontend/src/scripts/lib/scrollbars.ts +++ b/Frontend/src/scripts/lib/scrollbars.ts @@ -3,7 +3,6 @@ import { OverlayScrollbars } from 'overlayscrollbars'; // Use the official attribute name so OverlayScrollbars can hide native scrollbars // during initialization to reduce flicker. const OS_ATTR = 'data-overlayscrollbars-initialize'; -const SCROLL_TARGETS = '.list-scroll, .pop-list-scroll'; function initOne(el: HTMLElement) { if (el.hasAttribute(OS_ATTR)) return; @@ -29,7 +28,14 @@ function initOne(el: HTMLElement) { function queryScrollableElements(root: ParentNode): HTMLElement[] { return Array.from( - root.querySelectorAll(SCROLL_TARGETS), + root.querySelectorAll( + [ + // Main panes (use wrappers so re-rendering content doesn't destroy OS structure) + '.list-scroll', + // Popovers/menus that scroll (use wrappers) + '.pop-list-scroll', + ].join(','), + ), ); } @@ -60,31 +66,11 @@ export function destroyOverlayScrollbarsFor(selector: string) { export function observeOverlayScrollbars() { initOverlayScrollbars(); - let flushQueued = false; - const pending = new Set(); - - const queueInit = (el: HTMLElement) => { - pending.add(el); - if (flushQueued) return; - flushQueued = true; - queueMicrotask(() => { - flushQueued = false; - pending.forEach((node) => initOne(node)); - pending.clear(); - }); - }; - - const collect = (node: HTMLElement) => { - if (node.matches(SCROLL_TARGETS)) queueInit(node); - const matches = node.querySelectorAll(SCROLL_TARGETS); - matches.forEach((el) => queueInit(el)); - }; - const obs = new MutationObserver((records) => { for (const r of records) { r.addedNodes.forEach((n) => { if (!(n instanceof HTMLElement)) return; - collect(n); + if (n.matches?.('[class], [id]')) initOverlayScrollbars(n); }); } }); diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index 5a46138..3af3be9 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -498,6 +498,34 @@ async function boot() { if (document.visibilityState === 'visible') onFocus().catch(() => {}); }); + // Poll HEAD so external checkouts (CLI/other apps) update the UI while focused. + // This is intentionally lightweight: only re-hydrate when HEAD changes. + let headPollInFlight: Promise | null = null; + let lastHeadKey = ''; + const headPollMs = 2000; + setInterval(() => { + if (!TAURI.has) return; + if (!state.hasRepo) return; + if (document.visibilityState !== 'visible') return; + if (headPollInFlight) return; + headPollInFlight = (async () => { + try { + const head = await TAURI.invoke<{ detached: boolean; branch?: string; commit?: string }>('git_head_status'); + const key = `${head?.detached ? 1 : 0}:${String(head?.branch || '')}:${String(head?.commit || '')}`; + if (key === lastHeadKey) return; + + const ok = await hydrateBranches(); + if (!ok) return; + setRepoHeader(); + await Promise.allSettled([hydrateStatus(), hydrateCommits()]); + updateFetchUI(); + lastHeadKey = key; + } catch { + // ignore transient failures (e.g. repo switching / git busy) + } + })().finally(() => { headPollInFlight = null; }); + }, headPollMs); + // open settings via event TAURI.listen?.('ui:open-settings', ({ payload }) => { const section = typeof payload === 'string' From 17762a70a1f6717f3dd5fb9cd0613ae98d0a49dc Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 16:05:13 +0000 Subject: [PATCH 18/50] Fix some performance issues with scrollbars --- Frontend/src/scripts/features/branches.ts | 2 + Frontend/src/scripts/features/outputLog.ts | 6 +- .../src/scripts/features/repo/diffView.ts | 6 +- Frontend/src/scripts/lib/scrollbars.ts | 141 +++++++++++------- Frontend/src/scripts/main.ts | 30 +--- Frontend/src/scripts/plugins.ts | 6 +- Frontend/src/scripts/ui/modals.ts | 5 + Frontend/src/styles/overlayscrollbars.css | 6 +- 8 files changed, 115 insertions(+), 87 deletions(-) diff --git a/Frontend/src/scripts/features/branches.ts b/Frontend/src/scripts/features/branches.ts index 7bfba32..953f1af 100644 --- a/Frontend/src/scripts/features/branches.ts +++ b/Frontend/src/scripts/features/branches.ts @@ -2,6 +2,7 @@ import { qs } from '../lib/dom'; import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; +import { refreshOverlayScrollbarsFor } from '../lib/scrollbars'; import { state } from '../state/state'; import { openModal } from '../ui/modals'; import { openRenameBranch } from './renameBranch'; @@ -114,6 +115,7 @@ async function openBranchPopover() { branchPop.style.top = `${r.bottom + 6}px`; branchPop.hidden = false; branchBtn.setAttribute('aria-expanded', 'true'); + try { refreshOverlayScrollbarsFor(branchPop); } catch {} setTimeout(() => branchFilter?.focus(), 0); } diff --git a/Frontend/src/scripts/features/outputLog.ts b/Frontend/src/scripts/features/outputLog.ts index 4b483ee..3a1b37f 100644 --- a/Frontend/src/scripts/features/outputLog.ts +++ b/Frontend/src/scripts/features/outputLog.ts @@ -1,6 +1,6 @@ import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; -import { initOverlayScrollbars } from '../lib/scrollbars'; +import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from '../lib/scrollbars'; type OutputLevel = 'info' | 'warn' | 'error'; type OutputLogEntry = { ts_ms: number; level: OutputLevel; source: string; message: string }; @@ -78,7 +78,8 @@ export async function initOutputLogViewIfRequested(): Promise {
`; document.body.appendChild(root); - initOverlayScrollbars(root); + initOverlayScrollbarsFor(root); + refreshOverlayScrollbarsFor(root); root.dataset.activeTab = 'vcs'; @@ -110,6 +111,7 @@ export async function initOutputLogViewIfRequested(): Promise { listApp?.classList.toggle('outlog-hidden', tab !== 'app'); const list = listFor(tab); if (auto?.checked) list && (list.scrollTop = list.scrollHeight); + refreshOverlayScrollbarsFor(root); }; let appPollTimer: number | undefined; diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index d738ab4..af8bcef 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -2,6 +2,7 @@ import { qsa, escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; import { notify } from '../../lib/notify'; +import { refreshOverlayScrollbarsFor } from '../../lib/scrollbars'; import { state, prefs, disableDefaultSelectAll } from '../../state/state'; import type { FileStatus, ConflictDetails } from '../../types'; import { buildPatchForSelectedHunks } from '../diff'; @@ -14,7 +15,10 @@ import { openMergeModal, hasExternalMergeTool, launchExternalMergeTool } from '. function scrollDiffToTop() { if (!diffEl) return; const host = diffEl.closest('.diff-scroll') as HTMLElement | null; - const viewport = host?.querySelector('[data-overlayscrollbars-viewport]') || host || diffEl.parentElement || diffEl; + if (host) { + try { refreshOverlayScrollbarsFor(host); } catch {} + } + const viewport = host?.querySelector('.os-viewport, [data-overlayscrollbars-viewport]') || host || diffEl.parentElement || diffEl; if (viewport) { viewport.scrollTop = 0; viewport.scrollLeft = 0; diff --git a/Frontend/src/scripts/lib/scrollbars.ts b/Frontend/src/scripts/lib/scrollbars.ts index fddd05e..c7718e7 100644 --- a/Frontend/src/scripts/lib/scrollbars.ts +++ b/Frontend/src/scripts/lib/scrollbars.ts @@ -3,21 +3,49 @@ import { OverlayScrollbars } from 'overlayscrollbars'; // Use the official attribute name so OverlayScrollbars can hide native scrollbars // during initialization to reduce flicker. const OS_ATTR = 'data-overlayscrollbars-initialize'; +const SCROLLABLE_SELECTOR = [ + '.list-scroll', + '.diff-scroll', + '.pop-list-scroll', + '.plugins-list-scroll', + '#command-modal .recent', + '#repo-settings-modal .sheet-body', + '#new-branch-modal .sheet-body', + '#stash-confirm-modal .sheet-body', + '#stash-confirm-modal #stash-file-container', + '#ssh-keys-modal .ssh-box', + '.merge-readonly', + '.conflict-code', + '#output-log-view .outlog-list', +].join(', '); + +const OVERLAY_OPTIONS = { + overflow: { + x: 'hidden' as const, + y: 'scroll' as const, + }, + scrollbars: { + theme: 'os-theme-openvcs', + autoHide: 'never' as const, + }, +}; + +function getInstance(el: HTMLElement): any | null { + try { + return (OverlayScrollbars as any)(el) as any; + } catch { + return null; + } +} function initOne(el: HTMLElement) { + const existing = getInstance(el); + if (existing) return; if (el.hasAttribute(OS_ATTR)) return; + el.setAttribute(OS_ATTR, '1'); try { - const instance = OverlayScrollbars(el, { - overflow: { - x: 'hidden', - y: 'scroll', - }, - scrollbars: { - theme: 'os-theme-openvcs', - autoHide: 'never', - }, - }); + const instance = OverlayScrollbars(el, OVERLAY_OPTIONS); // If initialization didn't yield an instance, restore native scrollbars. if (!instance) el.removeAttribute(OS_ATTR); } catch { @@ -26,54 +54,65 @@ function initOne(el: HTMLElement) { } } +function refreshOne(el: HTMLElement) { + const instance = getInstance(el); + if (instance && typeof instance.update === 'function') { + try { + instance.update(); + return; + } catch { + // fall through to re-init + } + } + initOne(el); +} + +function destroyOne(el: HTMLElement) { + const instance = getInstance(el); + if (instance && typeof instance.destroy === 'function') { + try { + instance.destroy(); + } catch { + // ignore + } + } + if (el.hasAttribute(OS_ATTR)) el.removeAttribute(OS_ATTR); +} + function queryScrollableElements(root: ParentNode): HTMLElement[] { - return Array.from( - root.querySelectorAll( - [ - // Main panes (use wrappers so re-rendering content doesn't destroy OS structure) - '.list-scroll', - // Popovers/menus that scroll (use wrappers) - '.pop-list-scroll', - ].join(','), - ), - ); + const out = new Set(); + if (root instanceof HTMLElement && root.matches(SCROLLABLE_SELECTOR)) { + out.add(root); + } + try { + root.querySelectorAll(SCROLLABLE_SELECTOR).forEach((el) => out.add(el)); + } catch { + // ignore + } + return Array.from(out); } -export function initOverlayScrollbars(root: ParentNode = document) { +export function initOverlayScrollbarsFor(root: ParentNode = document) { queryScrollableElements(root).forEach(initOne); } -/** - * Destroy any OverlayScrollbars instance attached to elements matching - * `selector`. This is useful to ensure certain containers (like the diff - * view) keep native scrollbars and don't get wrapped by OverlayScrollbars. - */ -export function destroyOverlayScrollbarsFor(selector: string) { - try { - const els = Array.from(document.querySelectorAll(selector)); - els.forEach((el) => { - try { - const inst = (OverlayScrollbars as any)(el) as any; - if (inst && typeof inst.destroy === 'function') { - inst.destroy(); - } - } catch {} - // remove any initialization marker so future attempts can reapply if needed - if (el.hasAttribute(OS_ATTR)) el.removeAttribute(OS_ATTR); - }); - } catch {} +export function refreshOverlayScrollbarsFor(root: ParentNode = document) { + queryScrollableElements(root).forEach(refreshOne); } -export function observeOverlayScrollbars() { - initOverlayScrollbars(); - const obs = new MutationObserver((records) => { - for (const r of records) { - r.addedNodes.forEach((n) => { - if (!(n instanceof HTMLElement)) return; - if (n.matches?.('[class], [id]')) initOverlayScrollbars(n); - }); +export function destroyOverlayScrollbarsFor(target: string | ParentNode = document) { + if (typeof target === 'string') { + let els: HTMLElement[] = []; + try { + els = Array.from(document.querySelectorAll(target)); + } catch { + els = []; } - }); - obs.observe(document.body, { childList: true, subtree: true }); - return () => obs.disconnect(); + els.forEach(destroyOne); + return; + } + queryScrollableElements(target).forEach(destroyOne); } + +// Backward-compatible alias. +export const initOverlayScrollbars = initOverlayScrollbarsFor; diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index 3af3be9..6ec8567 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -1,7 +1,7 @@ import { TAURI } from './lib/tauri'; import { qs } from './lib/dom'; import { notify } from './lib/notify'; -import { observeOverlayScrollbars, destroyOverlayScrollbarsFor } from './lib/scrollbars'; +import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from './lib/scrollbars'; import { prefs, state, hasRepo } from './state/state'; import { bindTabs, initResizer, refreshRepoActions, setRepoHeader, resetRepoHeader, setTab, setTheme, @@ -36,34 +36,9 @@ const commitBtn = qs('#commit-btn'); const undoLeftBtn = qs('#undo-left-btn'); async function boot() { - // Measure native scrollbar width and set a CSS variable so we can reserve - // the same horizontal space in the diff content. This prevents layout - // shifts when the vertical scrollbar appears or disappears. - function computeAndSetScrollbarGutter() { - try { - const el = document.createElement('div'); - el.style.width = '100px'; - el.style.height = '100px'; - el.style.overflow = 'scroll'; - el.style.position = 'absolute'; - el.style.top = '-9999px'; - document.body.appendChild(el); - const gutter = Math.max(0, el.offsetWidth - el.clientWidth) || 0; - document.documentElement.style.setProperty('--os-scrollbar-gutter', `${gutter}px`); - document.body.removeChild(el); - } catch (e) { - /* best-effort: ignore failures */ - } - } - - // Compute once and update on resize so changes in zoom/OS settings are handled. - computeAndSetScrollbarGutter(); - window.addEventListener('resize', computeAndSetScrollbarGutter); // If launched as the Output Log window, render that view and skip the main app UI. if (await initOutputLogViewIfRequested()) return; - observeOverlayScrollbars(); - // Ensure the diff scroll area uses the native scrollbar (not OverlayScrollbars). - try { destroyOverlayScrollbarsFor('.diff-scroll'); } catch {} + initOverlayScrollbarsFor(document); await initPlugins(); // theme & basic layout // Prefer native settings for theme; fall back to current in-memory default @@ -264,6 +239,7 @@ async function boot() { fetchPop.style.left = `${r.left}px`; fetchPop.style.top = `${r.bottom + 6}px`; fetchCaret.setAttribute('aria-expanded', 'true'); + try { refreshOverlayScrollbarsFor(fetchPop); } catch {} const firstEnabled = fetchList?.querySelector('li[role="menuitem"][aria-disabled="false"]'); setTimeout(() => firstEnabled?.focus(), 0); diff --git a/Frontend/src/scripts/plugins.ts b/Frontend/src/scripts/plugins.ts index cb947ff..8d00324 100644 --- a/Frontend/src/scripts/plugins.ts +++ b/Frontend/src/scripts/plugins.ts @@ -1,6 +1,6 @@ import { TAURI } from './lib/tauri'; import { notify } from './lib/notify'; -import { destroyOverlayScrollbarsFor, initOverlayScrollbars } from './lib/scrollbars'; +import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from './lib/scrollbars'; import type { GlobalSettings, Json, ThemePayload, ThemeSummary } from './types'; export interface PluginSummary { @@ -556,8 +556,8 @@ export function applyPluginSettingsSections(modal?: HTMLElement | null): void { } if (insertedAny) { - destroyOverlayScrollbarsFor('#settings-panels-scroll'); - initOverlayScrollbars(); + initOverlayScrollbarsFor(m); + refreshOverlayScrollbarsFor(m); } } diff --git a/Frontend/src/scripts/ui/modals.ts b/Frontend/src/scripts/ui/modals.ts index 305193e..3adc15c 100644 --- a/Frontend/src/scripts/ui/modals.ts +++ b/Frontend/src/scripts/ui/modals.ts @@ -1,5 +1,6 @@ // src/scripts/ui/modals.ts import { qs } from "@scripts/lib/dom"; +import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from "../lib/scrollbars"; import settingsHtml from "@modals/settings.html?raw"; import cmdHtml from "@modals/commandSheet.html?raw"; import aboutHtml from "@modals/about.html?raw"; @@ -92,6 +93,9 @@ export function hydrate(id: string): void { if (id === "set-upstream-modal") wireSetUpstream(); if (id === "update-modal") wireUpdate(); if (id === "stash-confirm-modal") wireStashConfirm(); + + const inserted = document.getElementById(id); + if (inserted) initOverlayScrollbarsFor(inserted); } export function openModal(id: string): void { @@ -104,6 +108,7 @@ export function openModal(id: string): void { if (!el.hasAttribute("aria-hidden")) el.setAttribute("aria-hidden", "true"); el.setAttribute("aria-hidden", "false"); lockScroll(); + refreshOverlayScrollbarsFor(el); // Click-to-close once if (!(el as any).__closeWired) { diff --git a/Frontend/src/styles/overlayscrollbars.css b/Frontend/src/styles/overlayscrollbars.css index a02542d..f24a808 100644 --- a/Frontend/src/styles/overlayscrollbars.css +++ b/Frontend/src/styles/overlayscrollbars.css @@ -1,9 +1,9 @@ /* Custom OverlayScrollbars theme for OpenVCS */ .os-theme-openvcs.os-scrollbar { - --os-size: 8px; - --os-padding-perpendicular: 2px; - --os-padding-axis: 2px; + --os-size: 14px; + --os-padding-perpendicular: 1px; + --os-padding-axis: 0px; } .os-theme-openvcs.os-scrollbar .os-scrollbar-track { From 5e90a8750ba9f50de24cc51811471385b7d59b30 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 16:55:39 +0000 Subject: [PATCH 19/50] Improve scrollbar performance --- .../src/scripts/features/repo/diffView.ts | 4 - Frontend/src/scripts/features/repo/hydrate.ts | 80 ++++++++++++++----- Frontend/src/scripts/features/repo/list.ts | 10 +++ Frontend/src/scripts/lib/scrollbars.ts | 28 ++++++- Frontend/src/scripts/main.ts | 14 +++- Frontend/src/scripts/ui/layout.ts | 22 +++-- Frontend/src/styles/components.css | 3 +- Frontend/src/styles/overlayscrollbars.css | 4 +- 8 files changed, 123 insertions(+), 42 deletions(-) diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index af8bcef..b4990e3 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -2,7 +2,6 @@ import { qsa, escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; import { notify } from '../../lib/notify'; -import { refreshOverlayScrollbarsFor } from '../../lib/scrollbars'; import { state, prefs, disableDefaultSelectAll } from '../../state/state'; import type { FileStatus, ConflictDetails } from '../../types'; import { buildPatchForSelectedHunks } from '../diff'; @@ -15,9 +14,6 @@ import { openMergeModal, hasExternalMergeTool, launchExternalMergeTool } from '. function scrollDiffToTop() { if (!diffEl) return; const host = diffEl.closest('.diff-scroll') as HTMLElement | null; - if (host) { - try { refreshOverlayScrollbarsFor(host); } catch {} - } const viewport = host?.querySelector('.os-viewport, [data-overlayscrollbars-viewport]') || host || diffEl.parentElement || diffEl; if (viewport) { viewport.scrollTop = 0; diff --git a/Frontend/src/scripts/features/repo/hydrate.ts b/Frontend/src/scripts/features/repo/hydrate.ts index 61fba37..df98079 100644 --- a/Frontend/src/scripts/features/repo/hydrate.ts +++ b/Frontend/src/scripts/features/repo/hydrate.ts @@ -3,6 +3,36 @@ import { state, prefs } from '../../state/state'; import { renderList } from './list'; import { autoOpenFirstConflict } from '../conflicts'; +function normalizeFiles(files: any[]): any[] { + return [...files].sort((a, b) => String(a?.path || '').localeCompare(String(b?.path || ''))); +} + +function buildStatusSignature(input: { + files: any[]; + ahead: number; + behind: number; + mergeInProgress: boolean; + seenConflicts: Set; +}): string { + const files = normalizeFiles(input.files).map((f) => ({ + path: String(f?.path || ''), + oldPath: String((f as any)?.old_path || ''), + status: String((f as any)?.status || '').toUpperCase(), + staged: !!(f as any)?.staged, + resolvedConflict: !!(f as any)?.resolved_conflict, + })); + const conflicts = Array.from(input.seenConflicts).sort(); + return JSON.stringify({ + files, + ahead: Number(input.ahead || 0), + behind: Number(input.behind || 0), + mergeInProgress: !!input.mergeInProgress, + conflicts, + }); +} + +let lastStatusSignature = ''; + export async function hydrateBranches(): Promise { if (!TAURI.has) return false; try { @@ -32,39 +62,52 @@ export async function hydrateStatus() { if (!TAURI.has) return; try { const result = await TAURI.invoke<{ files: any[]; ahead?: number; behind?: number }>('git_status'); - state.hasRepo = true; - state.files = Array.isArray(result?.files) ? (result.files as any) : []; + const nextFiles = Array.isArray(result?.files) ? (result.files as any) : []; + let nextMergeInProgress = false; + let nextSeenConflicts = new Set(); // Track merge context for UI hints (e.g., resolved-conflict checkmarks) try { const ctx = await TAURI.invoke<{ in_progress: boolean }>('git_merge_context'); - const inMerge = !!ctx?.in_progress; - if (state.mergeInProgress !== inMerge) { - state.seenConflicts = new Set(); - } - state.mergeInProgress = inMerge; - if (inMerge) { - (state.files || []).forEach((f: any) => { + nextMergeInProgress = !!ctx?.in_progress; + if (nextMergeInProgress) { + nextSeenConflicts = new Set(); + nextFiles.forEach((f: any) => { if (String(f?.status || '').toUpperCase() === 'U' && f?.path) { - state.seenConflicts.add(String(f.path)); + nextSeenConflicts.add(String(f.path)); } }); - } else { - state.seenConflicts = new Set(); } } catch { - state.mergeInProgress = false; - state.seenConflicts = new Set(); + nextMergeInProgress = false; + nextSeenConflicts = new Set(); } - const currentPaths = new Set((state.files || []).map((f) => f.path)); + const nextAhead = Number((result as any)?.ahead || 0); + const nextBehind = Number((result as any)?.behind || 0); + const nextSignature = buildStatusSignature({ + files: nextFiles, + ahead: nextAhead, + behind: nextBehind, + mergeInProgress: nextMergeInProgress, + seenConflicts: nextSeenConflicts, + }); + if (nextSignature === lastStatusSignature) return; + lastStatusSignature = nextSignature; + + state.hasRepo = true; + state.files = nextFiles; + state.mergeInProgress = nextMergeInProgress; + state.seenConflicts = nextSeenConflicts; + + const currentPaths = new Set(nextFiles.map((f: any) => String(f?.path || ''))); if (state.defaultSelectAll) { state.selectionImplicitAll = true; - state.selectedFiles = new Set(Array.from(currentPaths)); + state.selectedFiles = new Set(Array.from(currentPaths)); } else { state.selectionImplicitAll = false; state.selectedFiles.forEach((p) => { if (!currentPaths.has(p)) state.selectedFiles.delete(p); }); } - (state as any).ahead = Number((result as any)?.ahead || 0); - (state as any).behind = Number((result as any)?.behind || 0); + (state as any).ahead = nextAhead; + (state as any).behind = nextBehind; renderList(); void autoOpenFirstConflict(state.files as any); window.dispatchEvent(new CustomEvent('app:status-updated')); @@ -75,6 +118,7 @@ export async function hydrateStatus() { state.seenConflicts = new Set(); state.selectedFiles.clear(); state.selectionImplicitAll = false; + lastStatusSignature = ''; renderList(); window.dispatchEvent(new CustomEvent('app:status-updated')); } diff --git a/Frontend/src/scripts/features/repo/list.ts b/Frontend/src/scripts/features/repo/list.ts index 94b7ef0..fdf19bd 100644 --- a/Frontend/src/scripts/features/repo/list.ts +++ b/Frontend/src/scripts/features/repo/list.ts @@ -1,4 +1,5 @@ import { escapeHtml } from '../../lib/dom'; +import { refreshOverlayScrollbarsFor } from '../../lib/scrollbars'; import { state, prefs, statusClass, statusLabel } from '../../state/state'; import { refreshRepoActions } from '../../ui/layout'; import { filterInput, listEl, countEl, diffHeadPath, diffEl } from './context'; @@ -37,14 +38,23 @@ export function renderList() { if (isHistory) { renderHistoryList(q); + refreshListScrollContainer(list); return; } if (isStash) { renderStashList(q); + refreshListScrollContainer(list); return; } renderChangesList(q); + refreshListScrollContainer(list); +} + +function refreshListScrollContainer(list: HTMLElement) { + const host = list.closest('.list-scroll') as HTMLElement | null; + if (!host) return; + try { refreshOverlayScrollbarsFor(host); } catch {} } function renderChangesList(query: string) { diff --git a/Frontend/src/scripts/lib/scrollbars.ts b/Frontend/src/scripts/lib/scrollbars.ts index c7718e7..99f322c 100644 --- a/Frontend/src/scripts/lib/scrollbars.ts +++ b/Frontend/src/scripts/lib/scrollbars.ts @@ -5,7 +5,6 @@ import { OverlayScrollbars } from 'overlayscrollbars'; const OS_ATTR = 'data-overlayscrollbars-initialize'; const SCROLLABLE_SELECTOR = [ '.list-scroll', - '.diff-scroll', '.pop-list-scroll', '.plugins-list-scroll', '#command-modal .recent', @@ -24,11 +23,19 @@ const OVERLAY_OPTIONS = { x: 'hidden' as const, y: 'scroll' as const, }, + update: { + // Rely on explicit refresh calls after major DOM changes and keep + // observer-driven updates less aggressive. + debounce: [80, 160] as [number, number], + elementEvents: [] as Array<[string, string]>, + }, scrollbars: { theme: 'os-theme-openvcs', autoHide: 'never' as const, }, }; +const REFRESH_MIN_INTERVAL_MS = 180; +const lastRefreshAt = new WeakMap(); function getInstance(el: HTMLElement): any | null { try { @@ -55,6 +62,11 @@ function initOne(el: HTMLElement) { } function refreshOne(el: HTMLElement) { + const now = Date.now(); + const last = lastRefreshAt.get(el) || 0; + if (now - last < REFRESH_MIN_INTERVAL_MS) return; + lastRefreshAt.set(el, now); + const instance = getInstance(el); if (instance && typeof instance.update === 'function') { try { @@ -79,7 +91,14 @@ function destroyOne(el: HTMLElement) { if (el.hasAttribute(OS_ATTR)) el.removeAttribute(OS_ATTR); } -function queryScrollableElements(root: ParentNode): HTMLElement[] { +function isVisibleForInit(el: HTMLElement): boolean { + if (!el.isConnected) return false; + if (el.closest('.modal[aria-hidden="true"]')) return false; + if (el.closest('.popover[hidden]')) return false; + return true; +} + +function queryScrollableElements(root: ParentNode, includeHidden = false): HTMLElement[] { const out = new Set(); if (root instanceof HTMLElement && root.matches(SCROLLABLE_SELECTOR)) { out.add(root); @@ -89,7 +108,8 @@ function queryScrollableElements(root: ParentNode): HTMLElement[] { } catch { // ignore } - return Array.from(out); + const all = Array.from(out); + return includeHidden ? all : all.filter(isVisibleForInit); } export function initOverlayScrollbarsFor(root: ParentNode = document) { @@ -111,7 +131,7 @@ export function destroyOverlayScrollbarsFor(target: string | ParentNode = docume els.forEach(destroyOne); return; } - queryScrollableElements(target).forEach(destroyOne); + queryScrollableElements(target, true).forEach(destroyOne); } // Backward-compatible alias. diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index 6ec8567..7de673c 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -1,7 +1,7 @@ import { TAURI } from './lib/tauri'; import { qs } from './lib/dom'; import { notify } from './lib/notify'; -import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from './lib/scrollbars'; +import { destroyOverlayScrollbarsFor, initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from './lib/scrollbars'; import { prefs, state, hasRepo } from './state/state'; import { bindTabs, initResizer, refreshRepoActions, setRepoHeader, resetRepoHeader, setTab, setTheme, @@ -39,6 +39,7 @@ async function boot() { // If launched as the Output Log window, render that view and skip the main app UI. if (await initOutputLogViewIfRequested()) return; initOverlayScrollbarsFor(document); + destroyOverlayScrollbarsFor('.diff-scroll'); await initPlugins(); // theme & basic layout // Prefer native settings for theme; fall back to current in-memory default @@ -379,11 +380,12 @@ async function boot() { // Global busy indicator for any Git activity (function(){ let busyTimer: any = null; - const setBusy = (msg: string) => { + const setBusy = (msg: string, showSpinner = true) => { const s = document.getElementById('status'); if (!s) return; s.textContent = msg || 'Working…'; - s.classList.add('busy'); + if (showSpinner) s.classList.add('busy'); + else s.classList.remove('busy'); if (busyTimer) clearTimeout(busyTimer); // Clear after a short quiet period busyTimer = setTimeout(() => { @@ -394,7 +396,10 @@ async function boot() { TAURI.listen?.('git-progress', ({ payload }) => { // Don't spam the footer with raw git output; keep it generic. void payload; - setBusy('Working…'); + // Avoid spinner-driven repaint churn for passive/background progress. + // Explicit user actions already set busy state via their own controllers. + const focused = document.visibilityState === 'visible' && document.hasFocus(); + setBusy('Working…', focused); }); })(); @@ -483,6 +488,7 @@ async function boot() { if (!TAURI.has) return; if (!state.hasRepo) return; if (document.visibilityState !== 'visible') return; + if (!document.hasFocus()) return; if (headPollInFlight) return; headPollInFlight = (async () => { try { diff --git a/Frontend/src/scripts/ui/layout.ts b/Frontend/src/scripts/ui/layout.ts index e367518..8ad5dbe 100644 --- a/Frontend/src/scripts/ui/layout.ts +++ b/Frontend/src/scripts/ui/layout.ts @@ -107,23 +107,29 @@ export function initResizer() { let dragging = false, x0 = 0, left0 = 0; - resizer.addEventListener('mousedown', (e) => { - dragging = true; x0 = (e as MouseEvent).clientX; left0 = leftPx; - document.body.style.cursor = 'col-resize'; - }); - - window.addEventListener('mousemove', (e) => { + const onMove = (e: MouseEvent) => { if (!dragging) return; const cw = containerW(); leftPx = clampLeft(left0 + ((e as MouseEvent).clientX - x0), cw); applyCols(leftPx); - }); + }; - window.addEventListener('mouseup', () => { + const onUp = () => { if (!dragging) return; dragging = false; document.body.style.cursor = ''; prefs.leftW = leftPx; savePrefs(); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + window.removeEventListener('blur', onUp); + }; + + resizer.addEventListener('mousedown', (e) => { + dragging = true; x0 = (e as MouseEvent).clientX; left0 = leftPx; + document.body.style.cursor = 'col-resize'; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + window.addEventListener('blur', onUp); }); window.addEventListener('resize', () => { diff --git a/Frontend/src/styles/components.css b/Frontend/src/styles/components.css index e583df8..cc3de6a 100644 --- a/Frontend/src/styles/components.css +++ b/Frontend/src/styles/components.css @@ -352,9 +352,8 @@ button:disabled,.btn:disabled,.btn.primary:disabled,.tbtn:disabled,.pick:disable #status.busy::after{ content:""; display:inline-block; width:12px; height:12px; margin-left:.5rem; border:2px solid var(--muted); border-top-color:var(--accent); - border-radius:50%; animation:spin .8s linear infinite; vertical-align:-2px; + border-radius:50%; vertical-align:-2px; } -@keyframes spin { from{ transform:rotate(0deg);} to{ transform:rotate(360deg);} } /* ========== Branch switcher (button only) ========== */ .branch-switch{ diff --git a/Frontend/src/styles/overlayscrollbars.css b/Frontend/src/styles/overlayscrollbars.css index f24a808..085dffb 100644 --- a/Frontend/src/styles/overlayscrollbars.css +++ b/Frontend/src/styles/overlayscrollbars.css @@ -1,9 +1,9 @@ /* Custom OverlayScrollbars theme for OpenVCS */ .os-theme-openvcs.os-scrollbar { - --os-size: 14px; + --os-size: 10px; --os-padding-perpendicular: 1px; - --os-padding-axis: 0px; + --os-padding-axis: 1px; } .os-theme-openvcs.os-scrollbar .os-scrollbar-track { From d19f79b0d7bc7a9cc03404489c9b807904611598 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 17:13:21 +0000 Subject: [PATCH 20/50] Improved performance --- Frontend/src/scripts/features/repo/diffView.ts | 6 ++++++ Frontend/src/scripts/features/repo/hydrate.ts | 1 + Frontend/src/scripts/state/state.ts | 1 + 3 files changed, 8 insertions(+) diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index b4990e3..40db99e 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -67,11 +67,16 @@ export function highlightRow(index: number) { export async function selectFile(file: FileStatus, index: number) { if (!diffHeadPath || !diffEl) return; + if (!state.diffDirty && state.currentFile === file.path) { + highlightRow(index); + return; + } highlightRow(index); const status = String(file.status || '').toUpperCase(); if (status === 'U') { diffHeadPath.textContent = `${file.path || '(unknown file)'} (conflicted)`; await renderConflictView(file); + state.diffDirty = false; return; } diffHeadPath.textContent = file.path || '(unknown file)'; @@ -211,6 +216,7 @@ export async function selectFile(file: FileStatus, index: number) { } syncFileCheckboxWithHunks(); updateCommitButton(); + state.diffDirty = false; } catch (e) { console.error(e); diffEl.innerHTML = '
Failed to load diff
'; diff --git a/Frontend/src/scripts/features/repo/hydrate.ts b/Frontend/src/scripts/features/repo/hydrate.ts index df98079..7316da9 100644 --- a/Frontend/src/scripts/features/repo/hydrate.ts +++ b/Frontend/src/scripts/features/repo/hydrate.ts @@ -92,6 +92,7 @@ export async function hydrateStatus() { }); if (nextSignature === lastStatusSignature) return; lastStatusSignature = nextSignature; + state.diffDirty = true; state.hasRepo = true; state.files = nextFiles; diff --git a/Frontend/src/scripts/state/state.ts b/Frontend/src/scripts/state/state.ts index a79d8ce..00649d7 100644 --- a/Frontend/src/scripts/state/state.ts +++ b/Frontend/src/scripts/state/state.ts @@ -29,6 +29,7 @@ export const state = { seenConflicts: new Set() as Set, defaultSelectAll: true as boolean, // by default select all files/hunks until user toggles selectionImplicitAll: true as boolean, // true when select-all was auto-applied (no manual picks yet) + diffDirty: true as boolean, // Selection state selectedFiles: new Set(), currentFile: '' as string, From 70c9a169df80a70f7e729096fc01606e4b8fc255 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 17:27:08 +0000 Subject: [PATCH 21/50] Improve --- .../src/scripts/features/repo/diffView.ts | 434 ++++++++++++------ Frontend/src/scripts/state/state.ts | 16 + 2 files changed, 314 insertions(+), 136 deletions(-) diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index 40db99e..fda8587 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -2,7 +2,7 @@ import { qsa, escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; import { notify } from '../../lib/notify'; -import { state, prefs, disableDefaultSelectAll } from '../../state/state'; +import { state, prefs, disableDefaultSelectAll, DiffMeta, HunkNodeRefs } from '../../state/state'; import type { FileStatus, ConflictDetails } from '../../types'; import { buildPatchForSelectedHunks } from '../diff'; import { diffEl, diffHeadPath, listEl } from './context'; @@ -108,14 +108,17 @@ export async function selectFile(file: FileStatus, index: number) { state.currentDiff = lines || []; const isBinary = detectBinaryDiff(state.currentDiff); state.currentDiffBinary = isBinary; - diffEl.innerHTML = isBinary - ? renderBinaryDiffPlaceholder(file.path) - : renderHunksWithSelection(state.currentDiff); - scrollDiffToTop(); - - if (!isBinary) { + if (isBinary) { + state.currentDiffMeta = null; + state.currentDiffHunkNodes = new Map(); + diffEl.innerHTML = renderBinaryDiffPlaceholder(file.path); + } else { + const fragment = buildDiffFragment(state.currentDiff); + diffEl.innerHTML = ''; + diffEl.appendChild(fragment); bindHunkToggles(diffEl); } + scrollDiffToTop(); const onCtx = (ev: Event) => { const mev = ev as MouseEvent; @@ -183,6 +186,7 @@ export async function selectFile(file: FileStatus, index: number) { if (!state.currentDiffBinary) { const cached = (state as any).selectedHunksByFile?.[file.path] as number[] | undefined; + const hunkNodes = state.currentDiffHunkNodes; if (Array.isArray(cached)) { state.selectedHunks = cached.slice(); updateHunkCheckboxes(); @@ -190,15 +194,18 @@ export async function selectFile(file: FileStatus, index: number) { state.selectedHunks = allHunkIndices(state.currentDiff); updateHunkCheckboxes(); const recExisting: Record = (state as any).selectedLinesByFile[state.currentFile] || {}; - const root = diffEl as HTMLElement; const rec: Record = { ...recExisting }; state.selectedHunks.forEach((h) => { if (rec[h] && rec[h].length > 0) return; - const boxes = root.querySelectorAll(`input.pick-line[data-hunk="${h}"]`); const picked: number[] = []; - boxes.forEach((b) => { - b.checked = true; - picked.push(Number(b.dataset.line || -1)); + const refs = hunkNodes.get(h); + Object.keys(refs?.lineCheckboxes || {}).forEach((ln) => { + const idx = Number(ln); + if (idx < 0) return; + const box = refs?.lineCheckboxes[idx]; + if (!box) return; + box.checked = true; + picked.push(idx); }); if (picked.length > 0) rec[h] = Array.from(new Set(picked)).sort((a, b) => a - b); }); @@ -219,6 +226,8 @@ export async function selectFile(file: FileStatus, index: number) { state.diffDirty = false; } catch (e) { console.error(e); + state.currentDiffMeta = null; + state.currentDiffHunkNodes = new Map(); diffEl.innerHTML = '
Failed to load diff
'; scrollDiffToTop(); } @@ -408,31 +417,38 @@ export function toggleFilePick(path: string, on: boolean) { disableDefaultSelectAll(); if (on) state.selectedFiles.add(path); else state.selectedFiles.delete(path); - if (state.currentFile && state.currentFile === path) { + if (state.currentFile && state.currentFile === path && !state.currentDiffBinary) { + const hunkNodes = state.currentDiffHunkNodes; if (on) { state.selectedHunks = allHunkIndices(state.currentDiff); (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); - const root = diffEl as HTMLElement; const rec: Record = {}; - const lineBoxes = root.querySelectorAll('input.pick-line'); - lineBoxes.forEach((b) => { - const h = Number(b.dataset.hunk || -1); - const l = Number(b.dataset.line || -1); - if (h < 0 || l < 0) return; - (rec[h] ||= []).push(l); - b.checked = true; - }); - Object.keys(rec).forEach((k) => { - rec[Number(k)] = Array.from(new Set(rec[Number(k)])).sort((a, b) => a - b); + hunkNodes.forEach((refs, idx) => { + if (refs.hunkCheckbox) { + refs.hunkCheckbox.checked = true; + refs.hunkCheckbox.indeterminate = false; + } + const picked: number[] = []; + Object.entries(refs.lineCheckboxes).forEach(([key, box]) => { + const lineIdx = Number(key); + if (lineIdx < 0) return; + picked.push(lineIdx); + box.checked = true; + }); + if (picked.length > 0) rec[idx] = Array.from(new Set(picked)).sort((a, b) => a - b); }); (state as any).selectedLinesByFile[state.currentFile] = rec; } else { state.selectedHunks = []; delete (state as any).selectedHunksByFile[state.currentFile]; - const root = diffEl as HTMLElement; - const lineBoxes = root.querySelectorAll('input.pick-line'); - lineBoxes.forEach((b) => { b.checked = false; }); delete (state as any).selectedLinesByFile[state.currentFile]; + hunkNodes.forEach((refs) => { + if (refs.hunkCheckbox) { + refs.hunkCheckbox.checked = false; + refs.hunkCheckbox.indeterminate = false; + } + Object.values(refs.lineCheckboxes).forEach((box) => { box.checked = false; }); + }); } updateHunkCheckboxes(); } @@ -440,99 +456,133 @@ export function toggleFilePick(path: string, on: boolean) { } export function updateHunkCheckboxes() { - const root = diffEl as HTMLElement; - if (!root) return; - const boxes = root.querySelectorAll('input.pick-hunk'); - boxes.forEach((b) => { - const idx = Number(b.dataset.hunk || -1); - const on = state.selectedHunks.includes(idx); - b.checked = on; - const hk = b.closest('.hunk') as HTMLElement | null; - if (hk) hk.classList.toggle('picked', on); + const nodes = state.currentDiffHunkNodes; + if (!nodes || nodes.size === 0) return; + const rec: Record = state.currentFile + ? (state as any).selectedLinesByFile[state.currentFile] || {} + : {}; + nodes.forEach((refs, idx) => { + const isSelected = state.selectedHunks.includes(idx); + if (refs.hunkCheckbox) { + refs.hunkCheckbox.checked = isSelected; + refs.hunkCheckbox.indeterminate = false; + } + refs.hunkEl.classList.toggle('picked', isSelected); + if (state.currentFile) { + const lines = rec[idx] || []; + Object.entries(refs.lineCheckboxes).forEach(([key, box]) => { + const lineIdx = Number(key); + const checked = Array.isArray(lines) && lines.includes(lineIdx); + box.checked = checked; + }); + if (refs.hunkCheckbox) { + const total = state.currentDiffMeta?.changeCounts[idx] ?? Object.keys(refs.lineCheckboxes).length; + const chosen = lines.length; + refs.hunkCheckbox.checked = total > 0 && chosen === total; + refs.hunkCheckbox.indeterminate = chosen > 0 && chosen < total; + } + } else { + Object.values(refs.lineCheckboxes).forEach((box) => { box.checked = false; }); + if (refs.hunkCheckbox) refs.hunkCheckbox.indeterminate = false; + } }); - if (state.currentFile) { - const rec: Record = (state as any).selectedLinesByFile[state.currentFile] || {}; - const lboxes = root.querySelectorAll('input.pick-line'); - lboxes.forEach((b) => { - const h = Number(b.dataset.hunk || -1); - const l = Number(b.dataset.line || -1); - const on = Array.isArray(rec[h]) && rec[h].includes(l); - b.checked = on; - }); - } } +let diffToggleHandlerBound = false; + function bindHunkToggles(root: HTMLElement) { - const boxes = root.querySelectorAll('input.pick-hunk'); - boxes.forEach((b) => { - b.addEventListener('change', () => { - const clearedImplicit = disableDefaultSelectAll(true); - if (clearedImplicit) clearAllFileSelections(); - const idx = Number(b.dataset.hunk || -1); - if (b.checked) { - if (!state.selectedHunks.includes(idx)) state.selectedHunks.push(idx); - } else { - state.selectedHunks = state.selectedHunks.filter((i) => i !== idx); - } - if (state.currentFile) { - (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); - const linesInHunk = Array.from(root.querySelectorAll(`input.pick-line[data-hunk="${idx}"]`)); - const rec: Record = (state as any).selectedLinesByFile[state.currentFile] || {}; - if (b.checked) { - const picked: number[] = []; - linesInHunk.forEach((el) => { el.checked = true; picked.push(Number(el.dataset.line || -1)); }); - rec[idx] = Array.from(new Set([...(rec[idx] || []), ...picked])).sort((a, b) => a - b); - } else { - linesInHunk.forEach((el) => { el.checked = false; }); - delete rec[idx]; - } - (state as any).selectedLinesByFile[state.currentFile] = rec; - } - syncFileCheckboxWithHunks(); - updateSelectAllState(getVisibleFiles()); - updateCommitButton(); - const hk = b.closest('.hunk') as HTMLElement | null; - if (hk) hk.classList.toggle('picked', b.checked); - }); - }); + if (!root || diffToggleHandlerBound) return; + root.addEventListener('change', handleDiffInputChange); + diffToggleHandlerBound = true; +} - const lineBoxes = root.querySelectorAll('input.pick-line'); - lineBoxes.forEach((b) => { - b.addEventListener('change', () => { - const clearedImplicit = disableDefaultSelectAll(true); - if (clearedImplicit) clearAllFileSelections(); - const hunk = Number(b.dataset.hunk || -1); - const line = Number(b.dataset.line || -1); - if (!state.currentFile || hunk < 0 || line < 0) return; - const rec: Record = (state as any).selectedLinesByFile[state.currentFile] || {}; - const cur = new Set(rec[hunk] || []); - if (b.checked) cur.add(line); - else cur.delete(line); - rec[hunk] = Array.from(cur).sort((a, b) => a - b); - if (rec[hunk].length === 0) delete rec[hunk]; - (state as any).selectedLinesByFile[state.currentFile] = rec; +function handleDiffInputChange(ev: Event) { + const target = ev.target as HTMLInputElement | null; + if (!target || !(target instanceof HTMLInputElement)) return; + if (target.classList.contains('pick-hunk')) { + handleHunkToggle(target); + } else if (target.classList.contains('pick-line')) { + handleLineToggle(target); + } +} - const hunkBox = root.querySelector(`input.pick-hunk[data-hunk="${hunk}"]`); - if (hunkBox) { - const total = root.querySelectorAll(`input.pick-line[data-hunk="${hunk}"]`).length; - const sel = rec[hunk]?.length || 0; - (hunkBox as any).indeterminate = sel > 0 && sel < total; - hunkBox.checked = sel === total && total > 0; - const idx = Number(hunkBox.dataset.hunk || -1); - if (sel === total && total > 0) { - if (!state.selectedHunks.includes(idx)) state.selectedHunks.push(idx); - } else { - state.selectedHunks = state.selectedHunks.filter((i) => i !== idx); - } - if (state.currentFile) { - (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); - } - } - syncFileCheckboxWithHunks(); - updateSelectAllState(getVisibleFiles()); - updateCommitButton(); +function handleHunkToggle(input: HTMLInputElement) { + const idx = Number(input.dataset.hunk || -1); + if (!state.currentFile || idx < 0) return; + const clearedImplicit = disableDefaultSelectAll(true); + if (clearedImplicit) clearAllFileSelections(); + const rec: Record = (state as any).selectedLinesByFile[state.currentFile] || {}; + if (input.checked) { + if (!state.selectedHunks.includes(idx)) state.selectedHunks.push(idx); + const refs = state.currentDiffHunkNodes.get(idx); + const picked: number[] = []; + Object.entries(refs?.lineCheckboxes || {}).forEach(([k, box]) => { + const lineIdx = Number(k); + if (lineIdx < 0) return; + picked.push(lineIdx); + box.checked = true; }); - }); + if (picked.length > 0) rec[idx] = Array.from(new Set(picked)).sort((a, b) => a - b); + } else { + state.selectedHunks = state.selectedHunks.filter((i) => i !== idx); + const refs = state.currentDiffHunkNodes.get(idx); + Object.values(refs?.lineCheckboxes || {}).forEach((box) => { box.checked = false; }); + delete rec[idx]; + } + (state as any).selectedLinesByFile[state.currentFile] = rec; + const refs = state.currentDiffHunkNodes.get(idx); + refs?.hunkEl?.classList.toggle('picked', input.checked); + if (refs?.hunkCheckbox) { + refs.hunkCheckbox.indeterminate = false; + refs.hunkCheckbox.checked = input.checked; + } + if (state.currentFile) { + (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); + } + syncFileCheckboxWithHunks(); + updateSelectAllState(getVisibleFiles()); + updateCommitButton(); +} + +function handleLineToggle(input: HTMLInputElement) { + const hunk = Number(input.dataset.hunk || -1); + const line = Number(input.dataset.line || -1); + if (!state.currentFile || hunk < 0 || line < 0) return; + const clearedImplicit = disableDefaultSelectAll(true); + if (clearedImplicit) clearAllFileSelections(); + const rec: Record = (state as any).selectedLinesByFile[state.currentFile] || {}; + const old = new Set(rec[hunk] || []); + if (input.checked) { + old.add(line); + } else { + old.delete(line); + } + const next = Array.from(old).sort((a, b) => a - b); + if (next.length > 0) { + rec[hunk] = next; + } else { + delete rec[hunk]; + } + (state as any).selectedLinesByFile[state.currentFile] = rec; + const refs = state.currentDiffHunkNodes.get(hunk); + const total = state.currentDiffMeta?.changeCounts[hunk] ?? Object.keys(refs?.lineCheckboxes || {}).length; + const hunkBox = refs?.hunkCheckbox; + if (hunkBox) { + hunkBox.checked = total > 0 && next.length === total; + hunkBox.indeterminate = next.length > 0 && next.length < total; + if (hunkBox.checked) { + if (!state.selectedHunks.includes(hunk)) state.selectedHunks.push(hunk); + } else { + state.selectedHunks = state.selectedHunks.filter((i) => i !== hunk); + } + refs?.hunkEl?.classList.toggle('picked', hunkBox.checked); + } + if (state.currentFile) { + (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); + } + syncFileCheckboxWithHunks(); + updateSelectAllState(getVisibleFiles()); + updateCommitButton(); } function syncFileCheckboxWithHunks() { @@ -542,7 +592,8 @@ function syncFileCheckboxWithHunks() { updateListCheckboxForPath(state.currentFile, on, false); return; } - const totalHunks = allHunkIndices(state.currentDiff).length; + const meta = state.currentDiffMeta; + const totalHunks = meta?.totalHunks ?? allHunkIndices(state.currentDiff).length; const selHunks = (state.selectedHunks || []).length; if (totalHunks === 0) { updateListCheckboxForPath(state.currentFile, false, false); @@ -550,29 +601,12 @@ function syncFileCheckboxWithHunks() { return; } const rec: Record = (state as any).selectedLinesByFile[state.currentFile] || {}; - const lines = state.currentDiff || []; - const first = lines.findIndex((l) => (l || '').startsWith('@@')); - const rest = first >= 0 ? lines.slice(first) : []; - const starts: number[] = []; - for (let i = 0; i < rest.length; i++) { if ((rest[i] || '').startsWith('@@')) starts.push(i); } - starts.push(rest.length); - const changeCounts: number[] = []; - for (let h = 0; h < Math.max(0, starts.length - 1); h++) { - const s = starts[h]; - const e = starts[h + 1]; - const block = rest.slice(s + 1, e); - const cnt = block.reduce((acc, ln) => { - const ch = (ln || '')[0] || ' '; - return acc + ((ch === '+' || ch === '-') ? 1 : 0); - }, 0); - changeCounts[h] = cnt; - } const hasAnyLineSel = Object.keys(rec).length > 0; - const hasPartialLineSel = Object.keys(rec).some((k) => { - const h = Number(k); - const chosen = Array.isArray(rec[h]) ? rec[h].length : 0; - const total = changeCounts[h] || 0; - return chosen > 0 && chosen < total; + const hasPartialLineSel = Object.keys(rec).some((key) => { + const idx = Number(key); + const chosen = Array.isArray(rec[idx]) ? rec[idx].length : 0; + const total = meta?.changeCounts[idx] ?? 0; + return total > 0 && chosen > 0 && chosen < total; }); if (selHunks === totalHunks && !hasPartialLineSel) { updateListCheckboxForPath(state.currentFile, true, false); @@ -587,6 +621,10 @@ function syncFileCheckboxWithHunks() { } export function allHunkIndices(lines: string[]) { + const meta = state.currentDiffMeta; + if (meta && meta.totalHunks > 0) { + return Array.from({ length: meta.totalHunks }, (_, i) => i); + } if (!Array.isArray(lines) || !lines.length) return [] as number[]; const idx = lines.findIndex((l) => l.startsWith('@@')); const rest = idx >= 0 ? lines.slice(idx) : []; @@ -595,6 +633,130 @@ export function allHunkIndices(lines: string[]) { return starts.map((_, i) => i); } +function buildDiffMeta(lines: string[]): DiffMeta { + const idx = lines.findIndex((l) => (l || '').startsWith('@@')); + const rest = idx >= 0 ? lines.slice(idx) : []; + const starts: number[] = []; + rest.forEach((l, i) => { if ((l || '').startsWith('@@')) starts.push(i); }); + if (starts.length === 0) return { + offset: Math.max(0, idx), + rest, + starts, + changeCounts: [], + totalHunks: 0, + }; + starts.push(rest.length); + const changeCounts: number[] = []; + for (let h = 0; h < starts.length - 1; h++) { + const s = starts[h]; + const e = starts[h + 1]; + const block = rest.slice(s + 1, e); + const cnt = block.reduce((acc, ln) => { + const first = (ln || '')[0] || ' '; + return acc + ((first === '+' || first === '-') ? 1 : 0); + }, 0); + changeCounts[h] = cnt; + } + return { + offset: Math.max(0, idx), + rest, + starts, + changeCounts, + totalHunks: Math.max(0, starts.length - 1), + }; +} + +function buildDiffFragment(lines: string[]): DocumentFragment { + const meta = buildDiffMeta(lines); + state.currentDiffMeta = meta; + const fragment = document.createDocumentFragment(); + const nodes = new Map(); + if (meta.totalHunks <= 0) { + const empty = document.createElement('div'); + empty.className = 'hunk'; + const hline = document.createElement('div'); + hline.className = 'hline'; + const gutter = document.createElement('div'); + gutter.className = 'gutter'; + const code = document.createElement('div'); + code.className = 'code'; + code.textContent = 'No textual hunks to display'; + hline.appendChild(gutter); + hline.appendChild(code); + empty.appendChild(hline); + fragment.appendChild(empty); + state.currentDiffHunkNodes = nodes; + return fragment; + } + for (let h = 0; h < meta.totalHunks; h++) { + const s = meta.starts[h]; + const e = meta.starts[h + 1]; + const hunkLines = meta.rest.slice(s, e); + const offset = meta.offset + s; + const hunkEl = document.createElement('div'); + hunkEl.className = 'hunk'; + hunkEl.dataset.hunkIndex = String(h); + + const header = document.createElement('div'); + header.className = 'hline'; + const gutter = document.createElement('div'); + gutter.className = 'gutter'; + const label = document.createElement('label'); + label.className = 'pick-toggle'; + const hunkCheckbox = document.createElement('input'); + hunkCheckbox.type = 'checkbox'; + hunkCheckbox.className = 'pick-hunk'; + hunkCheckbox.dataset.hunk = String(h); + label.appendChild(hunkCheckbox); + const srHunk = document.createElement('span'); + srHunk.className = 'sr-only'; + srHunk.textContent = 'Include hunk'; + label.appendChild(srHunk); + gutter.appendChild(label); + header.appendChild(gutter); + const codeHeader = document.createElement('div'); + codeHeader.className = 'code'; + header.appendChild(codeHeader); + hunkEl.appendChild(header); + + const lineCheckboxes: Record = {}; + hunkLines.forEach((ln, i) => { + const first = (typeof ln === 'string' ? ln[0] : ' ') || ' '; + const lineRow = document.createElement('div'); + lineRow.className = `hline${first === '+' ? ' add' : first === '-' ? ' del' : ''}`; + const lineGutter = document.createElement('div'); + lineGutter.className = 'gutter'; + if (first === '+' || first === '-') { + const lineLabel = document.createElement('label'); + lineLabel.className = 'pick-toggle'; + const lineCheckbox = document.createElement('input'); + lineCheckbox.type = 'checkbox'; + lineCheckbox.className = 'pick-line'; + lineCheckbox.dataset.hunk = String(h); + lineCheckbox.dataset.line = String(i); + lineLabel.appendChild(lineCheckbox); + const srLine = document.createElement('span'); + srLine.className = 'sr-only'; + srLine.textContent = 'Include line'; + lineLabel.appendChild(srLine); + lineGutter.appendChild(lineLabel); + lineCheckboxes[i] = lineCheckbox; + } + lineGutter.appendChild(document.createTextNode(String(offset + i + 1))); + const code = document.createElement('div'); + code.className = 'code'; + code.innerHTML = escapeHtml(String(ln || '')); + lineRow.appendChild(lineGutter); + lineRow.appendChild(code); + hunkEl.appendChild(lineRow); + }); + nodes.set(h, { hunkEl, hunkCheckbox, lineCheckboxes }); + fragment.appendChild(hunkEl); + } + state.currentDiffHunkNodes = nodes; + return fragment; +} + export function renderHunksWithSelection(lines: string[]) { if (!lines || !lines.length) return ''; let idx = lines.findIndex((l) => l.startsWith('@@')); diff --git a/Frontend/src/scripts/state/state.ts b/Frontend/src/scripts/state/state.ts index 00649d7..3da3966 100644 --- a/Frontend/src/scripts/state/state.ts +++ b/Frontend/src/scripts/state/state.ts @@ -13,6 +13,20 @@ export function savePrefs() { // no-op: web prefs are not persisted; native settings handle persistence } +export type DiffMeta = { + offset: number; + rest: string[]; + starts: number[]; + changeCounts: number[]; + totalHunks: number; +}; + +export type HunkNodeRefs = { + hunkEl: HTMLElement; + hunkCheckbox: HTMLInputElement | null; + lineCheckboxes: Record; +}; + export const state = { hasRepo: false, // backend truth (set after open/clone/add) branch: '' as string, // current branch name @@ -40,6 +54,8 @@ export const state = { selectedHunksByFile: {} as Record, selectedLinesByFile: {} as Record>, // file -> hunkIdx -> line indices diffSelectedFiles: new Set(), // files included in multi-file diff viewer + currentDiffMeta: null as DiffMeta | null, + currentDiffHunkNodes: new Map(), // Optional: track the current repo path if you want to show it anywhere // repoPath: '' as string, }; From c9917c59f277f3967a313a97777b7181d4765c27 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 17:53:32 +0000 Subject: [PATCH 22/50] Update scrollbars.ts --- Frontend/src/scripts/lib/scrollbars.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Frontend/src/scripts/lib/scrollbars.ts b/Frontend/src/scripts/lib/scrollbars.ts index 99f322c..dc5bc8d 100644 --- a/Frontend/src/scripts/lib/scrollbars.ts +++ b/Frontend/src/scripts/lib/scrollbars.ts @@ -24,10 +24,14 @@ const OVERLAY_OPTIONS = { y: 'scroll' as const, }, update: { - // Rely on explicit refresh calls after major DOM changes and keep - // observer-driven updates less aggressive. - debounce: [80, 160] as [number, number], - elementEvents: [] as Array<[string, string]>, + // Disable the built-in observers so only our explicit refresh logic runs. + debounce: { + mutation: null, + resize: null, + event: null, + env: null, + }, + elementEvents: null, }, scrollbars: { theme: 'os-theme-openvcs', From 4f60fb43c234c191f7118177a6ee1ab9c92aae89 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 18:19:51 +0000 Subject: [PATCH 23/50] Scroll bar issues --- Backend/tauri.conf.json | 2 +- Frontend/src/scripts/features/repo/list.ts | 10 --- Frontend/src/scripts/lib/scrollbars.ts | 1 - Frontend/src/scripts/main.ts | 53 +++++++----- scripts/ensure-built-in-plugins.js | 99 ++++++++++++++++++++++ 5 files changed, 130 insertions(+), 35 deletions(-) create mode 100755 scripts/ensure-built-in-plugins.js diff --git a/Backend/tauri.conf.json b/Backend/tauri.conf.json index d5e804a..9405a6d 100644 --- a/Backend/tauri.conf.json +++ b/Backend/tauri.conf.json @@ -3,7 +3,7 @@ "productName": "OpenVCS", "identifier": "dev.jordon.openvcs", "build": { - "beforeDevCommand": "cargo openvcs dist --all --plugin-dir ../Backend/built-in-plugins --out ../target/openvcs/built-in-plugins && npm run dev --prefix ../Frontend", + "beforeDevCommand": "node ../scripts/ensure-built-in-plugins.js && npm run dev --prefix ../Frontend", "devUrl": "http://localhost:1420", "frontendDist": "../Frontend/dist" }, diff --git a/Frontend/src/scripts/features/repo/list.ts b/Frontend/src/scripts/features/repo/list.ts index fdf19bd..94b7ef0 100644 --- a/Frontend/src/scripts/features/repo/list.ts +++ b/Frontend/src/scripts/features/repo/list.ts @@ -1,5 +1,4 @@ import { escapeHtml } from '../../lib/dom'; -import { refreshOverlayScrollbarsFor } from '../../lib/scrollbars'; import { state, prefs, statusClass, statusLabel } from '../../state/state'; import { refreshRepoActions } from '../../ui/layout'; import { filterInput, listEl, countEl, diffHeadPath, diffEl } from './context'; @@ -38,23 +37,14 @@ export function renderList() { if (isHistory) { renderHistoryList(q); - refreshListScrollContainer(list); return; } if (isStash) { renderStashList(q); - refreshListScrollContainer(list); return; } renderChangesList(q); - refreshListScrollContainer(list); -} - -function refreshListScrollContainer(list: HTMLElement) { - const host = list.closest('.list-scroll') as HTMLElement | null; - if (!host) return; - try { refreshOverlayScrollbarsFor(host); } catch {} } function renderChangesList(query: string) { diff --git a/Frontend/src/scripts/lib/scrollbars.ts b/Frontend/src/scripts/lib/scrollbars.ts index dc5bc8d..e5e2db2 100644 --- a/Frontend/src/scripts/lib/scrollbars.ts +++ b/Frontend/src/scripts/lib/scrollbars.ts @@ -4,7 +4,6 @@ import { OverlayScrollbars } from 'overlayscrollbars'; // during initialization to reduce flicker. const OS_ATTR = 'data-overlayscrollbars-initialize'; const SCROLLABLE_SELECTOR = [ - '.list-scroll', '.pop-list-scroll', '.plugins-list-scroll', '#command-modal .recent', diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index 7de673c..16ba3c6 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -483,30 +483,37 @@ async function boot() { // This is intentionally lightweight: only re-hydrate when HEAD changes. let headPollInFlight: Promise | null = null; let lastHeadKey = ''; - const headPollMs = 2000; - setInterval(() => { - if (!TAURI.has) return; - if (!state.hasRepo) return; - if (document.visibilityState !== 'visible') return; - if (!document.hasFocus()) return; - if (headPollInFlight) return; - headPollInFlight = (async () => { - try { - const head = await TAURI.invoke<{ detached: boolean; branch?: string; commit?: string }>('git_head_status'); - const key = `${head?.detached ? 1 : 0}:${String(head?.branch || '')}:${String(head?.commit || '')}`; - if (key === lastHeadKey) return; - - const ok = await hydrateBranches(); - if (!ok) return; - setRepoHeader(); - await Promise.allSettled([hydrateStatus(), hydrateCommits()]); - updateFetchUI(); - lastHeadKey = key; - } catch { - // ignore transient failures (e.g. repo switching / git busy) + const headPollMs = 15000; + const scheduleHeadPoll = () => { + window.setTimeout(async () => { + if (!TAURI.has || !state.hasRepo || document.visibilityState !== 'visible' || !document.hasFocus()) { + return scheduleHeadPoll(); } - })().finally(() => { headPollInFlight = null; }); - }, headPollMs); + if (headPollInFlight) { + return scheduleHeadPoll(); + } + headPollInFlight = (async () => { + try { + const head = await TAURI.invoke<{ detached: boolean; branch?: string; commit?: string }>('git_head_status'); + const key = `${head?.detached ? 1 : 0}:${String(head?.branch || '')}:${String(head?.commit || '')}`; + if (key === lastHeadKey) return; + + const ok = await hydrateBranches(); + if (!ok) return; + setRepoHeader(); + await Promise.allSettled([hydrateStatus(), hydrateCommits()]); + updateFetchUI(); + lastHeadKey = key; + } catch { + // ignore transient failures (e.g. repo switching / git busy) + } + })().finally(() => { + headPollInFlight = null; + scheduleHeadPoll(); + }); + }, headPollMs); + }; + scheduleHeadPoll(); // open settings via event TAURI.listen?.('ui:open-settings', ({ payload }) => { diff --git a/scripts/ensure-built-in-plugins.js b/scripts/ensure-built-in-plugins.js new file mode 100755 index 0000000..5058cec --- /dev/null +++ b/scripts/ensure-built-in-plugins.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const scriptDir = __dirname; +const repoRoot = path.resolve(scriptDir, '..'); +const backendDir = path.join(repoRoot, 'Backend'); +const pluginSources = path.join(backendDir, 'built-in-plugins'); +const pluginBundles = path.join(repoRoot, 'target', 'openvcs', 'built-in-plugins'); + +const skipDirs = new Set(['target', '.git', 'node_modules', 'dist']); + +function latestSourceTime(dir) { + let latest = 0; + let hasFile = false; + const stack = [dir]; + while (stack.length) { + const current = stack.pop(); + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const name = entry.name; + const full = path.join(current, name); + if (entry.isDirectory()) { + if (skipDirs.has(name)) continue; + stack.push(full); + continue; + } + let stat; + try { + stat = fs.statSync(full); + } catch { + continue; + } + if (!stat.isFile()) continue; + hasFile = true; + latest = Math.max(latest, stat.mtimeMs); + } + } + return hasFile ? latest : null; +} + +function pluginOutdated(name) { + const bundlePath = path.join(pluginBundles, `${name}.ovcsp`); + if (!fs.existsSync(bundlePath)) return true; + const bundleStat = fs.statSync(bundlePath); + const srcPath = path.join(pluginSources, name); + const srcTime = latestSourceTime(srcPath); + return srcTime === null || srcTime > bundleStat.mtimeMs; +} + +function findOutdatedPlugin() { + if (!fs.existsSync(pluginSources)) return null; + const entries = fs.readdirSync(pluginSources, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (pluginOutdated(entry.name)) { + return entry.name; + } + } + return null; +} + +function ensureBundlesDir() { + fs.mkdirSync(pluginBundles, { recursive: true }); +} + +function runDistCommand() { + console.log('Built-in plugin bundles need rebuilding; running cargo openvcs dist …'); + const pluginDirArg = 'built-in-plugins'; + const outArg = path.relative(backendDir, pluginBundles); + const res = spawnSync( + 'cargo', + ['openvcs', 'dist', '--all', '--plugin-dir', pluginDirArg, '--out', outArg], + { cwd: backendDir, stdio: 'inherit' } + ); + if (res.error) { + console.error('Failed to run cargo openvcs dist:', res.error); + process.exit(res.status || 1); + } + if (res.status !== 0) { + process.exit(res.status); + } +} + +ensureBundlesDir(); + +const outdated = findOutdatedPlugin(); +if (outdated) { + runDistCommand(); +} else { + console.log('Built-in plugin bundles are up to date.'); +} From c8803cba30e8f754b8bd832c8dcb2ea331c25f7d Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 18:20:26 +0000 Subject: [PATCH 24/50] Update OfficialThemes --- Backend/built-in-plugins/OfficialThemes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/OfficialThemes b/Backend/built-in-plugins/OfficialThemes index 19d1a79..cc93590 160000 --- a/Backend/built-in-plugins/OfficialThemes +++ b/Backend/built-in-plugins/OfficialThemes @@ -1 +1 @@ -Subproject commit 19d1a79c846323c3ec361e449dc1fadcb05e9c0a +Subproject commit cc9359012f4dd2b864f8c73a42ba7ed143bde2b5 From df20839eea0eaf96f41919c99b0047b2a1ff0aec Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 18:35:08 +0000 Subject: [PATCH 25/50] Update components.css --- Frontend/src/styles/components.css | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/Frontend/src/styles/components.css b/Frontend/src/styles/components.css index cc3de6a..4d916af 100644 --- a/Frontend/src/styles/components.css +++ b/Frontend/src/styles/components.css @@ -205,7 +205,7 @@ button:disabled,.btn:disabled,.btn.primary:disabled,.tbtn:disabled,.pick:disable /* Reserve scrollbar space for the right column header so the header width doesn't change when the diff area shows or hides a vertical scrollbar. */ -.right { box-sizing: border-box; padding-right: var(--os-scrollbar-gutter); } +.right { box-sizing: border-box; padding-right: 0; } /* Diff header (fixed) */ .diff-head{ @@ -239,21 +239,14 @@ button:disabled,.btn:disabled,.btn.primary:disabled,.tbtn:disabled,.pick:disable .diff{ min-height:0; height: 100%; - /* Keep the vertical scrollbar present even when content doesn't overflow - so switching between binary (short) and text (long) diffs doesn't - cause a scrollbar to appear/disappear and nudge layout. */ overflow-y: scroll; font-family:var(--mono); font-size:13px; overflow-anchor:none; - /* Reserve space for the scrollbar to avoid layout shifts when content grows */ scrollbar-gutter: stable both-edges; - /* Also reserve horizontal space via padding as a fallback for browsers - that don't fully honor scrollbar-gutter or when custom overlay - scrollbars are used. */ box-sizing: border-box; - padding-right: var(--os-scrollbar-gutter); + padding-right: 0; } -:root{ --os-scrollbar-gutter: 12px; } +:root{ --os-scrollbar-gutter: 0; } .diff-content{ min-width:0; } /* Ensure diff content won't collapse when toggling or loading hunks and keep @@ -268,9 +261,7 @@ button:disabled,.btn:disabled,.btn.primary:disabled,.tbtn:disabled,.pick:disable hiding the scrollbar doesn't nudge surrounding layout. The actual value is computed at runtime and written to `--os-scrollbar-gutter`. */ .diff .diff-content { - /* keep a small inner padding but avoid double-reserving space; the main - right-column padding handles layout stability for the header */ - padding-right: calc(var(--os-scrollbar-gutter) / 2); + padding-right: 0; } .hunk{ padding:.25rem 0; } /* Picked hunk (included in commit) — success tint */ From f56fc28b6ede6afc59b57c1dcd0b21f34aa0555a Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 18:35:34 +0000 Subject: [PATCH 26/50] Update OfficialThemes --- Backend/built-in-plugins/OfficialThemes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/OfficialThemes b/Backend/built-in-plugins/OfficialThemes index cc93590..91a9fa3 160000 --- a/Backend/built-in-plugins/OfficialThemes +++ b/Backend/built-in-plugins/OfficialThemes @@ -1 +1 @@ -Subproject commit cc9359012f4dd2b864f8c73a42ba7ed143bde2b5 +Subproject commit 91a9fa3f4d085fbc54864391dc716b0910b82740 From 5264d03ec5e9ae0e6f48a84081c708b83542dd8c Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 18:51:38 +0000 Subject: [PATCH 27/50] Update scrollbars.ts --- Frontend/src/scripts/lib/scrollbars.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/Frontend/src/scripts/lib/scrollbars.ts b/Frontend/src/scripts/lib/scrollbars.ts index e5e2db2..efb754d 100644 --- a/Frontend/src/scripts/lib/scrollbars.ts +++ b/Frontend/src/scripts/lib/scrollbars.ts @@ -14,7 +14,6 @@ const SCROLLABLE_SELECTOR = [ '#ssh-keys-modal .ssh-box', '.merge-readonly', '.conflict-code', - '#output-log-view .outlog-list', ].join(', '); const OVERLAY_OPTIONS = { From 91b7faf8308b6fd31c99459793961f0e126b170a Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 19:31:49 +0000 Subject: [PATCH 28/50] Implement new side bar for easier switching --- Frontend/src/modals/commandSheet.html | 27 -- Frontend/src/modals/repoSwitchDrawer.html | 28 +++ Frontend/src/scripts/features/commandSheet.ts | 88 +------ Frontend/src/scripts/features/repo/hotkeys.ts | 4 +- .../src/scripts/features/repoSelection.ts | 20 ++ .../src/scripts/features/repoSwitchDrawer.ts | 236 ++++++++++++++++++ Frontend/src/scripts/lib/scrollbars.ts | 1 + Frontend/src/scripts/main.ts | 12 +- Frontend/src/scripts/ui/modals.ts | 26 +- Frontend/src/styles/index.css | 1 + .../src/styles/modal/repo-switch-drawer.css | 211 ++++++++++++++++ 11 files changed, 535 insertions(+), 119 deletions(-) create mode 100644 Frontend/src/modals/repoSwitchDrawer.html create mode 100644 Frontend/src/scripts/features/repoSelection.ts create mode 100644 Frontend/src/scripts/features/repoSwitchDrawer.ts create mode 100644 Frontend/src/styles/modal/repo-switch-drawer.css diff --git a/Frontend/src/modals/commandSheet.html b/Frontend/src/modals/commandSheet.html index 752d95c..454c71a 100644 --- a/Frontend/src/modals/commandSheet.html +++ b/Frontend/src/modals/commandSheet.html @@ -33,16 +33,6 @@

Repository actions

Add Existing - @@ -104,22 +94,5 @@

Add existing repository

- diff --git a/Frontend/src/modals/repoSwitchDrawer.html b/Frontend/src/modals/repoSwitchDrawer.html new file mode 100644 index 0000000..3cca594 --- /dev/null +++ b/Frontend/src/modals/repoSwitchDrawer.html @@ -0,0 +1,28 @@ + diff --git a/Frontend/src/scripts/features/commandSheet.ts b/Frontend/src/scripts/features/commandSheet.ts index 3d5d340..c5f9806 100644 --- a/Frontend/src/scripts/features/commandSheet.ts +++ b/Frontend/src/scripts/features/commandSheet.ts @@ -2,12 +2,9 @@ import { TAURI } from "../lib/tauri"; import { notify } from "../lib/notify"; import { openModal, closeModal, hydrate } from "../ui/modals"; -import { state } from "../state/state"; +import { refreshRepoSummary } from "./repoSelection"; -type Which = "clone" | "add" | "switch"; - -type Branch = { name: string; current?: boolean; kind?: { type?: string; remote?: string } }; -type RepoSummary = { path: string; current_branch: string; branches: Branch[] }; +type Which = "clone" | "add"; // Elements inside the modal let root: HTMLElement | null = null; @@ -21,8 +18,6 @@ let doCloneBtn: HTMLButtonElement | null = null; let addPath: HTMLInputElement | null = null; let doAddBtn: HTMLButtonElement | null = null; -let recentList: HTMLElement | null = null; - // Slider indicator bits let seg: HTMLElement | null = null; let segIndicator: HTMLElement | null = null; @@ -61,21 +56,6 @@ async function validateAdd() { } } -/* ---------------- repo summary + broadcast ---------------- */ - -async function refreshRepoSummary() { - if (!TAURI.has) return; - const info = await TAURI.invoke("get_repo_summary"); - // Note: backend uses snake_case field names - // @ts-ignore - state.branch = (info as any).current_branch || ""; - state.branches = Array.isArray((info as any).branches) ? (info as any).branches : []; - const repoBranch = document.querySelector("#repo-branch"); - if (repoBranch) repoBranch.textContent = state.branch || "—"; - // Broadcast for any listeners (branches UI, status bar, etc.) - window.dispatchEvent(new CustomEvent("app:repo-selected", { detail: { path: (info as any).path } })); -} - /* ---------------- slider indicator helpers ---------------- */ function ensureIndicator(): HTMLElement | null { @@ -117,7 +97,7 @@ function setSheet(which: Which) { }); // Panels - (["clone", "add", "switch"] as Which[]).forEach((k) => { + (["clone", "add"] as Which[]).forEach((k) => { panels[k].classList.toggle("hidden", k !== which); }); @@ -156,7 +136,6 @@ export function bindCommandSheet() { panels = { clone: root.querySelector("#sheet-clone") as HTMLElement, add: root.querySelector("#sheet-add") as HTMLElement, - switch: root.querySelector("#sheet-switch") as HTMLElement, }; segIndicator = ensureIndicator(); @@ -168,8 +147,6 @@ export function bindCommandSheet() { addPath = el("#add-path", root); doAddBtn = el("#do-add", root); - recentList = el("#recent-list", root); - // Tab switching (click) tabs.forEach((btn) => btn.addEventListener("click", () => setSheet((btn.dataset.sheet as Which) || "clone")) @@ -252,65 +229,6 @@ export function bindCommandSheet() { } }); - // Recents (Open inline) — hardened mapping + empty state - (async function loadRecents() { - try { - let raw: unknown = []; - if (TAURI.has) { - raw = await TAURI.invoke("list_recent_repos").catch(() => []); - } - - type Recent = { path: string; name?: string }; - - const items: Recent[] = Array.isArray(raw) - ? raw - .filter((r: any): r is Recent => !!r && typeof r === "object" && typeof r.path === "string" && r.path.trim() !== "") - .map((r: any) => ({ - path: r.path.trim(), - name: typeof r.name === "string" ? r.name.trim() : undefined - })) - : []; - - if (recentList) { - if (items.length === 0) { - recentList.innerHTML = `
  • No recent repositories
  • `; - } else { - recentList.innerHTML = items.map(r => { - const base = r.name || r.path.split(/[\\/]/).pop() || r.path; - return ` -
  • -
    - ${base} -
    ${r.path}
    -
    - -
  • `; - }).join(""); - } - - recentList.onclick = async (e) => { - const openBtn = (e.target as HTMLElement).closest("[data-open]") as HTMLElement | null; - if (!openBtn) return; - const li = (e.target as HTMLElement).closest("li[data-path]") as HTMLElement | null; - const path = li?.dataset.path?.trim(); - if (!path) return; // ignore bogus entries - try { - if (TAURI.has) await TAURI.invoke("open_repo", { path }); - await refreshRepoSummary(); // ensure state + event - notify(`Opened ${path}`); - closeSheet(); - } catch { - notify("Open failed"); - } - }; - } - } catch { - if (recentList) { - recentList.innerHTML = `
  • No recent repositories
  • `; - } - } - })(); - // Keep the slider aligned on layout changes to the header if (seg) { const ro = new ResizeObserver(() => positionIndicator()); diff --git a/Frontend/src/scripts/features/repo/hotkeys.ts b/Frontend/src/scripts/features/repo/hotkeys.ts index eea725f..dec1d06 100644 --- a/Frontend/src/scripts/features/repo/hotkeys.ts +++ b/Frontend/src/scripts/features/repo/hotkeys.ts @@ -6,7 +6,7 @@ import { renderList } from './list'; export function bindRepoHotkeys( commitBtn: HTMLButtonElement | null, - openSheet: (w: 'clone' | 'add' | 'switch') => void, + openSwitchDrawer: () => void, fetchAction?: () => void | Promise ) { window.addEventListener('keydown', (e) => { @@ -26,7 +26,7 @@ export function bindRepoHotkeys( return; } if (chord && key === 'f') { e.preventDefault(); filterInput?.focus(); } - if (chord && key === 'r') { e.preventDefault(); openSheet('switch'); } + if (chord && key === 'r') { e.preventDefault(); openSwitchDrawer(); } if (chord && e.key === 'Enter') { e.preventDefault(); commitBtn?.click(); } if (chord && key === 'a' && !e.shiftKey && !e.altKey && !inEditable) { diff --git a/Frontend/src/scripts/features/repoSelection.ts b/Frontend/src/scripts/features/repoSelection.ts new file mode 100644 index 0000000..9157c1a --- /dev/null +++ b/Frontend/src/scripts/features/repoSelection.ts @@ -0,0 +1,20 @@ +// src/scripts/features/repoSelection.ts +import { TAURI } from '../lib/tauri'; +import { notify } from '../lib/notify'; +import { state } from '../state/state'; + +type RepoSummary = { path: string; current_branch: string; branches: { name: string }[] }; + +export async function refreshRepoSummary() { + if (!TAURI.has) return; + try { + const info = await TAURI.invoke('get_repo_summary'); + state.branch = (info as any).current_branch || ''; + state.branches = Array.isArray((info as any).branches) ? (info as any).branches : []; + const repoBranch = document.querySelector('#repo-branch'); + if (repoBranch) repoBranch.textContent = state.branch || '—'; + window.dispatchEvent(new CustomEvent('app:repo-selected', { detail: { path: (info as any).path } })); + } catch { + notify('Failed to refresh repo summary'); + } +} diff --git a/Frontend/src/scripts/features/repoSwitchDrawer.ts b/Frontend/src/scripts/features/repoSwitchDrawer.ts new file mode 100644 index 0000000..6765e9e --- /dev/null +++ b/Frontend/src/scripts/features/repoSwitchDrawer.ts @@ -0,0 +1,236 @@ +// src/scripts/features/repoSwitchDrawer.ts +import { TAURI } from '../lib/tauri'; +import { notify } from '../lib/notify'; +import { hydrate, openModal, closeModal } from '../ui/modals'; +import { refreshRepoSummary } from './repoSelection'; + +type Recent = { path: string; name?: string }; + +let drawerRoot: HTMLElement | null = null; +let drawerDialog: HTMLDivElement | null = null; +let recentList: HTMLElement | null = null; +let filterInput: HTMLInputElement | null = null; +let addTrigger: HTMLButtonElement | null = null; +let addMenu: HTMLElement | null = null; +let docClickWired = false; +let allRecents: Recent[] = []; +let closeTimer: number | null = null; + +const resizeHandler = () => positionDrawer(); + +let openCloneSheet = () => {}; +let openAddSheet = () => {}; + +function ensureDrawer() { + if (drawerRoot) return; + hydrate('repo-switch-drawer'); + drawerRoot = document.getElementById('repo-switch-drawer'); + if (!drawerRoot) return; + + drawerDialog = drawerRoot.querySelector('.dialog.drawer'); + recentList = drawerRoot.querySelector('#drawer-recent-list'); + filterInput = drawerRoot.querySelector('#drawer-filter'); + addTrigger = drawerRoot.querySelector('#drawer-add-trigger'); + addMenu = drawerRoot.querySelector('.drawer-add-menu'); + + addTrigger?.addEventListener('click', (event) => { + event.stopPropagation(); + const expanded = addTrigger?.getAttribute('aria-expanded') === 'true'; + if (expanded) hideAddMenu(); + else showAddMenu(); + }); + + addMenu?.addEventListener('click', (event) => { + event.stopPropagation(); + const target = (event.target as HTMLElement)?.closest('[data-add-action]'); + const action = target?.dataset.addAction; + if (action === 'clone_repo') { + hideAddMenu(); + closeSwitchDrawer(); + openCloneSheet(); + } else if (action === 'add_repo') { + hideAddMenu(); + closeSwitchDrawer(); + openAddSheet(); + } + }); + + filterInput?.addEventListener('input', () => renderRecents()); + + if (!docClickWired) { + document.addEventListener('click', () => hideAddMenu()); + docClickWired = true; + } +} + +function showAddMenu() { + if (!addMenu || !addTrigger) return; + addMenu.removeAttribute('hidden'); + addTrigger.setAttribute('aria-expanded', 'true'); +} + +function hideAddMenu() { + if (!addMenu || !addTrigger) return; + addMenu.setAttribute('hidden', 'true'); + addTrigger.setAttribute('aria-expanded', 'false'); +} + +function positionDrawer() { + if (!drawerDialog) return; + const anchor = document.getElementById('repo-switch'); + const rect = anchor?.getBoundingClientRect(); + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + const margin = 8; + const width = drawerDialog.offsetWidth || 320; + const height = drawerDialog.offsetHeight || 0; + const scrollX = window.scrollX || window.pageXOffset; + const scrollY = window.scrollY || window.pageYOffset; + + let left = (rect?.left ?? margin) + scrollX; + const maxLeft = viewportWidth - width - margin; + left = Math.min(Math.max(margin + scrollX, left), Math.max(margin + scrollX, maxLeft)); + + let top = (rect ? rect.bottom : 60) + scrollY + 4; + const maxTop = viewportHeight - height - margin; + if (top > maxTop) top = Math.max(margin + scrollY, maxTop); + + drawerDialog.style.left = `${left}px`; + drawerDialog.style.top = `${top}px`; +} + +function escapeHTML(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function filteredRecents(): Recent[] { + const term = (filterInput?.value || '').trim().toLowerCase(); + if (!term) return allRecents; + return allRecents.filter((item) => { + const name = (item.name || item.path.split(/[\\/]/).pop() || item.path).toLowerCase(); + const path = item.path.toLowerCase(); + return name.includes(term) || path.includes(term); + }); +} + +function renderRecents() { + if (!recentList) return; + const items = filteredRecents(); + if (items.length === 0) { + recentList.innerHTML = `
  • No matching repositories
  • `; + return; + } + + recentList.innerHTML = items + .map((item) => { + const base = item.name || item.path.split(/[\\/]/).pop() || item.path; + const label = escapeHTML(base); + const escapedPath = escapeHTML(item.path); + return ` +
  • + +
    + ${label} +
    ${escapedPath}
    +
    +
  • + `; + }) + .join(''); +} + +async function openRecent(path: string) { + try { + if (TAURI.has) await TAURI.invoke('open_repo', { path }); + await refreshRepoSummary(); + notify(`Opened ${path}`); + closeSwitchDrawer(); + } catch { + notify('Open failed'); + } +} + +async function loadRecents() { + if (!recentList) return; + try { + let raw: unknown = []; + if (TAURI.has) raw = await TAURI.invoke('list_recent_repos').catch(() => []); + + allRecents = Array.isArray(raw) + ? raw + .filter((r: any): r is Recent => !!r && typeof r === 'object' && typeof r.path === 'string' && r.path.trim() !== '') + .map((r: any) => ({ + path: r.path.trim(), + name: typeof r.name === 'string' ? r.name.trim() : undefined, + })) + : []; + + renderRecents(); + + recentList.onclick = async (event) => { + const row = (event.target as HTMLElement)?.closest('li[data-path]'); + const path = row?.dataset.path?.trim(); + if (!path) return; + await openRecent(path); + }; + + recentList.onkeydown = async (event) => { + const keyEvent = event as KeyboardEvent; + if (keyEvent.key !== 'Enter' && keyEvent.key !== ' ') return; + const row = (event.target as HTMLElement)?.closest('li[data-path]'); + const path = row?.dataset.path?.trim(); + if (!path) return; + keyEvent.preventDefault(); + await openRecent(path); + }; + } catch { + allRecents = []; + recentList.innerHTML = `
  • No recent repositories
  • `; + } +} + +export function registerDrawerActions(actions: { openClone: () => void; openAdd: () => void }) { + openCloneSheet = actions.openClone; + openAddSheet = actions.openAdd; +} + +export function openSwitchDrawer() { + ensureDrawer(); + if (!drawerRoot) return; + if (closeTimer !== null) { + window.clearTimeout(closeTimer); + closeTimer = null; + } + drawerRoot.classList.remove('is-closing'); + openModal('repo-switch-drawer'); + hideAddMenu(); + if (filterInput) filterInput.value = ''; + loadRecents(); + positionDrawer(); + window.addEventListener('resize', resizeHandler); +} + +export function closeSwitchDrawer() { + if (!drawerRoot) return; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const shouldAnimate = !reduceMotion; + if (!shouldAnimate) { + closeModal('repo-switch-drawer'); + window.removeEventListener('resize', resizeHandler); + hideAddMenu(); + return; + } + if (closeTimer !== null) window.clearTimeout(closeTimer); + drawerRoot.classList.add('is-closing'); + closeTimer = window.setTimeout(() => { + drawerRoot?.classList.remove('is-closing'); + closeModal('repo-switch-drawer'); + window.removeEventListener('resize', resizeHandler); + hideAddMenu(); + closeTimer = null; + }, 130); +} diff --git a/Frontend/src/scripts/lib/scrollbars.ts b/Frontend/src/scripts/lib/scrollbars.ts index efb754d..d766131 100644 --- a/Frontend/src/scripts/lib/scrollbars.ts +++ b/Frontend/src/scripts/lib/scrollbars.ts @@ -7,6 +7,7 @@ const SCROLLABLE_SELECTOR = [ '.pop-list-scroll', '.plugins-list-scroll', '#command-modal .recent', + '#repo-switch-drawer .recent', '#repo-settings-modal .sheet-body', '#new-branch-modal .sheet-body', '#stash-confirm-modal .sheet-body', diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index 16ba3c6..18ea90c 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -21,6 +21,7 @@ import { initSshAuthPrompt } from './features/sshAuth'; import { initOutputLogViewIfRequested } from './features/outputLog'; import { DEFAULT_LIGHT_THEME_ID, refreshAvailableThemes, selectThemePack } from './themes'; import { initPlugins, runHook, runPluginAction } from './plugins'; +import { openSwitchDrawer, closeSwitchDrawer, registerDrawerActions } from './features/repoSwitchDrawer'; const WIKI_URL = 'https://github.com/jordonbc/OpenVCS/wiki'; @@ -82,9 +83,13 @@ async function boot() { bindFilter(); bindCommit(); bindCommandSheet(); + registerDrawerActions({ + openClone: () => openSheet('clone'), + openAdd: () => openSheet('add'), + }); bindBranchUI(); bindLayoutActionState(); - bindRepoHotkeys(commitBtn || null, openSheet, defaultFetchAction); + bindRepoHotkeys(commitBtn || null, openSwitchDrawer, defaultFetchAction); initSshHostkeyPrompt(); initSshAuthPrompt(); @@ -284,7 +289,7 @@ async function boot() { switch (id) { case 'clone_repo': openSheet('clone'); break; case 'add_repo': openSheet('add'); break; - case 'open_repo': openSheet('switch');break; + case 'open_repo': openSwitchDrawer(); break; case 'fetch': await defaultFetchAction(); break; case 'push': await pushChanges(); break; case 'commit': commitBtn?.click(); break; @@ -331,7 +336,7 @@ async function boot() { }); pushBtn?.addEventListener('click', pushChanges); cloneBtn?.addEventListener('click', () => openSheet('clone')); - repoSwitch?.addEventListener('click', () => openSheet('switch')); + repoSwitch?.addEventListener('click', () => openSwitchDrawer()); document.getElementById('plugin-title-actions')?.addEventListener('click', (e) => { const target = (e.target as HTMLElement | null)?.closest('[data-action]') || null; const action = target?.dataset.action || ''; @@ -411,6 +416,7 @@ async function boot() { if (path) notify(`Opened ${path}`); setRepoHeader(path); closeSheet(); + closeSwitchDrawer(); await hydrateBranches(); setRepoHeader(path); diff --git a/Frontend/src/scripts/ui/modals.ts b/Frontend/src/scripts/ui/modals.ts index 3adc15c..46838ad 100644 --- a/Frontend/src/scripts/ui/modals.ts +++ b/Frontend/src/scripts/ui/modals.ts @@ -27,12 +27,14 @@ import { wireStashConfirm } from "../features/stashConfirm"; import mergeHtml from "@modals/merge.html?raw"; import conflictsSummaryHtml from "@modals/conflicts-summary.html?raw"; import { wireSshKeys } from "../features/sshKeys"; +import repoSwitchDrawerHtml from "@modals/repoSwitchDrawer.html?raw"; // Lazy fragments (only those NOT present at load) const FRAGMENTS: Record = { "settings-modal": settingsHtml, "about-modal": aboutHtml, "command-modal": cmdHtml, + "repo-switch-drawer": repoSwitchDrawerHtml, "repo-settings-modal": repoSettingsHtml, "ssh-hostkey-modal": sshHostkeyHtml, "ssh-auth-modal": sshAuthHtml, @@ -62,6 +64,26 @@ function unlockScroll() { if (openCount === 0) document.body.style.overflow = ""; } +function closeWithAnimation(id: string, el: HTMLElement) { + if (id !== "repo-switch-drawer") { + closeModal(id); + return; + } + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduceMotion) { + closeModal(id); + return; + } + const existing = (el as any).__drawerCloseTimer as number | undefined; + if (existing) window.clearTimeout(existing); + el.classList.add("is-closing"); + (el as any).__drawerCloseTimer = window.setTimeout(() => { + el.classList.remove("is-closing"); + closeModal(id); + (el as any).__drawerCloseTimer = undefined; + }, 130); +} + export function hydrate(id: string): void { // If it's already in the DOM, treat as loaded and skip const existing = document.getElementById(id); @@ -116,7 +138,7 @@ export function openModal(id: string): void { const t = evt.target as HTMLElement; const isBackdrop = t.classList?.contains("backdrop"); const wantsClose = isBackdrop || !!t.closest("[data-close]"); - if (wantsClose) closeModal(id); + if (wantsClose) closeWithAnimation(id, el); }); (el as any).__closeWired = true; } @@ -148,5 +170,5 @@ document.addEventListener("keydown", (e) => { document.querySelectorAll(".modal[aria-hidden='false']") ); const top = openModals.at(-1); - if (top?.id) closeModal(top.id); + if (top?.id) closeWithAnimation(top.id, top); }); diff --git a/Frontend/src/styles/index.css b/Frontend/src/styles/index.css index 8d115d0..0790b19 100644 --- a/Frontend/src/styles/index.css +++ b/Frontend/src/styles/index.css @@ -18,6 +18,7 @@ @import "./modal/merge.css"; @import "./modal/ssh-keys.css"; @import "./modal/delete-branch.css"; +@import "./modal/repo-switch-drawer.css"; /* Media queries last */ @import "./responsive.css"; diff --git a/Frontend/src/styles/modal/repo-switch-drawer.css b/Frontend/src/styles/modal/repo-switch-drawer.css new file mode 100644 index 0000000..d27d309 --- /dev/null +++ b/Frontend/src/styles/modal/repo-switch-drawer.css @@ -0,0 +1,211 @@ +#repo-switch-drawer.modal{ + justify-content:flex-start; + align-items:flex-start; + padding:0; +} + +#repo-switch-drawer .backdrop{ + background:transparent; +} + +#repo-switch-drawer .dialog.drawer{ + position:absolute; + width:min(360px, 85vw); + border-radius:0 0 var(--r-lg) var(--r-lg); + display:flex; + flex-direction:column; + background:var(--surface); + border:1px solid var(--border); + box-shadow:0 15px 40px rgba(0,0,0,0.4); + overflow:hidden; + max-height:calc(100vh - 80px); +} + +@media (prefers-reduced-motion: no-preference){ + #repo-switch-drawer[aria-hidden="false"] .dialog.drawer{ + animation: repoSwitchDrawerDrop 160ms cubic-bezier(.2,.8,.2,1); + } + #repo-switch-drawer.is-closing .dialog.drawer{ + animation: repoSwitchDrawerClose 130ms cubic-bezier(.4,0,1,1); + } +} + +@keyframes repoSwitchDrawerDrop{ + from{ + opacity:0; + transform:translateY(-8px); + } + to{ + opacity:1; + transform:translateY(0); + } +} + +@keyframes repoSwitchDrawerClose{ + from{ + opacity:1; + transform:translateY(0); + } + to{ + opacity:0; + transform:translateY(-6px); + } +} + +#repo-switch-drawer .drawer-head{ + padding:0.7rem 0.85rem 0.55rem; + display:flex; + flex-direction:column; + gap:0.5rem; + background:var(--surface-2); + border-bottom:1px solid var(--border); +} + +#repo-switch-drawer .drawer-controls{ + display:flex; + align-items:center; + gap:0.5rem; +} + +#repo-switch-drawer .drawer-actions{ + display:flex; + position:relative; + flex:0 0 auto; +} + +#repo-switch-drawer .drawer-filter{ + display:flex; + align-items:center; + gap:0.5rem; + border:1px solid var(--border); + border-radius:999px; + padding:0 0.75rem; + background:var(--surface); + flex:1 1 auto; +} + +#repo-switch-drawer .drawer-filter input{ + flex:1; + border:none; + background:transparent; + color:var(--text); + min-height:32px; +} + +#repo-switch-drawer .drawer-filter input:focus{ + outline:none; +} + +#repo-switch-drawer .drawer-add-menu{ + position:absolute; + top:100%; + left:0; + margin-top:0.25rem; + display:flex; + flex-direction:column; + gap:0.25rem; + padding:0.5rem; + background:var(--surface); + border:1px solid var(--border); + border-radius:0.5rem; + box-shadow:var(--shadow-1); + z-index:5; +} + +#repo-switch-drawer .drawer-add-menu[hidden]{ + display:none; +} + +#repo-switch-drawer .drawer-add-menu button{ + text-align:left; + width:100%; +} + +#repo-switch-drawer .drawer-body{ + padding:0.45rem 0.65rem 0.85rem; + display:flex; + flex-direction:column; + gap:0.55rem; + min-height:180px; + max-height:calc(100vh - 150px); +} + +#repo-switch-drawer .drawer-title{ + font-size:0.85rem; + font-weight:700; + text-transform:uppercase; + letter-spacing:0.08em; + color:var(--muted); +} + +#repo-switch-drawer .recent{ + display:flex !important; + flex-direction:column !important; + gap:0.25rem !important; + margin:0 !important; + padding:0 0.1rem 0 0 !important; + list-style:none !important; + min-height:0; + overflow-y:auto !important; +} + +#repo-switch-drawer .recent li{ + display:grid !important; + grid-template-columns:1rem minmax(0, 1fr) !important; + align-items:start !important; + gap:0.6rem !important; + width:100%; + padding:0.42rem 0.5rem !important; + border-radius:var(--r-sm); + border:1px solid transparent; + background:transparent; + min-height:3rem !important; + cursor:pointer; +} + +#repo-switch-drawer .recent li:hover, +#repo-switch-drawer .recent li:focus-visible{ + background:color-mix(in oklab, var(--text) 8%, transparent); + border-color:var(--border); + outline:none; +} + +#repo-switch-drawer .recent li .repo-icon{ + width:1.1rem; + text-align:center; + opacity:0.9; + color:var(--muted); + padding-top:0.1rem; + flex:0 0 auto; +} + +#repo-switch-drawer .recent li .repo-main{ + min-width:0; + display:flex; + flex-direction:column; + gap:0.08rem; +} + +#repo-switch-drawer .recent li strong{ + font-size:1rem; + line-height:1.2; + font-weight:600; +} + +#repo-switch-drawer .recent li .path{ + opacity:0.7; + font-size:0.84rem; + line-height:1.15; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; +} + +#repo-switch-drawer .recent li.empty{ + border:1px dashed var(--border); + border-radius:var(--r-sm); + padding:0.6rem; + justify-content:center !important; + color:var(--muted); + cursor:default; +} From 43fd9c4c39c8697160390f62bf2d98b1a78feba6 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 19:36:16 +0000 Subject: [PATCH 29/50] Improved drawer --- Frontend/src/modals/repoSwitchDrawer.html | 8 +-- .../src/scripts/features/repoSwitchDrawer.ts | 50 ++----------------- Frontend/src/scripts/ui/modals.ts | 11 ++-- Frontend/src/styles/modal/command-sheet.css | 21 +++++++- .../src/styles/modal/repo-switch-drawer.css | 27 +--------- 5 files changed, 32 insertions(+), 85 deletions(-) diff --git a/Frontend/src/modals/repoSwitchDrawer.html b/Frontend/src/modals/repoSwitchDrawer.html index 3cca594..237c011 100644 --- a/Frontend/src/modals/repoSwitchDrawer.html +++ b/Frontend/src/modals/repoSwitchDrawer.html @@ -9,13 +9,7 @@
    - - +
    diff --git a/Frontend/src/scripts/features/repoSwitchDrawer.ts b/Frontend/src/scripts/features/repoSwitchDrawer.ts index 6765e9e..4cf8236 100644 --- a/Frontend/src/scripts/features/repoSwitchDrawer.ts +++ b/Frontend/src/scripts/features/repoSwitchDrawer.ts @@ -11,15 +11,12 @@ let drawerDialog: HTMLDivElement | null = null; let recentList: HTMLElement | null = null; let filterInput: HTMLInputElement | null = null; let addTrigger: HTMLButtonElement | null = null; -let addMenu: HTMLElement | null = null; -let docClickWired = false; let allRecents: Recent[] = []; let closeTimer: number | null = null; const resizeHandler = () => positionDrawer(); let openCloneSheet = () => {}; -let openAddSheet = () => {}; function ensureDrawer() { if (drawerRoot) return; @@ -31,48 +28,12 @@ function ensureDrawer() { recentList = drawerRoot.querySelector('#drawer-recent-list'); filterInput = drawerRoot.querySelector('#drawer-filter'); addTrigger = drawerRoot.querySelector('#drawer-add-trigger'); - addMenu = drawerRoot.querySelector('.drawer-add-menu'); - - addTrigger?.addEventListener('click', (event) => { - event.stopPropagation(); - const expanded = addTrigger?.getAttribute('aria-expanded') === 'true'; - if (expanded) hideAddMenu(); - else showAddMenu(); - }); - - addMenu?.addEventListener('click', (event) => { - event.stopPropagation(); - const target = (event.target as HTMLElement)?.closest('[data-add-action]'); - const action = target?.dataset.addAction; - if (action === 'clone_repo') { - hideAddMenu(); - closeSwitchDrawer(); - openCloneSheet(); - } else if (action === 'add_repo') { - hideAddMenu(); - closeSwitchDrawer(); - openAddSheet(); - } + addTrigger?.addEventListener('click', () => { + closeSwitchDrawer(); + openCloneSheet(); }); filterInput?.addEventListener('input', () => renderRecents()); - - if (!docClickWired) { - document.addEventListener('click', () => hideAddMenu()); - docClickWired = true; - } -} - -function showAddMenu() { - if (!addMenu || !addTrigger) return; - addMenu.removeAttribute('hidden'); - addTrigger.setAttribute('aria-expanded', 'true'); -} - -function hideAddMenu() { - if (!addMenu || !addTrigger) return; - addMenu.setAttribute('hidden', 'true'); - addTrigger.setAttribute('aria-expanded', 'false'); } function positionDrawer() { @@ -195,7 +156,7 @@ async function loadRecents() { export function registerDrawerActions(actions: { openClone: () => void; openAdd: () => void }) { openCloneSheet = actions.openClone; - openAddSheet = actions.openAdd; + void actions.openAdd; } export function openSwitchDrawer() { @@ -207,7 +168,6 @@ export function openSwitchDrawer() { } drawerRoot.classList.remove('is-closing'); openModal('repo-switch-drawer'); - hideAddMenu(); if (filterInput) filterInput.value = ''; loadRecents(); positionDrawer(); @@ -221,7 +181,6 @@ export function closeSwitchDrawer() { if (!shouldAnimate) { closeModal('repo-switch-drawer'); window.removeEventListener('resize', resizeHandler); - hideAddMenu(); return; } if (closeTimer !== null) window.clearTimeout(closeTimer); @@ -230,7 +189,6 @@ export function closeSwitchDrawer() { drawerRoot?.classList.remove('is-closing'); closeModal('repo-switch-drawer'); window.removeEventListener('resize', resizeHandler); - hideAddMenu(); closeTimer = null; }, 130); } diff --git a/Frontend/src/scripts/ui/modals.ts b/Frontend/src/scripts/ui/modals.ts index 46838ad..5da603b 100644 --- a/Frontend/src/scripts/ui/modals.ts +++ b/Frontend/src/scripts/ui/modals.ts @@ -65,7 +65,7 @@ function unlockScroll() { } function closeWithAnimation(id: string, el: HTMLElement) { - if (id !== "repo-switch-drawer") { + if (id !== "repo-switch-drawer" && id !== "command-modal") { closeModal(id); return; } @@ -74,14 +74,15 @@ function closeWithAnimation(id: string, el: HTMLElement) { closeModal(id); return; } - const existing = (el as any).__drawerCloseTimer as number | undefined; + const existing = (el as any).__animatedCloseTimer as number | undefined; if (existing) window.clearTimeout(existing); el.classList.add("is-closing"); - (el as any).__drawerCloseTimer = window.setTimeout(() => { + const delay = id === "command-modal" ? 140 : 130; + (el as any).__animatedCloseTimer = window.setTimeout(() => { el.classList.remove("is-closing"); closeModal(id); - (el as any).__drawerCloseTimer = undefined; - }, 130); + (el as any).__animatedCloseTimer = undefined; + }, delay); } export function hydrate(id: string): void { diff --git a/Frontend/src/styles/modal/command-sheet.css b/Frontend/src/styles/modal/command-sheet.css index d0baeba..47678d2 100644 --- a/Frontend/src/styles/modal/command-sheet.css +++ b/Frontend/src/styles/modal/command-sheet.css @@ -14,6 +14,15 @@ width: min(680px, 95vw); } +@media (prefers-reduced-motion: no-preference) { + #command-modal[aria-hidden="false"] .dialog.sheet { + animation: commandModalOpen .18s cubic-bezier(.2,.8,.2,1); + } + #command-modal.is-closing .dialog.sheet { + animation: commandModalClose .14s cubic-bezier(.4,0,1,1); + } +} + /* Hide inactive panels completely */ #command-modal .sheet-body.hidden { display: none !important; @@ -151,6 +160,16 @@ to { opacity: 1; transform: translateY(0); } } +@keyframes commandModalOpen { + from { opacity: 0; transform: translateY(-8px) scale(.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes commandModalClose { + from { opacity: 1; transform: translateY(0) scale(1); } + to { opacity: 0; transform: translateY(-6px) scale(.99); } +} + /* -------------------------------------------------------------------------- Recent list (Switch panel) -------------------------------------------------------------------------- */ @@ -204,4 +223,4 @@ border-radius: var(--r-md, 8px); text-align: center; cursor: default; -} \ No newline at end of file +} diff --git a/Frontend/src/styles/modal/repo-switch-drawer.css b/Frontend/src/styles/modal/repo-switch-drawer.css index d27d309..a608d34 100644 --- a/Frontend/src/styles/modal/repo-switch-drawer.css +++ b/Frontend/src/styles/modal/repo-switch-drawer.css @@ -17,7 +17,7 @@ background:var(--surface); border:1px solid var(--border); box-shadow:0 15px 40px rgba(0,0,0,0.4); - overflow:hidden; + overflow:visible; max-height:calc(100vh - 80px); } @@ -96,31 +96,6 @@ outline:none; } -#repo-switch-drawer .drawer-add-menu{ - position:absolute; - top:100%; - left:0; - margin-top:0.25rem; - display:flex; - flex-direction:column; - gap:0.25rem; - padding:0.5rem; - background:var(--surface); - border:1px solid var(--border); - border-radius:0.5rem; - box-shadow:var(--shadow-1); - z-index:5; -} - -#repo-switch-drawer .drawer-add-menu[hidden]{ - display:none; -} - -#repo-switch-drawer .drawer-add-menu button{ - text-align:left; - width:100%; -} - #repo-switch-drawer .drawer-body{ padding:0.45rem 0.65rem 0.85rem; display:flex; From d142a5bf2adddc9481d32da4a0dcd7f3b5e6eef5 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 19:46:05 +0000 Subject: [PATCH 30/50] Add more animations --- Frontend/src/scripts/features/branches.ts | 24 +++++++++++- Frontend/src/scripts/main.ts | 20 +++++++++- Frontend/src/scripts/ui/layout.ts | 30 +++++++++++++- Frontend/src/scripts/ui/menubar.ts | 48 +++++++++++++++++++++-- Frontend/src/scripts/ui/modals.ts | 12 +++--- Frontend/src/styles/layout.css | 40 +++++++++++++++++++ Frontend/src/styles/modal/modal-base.css | 19 +++++++++ Frontend/src/styles/popover.css | 22 +++++++++++ 8 files changed, 202 insertions(+), 13 deletions(-) diff --git a/Frontend/src/scripts/features/branches.ts b/Frontend/src/scripts/features/branches.ts index 953f1af..3ef2b7e 100644 --- a/Frontend/src/scripts/features/branches.ts +++ b/Frontend/src/scripts/features/branches.ts @@ -23,6 +23,8 @@ const branchPop = qs('#branch-pop'); const branchFilter = qs('#branch-filter'); const branchList = qs('#branch-list'); const repoBranchEl = qs('#repo-branch'); +let branchCloseTimer: number | null = null; +const BRANCH_CLOSE_MS = 130; function syncBranchLabelsFromState() { const label = state.branchLabel || state.branch || '—'; @@ -108,9 +110,14 @@ function renderBranches() { async function openBranchPopover() { if (!branchBtn || !branchPop) return; + if (branchCloseTimer !== null) { + window.clearTimeout(branchCloseTimer); + branchCloseTimer = null; + } await loadBranches(); const r = branchBtn.getBoundingClientRect(); + branchPop.classList.remove('is-closing'); branchPop.style.left = `${r.left}px`; branchPop.style.top = `${r.bottom + 6}px`; branchPop.hidden = false; @@ -121,9 +128,22 @@ async function openBranchPopover() { function closeBranchPopover() { if (!branchPop || !branchBtn || !branchFilter) return; - branchPop.hidden = true; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reduceMotion) { + branchPop.hidden = true; + branchBtn.setAttribute('aria-expanded', 'false'); + branchFilter.value = ''; + return; + } + if (branchCloseTimer !== null) window.clearTimeout(branchCloseTimer); + branchPop.classList.add('is-closing'); branchBtn.setAttribute('aria-expanded', 'false'); - branchFilter.value = ''; + branchCloseTimer = window.setTimeout(() => { + branchPop.classList.remove('is-closing'); + branchPop.hidden = true; + branchFilter.value = ''; + branchCloseTimer = null; + }, BRANCH_CLOSE_MS); } /* ---------------- enable/disable ---------------- */ diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index 18ea90c..cce9deb 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -35,6 +35,8 @@ const cloneBtn = qs('#clone-btn'); const repoSwitch = qs('#repo-switch'); const commitBtn = qs('#commit-btn'); const undoLeftBtn = qs('#undo-left-btn'); +let fetchCloseTimer: number | null = null; +const FETCH_CLOSE_MS = 130; async function boot() { // If launched as the Output Log window, render that view and skip the main app UI. @@ -237,10 +239,15 @@ async function boot() { function openFetchPopover() { if (!fetchPop || !fetchCaret) return; + if (fetchCloseTimer !== null) { + window.clearTimeout(fetchCloseTimer); + fetchCloseTimer = null; + } const anchor = (document.getElementById('fetch-split') || fetchBtn || fetchCaret) as HTMLElement | null; if (!anchor) return; updateFetchUI(); const r = anchor.getBoundingClientRect(); + fetchPop.classList.remove('is-closing'); fetchPop.hidden = false; fetchPop.style.left = `${r.left}px`; fetchPop.style.top = `${r.bottom + 6}px`; @@ -253,8 +260,19 @@ async function boot() { function closeFetchPopover() { if (!fetchPop || !fetchCaret) return; - fetchPop.hidden = true; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; fetchCaret.setAttribute('aria-expanded', 'false'); + if (reduceMotion) { + fetchPop.hidden = true; + return; + } + if (fetchCloseTimer !== null) window.clearTimeout(fetchCloseTimer); + fetchPop.classList.add('is-closing'); + fetchCloseTimer = window.setTimeout(() => { + fetchPop.classList.remove('is-closing'); + fetchPop.hidden = true; + fetchCloseTimer = null; + }, FETCH_CLOSE_MS); } async function pushChanges() { diff --git a/Frontend/src/scripts/ui/layout.ts b/Frontend/src/scripts/ui/layout.ts index 8ad5dbe..6be2427 100644 --- a/Frontend/src/scripts/ui/layout.ts +++ b/Frontend/src/scripts/ui/layout.ts @@ -26,6 +26,7 @@ function ensureSystemSyncListener() { const tabs = qsa('.tab'); const commitBox = qs('#commit'); const diffHeadPath = qs('#diff-path'); +let tabSwitchAnimTimer: number | null = null; const repoTitleEl = qs('#repo-title'); const repoBranchEl = qs('#repo-branch'); @@ -69,8 +70,13 @@ export function toggleTheme() { } export function setTab(tab: 'changes'|'history'|'stash') { + const prevTab = prefs.tab; prefs.tab = tab; savePrefs(); - tabs.forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); + tabs.forEach((b) => { + const active = b.dataset.tab === tab; + b.classList.toggle('active', active); + b.setAttribute('aria-selected', active ? 'true' : 'false'); + }); const hideCommit = (tab === 'history' || tab === 'stash'); if (commitBox) commitBox.style.display = hideCommit ? 'none' : 'grid'; if (diffHeadPath) setText(diffHeadPath, @@ -79,6 +85,28 @@ export function setTab(tab: 'changes'|'history'|'stash') { : 'Select a file to view changes'); const historyActionsBtn = qs('#history-actions-btn'); if (historyActionsBtn && tab !== 'history') historyActionsBtn.hidden = true; + if (prevTab === 'history' && tab !== 'history') { + (state as any).selectedCommit = null; + } + if (tab === 'changes' && prevTab !== 'changes') { + // Force file diff repaint when leaving history/stash so commit details + // can't remain in the right pane. + state.diffDirty = true; + } + if (workGrid) { + if (tabSwitchAnimTimer !== null) { + window.clearTimeout(tabSwitchAnimTimer); + tabSwitchAnimTimer = null; + } + workGrid.classList.remove('is-tab-switching'); + // Force reflow so repeated switches replay the animation. + void workGrid.offsetWidth; + workGrid.classList.add('is-tab-switching'); + tabSwitchAnimTimer = window.setTimeout(() => { + workGrid?.classList.remove('is-tab-switching'); + tabSwitchAnimTimer = null; + }, 200); + } window.dispatchEvent(new CustomEvent('app:tab-changed', { detail: tab })); } diff --git a/Frontend/src/scripts/ui/menubar.ts b/Frontend/src/scripts/ui/menubar.ts index 0f52cba..3b4c8cc 100644 --- a/Frontend/src/scripts/ui/menubar.ts +++ b/Frontend/src/scripts/ui/menubar.ts @@ -1,19 +1,58 @@ type MenuAction = (id: string) => void | Promise; +const MENU_CLOSE_MS = 130; export function initMenubar(onAction: MenuAction) { const root = document.querySelector('.menubar'); if (!root) return; let openMenu: HTMLElement | null = null; + let closeTimer: number | null = null; + + const isReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + const finalizeClose = (menu: HTMLElement) => { + const trigger = menu.querySelector('.menu-trigger'); + const list = menu.querySelector('.menu-list'); + trigger?.setAttribute('aria-expanded', 'false'); + if (list) { + list.classList.remove('is-closing'); + list.setAttribute('hidden', ''); + } + if (openMenu === menu) openMenu = null; + }; + + const closeMenu = (menu: HTMLElement | null, immediate = false) => { + if (!menu) return; + if (closeTimer !== null) { + window.clearTimeout(closeTimer); + closeTimer = null; + } + const list = menu.querySelector('.menu-list'); + if (!list || list.hasAttribute('hidden')) { + finalizeClose(menu); + return; + } + if (immediate || isReducedMotion()) { + finalizeClose(menu); + return; + } + list.classList.add('is-closing'); + closeTimer = window.setTimeout(() => { + finalizeClose(menu); + closeTimer = null; + }, MENU_CLOSE_MS); + }; const closeMenus = () => { - openMenu?.querySelector('.menu-trigger')?.setAttribute('aria-expanded', 'false'); - openMenu?.querySelector('.menu-list')?.setAttribute('hidden', ''); - openMenu = null; + closeMenu(openMenu); }; const open = (menu: HTMLElement) => { - if (openMenu && openMenu !== menu) closeMenus(); + if (closeTimer !== null) { + window.clearTimeout(closeTimer); + closeTimer = null; + } + if (openMenu && openMenu !== menu) closeMenu(openMenu, true); const list = menu.querySelector('.menu-list'); const trigger = menu.querySelector('.menu-trigger'); if (!list || !trigger) return; @@ -22,6 +61,7 @@ export function initMenubar(onAction: MenuAction) { closeMenus(); return; } + list.classList.remove('is-closing'); list.removeAttribute('hidden'); trigger.setAttribute('aria-expanded', 'true'); openMenu = menu; diff --git a/Frontend/src/scripts/ui/modals.ts b/Frontend/src/scripts/ui/modals.ts index 5da603b..0c46f59 100644 --- a/Frontend/src/scripts/ui/modals.ts +++ b/Frontend/src/scripts/ui/modals.ts @@ -65,10 +65,6 @@ function unlockScroll() { } function closeWithAnimation(id: string, el: HTMLElement) { - if (id !== "repo-switch-drawer" && id !== "command-modal") { - closeModal(id); - return; - } const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduceMotion) { closeModal(id); @@ -77,7 +73,7 @@ function closeWithAnimation(id: string, el: HTMLElement) { const existing = (el as any).__animatedCloseTimer as number | undefined; if (existing) window.clearTimeout(existing); el.classList.add("is-closing"); - const delay = id === "command-modal" ? 140 : 130; + const delay = id === "repo-switch-drawer" ? 130 : 140; (el as any).__animatedCloseTimer = window.setTimeout(() => { el.classList.remove("is-closing"); closeModal(id); @@ -129,6 +125,12 @@ export function openModal(id: string): void { if (!el) return; if (!el.hasAttribute("aria-hidden")) el.setAttribute("aria-hidden", "true"); + el.classList.remove("is-closing"); + const existing = (el as any).__animatedCloseTimer as number | undefined; + if (existing) { + window.clearTimeout(existing); + (el as any).__animatedCloseTimer = undefined; + } el.setAttribute("aria-hidden", "false"); lockScroll(); refreshOverlayScrollbarsFor(el); diff --git a/Frontend/src/styles/layout.css b/Frontend/src/styles/layout.css index 5824ca2..306084a 100644 --- a/Frontend/src/styles/layout.css +++ b/Frontend/src/styles/layout.css @@ -46,6 +46,18 @@ display: flex; flex-direction: column; gap: .1rem; } .menubar .menu-list[hidden] { display: none; } +.menubar .menu-list.is-closing { display: flex; pointer-events: none; } + +@media (prefers-reduced-motion: no-preference) { + .menubar .menu-list:not([hidden]) { + animation: menubarDropdownOpen .14s cubic-bezier(.2,.8,.2,1); + transform-origin: top left; + } + .menubar .menu-list.is-closing { + animation: menubarDropdownClose .12s cubic-bezier(.4,0,1,1); + transform-origin: top left; + } +} .menubar .menu-item { display: flex; justify-content: space-between; align-items: center; gap: .5rem; padding: .45rem .55rem; @@ -108,6 +120,34 @@ .tab:hover { color: var(--text); } .tab.active { color: var(--text); border-bottom-color: var(--accent); } +.work.is-tab-switching .left { + animation: tabListIn .24s cubic-bezier(.2,.8,.2,1); +} + +.work.is-tab-switching .right { + animation: tabPaneIn .24s cubic-bezier(.2,.8,.2,1); +} + +@keyframes tabListIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes tabPaneIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes menubarDropdownOpen { + from { opacity: 0; transform: translateY(-4px) scale(.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes menubarDropdownClose { + from { opacity: 1; transform: translateY(0) scale(1); } + to { opacity: 0; transform: translateY(-3px) scale(.985); } +} + /* Work area */ .work { flex: 1 1 auto; display: grid; diff --git a/Frontend/src/styles/modal/modal-base.css b/Frontend/src/styles/modal/modal-base.css index a633636..cb94b36 100644 --- a/Frontend/src/styles/modal/modal-base.css +++ b/Frontend/src/styles/modal/modal-base.css @@ -20,6 +20,25 @@ } .modal[aria-hidden="true"]{ display:none; } +@media (prefers-reduced-motion: no-preference){ + .modal[aria-hidden="false"]:not(#repo-switch-drawer) .dialog{ + animation: modalOpenIn .18s cubic-bezier(.2,.8,.2,1); + } + .modal.is-closing:not(#repo-switch-drawer) .dialog{ + animation: modalCloseOut .14s cubic-bezier(.4,0,1,1); + } +} + +@keyframes modalOpenIn{ + from{ opacity:0; transform:translateY(-8px) scale(.985); } + to{ opacity:1; transform:translateY(0) scale(1); } +} + +@keyframes modalCloseOut{ + from{ opacity:1; transform:translateY(0) scale(1); } + to{ opacity:0; transform:translateY(-6px) scale(.99); } +} + .backdrop{ position:absolute; inset:0; background:rgba(0,0,0,.55); z-index:0; } .dialog.sheet{ diff --git a/Frontend/src/styles/popover.css b/Frontend/src/styles/popover.css index bf8f364..a496506 100644 --- a/Frontend/src/styles/popover.css +++ b/Frontend/src/styles/popover.css @@ -21,8 +21,20 @@ box-shadow: var(--shadow-1); } .popover[hidden] { display: none !important; } +.popover.is-closing { display: flex !important; pointer-events: none; } .popover.compact { min-width: 180px; } +@media (prefers-reduced-motion: no-preference) { + .popover:not([hidden]) { + animation: popoverOpen .14s cubic-bezier(.2,.8,.2,1); + transform-origin: top left; + } + .popover.is-closing { + animation: popoverClose .12s cubic-bezier(.4,0,1,1); + transform-origin: top left; + } +} + /* Header */ .pop-head { display: flex; @@ -103,3 +115,13 @@ border-radius: 999px; font-size: .75rem; } + +@keyframes popoverOpen { + from { opacity: 0; transform: translateY(-5px) scale(.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes popoverClose { + from { opacity: 1; transform: translateY(0) scale(1); } + to { opacity: 0; transform: translateY(-4px) scale(.985); } +} From 4876c9c0caecb78dbed68be07d7ef263e41bfa52 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 7 Feb 2026 19:47:57 +0000 Subject: [PATCH 31/50] frontend: add global animations performance toggle Add a new Performance setting for UI animations, defaulted to enabled. Wire it through backend config, settings UI load/save/reset, startup application, and a global CSS gate that disables transitions and animations when turned off. --- Backend/src/settings.rs | 7 +++++++ Frontend/src/modals/settings.html | 6 +++++- Frontend/src/scripts/features/settings.ts | 10 +++++++++- Frontend/src/scripts/main.ts | 5 ++++- Frontend/src/scripts/types.d.ts | 1 + Frontend/src/styles/base.css | 6 ++++++ 6 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Backend/src/settings.rs b/Backend/src/settings.rs index cdf0ca1..883cf8f 100644 --- a/Backend/src/settings.rs +++ b/Backend/src/settings.rs @@ -3,6 +3,10 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::{fs, io}; +fn default_true() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { pub schema_version: u32, @@ -233,12 +237,15 @@ pub struct Performance { pub progressive_render: bool, #[serde(default)] pub gpu_accel: bool, + #[serde(default = "default_true")] + pub animations: bool, } impl Default for Performance { fn default() -> Self { Self { progressive_render: true, gpu_accel: true, + animations: true, } } } diff --git a/Frontend/src/modals/settings.html b/Frontend/src/modals/settings.html index 14eb213..e37b286 100644 --- a/Frontend/src/modals/settings.html +++ b/Frontend/src/modals/settings.html @@ -156,7 +156,11 @@

    Settings