Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Backend/src/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,12 @@ pub fn init() {
let dir = crate::app_identity::project_dirs()
.map(|pd| pd.data_dir().join("logs"))
.unwrap_or_else(|| std::path::PathBuf::from("logs"));
let _ = fs::create_dir_all(&dir); // best effort
if let Err(e) = fs::create_dir_all(&dir) {
log::warn!(
"logging: failed to create log directory '{}': {e}",
dir.display()
);
}

rotate_existing_log(&dir);
prune_archives(&dir, cfg.logging.retain_archives as usize);
Expand Down
16 changes: 13 additions & 3 deletions Backend/src/plugin_bundles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,15 @@ fn copy_directory_recursive(source: &Path, dest: &Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(
if let Err(e) = fs::set_permissions(
&dest_path,
fs::Permissions::from_mode(metadata.permissions().mode()),
);
) {
warn!(
"copy_recursive: failed to set permissions on '{}': {e}",
dest_path.display()
);
}
}
}
}
Expand Down Expand Up @@ -452,7 +457,12 @@ impl PluginBundleStore {
plugin_dir.display()
)
})?;
let _ = fs::remove_dir_all(&staging);
if let Err(e) = fs::remove_dir_all(&staging) {
warn!(
"install_plugin_dir: failed to remove staging directory '{}': {e}",
staging.display()
);
}
write_plugin_source_metadata(&plugin_dir, source_metadata)?;

let approval = if auto_approve {
Expand Down
43 changes: 42 additions & 1 deletion Backend/src/plugin_vcs_backends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,37 @@ use crate::plugin_runtime::{PluginRuntimeManager, vcs_proxy::PluginVcsProxy};
use crate::settings::AppConfig;
use log::{debug, error, info, trace, warn};
use std::collections::BTreeMap;
use std::{path::Path, sync::Arc};
use std::path::Path;
use std::sync::{Arc, OnceLock, RwLock};

const MODULE: &str = "plugin_vcs_backends";

static BACKEND_CACHE: OnceLock<RwLock<Option<Vec<PluginBackendDescriptor>>>> = OnceLock::new();

fn backend_cache() -> &'static RwLock<Option<Vec<PluginBackendDescriptor>>> {
BACKEND_CACHE.get_or_init(|| RwLock::new(None))
}

fn cached_backends() -> Option<Vec<PluginBackendDescriptor>> {
backend_cache()
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}

fn store_backends(backends: Vec<PluginBackendDescriptor>) {
*backend_cache()
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(backends);
}

/// Clears cached VCS backend discovery results.
pub fn invalidate_plugin_vcs_backend_cache() {
*backend_cache()
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}

/// Returns plugin-scoped open config for a VCS backend plugin.
fn plugin_open_config(plugin_id: &str) -> serde_json::Value {
serde_json::Value::Object(settings_store::load_settings(plugin_id).unwrap_or_default())
Expand Down Expand Up @@ -72,6 +99,20 @@ pub fn list_plugin_vcs_backends() -> Result<Vec<PluginBackendDescriptor>, String
let _timer = LogTimer::new(MODULE, "list_plugin_vcs_backends");
info!("list_plugin_vcs_backends: discovering VCS backends",);

if let Some(cached) = cached_backends() {
trace!(
"list_plugin_vcs_backends: returning {} cached backend(s)",
cached.len()
);
return Ok(cached);
}

let discovered = discover_plugin_vcs_backends()?;
store_backends(discovered.clone());
Ok(discovered)
}

