Add local repository scanning and UI

Implement local repository scanning end-to-end. Frontend: add UI (toolbar, local-scan panel, list/cards), CSS styles, icons, new state fields, and templates; convert recent repo items to buttons and wire up actions to select, view and open scanned repos. JS: add helper utilities (repoNameFromPath, normalizeRemoteUrl, serverRepoRemoteUrls), scanForLocalRepos/selectLocalRepo functions, render template for scan results, and call scanLocalRepos via tauri-api. Tauri API: expose scan_local_repos (tauri command) in tauri-api.js. Backend (Rust): add LocalRepoCandidate type and a scanner implementation that walks configurable/default roots, deduplicates paths, matches local remotes against allowed server URLs, enforces depth/result limits, and returns matched candidates; register scan_local_repos in the command list. Includes error handling and user-facing messages for missing roots or remotes.
This commit is contained in:
2026-05-10 14:32:41 +12:00
parent f0358dbdfe
commit 346f8536f0
5 changed files with 678 additions and 28 deletions
+294 -2
View File
@@ -1,7 +1,11 @@
use reqwest::blocking::Client;
use reqwest::header::{ACCEPT, AUTHORIZATION};
use serde::Serialize;
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
#[derive(Serialize)]
@@ -47,6 +51,14 @@ struct LocalRepoFile {
too_large: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalRepoCandidate {
name: String,
path: String,
matched_remote_url: String,
}
fn normalize_api_base_url(server_url: &str) -> Result<String, String> {
// Normalize user input so every backend consistently resolves to /api/v1.
let trimmed = server_url.trim().trim_end_matches('/');
@@ -164,6 +176,206 @@ fn treeish(reference: &str, path: &str) -> String {
}
}
fn dedupe_key(path: &Path) -> String {
let path = path
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.replace('\\', "/")
.trim_end_matches('/')
.to_string();
#[cfg(windows)]
{
path.to_lowercase()
}
#[cfg(not(windows))]
{
path
}
}
fn add_if_exists(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !path.exists() {
return;
}
let key = dedupe_key(&path);
if !paths.iter().any(|existing| dedupe_key(existing) == key) {
paths.push(path);
}
}
fn default_repo_scan_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Some(home) = env::var_os("USERPROFILE").or_else(|| env::var_os("HOME")) {
let home = PathBuf::from(home);
add_if_exists(&mut roots, home.join("Repos"));
add_if_exists(&mut roots, home.join("repos"));
add_if_exists(&mut roots, home.join("Code"));
add_if_exists(&mut roots, home.join("code"));
add_if_exists(&mut roots, home.join("Source"));
add_if_exists(&mut roots, home.join("source"));
add_if_exists(&mut roots, home.join("Projects"));
add_if_exists(&mut roots, home.join("GitHub"));
add_if_exists(&mut roots, home.join("Documents").join("GitHub"));
add_if_exists(&mut roots, home.join("Documents").join("Repos"));
add_if_exists(&mut roots, home.join("Desktop"));
}
#[cfg(windows)]
{
for drive in b'A'..=b'Z' {
let root = format!("{}:\\", drive as char);
let root_path = PathBuf::from(&root);
if !root_path.exists() {
continue;
}
for folder in [
"Repos", "repos", "Code", "code", "Source", "source", "Projects", "GitHub", "Dev",
] {
add_if_exists(&mut roots, root_path.join(folder));
}
}
}
roots
}
fn is_skippable_dir(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
return false;
};
matches!(
name,
".cache"
| ".cargo"
| ".gradle"
| ".npm"
| ".rustup"
| ".svn"
| ".tauri"
| "AppData"
| "Library"
| "node_modules"
| "target"
| "vendor"
)
}
fn is_git_repo(path: &Path) -> bool {
path.join(".git").exists()
}
fn normalize_git_remote_url(value: &str) -> String {
let mut remote = value.trim().trim_end_matches('/').to_lowercase();
if let Some(stripped) = remote.strip_suffix(".git") {
remote = stripped.to_string();
}
remote
}
fn local_remote_urls(repo_path: &Path, git_path: Option<String>) -> Vec<String> {
let git_binary = resolve_git_binary(git_path);
let Ok(output) = Command::new(&git_binary)
.current_dir(repo_path)
.args(["config", "--get-regexp", r"^remote\..*\.url$"])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.split_once(' ').map(|(_, url)| url.trim().to_string()))
.filter(|url| !url.is_empty())
.collect()
}
fn matched_server_remote(
repo_path: &Path,
git_path: Option<String>,
allowed_remote_urls: &HashSet<String>,
) -> Option<String> {
local_remote_urls(repo_path, git_path)
.into_iter()
.find(|remote| allowed_remote_urls.contains(&normalize_git_remote_url(remote)))
}
fn collect_repo_candidates(
root: &Path,
depth: u8,
max_depth: u8,
results: &mut Vec<LocalRepoCandidate>,
seen: &mut HashSet<String>,
allowed_remote_urls: &HashSet<String>,
git_path: Option<String>,
max_results: usize,
) {
if results.len() >= max_results || is_skippable_dir(root) {
return;
}
if is_git_repo(root) {
if seen.insert(dedupe_key(root)) {
let display_path = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let path = display_path.to_string_lossy().to_string();
let Some(matched_remote_url) =
matched_server_remote(root, git_path.clone(), allowed_remote_urls)
else {
return;
};
let name = root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(&path)
.to_string();
results.push(LocalRepoCandidate {
name,
path,
matched_remote_url,
});
}
return;
}
if depth >= max_depth {
return;
}
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
if results.len() >= max_results {
return;
}
let path = entry.path();
if path.is_dir() {
collect_repo_candidates(
&path,
depth + 1,
max_depth,
results,
seen,
allowed_remote_urls,
git_path.clone(),
max_results,
);
}
}
}
#[tauri::command]
fn git_clone(
repo_url: String,
@@ -203,7 +415,11 @@ fn git_status(repo_path: String, git_path: Option<String>) -> Result<GitCommandR
run_git_command(
Some(repo_path.trim()),
git_path,
vec!["status".to_string(), "--short".to_string(), "--branch".to_string()],
vec![
"status".to_string(),
"--short".to_string(),
"--branch".to_string(),
],
)
}
@@ -216,6 +432,77 @@ fn git_branch(repo_path: String, git_path: Option<String>) -> Result<GitCommandR
)
}
#[tauri::command]
fn scan_local_repos(
roots: Option<Vec<String>>,
allowed_remote_urls: Vec<String>,
git_path: Option<String>,
max_depth: Option<u8>,
max_results: Option<usize>,
) -> Result<Vec<LocalRepoCandidate>, String> {
let max_depth = max_depth.unwrap_or(4).clamp(1, 8);
let max_results = max_results.unwrap_or(200).clamp(1, 500);
let allowed_remote_urls = allowed_remote_urls
.into_iter()
.map(|url| normalize_git_remote_url(&url))
.filter(|url| !url.is_empty())
.collect::<HashSet<_>>();
if allowed_remote_urls.is_empty() {
return Err(
"No selected server repository URLs are available to match against.".to_string(),
);
}
let root_paths = roots
.unwrap_or_default()
.into_iter()
.map(|path| path.trim().to_string())
.filter(|path| !path.is_empty())
.map(PathBuf::from)
.collect::<Vec<_>>();
let root_paths = if root_paths.is_empty() {
default_repo_scan_roots()
} else {
root_paths
};
if root_paths.is_empty() {
return Err(
"No scan roots found. Set a default clone directory or enter a scan root.".to_string(),
);
}
let mut results = Vec::new();
let mut seen = HashSet::new();
for root in root_paths {
if root.exists() && root.is_dir() {
collect_repo_candidates(
&root,
0,
max_depth,
&mut results,
&mut seen,
&allowed_remote_urls,
git_path.clone(),
max_results,
);
}
if results.len() >= max_results {
break;
}
}
results.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then_with(|| a.path.to_lowercase().cmp(&b.path.to_lowercase()))
});
Ok(results)
}
#[tauri::command]
fn local_repo_branches(
repo_path: String,
@@ -275,7 +562,11 @@ fn local_repo_tree(
let output = run_git_output(
repo_path.trim(),
git_path,
vec!["ls-tree".to_string(), "-l".to_string(), treeish(&reference, &path)],
vec![
"ls-tree".to_string(),
"-l".to_string(),
treeish(&reference, &path),
],
)?;
let mut entries = Vec::new();
@@ -447,6 +738,7 @@ pub fn run() {
git_push,
git_status,
git_branch,
scan_local_repos,
local_repo_branches,
local_repo_tree,
local_repo_file,