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:
+294
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user