fn discover_plugin_vcs_backends() -> Result<Vec<PluginBackendDescriptor>, String> {
let store = PluginBundleStore::new_default();
if let Err(err) = store.sync_built_in_plugins() {
warn!(
Expand Down
1 change: 1 addition & 0 deletions Backend/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ impl AppState {
next.validate();
next.save().map_err(|e| e.to_string())?;
crate::monitoring::sync_backend_monitoring(&next);
crate::plugin_vcs_backends::invalidate_plugin_vcs_backend_cache();
*self.config.write() = next;
self.enforce_recents_limit_and_persist();
Ok(())
Expand Down
3 changes: 3 additions & 0 deletions Backend/src/tauri_commands/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ pub fn sync_configured_plugins(state: State<'_, AppState>) -> Result<(), String>
state.set_config(cfg.clone())?;
PluginBundleStore::new_default().sync_built_in_plugins()?;
crate::plugin_sources::sync_configured_plugins(&cfg)?;
crate::plugin_vcs_backends::invalidate_plugin_vcs_backend_cache();
state.plugin_runtime().sync_plugin_runtime_with_config(&cfg)
}

Expand All @@ -146,6 +147,7 @@ pub fn uninstall_plugin(state: State<'_, AppState>, plugin_id: String) -> Result
let plugin_id = plugin_id.trim().to_string();
state.plugin_runtime().stop_plugin(&plugin_id)?;
PluginBundleStore::new_default().uninstall_plugin(&plugin_id)?;
crate::plugin_vcs_backends::invalidate_plugin_vcs_backend_cache();
info!("plugin: uninstalled '{}'", plugin_id);
Ok(())
}
Expand Down Expand Up @@ -278,6 +280,7 @@ pub fn set_plugin_approval(

let store = PluginBundleStore::new_default();
store.approve_capabilities(&plugin_id, &version, approved)?;
crate::plugin_vcs_backends::invalidate_plugin_vcs_backend_cache();

if approved {
if let Err(err) = state.plugin_runtime().sync_plugin_runtime() {
Expand Down
101 changes: 95 additions & 6 deletions Backend/src/tauri_commands/ssh.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Copyright © 2025-2026 OpenVCS Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
use std::{fs, path::PathBuf, process::Command};
use std::{
env, fs,
path::{Path, PathBuf},
process::Command,
};

use log::{debug, error, info, trace, warn};
use serde::Serialize;
Expand Down Expand Up @@ -107,6 +111,60 @@ fn run_command(cmd: &str, args: &[&str]) -> Result<SshCommandOutput, String> {
Ok(result)
}

fn is_executable(path: &Path) -> bool {
let Ok(metadata) = fs::metadata(path) else {
return false;
};

if !metadata.is_file() {
return false;
}

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}

#[cfg(not(unix))]
{
true
}
}

fn resolve_command(candidate: &Path) -> Option<PathBuf> {
if candidate.is_absolute() || candidate.components().count() > 1 {
return is_executable(candidate).then(|| candidate.to_path_buf());
}

let path = env::var_os("PATH")?;
env::split_paths(&path)
.map(|dir| dir.join(candidate))
.find(|path| is_executable(path))
}

fn resolve_ssh_askpass() -> Option<PathBuf> {
let mut candidates = Vec::new();

if let Some(path) = env::var_os("SSH_ASKPASS") {
candidates.push(PathBuf::from(path));
}

candidates.extend([
PathBuf::from("/usr/bin/ksshaskpass"),
PathBuf::from("/usr/bin/ssh-askpass"),
PathBuf::from("/usr/bin/x11-ssh-askpass"),
PathBuf::from("/usr/bin/gnome-ssh-askpass"),
PathBuf::from("/usr/bin/lxqt-openssh-askpass"),
PathBuf::from("/usr/libexec/openssh/ssh-askpass"),
PathBuf::from("/usr/lib/ssh/ssh-askpass"),
]);

candidates
.into_iter()
.find_map(|candidate| resolve_command(candidate.as_path()))
}

#[cfg(not(target_os = "windows"))]
/// Scans SSH host keys using `ssh-keyscan`.
///
Expand Down Expand Up @@ -338,13 +396,44 @@ pub fn ssh_add_key(path: String) -> Result<SshCommandOutput, String> {
return Err("Path cannot be empty".to_string());
}

let result = run_command("ssh-add", &[p])?;
let result = if let Some(askpass) = resolve_ssh_askpass() {
debug!("ssh_add_key: using SSH_ASKPASS='{}'", askpass.display());
let start = std::time::Instant::now();
let out = Command::new("ssh-add")
.env("SSH_ASKPASS", &askpass)
.arg(p)
.output()
.map_err(|e| {
error!("ssh_add_key: failed to spawn ssh-add: {}", e);
format!("Failed to run ssh-add: {e}")
})?;

let elapsed = start.elapsed();
let result = SshCommandOutput {
code: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(),
stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
};

if result.code == 0 {
debug!("ssh_add_key: key added successfully");
if out.status.success() {
debug!(
"ssh_add_key: key added successfully in {:?} (code={})",
elapsed, result.code
);
} else {
warn!(
"ssh_add_key: failed to add key in {:?} (code={}): {}",
elapsed, result.code, result.stderr
);
}

result
} else {
warn!("ssh_add_key: failed to add key: {}", result.stderr);
}
warn!(
"ssh_add_key: no usable SSH_ASKPASS found; encrypted keys may require terminal prompt"
);
run_command("ssh-add", &[p])?
};

Ok(result)
}
10 changes: 7 additions & 3 deletions Backend/src/tauri_commands/updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,22 @@ pub async fn updater_install_now<R: Runtime>(window: Window<R>) -> Result<(), St
"received": received,
"total": total_val
});
let _ = app2.emit("update:progress", payload);
if let Err(e) = app2.emit("update:progress", payload) {
log::warn!("updater_install_now: failed to emit progress: {e}");
}
},
|| {
let download_elapsed = download_start.elapsed();
info!(
"updater_install_now: download completed in {:?}",
download_elapsed
);
let _ = app2.emit(
if let Err(e) = app2.emit(
"update:progress",
serde_json::json!({ "kind": "downloaded" }),
);
) {
log::warn!("updater_install_now: failed to emit downloaded event: {e}");
}
},
)
.await
Expand Down
11 changes: 7 additions & 4 deletions Backend/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ use std::path::Path;
use std::sync::LazyLock;

/// Regex pattern for scp-like VCS URLs.
static SCP_LIKE_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^[\w.-]+@[\w.-]+:[\w./-]+(?:\.git)?$").unwrap());
static SCP_LIKE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"^[\w.-]+@[\w.-]+:[\w./-]+(?:\.git)?$")
.expect("hardcoded SCP-like regex is valid")
});

