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>,
|
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.
|
/// 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
|
/// 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())
|
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> {
|
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
|
||||||
let normalized = normalize_git_path(path);
|
let normalized = normalize_git_path(path);
|
||||||
if repo.join(path).is_dir() {
|
if repo.join(path).is_dir() {
|
||||||
@@ -3682,6 +3832,49 @@ mod tests {
|
|||||||
run_git_test(repo, ["commit", "-q", "-m", "init"]);
|
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]
|
#[test]
|
||||||
fn search_code_introductions_finds_added_string() {
|
fn search_code_introductions_finds_added_string() {
|
||||||
let repo = init_temp_repo("search_added_string");
|
let repo = init_temp_repo("search_added_string");
|
||||||
|
|||||||
+11
-9
@@ -6,15 +6,16 @@ mod git;
|
|||||||
use badge::set_sync_badge;
|
use badge::set_sync_badge;
|
||||||
use git::{
|
use git::{
|
||||||
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
|
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
|
||||||
checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
|
checkout_branch, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
||||||
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch,
|
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
||||||
cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch,
|
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch,
|
||||||
get_file_patch, get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status,
|
||||||
list_repository_files, list_stashes, merge_branch, open_repo_in_explorer, open_repository,
|
list_branches, list_commits, list_file_history, list_repository_files, list_stashes,
|
||||||
open_repository_bundle, open_repository_file, pull, push, read_conflict, rebase_abort,
|
merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||||
rebase_branch, rebase_continue, rename_branch, resolve_conflict, resolve_conflict_side,
|
open_repository_file, pull, push, read_conflict, rebase_abort, rebase_branch, rebase_continue,
|
||||||
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
|
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||||
stage_files, stash_apply, stash_drop, stash_pop, stash_push, unstage_files,
|
restore_files, restore_to_commit, search_code_introductions, stage_files, stash_apply,
|
||||||
|
stash_drop, stash_pop, stash_push, unstage_files,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -25,6 +26,7 @@ fn main() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_repository,
|
open_repository,
|
||||||
|
clone_repository,
|
||||||
open_repo_in_explorer,
|
open_repo_in_explorer,
|
||||||
open_repository_file,
|
open_repository_file,
|
||||||
get_status,
|
get_status,
|
||||||
|
|||||||
+53
-1
@@ -2,12 +2,13 @@
|
|||||||
import { onDestroy, onMount, tick } from "svelte";
|
import { onDestroy, onMount, tick } from "svelte";
|
||||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||||
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
import { AlertCircle, BookOpen, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
||||||
|
|
||||||
import TitleBar from "./lib/TitleBar.svelte";
|
import TitleBar from "./lib/TitleBar.svelte";
|
||||||
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||||||
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
||||||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||||
|
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
|
||||||
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
||||||
import CompareDialog from "./lib/components/CompareDialog.svelte";
|
import CompareDialog from "./lib/components/CompareDialog.svelte";
|
||||||
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
|
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
checkoutBranch,
|
checkoutBranch,
|
||||||
|
cloneRepository,
|
||||||
commit,
|
commit,
|
||||||
commitAiGenerate,
|
commitAiGenerate,
|
||||||
commitAiLoad,
|
commitAiLoad,
|
||||||
@@ -144,6 +146,8 @@
|
|||||||
let repoTabs: RepoTab[] = [];
|
let repoTabs: RepoTab[] = [];
|
||||||
let recentRepoPaths: string[] = [];
|
let recentRepoPaths: string[] = [];
|
||||||
let repoSearch = "";
|
let repoSearch = "";
|
||||||
|
let cloneDialogOpen = false;
|
||||||
|
let cloneDialogError = "";
|
||||||
let status: GitStatus | null = null;
|
let status: GitStatus | null = null;
|
||||||
let branches: GitBranchInfo[] = [];
|
let branches: GitBranchInfo[] = [];
|
||||||
let stashes: GitStash[] = [];
|
let stashes: GitStash[] = [];
|
||||||
@@ -983,6 +987,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cloneRepo(remoteUrl: string, parentPath: string, directoryName: string) {
|
||||||
|
if (isBusy) return;
|
||||||
|
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
|
||||||
|
if (!parentPath) { errorMessage = "Select a destination folder."; return; }
|
||||||
|
|
||||||
|
operation = "Cloning repository";
|
||||||
|
errorMessage = "";
|
||||||
|
cloneDialogError = "";
|
||||||
|
try {
|
||||||
|
const bundle = await cloneRepository(remoteUrl, parentPath, directoryName || undefined, 100);
|
||||||
|
resetRepositoryState(false);
|
||||||
|
applyStatus(bundle.status);
|
||||||
|
if (globalSearchBusy) void cancelGlobalSearch();
|
||||||
|
activeView = "repository";
|
||||||
|
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||||
|
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||||
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
|
cloneDialogOpen = false;
|
||||||
|
lastRepoSwitchAt = Date.now();
|
||||||
|
} catch (error) {
|
||||||
|
cloneDialogError = errorToMessage(error);
|
||||||
|
errorMessage = cloneDialogError;
|
||||||
|
} finally {
|
||||||
|
operation = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCloneDialog() {
|
||||||
|
if (isBusy) return;
|
||||||
|
cloneDialogError = "";
|
||||||
|
cloneDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
function openRepoManagement() {
|
function openRepoManagement() {
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
activeView = "management";
|
activeView = "management";
|
||||||
@@ -2060,6 +2098,10 @@
|
|||||||
<h1>Repositories</h1>
|
<h1>Repositories</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="repo-management-actions">
|
<div class="repo-management-actions">
|
||||||
|
<button class="btn-primary" type="button" onclick={openCloneDialog} disabled={isBusy}>
|
||||||
|
<Download size={15} aria-hidden="true" />
|
||||||
|
Clone
|
||||||
|
</button>
|
||||||
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
|
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
|
||||||
<FolderOpen size={15} aria-hidden="true" />
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
Browse
|
Browse
|
||||||
@@ -2472,6 +2514,16 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Clone repository dialog -->
|
||||||
|
{#if cloneDialogOpen}
|
||||||
|
<CloneRepositoryDialog
|
||||||
|
isBusy={operation === "Cloning repository"}
|
||||||
|
error={cloneDialogError}
|
||||||
|
onClone={cloneRepo}
|
||||||
|
onClose={() => { if (!isBusy) cloneDialogOpen = false; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Full-screen overlay while a repository is being opened -->
|
<!-- Full-screen overlay while a repository is being opened -->
|
||||||
{#if openingRepo}
|
{#if openingRepo}
|
||||||
<RepoLoadingOverlay repoName={repoDisplayName} />
|
<RepoLoadingOverlay repoName={repoDisplayName} />
|
||||||
|
|||||||
+54
@@ -2363,6 +2363,54 @@
|
|||||||
max-height: calc(100vh - 32px);
|
max-height: calc(100vh - 32px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.clone-repository-dialog {
|
||||||
|
display: block;
|
||||||
|
width: min(620px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.clone-dialog-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.clone-dialog-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.clone-dialog-field > span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.clone-dialog-path-field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.clone-dialog-error {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid rgba(232,96,90,0.3);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #f09090;
|
||||||
|
background: rgba(232,96,90,0.08);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.clone-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
.ai-settings-form {
|
.ai-settings-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -3779,6 +3827,12 @@
|
|||||||
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
|
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
.repo-tab.management { min-width: 0; }
|
.repo-tab.management { min-width: 0; }
|
||||||
.repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); }
|
.repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); }
|
||||||
|
.repo-management-head,
|
||||||
|
.repo-management-tools { align-items: stretch; flex-direction: column; }
|
||||||
|
.repo-management-actions { justify-content: flex-start; }
|
||||||
|
.clone-dialog-path-field { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.clone-dialog-actions { flex-direction: column-reverse; }
|
||||||
|
.clone-dialog-actions button { width: 100%; }
|
||||||
.repo-row-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; }
|
.repo-row-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; }
|
||||||
.repo-row { min-height: 58px; }
|
.repo-row { min-height: 58px; }
|
||||||
.repo-row-icon { min-height: 58px; }
|
.repo-row-icon { min-height: 58px; }
|
||||||
|
|||||||
@@ -126,7 +126,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
|
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
|
|||||||
@@ -20,13 +20,9 @@
|
|||||||
|
|
||||||
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
|
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
|
||||||
|
|
||||||
function closeFromBackdrop(event: MouseEvent) {
|
|
||||||
if (isBusy || event.target !== event.currentTarget) return;
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isBusy: boolean;
|
||||||
|
error: string;
|
||||||
|
onClone: (remoteUrl: string, parentPath: string, directoryName: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
isBusy = false,
|
||||||
|
error = "",
|
||||||
|
onClone = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let remoteUrl = $state("");
|
||||||
|
let parentPath = $state("");
|
||||||
|
let directoryName = $state("");
|
||||||
|
let directoryNameEdited = $state(false);
|
||||||
|
let directoryAutoName = $state("");
|
||||||
|
let browseError = $state("");
|
||||||
|
|
||||||
|
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
||||||
|
let canSubmit = $derived(
|
||||||
|
!isBusy &&
|
||||||
|
remoteUrl.trim().length > 0 &&
|
||||||
|
parentPath.trim().length > 0,
|
||||||
|
);
|
||||||
|
let visibleError = $derived(error || browseError);
|
||||||
|
|
||||||
|
function directoryNameFromRemoteUrl(url: string): string {
|
||||||
|
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
|
||||||
|
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
|
||||||
|
return lastSegment.replace(/\.git$/i, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorToMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof error === "string") return error;
|
||||||
|
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseParentFolder() {
|
||||||
|
if (isBusy) return;
|
||||||
|
browseError = "";
|
||||||
|
try {
|
||||||
|
const selected = await openDialog({
|
||||||
|
title: "Select clone destination",
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: parentPath.trim() || undefined,
|
||||||
|
});
|
||||||
|
if (typeof selected !== "string") return;
|
||||||
|
parentPath = selected;
|
||||||
|
} catch (error) {
|
||||||
|
browseError = errorToMessage(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoteInput(event: Event) {
|
||||||
|
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value;
|
||||||
|
if (directoryNameEdited) return;
|
||||||
|
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
|
||||||
|
directoryName = directoryAutoName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDirectoryInput(event: Event) {
|
||||||
|
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
|
||||||
|
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Repository Management</span>
|
||||||
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Clone repository</h2>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close">
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="clone-dialog-form" onsubmit={submit}>
|
||||||
|
<label class="clone-dialog-field">
|
||||||
|
<span>Remote URL</span>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
bind:value={remoteUrl}
|
||||||
|
oninput={handleRemoteInput}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="https://github.com/org/project.git"
|
||||||
|
disabled={isBusy}
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="clone-dialog-field">
|
||||||
|
<span>Destination</span>
|
||||||
|
<div class="clone-dialog-path-field">
|
||||||
|
<input
|
||||||
|
bind:value={parentPath}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="Choose parent folder"
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
<button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}>
|
||||||
|
<FolderOpen size={14} aria-hidden="true" />
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="clone-dialog-field">
|
||||||
|
<span>Folder name</span>
|
||||||
|
<input
|
||||||
|
bind:value={directoryName}
|
||||||
|
oninput={handleDirectoryInput}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder={directorySuggestion || "Optional"}
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if visibleError}
|
||||||
|
<div class="clone-dialog-error" role="alert">{visibleError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="clone-dialog-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" type="submit" disabled={!canSubmit}>
|
||||||
|
{#if isBusy}
|
||||||
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Download size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
Clone
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -182,7 +182,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
|
|||||||
@@ -66,7 +66,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
>
|
>
|
||||||
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
|
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
|
||||||
<div class="cred-hero">
|
<div class="cred-hero">
|
||||||
|
|||||||
@@ -25,13 +25,9 @@
|
|||||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
||||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
||||||
|
|
||||||
function closeFromBackdrop(event: MouseEvent) {
|
|
||||||
if (isBusy || event.target !== event.currentTarget) return;
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -129,7 +129,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
|
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
|
|||||||
@@ -316,12 +316,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBranchDialogBackdropClick(event: MouseEvent) {
|
|
||||||
if (event.target === event.currentTarget) {
|
|
||||||
closeBranchDialog();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function localBranchRefs(commit: GitCommit): string[] {
|
function localBranchRefs(commit: GitCommit): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const labels: string[] = [];
|
const labels: string[] = [];
|
||||||
@@ -569,7 +563,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if branchDialogOpen}
|
{#if branchDialogOpen}
|
||||||
<div class="branch-filter-backdrop" role="presentation" onclick={handleBranchDialogBackdropClick}>
|
<div class="branch-filter-backdrop" role="presentation">
|
||||||
<div
|
<div
|
||||||
class="branch-filter-dialog"
|
class="branch-filter-dialog"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
|
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
|
|||||||
@@ -254,7 +254,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="dialog"
|
class="dialog"
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ export function openRepositoryBundle(path: string, commitLimit = 100): Promise<R
|
|||||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cloneRepository(
|
||||||
|
remoteUrl: string,
|
||||||
|
parentPath: string,
|
||||||
|
directoryName?: string,
|
||||||
|
commitLimit = 100,
|
||||||
|
): Promise<RepositoryBundle> {
|
||||||
|
return invoke<RepositoryBundle>("clone_repository", {
|
||||||
|
remoteUrl,
|
||||||
|
parentPath,
|
||||||
|
directoryName: directoryName?.trim() ? directoryName.trim() : null,
|
||||||
|
commitLimit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getStatus(path: string): Promise<GitStatus> {
|
export function getStatus(path: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("get_status", { path });
|
return invoke<GitStatus>("get_status", { path });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user