Feature/clone repo #15
@@ -250,6 +250,29 @@ pub struct RepositoryBundle {
|
||||
pub files: Vec<GitRepositoryFile>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clone_repository(
|
||||
remote_url: String,
|
||||
parent_path: String,
|
||||
directory_name: Option<String>,
|
||||
username: Option<String>,
|
||||
password: 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(),
|
||||
username.as_deref(),
|
||||
password.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 +2231,166 @@ fn repository_files_with_status(
|
||||
Ok(files.into_values().collect())
|
||||
}
|
||||
|
||||
fn clone_repository_core(
|
||||
remote_url: &str,
|
||||
parent_path: &str,
|
||||
directory_name: Option<&str>,
|
||||
username: Option<&str>,
|
||||
password: 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, username, password)?;
|
||||
|
||||
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,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let mut command = git_command();
|
||||
command
|
||||
.arg("clone")
|
||||
.arg("--")
|
||||
.arg(remote_url)
|
||||
.arg(target)
|
||||
.env("GIT_TERMINAL_PROMPT", "0");
|
||||
|
||||
let askpass = match (username, password) {
|
||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||
let askpass = write_askpass_script()?;
|
||||
command
|
||||
.env("GIT_ASKPASS", &askpass)
|
||||
.env("GIT_CRED_USER", u)
|
||||
.env("GIT_CRED_PASS", p);
|
||||
Some(askpass)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let output = command
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"));
|
||||
if let Some(path) = askpass {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
let output = output?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let details = command_output_details(&output);
|
||||
if is_auth_error(&details) {
|
||||
return Err(format!("AUTH_FAILED:{details}"));
|
||||
}
|
||||
|
||||
Err(format!("Git clone failed: {}", details))
|
||||
}
|
||||
|
||||
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 +3865,51 @@ 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"),
|
||||
None,
|
||||
None,
|
||||
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");
|
||||
|
||||
+11
-9
@@ -6,15 +6,16 @@ mod git;
|
||||
use badge::set_sync_badge;
|
||||
use git::{
|
||||
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
|
||||
checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
|
||||
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch,
|
||||
cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch,
|
||||
get_file_patch, get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
||||
list_repository_files, list_stashes, merge_branch, open_repo_in_explorer, open_repository,
|
||||
open_repository_bundle, open_repository_file, pull, push, read_conflict, rebase_abort,
|
||||
rebase_branch, rebase_continue, rename_branch, resolve_conflict, resolve_conflict_side,
|
||||
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
|
||||
stage_files, stash_apply, stash_drop, stash_pop, stash_push, unstage_files,
|
||||
checkout_branch, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
||||
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
||||
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch,
|
||||
diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status,
|
||||
list_branches, list_commits, list_file_history, list_repository_files, list_stashes,
|
||||
merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||
open_repository_file, pull, push, read_conflict, rebase_abort, rebase_branch, rebase_continue,
|
||||
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||
restore_files, restore_to_commit, search_code_introductions, stage_files, stash_apply,
|
||||
stash_drop, stash_pop, stash_push, unstage_files,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -25,6 +26,7 @@ fn main() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_repository,
|
||||
clone_repository,
|
||||
open_repo_in_explorer,
|
||||
open_repository_file,
|
||||
get_status,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"icon": ["icons/icon.ico"],
|
||||
"icon": ["icons/icon.png", "icons/icon.ico"],
|
||||
"createUpdaterArtifacts": true,
|
||||
"windows": {
|
||||
"nsis": {
|
||||
|
||||
+200
-5
@@ -2,12 +2,13 @@
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
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 AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||||
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
||||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
|
||||
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
||||
import CompareDialog from "./lib/components/CompareDialog.svelte";
|
||||
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
|
||||
@@ -28,6 +29,7 @@
|
||||
|
||||
import {
|
||||
checkoutBranch,
|
||||
cloneRepository,
|
||||
commit,
|
||||
commitAiGenerate,
|
||||
commitAiLoad,
|
||||
@@ -110,6 +112,7 @@
|
||||
|
||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||
type AppView = "management" | "repository";
|
||||
type CredentialAction = "push" | "pull" | "fetch" | "clone";
|
||||
type PendingDiscard =
|
||||
| { kind: "file"; file: GitFileStatus; staged: boolean }
|
||||
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
|
||||
@@ -124,6 +127,12 @@
|
||||
lastOpened: number;
|
||||
}
|
||||
|
||||
interface CloneRequest {
|
||||
remoteUrl: string;
|
||||
parentPath: string;
|
||||
directoryName: string;
|
||||
}
|
||||
|
||||
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
||||
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
||||
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
||||
@@ -135,6 +144,7 @@
|
||||
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
|
||||
const HISTORY_ASIDE_MIN_WIDTH = 560;
|
||||
const HISTORY_ASIDE_MAX_WIDTH = 920;
|
||||
const ERROR_AUTO_HIDE_MS = 6000;
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -144,6 +154,10 @@
|
||||
let repoTabs: RepoTab[] = [];
|
||||
let recentRepoPaths: string[] = [];
|
||||
let repoSearch = "";
|
||||
let cloneDialogOpen = false;
|
||||
let cloneDialogError = "";
|
||||
let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingClone: CloneRequest | null = null;
|
||||
let status: GitStatus | null = null;
|
||||
let branches: GitBranchInfo[] = [];
|
||||
let stashes: GitStash[] = [];
|
||||
@@ -200,7 +214,7 @@
|
||||
let autoRefreshEnabled = true;
|
||||
let autoRefreshInFlight = false;
|
||||
let credDialogOpen = false;
|
||||
let credDialogAction: "push" | "pull" | "fetch" | null = null;
|
||||
let credDialogAction: CredentialAction | null = null;
|
||||
let credDialogError = "";
|
||||
let credDialogKey: string | null = null;
|
||||
let lastStatusFingerprint = "";
|
||||
@@ -221,6 +235,7 @@
|
||||
let updateCheckInFlight = false;
|
||||
let updateDownloadTotal = 0;
|
||||
let updateDownloadedBytes = 0;
|
||||
let errorAutoHideTimers: Partial<Record<string, ReturnType<typeof setTimeout>>> = {};
|
||||
let commitPanelHeight = loadCommitPanelHeight();
|
||||
let resizingCommitPanel = false;
|
||||
let resizeStartY = 0;
|
||||
@@ -236,7 +251,9 @@
|
||||
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
||||
$: workspaceActive = activeView === "repository" && hasRepository;
|
||||
$: openingRepo = operation === "Opening repository";
|
||||
$: cloningRepo = operation === "Cloning repository";
|
||||
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
|
||||
$: cloneDisplayName = pendingClone?.directoryName || repoNameFromCloneUrl(pendingClone?.remoteUrl ?? "");
|
||||
$: changedFiles = status?.files ?? [];
|
||||
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
|
||||
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
|
||||
@@ -277,9 +294,37 @@
|
||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
||||
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
||||
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
|
||||
Object.values(errorAutoHideTimers).forEach((timer) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
||||
});
|
||||
|
||||
$: scheduleAutoHideError("errorMessage", errorMessage, (message) => {
|
||||
if (errorMessage === message) errorMessage = "";
|
||||
});
|
||||
$: scheduleAutoHideError("linePatchError", linePatchError, (message) => {
|
||||
if (linePatchError === message) linePatchError = "";
|
||||
});
|
||||
$: scheduleAutoHideError("globalSearchError", globalSearchError, (message) => {
|
||||
if (globalSearchError === message) globalSearchError = "";
|
||||
});
|
||||
$: scheduleAutoHideError("credDialogError", credDialogError, (message) => {
|
||||
if (credDialogError === message) credDialogError = "";
|
||||
});
|
||||
$: scheduleAutoHideError("updateError", updateError, (message) => {
|
||||
if (updateError === message) updateError = "";
|
||||
if (updateToastState === "error") updateToastOpen = false;
|
||||
});
|
||||
$: scheduleAutoHideError(
|
||||
"updateErrorToast",
|
||||
updateToastOpen && updateToastState === "error" ? (updateError || "Update failed") : "",
|
||||
() => {
|
||||
if (updateToastState === "error") updateToastOpen = false;
|
||||
},
|
||||
);
|
||||
|
||||
// ── Auto-refresh ───────────────────────────────────────────────────────────
|
||||
|
||||
function statusFingerprint(value: GitStatus): string {
|
||||
@@ -517,6 +562,41 @@
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
||||
}
|
||||
|
||||
function repoNameFromCloneUrl(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 setCloneDialogError(message: string) {
|
||||
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
|
||||
cloneDialogError = message;
|
||||
if (message) {
|
||||
cloneDialogErrorTimer = setTimeout(() => {
|
||||
if (cloneDialogError === message) cloneDialogError = "";
|
||||
}, ERROR_AUTO_HIDE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoHideError(
|
||||
key: string,
|
||||
message: string,
|
||||
clearIfCurrent: (message: string) => void,
|
||||
) {
|
||||
const existing = errorAutoHideTimers[key];
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
delete errorAutoHideTimers[key];
|
||||
}
|
||||
|
||||
if (!message) return;
|
||||
|
||||
errorAutoHideTimers[key] = setTimeout(() => {
|
||||
clearIfCurrent(message);
|
||||
delete errorAutoHideTimers[key];
|
||||
}, ERROR_AUTO_HIDE_MS);
|
||||
}
|
||||
|
||||
function repoKey(path: string): string {
|
||||
return path.replace(/\\/g, "/").trim().toLowerCase();
|
||||
}
|
||||
@@ -983,6 +1063,91 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneRepo(
|
||||
remoteUrl: string,
|
||||
parentPath: string,
|
||||
directoryName: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
key?: string | null,
|
||||
fromStore = false,
|
||||
) {
|
||||
if (isBusy) return;
|
||||
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
|
||||
if (!parentPath) { errorMessage = "Select a destination folder."; return; }
|
||||
|
||||
const request: CloneRequest = { remoteUrl, parentPath, directoryName };
|
||||
pendingClone = request;
|
||||
const credentialKey = key === undefined ? orgKeyFromUrl(remoteUrl) : key;
|
||||
|
||||
if (!username && !password) {
|
||||
const stored = await loadStoredCredential(credentialKey);
|
||||
if (stored && !isCredentialExpired(stored)) {
|
||||
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true);
|
||||
return;
|
||||
}
|
||||
if (stored && credentialKey) await credDelete(credentialKey).catch(() => {});
|
||||
}
|
||||
|
||||
operation = "Cloning repository";
|
||||
errorMessage = "";
|
||||
setCloneDialogError("");
|
||||
try {
|
||||
const bundle = await cloneRepository(
|
||||
remoteUrl,
|
||||
parentPath,
|
||||
directoryName || undefined,
|
||||
username,
|
||||
password,
|
||||
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;
|
||||
pendingClone = null;
|
||||
if (credDialogAction === "clone") {
|
||||
credDialogOpen = false;
|
||||
credDialogAction = null;
|
||||
credDialogError = "";
|
||||
credDialogKey = null;
|
||||
}
|
||||
lastRepoSwitchAt = Date.now();
|
||||
} catch (error) {
|
||||
const rawMessage = errorToMessage(error);
|
||||
const message = stripAuthPrefix(rawMessage);
|
||||
if (isAuthError(rawMessage)) {
|
||||
errorMessage = "";
|
||||
setCloneDialogError("");
|
||||
if (fromStore) {
|
||||
if (credentialKey) void credDelete(credentialKey).catch(() => {});
|
||||
credDialogError = "Credentials were rejected or have expired. Please sign in again.";
|
||||
} else {
|
||||
credDialogError = message || "Sign-in is required to clone this repository.";
|
||||
}
|
||||
credDialogAction = "clone";
|
||||
credDialogKey = credentialKey;
|
||||
credDialogOpen = true;
|
||||
} else {
|
||||
setCloneDialogError(message);
|
||||
errorMessage = "";
|
||||
}
|
||||
} finally {
|
||||
operation = "";
|
||||
}
|
||||
}
|
||||
|
||||
function openCloneDialog() {
|
||||
if (isBusy) return;
|
||||
setCloneDialogError("");
|
||||
cloneDialogOpen = true;
|
||||
}
|
||||
|
||||
function openRepoManagement() {
|
||||
if (isBusy) return;
|
||||
activeView = "management";
|
||||
@@ -1221,11 +1386,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openCredentialDialog(action: "push" | "pull" | "fetch", key?: string | null) {
|
||||
if (!activeRepoPath) return;
|
||||
async function openCredentialDialog(action: CredentialAction, key?: string | null) {
|
||||
if (!activeRepoPath && action !== "clone") return;
|
||||
credDialogError = "";
|
||||
credDialogAction = action;
|
||||
credDialogKey = key === undefined ? await currentCredKey() : key;
|
||||
credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null);
|
||||
credDialogOpen = true;
|
||||
}
|
||||
|
||||
@@ -1358,6 +1523,17 @@
|
||||
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
|
||||
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
|
||||
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false);
|
||||
else if (credDialogAction === "clone" && pendingClone) {
|
||||
await cloneRepo(
|
||||
pendingClone.remoteUrl,
|
||||
pendingClone.parentPath,
|
||||
pendingClone.directoryName,
|
||||
username,
|
||||
password,
|
||||
key,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// Only persist once the operation actually succeeded (dialog has closed).
|
||||
if (!credDialogOpen && save && key) {
|
||||
@@ -2060,6 +2236,10 @@
|
||||
<h1>Repositories</h1>
|
||||
</div>
|
||||
<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}>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
Browse
|
||||
@@ -2472,11 +2652,26 @@
|
||||
/>
|
||||
{/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 -->
|
||||
{#if openingRepo}
|
||||
<RepoLoadingOverlay repoName={repoDisplayName} />
|
||||
{/if}
|
||||
|
||||
<!-- Full-screen overlay while a repository is being cloned -->
|
||||
{#if cloningRepo}
|
||||
<RepoLoadingOverlay label="Cloning repository" repoName={cloneDisplayName} />
|
||||
{/if}
|
||||
|
||||
<!-- Conflict resolve dialog -->
|
||||
{#if resolveDialogOpen}
|
||||
<ResolveDialog
|
||||
|
||||
+54
@@ -2363,6 +2363,54 @@
|
||||
max-height: calc(100vh - 32px);
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3779,6 +3827,12 @@
|
||||
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.repo-tab.management { min-width: 0; }
|
||||
.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 { min-height: 58px; }
|
||||
.repo-row-icon { min-height: 58px; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
|
||||
import { credDelete, credLoad, credSave } from "../git";
|
||||
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
|
||||
@@ -36,6 +36,7 @@
|
||||
let loadingKeys = $state(true);
|
||||
let saving = $state(false);
|
||||
let error = $state("");
|
||||
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
$effect(() => {
|
||||
provider = settings.provider;
|
||||
@@ -47,6 +48,16 @@
|
||||
customModel = settings.customModel;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||
const currentError = error;
|
||||
if (currentError) {
|
||||
errorHideTimer = setTimeout(() => {
|
||||
if (error === currentError) error = "";
|
||||
}, 6000);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -66,6 +77,10 @@
|
||||
})();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||
});
|
||||
|
||||
async function persistKey(target: CloudProvider, value: string) {
|
||||
const key = CRED_KEYS[target];
|
||||
const trimmed = value.trim();
|
||||
@@ -126,7 +141,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
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">
|
||||
<header class="dialog-header">
|
||||
|
||||
@@ -20,13 +20,9 @@
|
||||
|
||||
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
|
||||
|
||||
function closeFromBackdrop(event: MouseEvent) {
|
||||
if (isBusy || event.target !== event.currentTarget) return;
|
||||
onClose();
|
||||
}
|
||||
</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}>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
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 visibleError = $state("");
|
||||
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
||||
let canSubmit = $derived(
|
||||
!isBusy &&
|
||||
remoteUrl.trim().length > 0 &&
|
||||
parentPath.trim().length > 0,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const nextError = error || browseError;
|
||||
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||
visibleError = nextError;
|
||||
if (nextError) {
|
||||
errorHideTimer = setTimeout(() => {
|
||||
visibleError = "";
|
||||
}, 6000);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||
});
|
||||
|
||||
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
|
||||
class="dialog-backdrop"
|
||||
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">
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
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">
|
||||
<header class="dialog-header">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
} from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
action: "push" | "pull" | "fetch";
|
||||
action: "push" | "pull" | "fetch" | "clone";
|
||||
error: string;
|
||||
isBusy: boolean;
|
||||
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
|
||||
@@ -43,12 +43,20 @@
|
||||
password.trim().length > 0 &&
|
||||
(mode === "token" || username.trim().length > 0),
|
||||
);
|
||||
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : "Pull");
|
||||
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
|
||||
let actionTitle = $derived(
|
||||
action === "push" ? "Authenticate push" : action === "fetch" ? "Authenticate fetch" : "Authenticate pull",
|
||||
action === "push"
|
||||
? "Authenticate push"
|
||||
: action === "fetch"
|
||||
? "Authenticate fetch"
|
||||
: action === "clone"
|
||||
? "Authenticate clone"
|
||||
: "Authenticate pull",
|
||||
);
|
||||
let actionHint = $derived(action === "push"
|
||||
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
|
||||
: action === "clone"
|
||||
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
|
||||
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
@@ -66,7 +74,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
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-hero">
|
||||
|
||||
@@ -25,13 +25,9 @@
|
||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
||||
|
||||
function closeFromBackdrop(event: MouseEvent) {
|
||||
if (isBusy || event.target !== event.currentTarget) return;
|
||||
onClose();
|
||||
}
|
||||
</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}>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
|
||||
@@ -129,7 +129,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
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">
|
||||
<header class="dialog-header">
|
||||
|
||||
@@ -316,12 +316,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleBranchDialogBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeBranchDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function localBranchRefs(commit: GitCommit): string[] {
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
@@ -569,7 +563,7 @@
|
||||
</section>
|
||||
|
||||
{#if branchDialogOpen}
|
||||
<div class="branch-filter-backdrop" role="presentation" onclick={handleBranchDialogBackdropClick}>
|
||||
<div class="branch-filter-backdrop" role="presentation">
|
||||
<div
|
||||
class="branch-filter-dialog"
|
||||
role="dialog"
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
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">
|
||||
<header class="dialog-header">
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
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">
|
||||
<header class="dialog-header">
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
|
||||
@@ -34,6 +34,24 @@ export function openRepositoryBundle(path: string, commitLimit = 100): Promise<R
|
||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||
}
|
||||
|
||||
export function cloneRepository(
|
||||
remoteUrl: string,
|
||||
parentPath: string,
|
||||
directoryName?: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
commitLimit = 100,
|
||||
): Promise<RepositoryBundle> {
|
||||
return invoke<RepositoryBundle>("clone_repository", {
|
||||
remoteUrl,
|
||||
parentPath,
|
||||
directoryName: directoryName?.trim() ? directoryName.trim() : null,
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
commitLimit,
|
||||
});
|
||||
}
|
||||
|
||||
export function getStatus(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("get_status", { path });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user