/// Regex pattern for Windows absolute paths.
static WIN_ABS_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^[A-Za-z]:[\\/]").unwrap());
static WIN_ABS_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"^[A-Za-z]:[\\/]").expect("hardcoded Windows path regex is valid")
});

#[derive(serde::Serialize)]
pub struct Validation {
Expand Down
4 changes: 2 additions & 2 deletions Frontend/src/scripts/features/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ export function bindCommit() {
clearBusy('Ready');
return;
}
if (commitSummary?.maxLength === 72 && summary.length > 72) {
summary = summary.slice(0, 72);
if (commitSummary?.maxLength === 72 && String(hookData.summary || '').length > 72) {
summary = String(hookData.summary || '').trim().slice(0, 72);
commitSummary.value = summary;
} else {
summary = String(hookData.summary || '').trim() || summary;
Expand Down
43 changes: 43 additions & 0 deletions Frontend/src/scripts/features/newBranch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,49 @@ describe('wireNewBranch', () => {
expect(checkout.checked).toBe(true);
});

it('shows normalized branch name when spaces collapse to dashes', async () => {
installTauriMock();

const { wireNewBranch } = await import('./newBranch');
wireNewBranch();
await flushPromises();

const name = document.getElementById('new-branch-name') as HTMLInputElement;
const hint = document.getElementById('new-branch-name-hint') as HTMLElement;
const create = document.getElementById('new-branch-create') as HTMLButtonElement;

name.value = ' feature branch ';
name.dispatchEvent(new Event('input'));
await flushPromises();

expect(hint.hidden).toBe(false);
expect(hint.classList.contains('error')).toBe(false);
expect(hint.textContent).toContain('Will be created as');
expect(hint.querySelector('code')?.textContent).toBe('feature-branch');
expect(create.disabled).toBe(false);
});

it('rejects branch names with invalid characters', async () => {
installTauriMock();

const { wireNewBranch } = await import('./newBranch');
wireNewBranch();
await flushPromises();

const name = document.getElementById('new-branch-name') as HTMLInputElement;
const hint = document.getElementById('new-branch-name-hint') as HTMLElement;
const create = document.getElementById('new-branch-create') as HTMLButtonElement;

name.value = 'bad~branch';
name.dispatchEvent(new Event('input'));
await flushPromises();

expect(hint.hidden).toBe(false);
expect(hint.classList.contains('error')).toBe(true);
expect(hint.textContent).toBe('Branch name contains invalid characters');
expect(create.disabled).toBe(true);
});

it('passes the checkout choice to branch creation', async () => {
const invoke = vi.fn(async () => null);
(window as any).__TAURI__ = {
Expand Down
2 changes: 1 addition & 1 deletion Frontend/src/scripts/features/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ export function openSettings(section?: string){
}

loadSettingsIntoForm(modal)
.catch(console.error)
.catch((err) => { console.error('Failed to load settings into form:', err); })
.finally(() => {
modal.removeAttribute('aria-busy');
const setThemeAuto = modal.querySelector<HTMLInputElement>('#set-theme-auto');
Expand Down
Loading
Loading