feat(clone): add repository cloning flow to UI and backend
This introduces a new Tauri command to clone a remote repository into a validated destination folder, then return the full repository bundle for immediate rendering. The Svelte app gains a clone dialog and a new action in repository management, wiring the cloned bundle into existing refresh helpers. Dialog backdrops were also adjusted to rely on explicit close controls. - Add clone_repository command and core cloning logic in Rust - Create CloneRepositoryDialog and integrate it into App.svelte - Improve clone-related styling and tighten dialog backdrop behavior
This commit is contained in:
@@ -250,6 +250,25 @@ pub struct RepositoryBundle {
|
||||
pub files: Vec<GitRepositoryFile>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clone_repository(
|
||||
remote_url: String,
|
||||
parent_path: String,
|
||||
directory_name: Option<String>,
|
||||
commit_limit: Option<u32>,
|
||||
) -> Result<RepositoryBundle, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
clone_repository_core(
|
||||
&remote_url,
|
||||
&parent_path,
|
||||
directory_name.as_deref(),
|
||||
commit_limit,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not clone repository: {err}"))?
|
||||
}
|
||||
|
||||
/// Opens a repository and gathers everything the UI needs in a single call.
|
||||
///
|
||||
/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves
|
||||
@@ -2208,6 +2227,137 @@ fn repository_files_with_status(
|
||||
Ok(files.into_values().collect())
|
||||
}
|
||||
|
||||
fn clone_repository_core(
|
||||
remote_url: &str,
|
||||
parent_path: &str,
|
||||
directory_name: Option<&str>,
|
||||
commit_limit: Option<u32>,
|
||||
) -> Result<RepositoryBundle, String> {
|
||||
let target = clone_target_path(remote_url, parent_path, directory_name)?;
|
||||
run_git_clone(remote_url.trim(), &target)?;
|
||||
|
||||
let repo = resolve_repo(&target.to_string_lossy())?;
|
||||
let status = status_for_repo(&repo)?;
|
||||
let branches = branches_for_repo(&repo)?;
|
||||
let stashes = stashes_for_repo(&repo)?;
|
||||
let commits = commits_for_repo(&repo, commit_limit)?;
|
||||
let files = repository_files_with_status(&repo, &status)?;
|
||||
|
||||
Ok(RepositoryBundle {
|
||||
status,
|
||||
branches,
|
||||
stashes,
|
||||
commits,
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
fn clone_target_path(
|
||||
remote_url: &str,
|
||||
parent_path: &str,
|
||||
directory_name: Option<&str>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let remote = remote_url.trim();
|
||||
if remote.is_empty() {
|
||||
return Err("Remote URL must not be empty.".to_string());
|
||||
}
|
||||
if remote.starts_with('-') || remote.chars().any(|c| c.is_control()) {
|
||||
return Err("Remote URL contains invalid characters.".to_string());
|
||||
}
|
||||
|
||||
let parent = PathBuf::from(parent_path.trim());
|
||||
if parent_path.trim().is_empty() {
|
||||
return Err("Destination folder must not be empty.".to_string());
|
||||
}
|
||||
if !parent.exists() {
|
||||
return Err("Destination folder does not exist.".to_string());
|
||||
}
|
||||
if !parent.is_dir() {
|
||||
return Err("Destination path must be a folder.".to_string());
|
||||
}
|
||||
|
||||
let raw_name = directory_name
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| infer_clone_directory_name(remote));
|
||||
let name = validate_clone_directory_name(&raw_name)?;
|
||||
let target = parent.join(name);
|
||||
|
||||
if target.exists() {
|
||||
if !target.is_dir() {
|
||||
return Err("Clone destination already exists and is not a folder.".to_string());
|
||||
}
|
||||
let mut entries = target
|
||||
.read_dir()
|
||||
.map_err(|err| format!("Could not inspect clone destination: {err}"))?;
|
||||
if entries.next().is_some() {
|
||||
return Err("Clone destination already exists and is not empty.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn infer_clone_directory_name(remote_url: &str) -> String {
|
||||
let trimmed = remote_url
|
||||
.trim()
|
||||
.split(['?', '#'])
|
||||
.next()
|
||||
.unwrap_or(remote_url)
|
||||
.trim_end_matches(['/', '\\']);
|
||||
let last_segment = trimmed
|
||||
.rsplit(['/', '\\', ':'])
|
||||
.find(|part| !part.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
last_segment
|
||||
.strip_suffix(".git")
|
||||
.unwrap_or(last_segment)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn validate_clone_directory_name(name: &str) -> Result<String, String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Folder name could not be inferred. Enter a folder name.".to_string());
|
||||
}
|
||||
if trimmed == "." || trimmed == ".." {
|
||||
return Err("Folder name is not valid.".to_string());
|
||||
}
|
||||
if trimmed.chars().any(|c| {
|
||||
c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*')
|
||||
}) {
|
||||
return Err("Folder name contains invalid characters.".to_string());
|
||||
}
|
||||
if Path::new(trimmed).is_absolute() {
|
||||
return Err("Folder name must be relative.".to_string());
|
||||
}
|
||||
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn run_git_clone(remote_url: &str, target: &Path) -> Result<(), String> {
|
||||
let output = git_command()
|
||||
.arg("clone")
|
||||
.arg("--")
|
||||
.arg(remote_url)
|
||||
.arg(target)
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Git clone failed: {}",
|
||||
command_output_details(&output)
|
||||
))
|
||||
}
|
||||
|
||||
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
|
||||
let normalized = normalize_git_path(path);
|
||||
if repo.join(path).is_dir() {
|
||||
@@ -3682,6 +3832,49 @@ mod tests {
|
||||
run_git_test(repo, ["commit", "-q", "-m", "init"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_directory_name_is_inferred_from_common_remote_urls() {
|
||||
assert_eq!(
|
||||
infer_clone_directory_name("https://github.com/example/project.git"),
|
||||
"project"
|
||||
);
|
||||
assert_eq!(
|
||||
infer_clone_directory_name("git@github.com:example/project.git"),
|
||||
"project"
|
||||
);
|
||||
assert_eq!(
|
||||
infer_clone_directory_name("ssh://git@example.com/example/project.git/"),
|
||||
"project"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_repository_core_clones_and_returns_repository_bundle() {
|
||||
let source = init_temp_repo("clone_source");
|
||||
commit_initial_file(&source.path);
|
||||
let parent = temp_dir("clone_parent");
|
||||
|
||||
let bundle = clone_repository_core(
|
||||
source.path.to_str().expect("source path should be UTF-8"),
|
||||
parent.path.to_str().expect("parent path should be UTF-8"),
|
||||
Some("local-copy"),
|
||||
Some(100),
|
||||
)
|
||||
.expect("repository should clone");
|
||||
|
||||
let cloned_repo = parent.path.join("local-copy");
|
||||
assert_eq!(
|
||||
PathBuf::from(bundle.status.repo_path),
|
||||
cloned_repo
|
||||
.canonicalize()
|
||||
.expect("clone path should resolve")
|
||||
);
|
||||
assert!(cloned_repo.join("old.txt").exists());
|
||||
assert!(bundle.status.clean);
|
||||
assert_eq!(bundle.commits.len(), 1);
|
||||
assert!(bundle.files.iter().any(|file| file.path == "old.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_code_introductions_finds_added_string() {
|
||||
let repo = init_temp_repo("search_added_string");
|
||||
|
||||
Reference in New Issue
Block a user