Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
106cc98afb | ||
|
|
add98f962e | ||
|
|
90df347e4a | ||
|
|
4c58b14691 | ||
|
|
8bec7dfc9a | ||
|
|
7950edb145 | ||
|
|
e3af6653cd | ||
|
|
b752c0804b | ||
|
|
8f98e79df9 | ||
|
|
f0a1d89152 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.8.8",
|
"version": "2026.8.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.8.8",
|
"version": "2026.8.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.8.8",
|
"version": "2026.8.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
@@ -1478,6 +1478,7 @@ dependencies = [
|
|||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"shlex",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] }
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
||||||
|
shlex = "2"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
+325
-4
@@ -632,8 +632,24 @@ pub async fn clone_repository(
|
|||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
commit_limit: Option<u32>,
|
commit_limit: Option<u32>,
|
||||||
|
branch: Option<String>,
|
||||||
|
blobless: Option<bool>,
|
||||||
|
custom_flags: Option<String>,
|
||||||
|
shallow_depth: Option<u32>,
|
||||||
|
shallow_since: Option<String>,
|
||||||
|
sparse: Option<bool>,
|
||||||
|
sparse_paths: Option<Vec<String>>,
|
||||||
) -> Result<RepositoryBundle, String> {
|
) -> Result<RepositoryBundle, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let clone_options = CloneRunOptions {
|
||||||
|
branch,
|
||||||
|
blobless: blobless.unwrap_or(false),
|
||||||
|
custom_flags: custom_flags.unwrap_or_default(),
|
||||||
|
shallow_depth,
|
||||||
|
shallow_since,
|
||||||
|
sparse: sparse.unwrap_or(false),
|
||||||
|
sparse_paths: sparse_paths.unwrap_or_default(),
|
||||||
|
};
|
||||||
clone_repository_core(
|
clone_repository_core(
|
||||||
&remote_url,
|
&remote_url,
|
||||||
&parent_path,
|
&parent_path,
|
||||||
@@ -641,6 +657,7 @@ pub async fn clone_repository(
|
|||||||
username.as_deref(),
|
username.as_deref(),
|
||||||
password.as_deref(),
|
password.as_deref(),
|
||||||
commit_limit,
|
commit_limit,
|
||||||
|
&clone_options,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -5405,8 +5422,20 @@ fn repository_files_with_status(
|
|||||||
) -> Result<Vec<GitRepositoryFile>, String> {
|
) -> Result<Vec<GitRepositoryFile>, String> {
|
||||||
let mut files = BTreeMap::<String, GitRepositoryFile>::new();
|
let mut files = BTreeMap::<String, GitRepositoryFile>::new();
|
||||||
|
|
||||||
let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?;
|
let tracked_output = run_git(repo, ["ls-files", "-z", "-t", "--cached", "--deleted"])?;
|
||||||
for path in parse_nul_paths(&tracked_output) {
|
for entry in tracked_output
|
||||||
|
.split(|byte| *byte == 0)
|
||||||
|
.filter(|entry| !entry.is_empty())
|
||||||
|
{
|
||||||
|
let (tag, path_bytes) = if entry.len() >= 2 && entry[1] == b' ' {
|
||||||
|
(entry[0], &entry[2..])
|
||||||
|
} else {
|
||||||
|
(b'H', entry)
|
||||||
|
};
|
||||||
|
if tag == b'S' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = String::from_utf8_lossy(path_bytes).into_owned();
|
||||||
let status = status_for_file(&status.files, &path);
|
let status = status_for_file(&status.files, &path);
|
||||||
files.insert(
|
files.insert(
|
||||||
path.clone(),
|
path.clone(),
|
||||||
@@ -5434,6 +5463,17 @@ fn repository_files_with_status(
|
|||||||
Ok(files.into_values().collect())
|
Ok(files.into_values().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct CloneRunOptions {
|
||||||
|
branch: Option<String>,
|
||||||
|
blobless: bool,
|
||||||
|
custom_flags: String,
|
||||||
|
shallow_depth: Option<u32>,
|
||||||
|
shallow_since: Option<String>,
|
||||||
|
sparse: bool,
|
||||||
|
sparse_paths: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
fn clone_repository_core(
|
fn clone_repository_core(
|
||||||
remote_url: &str,
|
remote_url: &str,
|
||||||
parent_path: &str,
|
parent_path: &str,
|
||||||
@@ -5441,9 +5481,16 @@ fn clone_repository_core(
|
|||||||
username: Option<&str>,
|
username: Option<&str>,
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
commit_limit: Option<u32>,
|
commit_limit: Option<u32>,
|
||||||
|
clone_options: &CloneRunOptions,
|
||||||
) -> Result<RepositoryBundle, String> {
|
) -> Result<RepositoryBundle, String> {
|
||||||
let target = clone_target_path(remote_url, parent_path, directory_name)?;
|
let target = clone_target_path(remote_url, parent_path, directory_name)?;
|
||||||
run_git_clone(remote_url.trim(), &target, username, password)?;
|
run_git_clone(
|
||||||
|
remote_url.trim(),
|
||||||
|
&target,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
clone_options,
|
||||||
|
)?;
|
||||||
|
|
||||||
let repo = resolve_repo(&target.to_string_lossy())?;
|
let repo = resolve_repo(&target.to_string_lossy())?;
|
||||||
let lfs_warning = if verify_commit(&repo, "HEAD").is_ok() {
|
let lfs_warning = if verify_commit(&repo, "HEAD").is_ok() {
|
||||||
@@ -5564,7 +5611,21 @@ fn run_git_clone(
|
|||||||
target: &Path,
|
target: &Path,
|
||||||
username: Option<&str>,
|
username: Option<&str>,
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
|
clone_options: &CloneRunOptions,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
if matches!(clone_options.shallow_depth, Some(0)) {
|
||||||
|
return Err("Shallow clone depth must be at least 1.".to_string());
|
||||||
|
}
|
||||||
|
if clone_options.shallow_depth.is_some() && clone_options.shallow_since.is_some() {
|
||||||
|
return Err(
|
||||||
|
"Choose either shallow clone depth or shallow clone date, not both.".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let branch = validate_clone_branch(clone_options.branch.as_deref())?;
|
||||||
|
let shallow_since = validate_shallow_since(clone_options.shallow_since.as_deref())?;
|
||||||
|
let custom_flags = parse_custom_clone_flags(&clone_options.custom_flags)?;
|
||||||
|
let sparse_paths = validate_sparse_checkout_paths(&clone_options.sparse_paths)?;
|
||||||
|
let sparse_enabled = clone_options.sparse || !sparse_paths.is_empty();
|
||||||
let mut command = git_command();
|
let mut command = git_command();
|
||||||
let has_explicit_credentials = matches!(
|
let has_explicit_credentials = matches!(
|
||||||
(username, password),
|
(username, password),
|
||||||
@@ -5573,8 +5634,24 @@ fn run_git_clone(
|
|||||||
if has_explicit_credentials {
|
if has_explicit_credentials {
|
||||||
command.arg("-c").arg("credential.helper=");
|
command.arg("-c").arg("credential.helper=");
|
||||||
}
|
}
|
||||||
|
command.arg("clone");
|
||||||
|
if let Some(branch) = branch {
|
||||||
|
command.arg("--branch").arg(branch);
|
||||||
|
}
|
||||||
|
if let Some(depth) = clone_options.shallow_depth {
|
||||||
|
command.arg("--depth").arg(depth.to_string());
|
||||||
|
}
|
||||||
|
if let Some(since) = shallow_since {
|
||||||
|
command.arg("--shallow-since").arg(since);
|
||||||
|
}
|
||||||
|
if clone_options.blobless {
|
||||||
|
command.arg("--filter=blob:none");
|
||||||
|
}
|
||||||
|
if sparse_enabled {
|
||||||
|
command.arg("--sparse");
|
||||||
|
}
|
||||||
|
command.args(custom_flags);
|
||||||
command
|
command
|
||||||
.arg("clone")
|
|
||||||
.arg("--")
|
.arg("--")
|
||||||
.arg(remote_url)
|
.arg(remote_url)
|
||||||
.arg(target)
|
.arg(target)
|
||||||
@@ -5601,6 +5678,9 @@ fn run_git_clone(
|
|||||||
let output = output?;
|
let output = output?;
|
||||||
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
|
if !sparse_paths.is_empty() {
|
||||||
|
configure_sparse_checkout(target, &sparse_paths)?;
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5612,6 +5692,104 @@ fn run_git_clone(
|
|||||||
Err(format!("Git clone failed: {}", details))
|
Err(format!("Git clone failed: {}", details))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_clone_branch(branch: Option<&str>) -> Result<Option<String>, String> {
|
||||||
|
let Some(branch) = branch.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if branch.starts_with('-') || branch.chars().any(|character| character.is_control()) {
|
||||||
|
return Err("Branch to clone contains invalid characters.".to_string());
|
||||||
|
}
|
||||||
|
Ok(Some(branch.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_shallow_since(since: Option<&str>) -> Result<Option<String>, String> {
|
||||||
|
let Some(since) = since.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if since.starts_with('-') || since.chars().any(|character| character.is_control()) {
|
||||||
|
return Err("Shallow clone date contains invalid characters.".to_string());
|
||||||
|
}
|
||||||
|
Ok(Some(since.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_custom_clone_flags(input: &str) -> Result<Vec<String>, String> {
|
||||||
|
let input = input.trim();
|
||||||
|
if input.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let flags = shlex::split(input).ok_or_else(|| {
|
||||||
|
"Custom clone flags contain an unclosed quote or invalid escape.".to_string()
|
||||||
|
})?;
|
||||||
|
const BLOCKED_LONG_FLAGS: &[&str] = &[
|
||||||
|
"--bare",
|
||||||
|
"--branch",
|
||||||
|
"--config",
|
||||||
|
"--depth",
|
||||||
|
"--filter",
|
||||||
|
"--mirror",
|
||||||
|
"--no-checkout",
|
||||||
|
"--reference",
|
||||||
|
"--reference-if-able",
|
||||||
|
"--separate-git-dir",
|
||||||
|
"--shallow-exclude",
|
||||||
|
"--shallow-since",
|
||||||
|
"--sparse",
|
||||||
|
"--template",
|
||||||
|
"--upload-pack",
|
||||||
|
];
|
||||||
|
for flag in &flags {
|
||||||
|
let name = flag.split_once('=').map_or(flag.as_str(), |(name, _)| name);
|
||||||
|
if flag == "--"
|
||||||
|
|| BLOCKED_LONG_FLAGS.contains(&name)
|
||||||
|
|| matches!(name, "-b" | "-c" | "-n" | "-u")
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"Custom clone flag '{name}' is managed by Gitty or is not allowed."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_sparse_checkout_paths(paths: &[String]) -> Result<Vec<String>, String> {
|
||||||
|
let mut unique = BTreeSet::new();
|
||||||
|
for raw_path in paths {
|
||||||
|
let path = raw_path.trim();
|
||||||
|
if path.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.chars().any(|character| character.is_control()) {
|
||||||
|
return Err("Sparse checkout paths contain invalid control characters.".to_string());
|
||||||
|
}
|
||||||
|
if Path::new(path).is_absolute()
|
||||||
|
|| path == ".."
|
||||||
|
|| path.starts_with("../")
|
||||||
|
|| path.starts_with("..\\")
|
||||||
|
{
|
||||||
|
return Err("Sparse checkout paths must be relative to the repository.".to_string());
|
||||||
|
}
|
||||||
|
unique.insert(path.to_string());
|
||||||
|
}
|
||||||
|
if paths.len() > 0 && unique.is_empty() {
|
||||||
|
return Err("Enter at least one sparse checkout path.".to_string());
|
||||||
|
}
|
||||||
|
Ok(unique.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_sparse_checkout(target: &Path, paths: &[String]) -> Result<(), String> {
|
||||||
|
let mut stdin = paths.join("\n");
|
||||||
|
stdin.push('\n');
|
||||||
|
run_git_with_stdin(
|
||||||
|
target,
|
||||||
|
["sparse-checkout", "set", "--no-cone", "--stdin"],
|
||||||
|
stdin.as_bytes(),
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("Repository cloned, but sparse checkout could not be configured: {error}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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() {
|
||||||
@@ -7795,6 +7973,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(100),
|
Some(100),
|
||||||
|
&CloneRunOptions::default(),
|
||||||
)
|
)
|
||||||
.expect("repository should clone");
|
.expect("repository should clone");
|
||||||
|
|
||||||
@@ -7812,6 +7991,146 @@ mod tests {
|
|||||||
assert!(bundle.warning.is_none());
|
assert!(bundle.warning.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg_attr(windows, ignore = "file:// clone URL differs on Windows")]
|
||||||
|
fn clone_repository_core_supports_shallow_and_sparse_options() {
|
||||||
|
let source = init_temp_repo("clone_options_source");
|
||||||
|
commit_initial_file(&source.path);
|
||||||
|
fs::create_dir_all(source.path.join("src")).expect("src directory should be created");
|
||||||
|
fs::create_dir_all(source.path.join("docs")).expect("docs directory should be created");
|
||||||
|
fs::write(source.path.join("src/included.txt"), b"included\n")
|
||||||
|
.expect("included fixture should be written");
|
||||||
|
fs::write(source.path.join("docs/excluded.txt"), b"excluded\n")
|
||||||
|
.expect("excluded fixture should be written");
|
||||||
|
run_git_test(
|
||||||
|
&source.path,
|
||||||
|
["add", "src/included.txt", "docs/excluded.txt"],
|
||||||
|
);
|
||||||
|
run_git_test(&source.path, ["commit", "-q", "-m", "add sparse fixtures"]);
|
||||||
|
fs::write(source.path.join("src/included.txt"), b"latest\n")
|
||||||
|
.expect("latest fixture should be written");
|
||||||
|
run_git_test(&source.path, ["add", "src/included.txt"]);
|
||||||
|
run_git_test(
|
||||||
|
&source.path,
|
||||||
|
["commit", "-q", "-m", "update included fixture"],
|
||||||
|
);
|
||||||
|
|
||||||
|
let parent = temp_dir("clone_options_parent");
|
||||||
|
let remote_url = format!("file://{}", source.path.display());
|
||||||
|
let branch = git_output_test(&source.path, ["branch", "--show-current"]);
|
||||||
|
let clone_options = CloneRunOptions {
|
||||||
|
branch: Some(branch),
|
||||||
|
blobless: true,
|
||||||
|
custom_flags: "--single-branch".to_string(),
|
||||||
|
shallow_depth: Some(1),
|
||||||
|
shallow_since: None,
|
||||||
|
sparse: true,
|
||||||
|
sparse_paths: vec!["src/".to_string()],
|
||||||
|
};
|
||||||
|
let bundle = clone_repository_core(
|
||||||
|
&remote_url,
|
||||||
|
parent.path.to_str().expect("parent path should be UTF-8"),
|
||||||
|
Some("local-copy"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(100),
|
||||||
|
&clone_options,
|
||||||
|
)
|
||||||
|
.expect("repository should clone shallow and sparse");
|
||||||
|
|
||||||
|
let cloned_repo = parent.path.join("local-copy");
|
||||||
|
assert!(cloned_repo.join(".git/shallow").exists());
|
||||||
|
assert!(cloned_repo.join("src/included.txt").exists());
|
||||||
|
assert!(!cloned_repo.join("docs/excluded.txt").exists());
|
||||||
|
assert_eq!(bundle.commits.len(), 1);
|
||||||
|
assert!(
|
||||||
|
bundle
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.any(|file| file.path == "src/included.txt")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!bundle
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.any(|file| file.path == "docs/excluded.txt")
|
||||||
|
);
|
||||||
|
|
||||||
|
let root_only_parent = temp_dir("clone_sparse_root_parent");
|
||||||
|
let root_only_bundle = clone_repository_core(
|
||||||
|
&remote_url,
|
||||||
|
root_only_parent
|
||||||
|
.path
|
||||||
|
.to_str()
|
||||||
|
.expect("root-only parent path should be UTF-8"),
|
||||||
|
Some("local-copy"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(100),
|
||||||
|
&CloneRunOptions {
|
||||||
|
sparse: true,
|
||||||
|
..CloneRunOptions::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("repository should clone sparse with root files only");
|
||||||
|
let root_only_repo = root_only_parent.path.join("local-copy");
|
||||||
|
assert!(root_only_repo.join("old.txt").exists());
|
||||||
|
assert!(!root_only_repo.join("src/included.txt").exists());
|
||||||
|
assert!(!root_only_repo.join("docs/excluded.txt").exists());
|
||||||
|
assert!(
|
||||||
|
root_only_bundle
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.any(|file| file.path == "old.txt")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!root_only_bundle
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.any(|file| file.path == "src/included.txt")
|
||||||
|
);
|
||||||
|
|
||||||
|
let since_parent = temp_dir("clone_since_parent");
|
||||||
|
let since_options = CloneRunOptions {
|
||||||
|
branch: clone_options.branch.clone(),
|
||||||
|
shallow_since: Some("2000-01-01".to_string()),
|
||||||
|
..CloneRunOptions::default()
|
||||||
|
};
|
||||||
|
let since_bundle = clone_repository_core(
|
||||||
|
&remote_url,
|
||||||
|
since_parent
|
||||||
|
.path
|
||||||
|
.to_str()
|
||||||
|
.expect("since parent path should be UTF-8"),
|
||||||
|
Some("local-copy"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(100),
|
||||||
|
&since_options,
|
||||||
|
)
|
||||||
|
.expect("repository should clone with a shallow-since date");
|
||||||
|
assert_eq!(since_bundle.status.current_branch, clone_options.branch);
|
||||||
|
assert!(
|
||||||
|
since_parent
|
||||||
|
.path
|
||||||
|
.join("local-copy/src/included.txt")
|
||||||
|
.exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_clone_flags_are_parsed_without_a_shell_and_managed_flags_are_rejected() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_custom_clone_flags("--recurse-submodules --origin 'team remote'")
|
||||||
|
.expect("custom flags should parse"),
|
||||||
|
vec!["--recurse-submodules", "--origin", "team remote"]
|
||||||
|
);
|
||||||
|
assert!(parse_custom_clone_flags("--depth 5").is_err());
|
||||||
|
assert!(parse_custom_clone_flags("--upload-pack=/tmp/helper").is_err());
|
||||||
|
assert!(parse_custom_clone_flags("--config core.hooksPath=/tmp/hooks").is_err());
|
||||||
|
assert!(parse_custom_clone_flags("--recurse-submodules '").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clone_repository_core_supports_empty_repository() {
|
fn clone_repository_core_supports_empty_repository() {
|
||||||
let source = init_bare_temp_repo("empty_clone_source");
|
let source = init_bare_temp_repo("empty_clone_source");
|
||||||
@@ -7824,6 +8143,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(100),
|
Some(100),
|
||||||
|
&CloneRunOptions::default(),
|
||||||
)
|
)
|
||||||
.expect("empty repository should clone");
|
.expect("empty repository should clone");
|
||||||
|
|
||||||
@@ -7882,6 +8202,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(100),
|
Some(100),
|
||||||
|
&CloneRunOptions::default(),
|
||||||
)
|
)
|
||||||
.expect("LFS repository should clone");
|
.expect("LFS repository should clone");
|
||||||
let cloned_repo = parent.path.join("local-copy");
|
let cloned_repo = parent.path.join("local-copy");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gitty",
|
"productName": "Gitty",
|
||||||
"version": "2026.8.8",
|
"version": "2026.8.10",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
||||||
|
|||||||
+55
-13
@@ -26,6 +26,8 @@
|
|||||||
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
|
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
|
||||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||||
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
||||||
|
import InitRepositoryDialog from "./lib/components/InitRepositoryDialog.svelte";
|
||||||
|
import MergeBranchDialog from "./lib/components/MergeBranchDialog.svelte";
|
||||||
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
||||||
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
||||||
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
|
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
|
||||||
@@ -148,6 +150,7 @@
|
|||||||
AppLanguage,
|
AppLanguage,
|
||||||
AppTheme,
|
AppTheme,
|
||||||
AnalyticsSettings,
|
AnalyticsSettings,
|
||||||
|
CloneOptions,
|
||||||
CustomThemeColors,
|
CustomThemeColors,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
DetectedExternalTool,
|
DetectedExternalTool,
|
||||||
@@ -167,6 +170,7 @@
|
|||||||
GitFileStatus,
|
GitFileStatus,
|
||||||
GitIgnoreKind,
|
GitIgnoreKind,
|
||||||
GitLfsStatus,
|
GitLfsStatus,
|
||||||
|
MergeStrategy,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitRemote,
|
GitRemote,
|
||||||
PullStrategy,
|
PullStrategy,
|
||||||
@@ -233,6 +237,7 @@
|
|||||||
remoteUrl: string;
|
remoteUrl: string;
|
||||||
parentPath: string;
|
parentPath: string;
|
||||||
directoryName: string;
|
directoryName: string;
|
||||||
|
cloneOptions?: CloneOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorAutoHideState {
|
interface ErrorAutoHideState {
|
||||||
@@ -311,6 +316,7 @@
|
|||||||
let repoStatusCache: Record<string, RepoTab> = {};
|
let repoStatusCache: Record<string, RepoTab> = {};
|
||||||
let repoSearch = "";
|
let repoSearch = "";
|
||||||
let cloneDialogOpen = false;
|
let cloneDialogOpen = false;
|
||||||
|
let initRepositoryDialogOpen = false;
|
||||||
let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
|
let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
|
||||||
let selectedRemote = localStorage.getItem("gitlite.selectedRemote") || "";
|
let selectedRemote = localStorage.getItem("gitlite.selectedRemote") || "";
|
||||||
let remoteActionForceWithLease = false;
|
let remoteActionForceWithLease = false;
|
||||||
@@ -387,6 +393,7 @@
|
|||||||
let comparisonFromLabel = "";
|
let comparisonFromLabel = "";
|
||||||
let comparisonToLabel = "";
|
let comparisonToLabel = "";
|
||||||
let newBranchCommit: GitCommit | null = null;
|
let newBranchCommit: GitCommit | null = null;
|
||||||
|
let mergeBranchTarget: GitBranchInfo | null = null;
|
||||||
let renameBranchTarget: GitBranchInfo | null = null;
|
let renameBranchTarget: GitBranchInfo | null = null;
|
||||||
let deleteBranchTarget: GitBranchInfo | null = null;
|
let deleteBranchTarget: GitBranchInfo | null = null;
|
||||||
let deleteBranchForce = false;
|
let deleteBranchForce = false;
|
||||||
@@ -719,7 +726,7 @@
|
|||||||
if (pendingStartupCloneRequest) {
|
if (pendingStartupCloneRequest) {
|
||||||
const request = pendingStartupCloneRequest;
|
const request = pendingStartupCloneRequest;
|
||||||
pendingStartupCloneRequest = null;
|
pendingStartupCloneRequest = null;
|
||||||
await cloneRepo(request.remoteUrl, request.parentPath, request.directoryName);
|
await cloneRepo(request.remoteUrl, request.parentPath, request.directoryName, undefined, undefined, undefined, false, "credentials", request.cloneOptions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const path = pendingStartupRepoPath;
|
const path = pendingStartupRepoPath;
|
||||||
@@ -2462,12 +2469,13 @@
|
|||||||
key?: string | null,
|
key?: string | null,
|
||||||
fromStore = false,
|
fromStore = false,
|
||||||
credentialMode: CredentialMode = "credentials",
|
credentialMode: CredentialMode = "credentials",
|
||||||
|
cloneOptions: CloneOptions = { branch: null, blobless: false, customFlags: "", shallowDepth: null, shallowSince: null, sparse: false, sparsePaths: [] },
|
||||||
) {
|
) {
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
|
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
|
||||||
if (!parentPath) { errorMessage = "Select a destination folder."; return; }
|
if (!parentPath) { errorMessage = "Select a destination folder."; return; }
|
||||||
|
|
||||||
const request: CloneRequest = { remoteUrl, parentPath, directoryName };
|
const request: CloneRequest = { remoteUrl, parentPath, directoryName, cloneOptions };
|
||||||
pendingClone = request;
|
pendingClone = request;
|
||||||
const credentialKey = key === undefined ? orgKeyFromUrl(remoteUrl) : key;
|
const credentialKey = key === undefined ? orgKeyFromUrl(remoteUrl) : key;
|
||||||
|
|
||||||
@@ -2483,7 +2491,7 @@
|
|||||||
credDialogOpen = true;
|
credDialogOpen = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true, storedMode);
|
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true, storedMode, cloneOptions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2499,6 +2507,7 @@
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
COMMIT_HISTORY_PAGE_SIZE + 1,
|
COMMIT_HISTORY_PAGE_SIZE + 1,
|
||||||
|
cloneOptions,
|
||||||
);
|
);
|
||||||
resetRepositoryState(false);
|
resetRepositoryState(false);
|
||||||
await applyRepositoryBundle(bundle.status.repo_path, bundle);
|
await applyRepositoryBundle(bundle.status.repo_path, bundle);
|
||||||
@@ -2555,9 +2564,9 @@
|
|||||||
trackEvent("clone_dialog_opened");
|
trackEvent("clone_dialog_opened");
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneFromDialog(remoteUrl: string, parentPath: string, directoryName: string, provider?: GitIntegrationProvider, accountId?: string) {
|
function cloneFromDialog(remoteUrl: string, parentPath: string, directoryName: string, cloneOptions: CloneOptions, provider?: GitIntegrationProvider, accountId?: string) {
|
||||||
const credentialKey = provider ? integrationCredentialKey(provider, accountId) : undefined;
|
const credentialKey = provider ? integrationCredentialKey(provider, accountId) : undefined;
|
||||||
void cloneRepo(remoteUrl, parentPath, directoryName, undefined, undefined, credentialKey, false, provider ? "token" : "credentials");
|
void cloneRepo(remoteUrl, parentPath, directoryName, undefined, undefined, credentialKey, false, provider ? "token" : "credentials", cloneOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRepoManagement() {
|
function openRepoManagement() {
|
||||||
@@ -3201,11 +3210,15 @@
|
|||||||
|
|
||||||
async function merge(branch: GitBranchInfo) {
|
async function merge(branch: GitBranchInfo) {
|
||||||
if (!activeRepoPath || branch.current) return;
|
if (!activeRepoPath || branch.current) return;
|
||||||
const strategy = (window.prompt("Merge strategy: default, squash, ff-only, or no-ff", "default") ?? "").trim();
|
mergeBranchTarget = branch;
|
||||||
if (!strategy) return;
|
}
|
||||||
if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; }
|
|
||||||
|
async function confirmMerge(strategy: MergeStrategy) {
|
||||||
|
const branch = mergeBranchTarget;
|
||||||
|
if (!activeRepoPath || !branch || branch.current) return;
|
||||||
await runOperation(`Merging ${branch.name}`, async () => {
|
await runOperation(`Merging ${branch.name}`, async () => {
|
||||||
applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy));
|
applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy));
|
||||||
|
mergeBranchTarget = null;
|
||||||
await refreshRepositoryViews(activeRepoPath);
|
await refreshRepositoryViews(activeRepoPath);
|
||||||
trackEvent("branch_merged", {
|
trackEvent("branch_merged", {
|
||||||
remote: branch.remote ? 1 : 0,
|
remote: branch.remote ? 1 : 0,
|
||||||
@@ -3877,6 +3890,7 @@
|
|||||||
key,
|
key,
|
||||||
false,
|
false,
|
||||||
mode,
|
mode,
|
||||||
|
pendingClone.cloneOptions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3921,10 +3935,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initializeRepository() {
|
async function initializeRepository() {
|
||||||
const selected = await openDialog({ directory: true, multiple: false, title: "Choose an empty or existing folder" });
|
initRepositoryDialogOpen = true;
|
||||||
if (typeof selected !== "string") return;
|
}
|
||||||
const branch = window.prompt("Initial branch name", "main")?.trim(); if (!branch) return;
|
|
||||||
await runOperation("Initializing repository", async () => { await initRepository(selected, branch); await openRepo(selected); });
|
async function confirmInitializeRepository(path: string, branch: string) {
|
||||||
|
const selected = path.trim();
|
||||||
|
if (!selected) return;
|
||||||
|
await runOperation("Initializing repository", async () => {
|
||||||
|
await initRepository(selected, branch);
|
||||||
|
initRepositoryDialogOpen = false;
|
||||||
|
await openRepo(selected);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revertHistoryCommit(commit: GitCommit) {
|
async function revertHistoryCommit(commit: GitCommit) {
|
||||||
@@ -5912,6 +5933,17 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if mergeBranchTarget}
|
||||||
|
<MergeBranchDialog
|
||||||
|
branch={mergeBranchTarget}
|
||||||
|
currentBranch={status?.current_branch ?? ""}
|
||||||
|
{isBusy}
|
||||||
|
language={appLanguage}
|
||||||
|
onMerge={confirmMerge}
|
||||||
|
onClose={() => { if (!isBusy) mergeBranchTarget = null; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if commitNoteTarget}
|
{#if commitNoteTarget}
|
||||||
<CommitNoteDialog
|
<CommitNoteDialog
|
||||||
commit={commitNoteTarget}
|
commit={commitNoteTarget}
|
||||||
@@ -6085,6 +6117,16 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Initialize repository dialog -->
|
||||||
|
{#if initRepositoryDialogOpen}
|
||||||
|
<InitRepositoryDialog
|
||||||
|
isBusy={operation === "Initializing repository"}
|
||||||
|
language={appLanguage}
|
||||||
|
onInit={confirmInitializeRepository}
|
||||||
|
onClose={() => { if (!isBusy) initRepositoryDialogOpen = 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} />
|
||||||
|
|||||||
+123
-12
@@ -3878,6 +3878,20 @@
|
|||||||
max-height: calc(100vh - 32px);
|
max-height: calc(100vh - 32px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.init-repository-dialog {
|
||||||
|
display: block;
|
||||||
|
width: min(540px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.merge-branch-dialog {
|
||||||
|
display: block;
|
||||||
|
width: min(590px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
.rename-branch-dialog {
|
.rename-branch-dialog {
|
||||||
display: block;
|
display: block;
|
||||||
width: min(520px, calc(100vw - 32px));
|
width: min(520px, calc(100vw - 32px));
|
||||||
@@ -4172,6 +4186,69 @@
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
.init-repository-header { padding: 16px 18px; }
|
||||||
|
.init-repository-heading { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.init-repository-heading h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; line-height: 1.25; }
|
||||||
|
.init-repository-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 34%, var(--color-border));
|
||||||
|
border-radius: 9px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface-raised));
|
||||||
|
}
|
||||||
|
.init-repository-form { display: flex; flex-direction: column; gap: 16px; padding: 18px; }
|
||||||
|
.init-repository-description { margin: 0; color: var(--color-ink-dim); font-size: 11.5px; line-height: 1.5; }
|
||||||
|
.init-repository-field > span { font-size: 9.5px; }
|
||||||
|
.init-repository-field > div { position: relative; align-items: center; }
|
||||||
|
.init-repository-field > div > svg { position: absolute; left: 11px; z-index: 1; color: var(--color-ink-faint); pointer-events: none; }
|
||||||
|
.init-repository-field > div > input { height: 36px; padding-left: 36px; font-family: var(--font-mono); font-size: 12px; }
|
||||||
|
.init-repository-field small { color: var(--color-ink-faint); font-size: 9.5px; line-height: 1.4; }
|
||||||
|
.init-repository-path-field { padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
|
||||||
|
.init-repository-path-control { display: grid !important; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||||
|
.init-repository-path-control > input { grid-column: 1; }
|
||||||
|
.init-repository-browse { grid-column: 2; display: inline-flex; align-items: center; gap: 6px; height: 36px; font-size: 11.5px; white-space: nowrap; }
|
||||||
|
.init-repository-error { color: var(--color-danger, #ff6b78) !important; }
|
||||||
|
.init-repository-actions { padding-top: 2px; }
|
||||||
|
.init-repository-actions > button { font-size: 11.5px; }
|
||||||
|
.merge-branch-header { padding: 15px 17px; }
|
||||||
|
.merge-branch-heading { display: flex; align-items: center; gap: 11px; }
|
||||||
|
.merge-branch-heading h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; line-height: 1.25; }
|
||||||
|
.merge-branch-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 34%, var(--color-border));
|
||||||
|
border-radius: 9px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface-raised));
|
||||||
|
}
|
||||||
|
.merge-branch-form { display: flex; flex-direction: column; gap: 15px; padding: 17px; }
|
||||||
|
.merge-branch-route { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 12px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
|
||||||
|
.merge-branch-route > div { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.merge-branch-route > div:last-child { text-align: right; }
|
||||||
|
.merge-branch-route span { color: var(--color-ink-faint); font-size: 9px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
|
.merge-branch-route strong { overflow: hidden; color: var(--color-ink); font-family: var(--font-mono); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.merge-branch-route > svg { color: var(--color-accent); }
|
||||||
|
.merge-strategy-fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||||
|
.merge-strategy-fieldset legend { margin-bottom: 7px; color: var(--color-ink-faint); font-size: 9.5px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
|
.merge-strategy-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||||
|
.merge-strategy-option { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 9px; min-width: 0; padding: 11px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-dim); cursor: pointer; }
|
||||||
|
.merge-strategy-option:hover { border-color: var(--color-border); background: var(--color-surface-hover); }
|
||||||
|
.merge-strategy-option.active { border-color: color-mix(in srgb, var(--color-accent) 52%, var(--color-border)); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||||
|
.merge-strategy-option > input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||||
|
.merge-strategy-check { display: grid; place-items: center; width: 17px; height: 17px; margin-top: 1px; border: 1px solid var(--color-border-input); border-radius: 50%; color: #fff; background: var(--color-surface-raised); }
|
||||||
|
.merge-strategy-option.active .merge-strategy-check { border-color: var(--color-accent); background: var(--color-accent); }
|
||||||
|
.merge-strategy-copy { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.merge-strategy-copy strong { color: var(--color-ink); font-size: 11.5px; }
|
||||||
|
.merge-strategy-copy small { color: var(--color-ink-faint); font-size: 9.5px; line-height: 1.4; }
|
||||||
|
.merge-branch-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 1px; }
|
||||||
|
.merge-branch-actions > button { font-size: 11.5px; }
|
||||||
.rename-branch-form {
|
.rename-branch-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -8843,7 +8920,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
.repo-tab {
|
.repo-tab {
|
||||||
min-height: 35px;
|
min-height: 35px;
|
||||||
height: 35px;
|
height: 35px;
|
||||||
padding: 0 31px 0 15px;
|
padding: 0 34px 0 15px;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
@@ -8869,19 +8946,21 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
|
|
||||||
.repo-tab-close {
|
.repo-tab-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 6px;
|
||||||
right: 6px;
|
right: 5px;
|
||||||
width: 19px;
|
width: 23px;
|
||||||
min-width: 19px;
|
min-width: 23px;
|
||||||
max-width: 19px;
|
max-width: 23px;
|
||||||
height: 19px;
|
height: 23px;
|
||||||
min-height: 19px;
|
min-height: 23px;
|
||||||
max-height: 19px;
|
max-height: 23px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 2px;
|
border-radius: 3px;
|
||||||
color: var(--color-ink-faint);
|
color: var(--color-ink-faint);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
opacity: 0.62;
|
opacity: 0.62;
|
||||||
|
transition: opacity 120ms ease, color 120ms ease, background 120ms ease, box-shadow 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-tab-wrap.active .repo-tab-close { opacity: 0.72; }
|
.repo-tab-wrap.active .repo-tab-close { opacity: 0.72; }
|
||||||
@@ -8889,8 +8968,16 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
.repo-tab-wrap:focus-within .repo-tab-close { opacity: 1; }
|
.repo-tab-wrap:focus-within .repo-tab-close { opacity: 1; }
|
||||||
.repo-tab-close:hover:not(:disabled),
|
.repo-tab-close:hover:not(:disabled),
|
||||||
.repo-tab-close:focus-visible:not(:disabled) {
|
.repo-tab-close:focus-visible:not(:disabled) {
|
||||||
color: #e1848b;
|
color: #ffffff;
|
||||||
background: transparent;
|
background: #d93641;
|
||||||
|
box-shadow: inset 0 0 0 1px #f0646d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .repo-tab-close:hover:not(:disabled),
|
||||||
|
:root[data-theme="light"] .repo-tab-close:focus-visible:not(:disabled) {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #c92f3a;
|
||||||
|
box-shadow: inset 0 0 0 1px #a9212b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-tab-add {
|
.repo-tab-add {
|
||||||
@@ -8939,3 +9026,27 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
:root:not([data-theme="light"]) .repo-tab.management.active > svg {
|
:root:not([data-theme="light"]) .repo-tab.management.active > svg {
|
||||||
color: #d7dae0;
|
color: #d7dae0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep every dialog close action neutral until it is intentionally targeted. */
|
||||||
|
:root .dialog-close:hover:not(:disabled),
|
||||||
|
:root .dialog-close:focus-visible:not(:disabled),
|
||||||
|
:root .dialog-icon-button:hover:not(:disabled),
|
||||||
|
:root .dialog-icon-button:focus-visible:not(:disabled),
|
||||||
|
:root .cred-close:hover:not(:disabled),
|
||||||
|
:root .cred-close:focus-visible:not(:disabled) {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #f0646d;
|
||||||
|
background: #d93641;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .dialog-close:hover:not(:disabled),
|
||||||
|
:root[data-theme="light"] .dialog-close:focus-visible:not(:disabled),
|
||||||
|
:root[data-theme="light"] .dialog-icon-button:hover:not(:disabled),
|
||||||
|
:root[data-theme="light"] .dialog-icon-button:focus-visible:not(:disabled),
|
||||||
|
:root[data-theme="light"] .cred-close:hover:not(:disabled),
|
||||||
|
:root[data-theme="light"] .cred-close:focus-visible:not(:disabled) {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #a9212b;
|
||||||
|
background: #c92f3a;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte";
|
import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte";
|
||||||
import { listIntegrationRepositories } from "../git";
|
import { listIntegrationRepositories } from "../git";
|
||||||
import { configuredIntegrationSources } from "../integrations";
|
import { configuredIntegrationSources } from "../integrations";
|
||||||
import type { AppLanguage, GitIntegrationProvider, GitIntegrationRepository, GitIntegrationSettings, GitIntegrationSource } from "../types";
|
import type { AppLanguage, CloneOptions, GitIntegrationProvider, GitIntegrationRepository, GitIntegrationSettings, GitIntegrationSource } from "../types";
|
||||||
|
|
||||||
type CloneSource = "url" | "integrations";
|
type CloneSource = "url" | "integrations";
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
error: string;
|
error: string;
|
||||||
language: AppLanguage;
|
language: AppLanguage;
|
||||||
integrations: GitIntegrationSettings;
|
integrations: GitIntegrationSettings;
|
||||||
onClone: (remoteUrl: string, parentPath: string, directoryName: string, provider?: GitIntegrationProvider, accountId?: string) => void;
|
onClone: (remoteUrl: string, parentPath: string, directoryName: string, options: CloneOptions, provider?: GitIntegrationProvider, accountId?: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +45,15 @@
|
|||||||
let repositoryScrollbarDragScrollTop = 0;
|
let repositoryScrollbarDragScrollTop = 0;
|
||||||
let repositoryScrollbarFrame: number | undefined;
|
let repositoryScrollbarFrame: number | undefined;
|
||||||
let repositoryRequestId = 0;
|
let repositoryRequestId = 0;
|
||||||
|
let shallowClone = $state(false);
|
||||||
|
let branchToClone = $state("");
|
||||||
|
let shallowLimitMode = $state<"depth" | "since">("depth");
|
||||||
|
let shallowDepth = $state(1);
|
||||||
|
let shallowSince = $state("");
|
||||||
|
let customFlags = $state("");
|
||||||
|
let sparseCheckout = $state(false);
|
||||||
|
let bloblessClone = $state(false);
|
||||||
|
let sparsePathInput = $state("");
|
||||||
|
|
||||||
const isGerman = $derived(language === "de");
|
const isGerman = $derived(language === "de");
|
||||||
const configuredSources = $derived(configuredIntegrationSources(integrations));
|
const configuredSources = $derived(configuredIntegrationSources(integrations));
|
||||||
@@ -55,9 +64,24 @@
|
|||||||
if (!query) return activeRepositories;
|
if (!query) return activeRepositories;
|
||||||
return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query));
|
return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query));
|
||||||
});
|
});
|
||||||
|
const azureRepositoryGroups = $derived.by(() => {
|
||||||
|
if (activeSource?.provider !== "azure-devops") return [];
|
||||||
|
const groups = new Map<string, GitIntegrationRepository[]>();
|
||||||
|
for (const repository of filteredRepositories) {
|
||||||
|
const separator = repository.fullName.indexOf("/");
|
||||||
|
const project = separator > 0 ? repository.fullName.slice(0, separator) : (isGerman ? "Weitere Repositories" : "Other repositories");
|
||||||
|
const repositories = groups.get(project) ?? [];
|
||||||
|
repositories.push(repository);
|
||||||
|
groups.set(project, repositories);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([project, repositories]) => ({ project, repositories }));
|
||||||
|
});
|
||||||
const selectedRepository = $derived(activeRepositories.find((repository) => repository.id === selectedRepositoryId));
|
const selectedRepository = $derived(activeRepositories.find((repository) => repository.id === selectedRepositoryId));
|
||||||
const directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
const directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
||||||
const canSubmit = $derived(!isBusy && remoteUrl.trim().length > 0 && parentPath.trim().length > 0);
|
const sparsePaths = $derived.by(() => [...new Set(sparsePathInput.split(/\r?\n/).map((path) => path.trim()).filter(Boolean))]);
|
||||||
|
const shallowLimitValid = $derived(!shallowClone || (shallowLimitMode === "depth" ? Number.isInteger(shallowDepth) && shallowDepth > 0 : shallowSince.trim().length > 0));
|
||||||
|
const cloneOptionsValid = $derived(shallowLimitValid);
|
||||||
|
const canSubmit = $derived(!isBusy && remoteUrl.trim().length > 0 && parentPath.trim().length > 0 && cloneOptionsValid);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const nextError = error || browseError;
|
const nextError = error || browseError;
|
||||||
@@ -230,10 +254,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showIntegrations() {
|
function selectIntegrationSource(integrationSource: GitIntegrationSource) {
|
||||||
source = "integrations";
|
source = "integrations";
|
||||||
const nextSource = configuredSources.find((candidate) => candidate.id === selectedSourceId) ?? configuredSources[0];
|
void loadRepositories(integrationSource);
|
||||||
if (nextSource) void loadRepositories(nextSource);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUrlInput() {
|
function showUrlInput() {
|
||||||
@@ -249,39 +272,114 @@
|
|||||||
|
|
||||||
function submit(event: SubmitEvent) {
|
function submit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (canSubmit) onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim(), source === "integrations" ? activeSource?.provider : undefined, source === "integrations" ? activeSource?.accountId : undefined);
|
if (canSubmit) onClone(
|
||||||
|
remoteUrl.trim(),
|
||||||
|
parentPath.trim(),
|
||||||
|
directoryName.trim(),
|
||||||
|
{
|
||||||
|
branch: shallowClone && branchToClone.trim() ? branchToClone.trim() : null,
|
||||||
|
blobless: sparseCheckout && bloblessClone,
|
||||||
|
customFlags: shallowClone ? customFlags.trim() : "",
|
||||||
|
shallowDepth: shallowClone && shallowLimitMode === "depth" ? shallowDepth : null,
|
||||||
|
shallowSince: shallowClone && shallowLimitMode === "since" ? shallowSince : null,
|
||||||
|
sparse: sparseCheckout,
|
||||||
|
sparsePaths: sparseCheckout ? sparsePaths : [],
|
||||||
|
},
|
||||||
|
source === "integrations" ? activeSource?.provider : undefined,
|
||||||
|
source === "integrations" ? activeSource?.accountId : undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Repository klonen" : "Clone repository"} tabindex="-1">
|
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Repository klonen" : "Clone repository"} tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header clone-dialog-header">
|
||||||
<div><span class="eyebrow">Repository Management</span><h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{isGerman ? "Repository klonen" : "Clone repository"}</h2></div>
|
<div><h2>{isGerman ? "Repository klonen" : "Clone a Repository"}</h2></div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Klonen schließen" : "Close clone dialog"}><X size={18} /></button>
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Klonen schließen" : "Close clone dialog"}><X size={18} /></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="clone-dialog-form" onsubmit={submit}>
|
<form class="clone-dialog-form" onsubmit={submit}>
|
||||||
<div class="clone-source-tabs" role="tablist" aria-label={isGerman ? "Repository-Quelle" : "Repository source"}>
|
<div class="clone-dialog-layout">
|
||||||
<button type="button" role="tab" aria-selected={source === "url"} class:active={source === "url"} onclick={showUrlInput}><Globe2 size={15} />URL</button>
|
<aside class="clone-source-nav" aria-label={isGerman ? "Repository-Quellen" : "Repository sources"}>
|
||||||
<button type="button" role="tab" aria-selected={source === "integrations"} class:active={source === "integrations"} onclick={showIntegrations}><Cloud size={15} />{isGerman ? "Integrationen" : "Integrations"}{#if configuredSources.length}<em>{configuredSources.length}</em>{/if}</button>
|
<div class="clone-source-heading">{isGerman ? "Quelle" : "Source"}</div>
|
||||||
</div>
|
<div class="clone-source-list" role="tablist" aria-label={isGerman ? "Repository-Quelle" : "Repository source"}>
|
||||||
|
<button type="button" role="tab" aria-selected={source === "url"} class:active={source === "url"} onclick={showUrlInput}><Globe2 size={15} /><span>{isGerman ? "Mit URL klonen" : "Clone with URL"}</span></button>
|
||||||
|
{#each configuredSources as integrationSource}
|
||||||
|
<button type="button" role="tab" aria-selected={source === "integrations" && selectedSourceId === integrationSource.id} class:active={source === "integrations" && selectedSourceId === integrationSource.id} onclick={() => selectIntegrationSource(integrationSource)}>
|
||||||
|
{#if integrationSource.provider === "azure-devops"}<Cloud size={15} />{:else}<GitBranch size={15} />{/if}
|
||||||
|
<span>{integrationSource.provider === "azure-devops" ? `Azure · ${integrationSource.label}` : integrationSource.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if configuredSources.length === 0}<p>{isGerman ? "Integrationen kannst du in den Einstellungen einrichten." : "Set up integrations in Settings."}</p>{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
{#if source === "url"}
|
<section class="clone-dialog-content">
|
||||||
<label class="clone-dialog-field">
|
<div class="clone-dialog-title">
|
||||||
<span>{isGerman ? "Remote-URL" : "Remote URL"}</span>
|
<span>{source === "integrations" ? (activeSource?.label ?? "Integration") : "URL"}</span>
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
<h3>{isGerman ? "Repository klonen" : "Clone a Repo"}</h3>
|
||||||
<input value={remoteUrl} oninput={handleRemoteInput} autocomplete="off" spellcheck="false" placeholder="https://gitlab.com/org/project.git" disabled={isBusy} autofocus />
|
</div>
|
||||||
</label>
|
|
||||||
{:else}
|
<div class="clone-target-grid">
|
||||||
<section class="integration-browser" aria-label={isGerman ? "Repositories aus Integrationen" : "Repositories from integrations"}>
|
<label class="clone-dialog-field"><span>{isGerman ? "Klonen nach" : "Where to clone to"}</span><div class="clone-dialog-path-field"><input bind:value={parentPath} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Übergeordneten Ordner auswählen" : "Choose parent folder"} disabled={isBusy} /><button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}><FolderOpen size={14} />{isGerman ? "Durchsuchen" : "Browse"}</button></div></label>
|
||||||
|
<label class="clone-dialog-field"><span>{isGerman ? "Ordnername" : "Folder name"}</span><input bind:value={directoryName} oninput={handleDirectoryInput} autocomplete="off" spellcheck="false" placeholder={directorySuggestion || "Optional"} disabled={isBusy} /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="clone-options" aria-label={isGerman ? "Clone-Optionen" : "Clone options"}>
|
||||||
|
<div class="clone-option-card" class:expanded={shallowClone}>
|
||||||
|
<label class="clone-option-toggle">
|
||||||
|
<input type="checkbox" bind:checked={shallowClone} disabled={isBusy} />
|
||||||
|
<span><strong>Shallow Clone</strong><small>{isGerman ? "Nur die neuesten Commits laden" : "Download only the latest commits"}</small></span>
|
||||||
|
</label>
|
||||||
|
{#if shallowClone}
|
||||||
|
<div class="clone-option-body">
|
||||||
|
<label class="clone-option-detail"><span>{isGerman ? "Zu klonender Branch" : "Branch to clone"}</span><input bind:value={branchToClone} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Standard-Branch" : "Default branch"} disabled={isBusy} /></label>
|
||||||
|
<fieldset class="clone-history-mode">
|
||||||
|
<legend>{isGerman ? "Historie begrenzen nach" : "Limit history by"}</legend>
|
||||||
|
<label><input type="radio" bind:group={shallowLimitMode} value="depth" disabled={isBusy} />{isGerman ? "Commit-Tiefe" : "Commit depth"}</label>
|
||||||
|
<label><input type="radio" bind:group={shallowLimitMode} value="since" disabled={isBusy} />{isGerman ? "Seit Datum" : "Since date"}</label>
|
||||||
|
</fieldset>
|
||||||
|
{#if shallowLimitMode === "depth"}
|
||||||
|
<label class="clone-option-detail"><span>{isGerman ? "Tiefe" : "Depth"}</span><input type="number" min="1" step="1" bind:value={shallowDepth} disabled={isBusy} aria-invalid={!Number.isInteger(shallowDepth) || shallowDepth < 1} /></label>
|
||||||
|
{:else}
|
||||||
|
<label class="clone-option-detail"><span>{isGerman ? "Seit" : "Since"}</span><input type="date" bind:value={shallowSince} disabled={isBusy} aria-invalid={!shallowSince.trim()} /></label>
|
||||||
|
{/if}
|
||||||
|
<label class="clone-option-detail"><span>{isGerman ? "Zusätzliche Flags" : "Custom flags"}</span><input bind:value={customFlags} autocomplete="off" spellcheck="false" placeholder="--recurse-submodules --single-branch" disabled={isBusy} /></label>
|
||||||
|
<small class="clone-option-help">{isGerman ? "Flags wie in der Git-Kommandozeile; verwaltete oder unsichere Flags werden abgewiesen." : "Enter flags as on the Git command line; managed or unsafe flags are rejected."}</small>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="clone-option-card" class:expanded={sparseCheckout}>
|
||||||
|
<label class="clone-option-toggle">
|
||||||
|
<input type="checkbox" bind:checked={sparseCheckout} disabled={isBusy} />
|
||||||
|
<span><strong>Sparse Checkout</strong><small>{isGerman ? "Nur ausgewählte Pfade auschecken" : "Check out only selected paths"}</small></span>
|
||||||
|
</label>
|
||||||
|
{#if sparseCheckout}
|
||||||
|
<div class="clone-option-body">
|
||||||
|
<label class="clone-blobless-toggle"><input type="checkbox" bind:checked={bloblessClone} disabled={isBusy} /><span><strong>Blobless Clone</strong><small>{isGerman ? "Lädt zunächst Bäume und Commits ohne Dateiinhalte. Blobs werden bei Bedarf nachgeladen." : "Fetch trees and commits without file contents initially. Blobs are downloaded on demand."}</small></span></label>
|
||||||
|
<label class="clone-option-detail clone-sparse-paths">
|
||||||
|
<span>{isGerman ? "Pfade einschließen" : "Paths to include"}</span>
|
||||||
|
<textarea bind:value={sparsePathInput} rows="3" spellcheck="false" placeholder={"src/\ndocs/\nREADME.md"} disabled={isBusy}></textarea>
|
||||||
|
<small class="clone-option-help">{isGerman ? "Ein Pfad pro Zeile. Nur diese Pfade werden im Arbeitsverzeichnis ausgecheckt. Ohne Pfade werden nur Dateien im Repository-Root ausgecheckt." : "Enter one path per line. Only these paths will be checked out in the working directory. With no paths, only files in the repository root are checked out."}</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if source === "url"}
|
||||||
|
<label class="clone-dialog-field clone-url-field">
|
||||||
|
<span>{isGerman ? "Repository-URL" : "Repository URL"}</span>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input value={remoteUrl} oninput={handleRemoteInput} autocomplete="off" spellcheck="false" placeholder="https://gitlab.com/org/project.git" disabled={isBusy} autofocus />
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<section class="integration-browser" aria-label={isGerman ? "Repositories aus Integrationen" : "Repositories from integrations"}>
|
||||||
{#if configuredSources.length === 0}
|
{#if configuredSources.length === 0}
|
||||||
<div class="integration-empty"><Cloud size={26} /><strong>{isGerman ? "Keine aktive Integration" : "No active integration"}</strong><p>{isGerman ? "Richte unter Einstellungen → Integrationen zuerst GitHub, GitLab, Azure DevOps oder Gitea ein." : "Set up GitHub, GitLab, Azure DevOps, or Gitea under Settings → Integrations first."}</p></div>
|
<div class="integration-empty"><Cloud size={26} /><strong>{isGerman ? "Keine aktive Integration" : "No active integration"}</strong><p>{isGerman ? "Richte unter Einstellungen → Integrationen zuerst GitHub, GitLab, Azure DevOps oder Gitea ein." : "Set up GitHub, GitLab, Azure DevOps, or Gitea under Settings → Integrations first."}</p></div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="integration-provider-tabs" role="tablist" aria-label={isGerman ? "Konfigurierte Anbieter" : "Configured providers"}>
|
|
||||||
{#each configuredSources as integrationSource}<button type="button" role="tab" aria-selected={selectedSourceId === integrationSource.id} class:active={selectedSourceId === integrationSource.id} onclick={() => loadRepositories(integrationSource)}>{integrationSource.provider === "azure-devops" ? `Azure · ${integrationSource.label}` : integrationSource.label}</button>{/each}
|
|
||||||
</div>
|
|
||||||
<div class="repository-toolbar">
|
<div class="repository-toolbar">
|
||||||
<label><Search size={14} /><input bind:value={repositorySearch} placeholder={isGerman ? "Repositories filtern…" : "Filter repositories…"} aria-label={isGerman ? "Repositories filtern" : "Filter repositories"} /></label>
|
<label><Search size={14} /><input bind:value={repositorySearch} placeholder={isGerman ? "Repositories durchsuchen…" : "Search repositories…"} aria-label={isGerman ? "Repositories durchsuchen" : "Search repositories"} /></label>
|
||||||
<button type="button" onclick={() => activeSource && loadRepositories(activeSource, true)} disabled={!activeSource || loadingSourceId.length > 0} title={isGerman ? "Neu laden" : "Refresh"} aria-label={isGerman ? "Repository-Liste neu laden" : "Refresh repository list"}><RefreshCw class={loadingSourceId ? "spin" : ""} size={14} /></button>
|
<button type="button" onclick={() => activeSource && loadRepositories(activeSource, true)} disabled={!activeSource || loadingSourceId.length > 0} title={isGerman ? "Neu laden" : "Refresh"} aria-label={isGerman ? "Repository-Liste neu laden" : "Refresh repository list"}><RefreshCw class={loadingSourceId ? "spin" : ""} size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="repository-list-shell">
|
<div class="repository-list-shell">
|
||||||
@@ -292,6 +390,19 @@
|
|||||||
<div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
|
<div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
|
||||||
{:else if filteredRepositories.length === 0}
|
{:else if filteredRepositories.length === 0}
|
||||||
<div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
|
<div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
|
||||||
|
{:else if activeSource?.provider === "azure-devops"}
|
||||||
|
{#each azureRepositoryGroups as group (group.project)}
|
||||||
|
<section class="repository-project-group" aria-label={group.project}>
|
||||||
|
<div class="repository-project-header"><span>{group.project}</span><em>{group.repositories.length}</em></div>
|
||||||
|
{#each group.repositories as repository (repository.id)}
|
||||||
|
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
|
||||||
|
<span class="repository-option-icon"><GitBranch size={15} /></span>
|
||||||
|
<span class="repository-option-copy"><strong>{repository.name}</strong><small>{repository.description || repository.cloneUrl}</small></span>
|
||||||
|
<span class="repository-option-meta">{#if repository.private}<LockKeyhole size={12} aria-label={isGerman ? "Privat" : "Private"} />{/if}{formatUpdatedAt(repository.updatedAt)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each filteredRepositories as repository (repository.id)}
|
{#each filteredRepositories as repository (repository.id)}
|
||||||
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
|
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
|
||||||
@@ -331,38 +442,68 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if}
|
{#if selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if visibleError}<div class="clone-dialog-error" role="alert">{visibleError}</div>{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="clone-target-grid">
|
|
||||||
<label class="clone-dialog-field"><span>{isGerman ? "Ziel" : "Destination"}</span><div class="clone-dialog-path-field"><input bind:value={parentPath} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Übergeordneten Ordner auswählen" : "Choose parent folder"} disabled={isBusy} /><button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}><FolderOpen size={14} />{isGerman ? "Durchsuchen" : "Browse"}</button></div></label>
|
|
||||||
<label class="clone-dialog-field"><span>{isGerman ? "Ordnername" : "Folder name"}</span><input bind:value={directoryName} oninput={handleDirectoryInput} autocomplete="off" spellcheck="false" placeholder={directorySuggestion || "Optional"} disabled={isBusy} /></label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#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}>{isGerman ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="submit" disabled={!canSubmit}>{#if isBusy}<LoaderCircle class="spin" size={16} />{:else}<Download size={16} />{/if}{isGerman ? "Klonen" : "Clone"}</button></div>
|
<div class="clone-dialog-actions"><button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{isGerman ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="submit" disabled={!canSubmit}>{#if isBusy}<LoaderCircle class="spin" size={16} />{:else}<Download size={16} />{/if}{isGerman ? "Klonen" : "Clone"}</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.clone-repository-dialog { width: min(760px, calc(100vw - 32px)); }
|
.clone-repository-dialog { width: min(900px, calc(100vw - 32px)); height: min(660px, calc(100vh - 32px)); }
|
||||||
.clone-source-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--app-settings-row-bg); }
|
.clone-dialog-header { min-height: 52px; padding: 0 16px 0 20px; }
|
||||||
.clone-source-tabs button { min-height: 36px; border-color: transparent; color: var(--color-ink-dim); background: transparent; font-size: 11px; font-weight: 800; }
|
.clone-dialog-header h2 { margin: 0; color: var(--color-ink); font-size: 15px; font-weight: 650; }
|
||||||
.clone-source-tabs button.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.12); }
|
.clone-dialog-form { grid-template-rows: minmax(0, 1fr) auto; gap: 0; height: calc(100% - 53px); padding: 0; }
|
||||||
.clone-source-tabs button.active :global(svg) { color: var(--color-accent); }
|
.clone-dialog-layout { display: grid; grid-template-columns: 205px minmax(0, 1fr); min-height: 0; }
|
||||||
.clone-source-tabs em { display: grid; place-items: center; min-width: 19px; height: 18px; padding: 0 5px; border-radius: 9px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; font-style: normal; }
|
.clone-source-nav { min-width: 0; padding: 12px 0; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||||
.integration-browser { display: grid; gap: 9px; min-height: 270px; padding: 11px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
|
.clone-source-heading { padding: 2px 14px 8px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
.integration-provider-tabs { display: flex; gap: 5px; overflow-x: auto; }
|
.clone-source-list { display: grid; gap: 2px; }
|
||||||
.integration-provider-tabs button { flex: 0 0 auto; min-height: 29px; padding: 0 9px; border-color: transparent; color: var(--color-ink-dim); background: transparent; font-size: 10px; font-weight: 750; }
|
.clone-source-list button { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 8px; width: 100%; min-height: 38px; padding: 0 14px; border: 0; border-radius: 0; color: var(--color-ink-dim); background: transparent; box-shadow: none; font-size: 10.5px; font-weight: 650; text-align: left; }
|
||||||
.integration-provider-tabs button.active { border-color: color-mix(in srgb, var(--color-accent) 30%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised)); }
|
.clone-source-list button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.clone-source-list button :global(svg) { color: var(--color-ink-faint); }
|
||||||
|
.clone-source-list button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
|
.clone-source-list button.active { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 18%, var(--color-surface-hover)); box-shadow: inset 3px 0 0 var(--color-accent); }
|
||||||
|
.clone-source-list button.active :global(svg) { color: var(--color-accent); }
|
||||||
|
.clone-source-nav > p { margin: 12px 14px 0; color: var(--color-ink-faint); font-size: 9px; line-height: 1.45; }
|
||||||
|
.clone-dialog-content { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); grid-auto-rows: auto; align-content: stretch; gap: 12px; min-width: 0; min-height: 0; padding: 16px 18px; overflow: hidden; }
|
||||||
|
.clone-dialog-title { display: grid; gap: 3px; }
|
||||||
|
.clone-dialog-title > span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .07em; }
|
||||||
|
.clone-dialog-title h3 { margin: 0; color: var(--color-ink); font-size: 16px; font-weight: 650; }
|
||||||
|
.clone-url-field { align-self: start; margin-top: 2px; }
|
||||||
|
.clone-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 8px; }
|
||||||
|
.clone-option-card { min-width: 0; border: 1px solid var(--color-border-subtle); border-radius: 6px; background: color-mix(in srgb, var(--color-surface-raised) 70%, transparent); }
|
||||||
|
.clone-option-card.expanded { border-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); }
|
||||||
|
.clone-option-toggle { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; min-height: 42px; padding: 6px 9px; cursor: pointer; }
|
||||||
|
.clone-option-toggle > input { width: 14px; height: 14px; margin: 0; accent-color: var(--color-accent); }
|
||||||
|
.clone-option-toggle > span { display: grid; min-width: 0; gap: 2px; }
|
||||||
|
.clone-option-toggle strong { color: var(--color-ink); font-size: 10.5px; }
|
||||||
|
.clone-option-toggle small { overflow: hidden; color: var(--color-ink-faint); font-size: 8.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.clone-option-body { display: grid; gap: 8px; padding: 1px 9px 9px 31px; }
|
||||||
|
.clone-option-detail { display: grid; grid-template-columns: minmax(82px, auto) minmax(64px, 1fr); align-items: center; gap: 8px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 750; text-transform: uppercase; letter-spacing: .035em; }
|
||||||
|
.clone-option-detail input { height: 28px; min-width: 0; font-size: 10px; }
|
||||||
|
.clone-history-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 7px 12px; min-width: 0; margin: 0; padding: 0; border: 0; color: var(--color-ink-dim); font-size: 9.5px; }
|
||||||
|
.clone-history-mode legend { float: left; min-width: 100%; margin-bottom: 1px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 750; text-transform: uppercase; letter-spacing: .035em; }
|
||||||
|
.clone-history-mode label, .clone-blobless-toggle { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
|
||||||
|
.clone-history-mode input, .clone-blobless-toggle > input { width: 13px; height: 13px; margin: 0; accent-color: var(--color-accent); }
|
||||||
|
.clone-option-help { color: var(--color-ink-faint); font-size: 8px; line-height: 1.35; }
|
||||||
|
.clone-blobless-toggle { align-items: start; }
|
||||||
|
.clone-blobless-toggle > span { display: grid; gap: 2px; }
|
||||||
|
.clone-blobless-toggle strong { color: var(--color-ink); font-size: 9.5px; }
|
||||||
|
.clone-blobless-toggle small { color: var(--color-ink-faint); font-size: 8px; }
|
||||||
|
.clone-sparse-paths { grid-template-columns: 1fr; gap: 5px; }
|
||||||
|
.clone-sparse-paths textarea { min-height: 54px; max-height: 72px; resize: vertical; font: 9.5px/1.35 var(--font-mono); }
|
||||||
|
.integration-browser { display: grid; grid-template-rows: auto minmax(0, 1fr); grid-auto-rows: auto; gap: 8px; min-height: 0; }
|
||||||
.repository-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) 32px; gap: 6px; }
|
.repository-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) 32px; gap: 6px; }
|
||||||
.repository-toolbar label { position: relative; min-width: 0; }
|
.repository-toolbar label { position: relative; min-width: 0; }
|
||||||
.repository-toolbar label > :global(svg) { position: absolute; z-index: 1; top: 10px; left: 10px; color: var(--color-ink-faint); }
|
.repository-toolbar label > :global(svg) { position: absolute; z-index: 1; top: 10px; left: 10px; color: var(--color-ink-faint); }
|
||||||
.repository-toolbar input { height: 34px; padding-left: 31px; font-size: 11px; }
|
.repository-toolbar input { height: 34px; padding-left: 31px; font-size: 11px; }
|
||||||
.repository-toolbar button { min-height: 32px; padding: 0; }
|
.repository-toolbar button { min-height: 32px; padding: 0; }
|
||||||
.repository-list-shell { position: relative; min-height: 162px; max-height: 250px; overflow: hidden; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
.repository-list-shell { position: relative; min-height: 0; height: 100%; overflow: hidden; border: 1px solid var(--color-border-input); border-radius: 7px; background: var(--color-surface-raised); box-shadow: 0 8px 18px rgba(0,0,0,.13); }
|
||||||
.repository-list { min-height: 160px; max-height: 248px; padding-right: 8px; overflow: auto; scrollbar-width: none; border-radius: inherit; background: transparent; }
|
.repository-list { min-height: 0; height: 100%; padding-right: 8px; overflow: auto; scrollbar-width: none; border-radius: inherit; background: transparent; }
|
||||||
.repository-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
.repository-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||||
.repository-scrollbar { position: absolute; z-index: 2; top: 3px; right: 1px; bottom: 3px; width: 7px; border-radius: 4px; opacity: 0; pointer-events: none; touch-action: none; transition: opacity 120ms ease; }
|
.repository-scrollbar { position: absolute; z-index: 2; top: 3px; right: 1px; bottom: 3px; width: 7px; border-radius: 4px; opacity: 0; pointer-events: none; touch-action: none; transition: opacity 120ms ease; }
|
||||||
.repository-scrollbar.visible { opacity: 1; pointer-events: auto; }
|
.repository-scrollbar.visible { opacity: 1; pointer-events: auto; }
|
||||||
@@ -371,28 +512,57 @@
|
|||||||
.repository-scrollbar:focus-visible .repository-scrollbar-thumb,
|
.repository-scrollbar:focus-visible .repository-scrollbar-thumb,
|
||||||
.repository-scrollbar.dragging .repository-scrollbar-thumb { right: 1px; width: 4px; background: var(--app-scrollbar-thumb-hover); }
|
.repository-scrollbar.dragging .repository-scrollbar-thumb { right: 1px; width: 4px; background: var(--app-scrollbar-thumb-hover); }
|
||||||
.repository-scrollbar:focus-visible { outline: 1px solid color-mix(in srgb, var(--color-accent) 70%, transparent); outline-offset: 1px; }
|
.repository-scrollbar:focus-visible { outline: 1px solid color-mix(in srgb, var(--color-accent) 70%, transparent); outline-offset: 1px; }
|
||||||
.repository-option { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; min-height: 52px; padding: 7px 9px; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
.repository-project-group + .repository-project-group { border-top: 1px solid var(--color-border-subtle); }
|
||||||
|
.repository-project-header { position: sticky; z-index: 1; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 34px; padding: 7px 10px 6px 12px; color: var(--color-accent); background: color-mix(in srgb, var(--color-surface-raised) 96%, transparent); font-size: 9.5px; font-weight: 900; text-transform: uppercase; letter-spacing: .045em; }
|
||||||
|
.repository-project-header span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.repository-project-header em { display: grid; flex: 0 0 auto; place-items: center; min-width: 19px; height: 16px; padding: 0 5px; color: var(--color-ink); background: color-mix(in srgb, var(--color-ink) 12%, transparent); font-size: 8px; font-style: normal; line-height: 1; letter-spacing: 0; }
|
||||||
|
.repository-option { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 6px 10px 6px 12px; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||||
.repository-option:last-child { border-bottom: 0; }
|
.repository-option:last-child { border-bottom: 0; }
|
||||||
.repository-option:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
.repository-option:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
.repository-option.selected { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-hover)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
.repository-option.selected { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 15%, var(--color-surface-hover)); box-shadow: inset 3px 0 0 var(--color-accent); }
|
||||||
.repository-option-icon { display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--color-border-subtle); border-radius: 7px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 7%, transparent); }
|
.repository-option-icon { display: grid; place-items: center; width: 22px; height: 22px; color: var(--color-ink-faint); }
|
||||||
|
.repository-option.selected .repository-option-icon { color: var(--color-accent); }
|
||||||
.repository-option-copy { display: grid; min-width: 0; gap: 3px; }
|
.repository-option-copy { display: grid; min-width: 0; gap: 3px; }
|
||||||
.repository-option-copy strong, .repository-option-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.repository-option-copy strong, .repository-option-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.repository-option-copy strong { color: inherit; font-size: 10.5px; }
|
.repository-option-copy strong { color: inherit; font-size: 10.5px; }
|
||||||
.repository-option-copy small { color: var(--color-ink-faint); font-size: 9px; }
|
.repository-option-copy small { color: var(--color-ink-faint); font-size: 9px; }
|
||||||
|
.repository-project-group .repository-option { min-height: 38px; padding-block: 5px; }
|
||||||
|
.repository-project-group .repository-option-copy { gap: 0; }
|
||||||
|
.repository-project-group .repository-option-copy strong { color: var(--color-ink); font-size: 11.5px; font-weight: 800; }
|
||||||
|
.repository-project-group .repository-option-copy small { display: none; }
|
||||||
.repository-option-meta { display: flex; align-items: center; gap: 5px; color: var(--color-ink-faint); font-size: 8.5px; }
|
.repository-option-meta { display: flex; align-items: center; gap: 5px; color: var(--color-ink-faint); font-size: 8.5px; }
|
||||||
.repository-state, .integration-empty { display: grid; place-items: center; align-content: center; min-height: 160px; padding: 20px; color: var(--color-ink-faint); text-align: center; }
|
.repository-state, .integration-empty { display: grid; place-items: center; align-content: center; min-height: 188px; padding: 20px; color: var(--color-ink-faint); text-align: center; }
|
||||||
.repository-state { gap: 7px; font-size: 10.5px; }
|
.repository-state { gap: 7px; font-size: 10.5px; }
|
||||||
.repository-state strong, .integration-empty strong { color: var(--color-ink); font-size: 11px; }
|
.repository-state strong, .integration-empty strong { color: var(--color-ink); font-size: 11px; }
|
||||||
.repository-state-error strong { color: #e86060; }
|
.repository-state-error strong { color: #e86060; }
|
||||||
.repository-state-error span { max-width: 520px; line-height: 1.45; }
|
.repository-state-error span { max-width: 520px; line-height: 1.45; }
|
||||||
.integration-empty { min-height: 235px; gap: 8px; }
|
.integration-empty { min-height: 260px; gap: 8px; }
|
||||||
.integration-empty :global(svg) { color: var(--color-accent); }
|
.integration-empty :global(svg) { color: var(--color-accent); }
|
||||||
.integration-empty p { max-width: 400px; margin: 0; color: var(--color-ink-faint); font-size: 10px; line-height: 1.5; }
|
.integration-empty p { max-width: 400px; margin: 0; color: var(--color-ink-faint); font-size: 10px; line-height: 1.5; }
|
||||||
.selected-repository { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 3px 8px; min-width: 0; padding: 8px 9px; border-left: 2px solid var(--color-accent); background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
|
.selected-repository { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 3px 8px; min-width: 0; padding: 8px 9px; border-left: 2px solid var(--color-accent); background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
|
||||||
.selected-repository span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
|
.selected-repository span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
.selected-repository strong { overflow: hidden; color: var(--color-ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
.selected-repository strong { overflow: hidden; color: var(--color-ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.selected-repository code { grid-column: 2; overflow: hidden; color: var(--color-ink-faint); font: 8.5px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
.selected-repository code { grid-column: 2; overflow: hidden; color: var(--color-ink-faint); font: 8.5px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.clone-target-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(150px, .75fr); gap: 10px; }
|
.clone-target-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(150px, .72fr); gap: 10px; }
|
||||||
@media (max-width: 620px) { .clone-repository-dialog { width: min(620px, calc(100vw - 20px)); } .clone-target-grid { grid-template-columns: 1fr; } .repository-option-meta { display: none; } }
|
.clone-dialog-actions { min-height: 54px; align-items: center; padding: 9px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.clone-repository-dialog { width: min(660px, calc(100vw - 20px)); }
|
||||||
|
.clone-dialog-layout { grid-template-columns: 155px minmax(0, 1fr); }
|
||||||
|
.clone-source-list button { padding-inline: 10px; }
|
||||||
|
.clone-target-grid { grid-template-columns: 1fr; }
|
||||||
|
.repository-option-meta { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.dialog-backdrop { padding: 10px; }
|
||||||
|
.clone-repository-dialog { width: calc(100vw - 20px); height: min(660px, calc(100vh - 20px)); }
|
||||||
|
.clone-dialog-layout { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||||
|
.clone-source-nav { padding: 6px 0; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--color-border-subtle); }
|
||||||
|
.clone-source-heading, .clone-source-nav > p { display: none; }
|
||||||
|
.clone-source-list { display: flex; width: max-content; min-width: 100%; padding: 0 6px; }
|
||||||
|
.clone-source-list button { width: auto; min-height: 34px; padding-inline: 9px; border-radius: 5px; }
|
||||||
|
.clone-source-list button.active { box-shadow: inset 0 -2px 0 var(--color-accent); }
|
||||||
|
.clone-options { grid-template-columns: 1fr; }
|
||||||
|
.clone-dialog-content { grid-template-rows: auto; grid-auto-rows: auto; align-content: start; padding: 13px 12px; overflow: auto; }
|
||||||
|
.integration-browser { min-height: 260px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2116,7 +2116,8 @@
|
|||||||
.help-search-icon { position: absolute; left: 12px; display: grid; color: var(--color-ink-faint); pointer-events: none; }
|
.help-search-icon { position: absolute; left: 12px; display: grid; color: var(--color-ink-faint); pointer-events: none; }
|
||||||
.help-search input { width: 100%; height: 40px; padding: 0 68px 0 39px; border-color: var(--color-border-input); border-radius: 8px; background: var(--app-input-bg); color: var(--color-ink); font-size: 12.5px; }
|
.help-search input { width: 100%; height: 40px; padding: 0 68px 0 39px; border-color: var(--color-border-input); border-radius: 8px; background: var(--app-input-bg); color: var(--color-ink); font-size: 12.5px; }
|
||||||
.help-search kbd { position: absolute; right: 8px; }
|
.help-search kbd { position: absolute; right: 8px; }
|
||||||
.help-close { justify-self: end; width: 36px; min-height: 36px; padding: 0; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); }
|
.help-close { justify-self: end; width: 36px; min-height: 36px; padding: 0; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); transition: color 120ms ease, border-color 120ms ease, background 120ms ease, box-shadow 120ms ease; }
|
||||||
|
.help-close:hover:not(:disabled), .help-close:focus-visible:not(:disabled) { color: #fff; border-color: #f0646d; background: #d93641; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
|
||||||
|
|
||||||
.help-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 0; }
|
.help-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 0; }
|
||||||
.help-nav { display: flex; flex-direction: column; min-height: 0; padding: 14px 10px 12px; border-right: 1px solid var(--color-border); background: var(--color-surface-dim); }
|
.help-nav { display: flex; flex-direction: column; min-height: 0; padding: 14px 10px 12px; border-right: 1px solid var(--color-border); background: var(--color-surface-dim); }
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { Folder, FolderOpen, GitBranch, LoaderCircle, Plus, X } from "@lucide/svelte";
|
||||||
|
import type { AppLanguage } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isBusy: boolean;
|
||||||
|
language: AppLanguage;
|
||||||
|
onInit: (path: string, branch: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
isBusy = false,
|
||||||
|
language = "en",
|
||||||
|
onInit = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let path = $state("");
|
||||||
|
let branch = $state("main");
|
||||||
|
let browseError = $state("");
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
const pathPlaceholder = navigator.userAgent.includes("Windows")
|
||||||
|
? "C:\\Projects\\my-repository"
|
||||||
|
: "/home/user/projects/my-repository";
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const targetPath = path.trim();
|
||||||
|
const branchName = branch.trim();
|
||||||
|
if (!targetPath || !branchName || isBusy) return;
|
||||||
|
onInit(targetPath, branchName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape" && !isBusy) onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseFolder() {
|
||||||
|
if (isBusy) return;
|
||||||
|
browseError = "";
|
||||||
|
try {
|
||||||
|
const selected = await openDialog({
|
||||||
|
title: isGerman ? "Ordner für das neue Repository auswählen" : "Select folder for the new repository",
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: path.trim() || undefined,
|
||||||
|
});
|
||||||
|
if (typeof selected === "string") path = selected;
|
||||||
|
} catch (error) {
|
||||||
|
browseError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener("keydown", handleKeydown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
|
<div
|
||||||
|
class="dialog init-repository-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="init-repository-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<header class="dialog-header init-repository-header">
|
||||||
|
<div class="init-repository-heading">
|
||||||
|
<span class="init-repository-icon" aria-hidden="true"><Plus size={18} /></span>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">{isGerman ? "Neues Repository" : "New repository"}</span>
|
||||||
|
<h2 id="init-repository-title">{isGerman ? "Repository initialisieren" : "Initialize repository"}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"}>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="init-repository-form" onsubmit={submit}>
|
||||||
|
<p class="init-repository-description">
|
||||||
|
{isGerman
|
||||||
|
? "GitLite richtet in diesem Ordner ein neues Git-Repository ein. Vorhandene Dateien bleiben unverändert."
|
||||||
|
: "GitLite will create a new Git repository in this folder. Existing files will remain unchanged."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="new-branch-field init-repository-field init-repository-path-field">
|
||||||
|
<span>{isGerman ? "Zielordner" : "Repository folder"}</span>
|
||||||
|
<div class="init-repository-path-control">
|
||||||
|
<Folder size={16} aria-hidden="true" />
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
bind:value={path}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder={pathPlaceholder}
|
||||||
|
disabled={isBusy}
|
||||||
|
autofocus
|
||||||
|
aria-describedby="init-repository-path-hint"
|
||||||
|
/>
|
||||||
|
<button class="btn-secondary init-repository-browse" type="button" onclick={chooseFolder} disabled={isBusy}>
|
||||||
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
|
{isGerman ? "Auswählen…" : "Browse…"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small id="init-repository-path-hint">
|
||||||
|
{isGerman ? "Der Ordner wird angelegt, falls er noch nicht existiert." : "The folder will be created if it does not exist."}
|
||||||
|
</small>
|
||||||
|
{#if browseError}<small class="init-repository-error" role="alert">{browseError}</small>{/if}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="new-branch-field init-repository-field">
|
||||||
|
<span>{isGerman ? "Name des ersten Branches" : "Initial branch name"}</span>
|
||||||
|
<div>
|
||||||
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
bind:value={branch}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="main"
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-describedby="init-repository-branch-hint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small id="init-repository-branch-hint">
|
||||||
|
{isGerman ? "Du kannst den Branch später jederzeit umbenennen." : "You can rename the branch at any time."}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="new-branch-actions init-repository-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||||
|
{isGerman ? "Abbrechen" : "Cancel"}
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" type="submit" disabled={isBusy || path.trim().length === 0 || branch.trim().length === 0}>
|
||||||
|
{#if isBusy}
|
||||||
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Plus size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
{isGerman ? "Repository erstellen" : "Create repository"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { Check, GitMerge, LoaderCircle, X } from "@lucide/svelte";
|
||||||
|
import type { AppLanguage, GitBranch, MergeStrategy } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
branch: GitBranch;
|
||||||
|
currentBranch: string;
|
||||||
|
isBusy: boolean;
|
||||||
|
language: AppLanguage;
|
||||||
|
onMerge: (strategy: MergeStrategy) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
branch,
|
||||||
|
currentBranch,
|
||||||
|
isBusy = false,
|
||||||
|
language = "en",
|
||||||
|
onMerge = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let strategy = $state<MergeStrategy>("default");
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
const options = $derived([
|
||||||
|
{
|
||||||
|
value: "default" as const,
|
||||||
|
label: isGerman ? "Standard" : "Default",
|
||||||
|
description: isGerman ? "Git wählt Fast-forward oder erstellt einen Merge-Commit." : "Git chooses fast-forward or creates a merge commit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "squash" as const,
|
||||||
|
label: "Squash",
|
||||||
|
description: isGerman ? "Fasst alle Änderungen zu einem neuen Commit zusammen." : "Combines all changes into one new commit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ff-only" as const,
|
||||||
|
label: "Fast-forward only",
|
||||||
|
description: isGerman ? "Bricht ab, wenn ein Merge-Commit erforderlich wäre." : "Stops if a merge commit would be required.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "no-ff" as const,
|
||||||
|
label: "No fast-forward",
|
||||||
|
description: isGerman ? "Erstellt immer einen eigenen Merge-Commit." : "Always creates a dedicated merge commit.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isBusy) onMerge(strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape" && !isBusy) onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener("keydown", handleKeydown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
|
<div class="dialog merge-branch-dialog" role="dialog" aria-modal="true" aria-labelledby="merge-branch-title" tabindex="-1">
|
||||||
|
<header class="dialog-header merge-branch-header">
|
||||||
|
<div class="merge-branch-heading">
|
||||||
|
<span class="merge-branch-icon" aria-hidden="true"><GitMerge size={18} /></span>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">{isGerman ? "Branches zusammenführen" : "Combine branches"}</span>
|
||||||
|
<h2 id="merge-branch-title">{isGerman ? "Merge konfigurieren" : "Configure merge"}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"}>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="merge-branch-form" onsubmit={submit}>
|
||||||
|
<div class="merge-branch-route" aria-label={isGerman ? "Merge-Richtung" : "Merge direction"}>
|
||||||
|
<div>
|
||||||
|
<span>{isGerman ? "Quell-Branch" : "Source branch"}</span>
|
||||||
|
<strong>{branch.name}</strong>
|
||||||
|
</div>
|
||||||
|
<GitMerge size={18} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<span>{isGerman ? "In aktuellen Branch" : "Into current branch"}</span>
|
||||||
|
<strong>{currentBranch || "HEAD"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="merge-strategy-fieldset" disabled={isBusy}>
|
||||||
|
<legend>{isGerman ? "Merge-Strategie" : "Merge strategy"}</legend>
|
||||||
|
<div class="merge-strategy-grid">
|
||||||
|
{#each options as option}
|
||||||
|
<label class:active={strategy === option.value} class="merge-strategy-option">
|
||||||
|
<input type="radio" name="merge-strategy" value={option.value} bind:group={strategy} />
|
||||||
|
<span class="merge-strategy-check" aria-hidden="true">
|
||||||
|
{#if strategy === option.value}<Check size={13} />{/if}
|
||||||
|
</span>
|
||||||
|
<span class="merge-strategy-copy">
|
||||||
|
<strong>{option.label}</strong>
|
||||||
|
<small>{option.description}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="merge-branch-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||||
|
<button class="btn-primary" type="submit" disabled={isBusy}>
|
||||||
|
{#if isBusy}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<GitMerge size={16} aria-hidden="true" />{/if}
|
||||||
|
{isGerman ? "Branch mergen" : "Merge branch"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.repo-loading {
|
.repo-loading {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(var(--app-titlebar-height, 42px) + 56px);
|
top: calc(var(--app-titlebar-height, 40px) + var(--app-repo-tabbar-height, 36px));
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
color-mix(in srgb, var(--color-accent) 13%, transparent),
|
color-mix(in srgb, var(--color-accent) 13%, transparent),
|
||||||
transparent 38%
|
transparent 38%
|
||||||
),
|
),
|
||||||
var(--app-dialog-backdrop);
|
color-mix(in srgb, var(--app-dialog-bg) 78%, #05070a 22%);
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(9px);
|
||||||
animation: overlay-in 180ms ease;
|
animation: overlay-in 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AiReviewResult,
|
AiReviewResult,
|
||||||
AiCommitPlan,
|
AiCommitPlan,
|
||||||
CommitAiProvider,
|
CommitAiProvider,
|
||||||
|
CloneOptions,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
DetectedExternalTool,
|
DetectedExternalTool,
|
||||||
ExternalToolCommand,
|
ExternalToolCommand,
|
||||||
@@ -80,6 +81,7 @@ export function cloneRepository(
|
|||||||
username?: string,
|
username?: string,
|
||||||
password?: string,
|
password?: string,
|
||||||
commitLimit = 100,
|
commitLimit = 100,
|
||||||
|
options: CloneOptions = { branch: null, blobless: false, customFlags: "", shallowDepth: null, shallowSince: null, sparse: false, sparsePaths: [] },
|
||||||
): Promise<RepositoryBundle> {
|
): Promise<RepositoryBundle> {
|
||||||
return invoke<RepositoryBundle>("clone_repository", {
|
return invoke<RepositoryBundle>("clone_repository", {
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
@@ -88,6 +90,13 @@ export function cloneRepository(
|
|||||||
username: username ?? null,
|
username: username ?? null,
|
||||||
password: password ?? null,
|
password: password ?? null,
|
||||||
commitLimit,
|
commitLimit,
|
||||||
|
branch: options.branch,
|
||||||
|
blobless: options.blobless,
|
||||||
|
customFlags: options.customFlags,
|
||||||
|
shallowDepth: options.shallowDepth,
|
||||||
|
shallowSince: options.shallowSince,
|
||||||
|
sparse: options.sparse,
|
||||||
|
sparsePaths: options.sparsePaths,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,16 @@ export interface GitRemote { name: string; fetch_url: string; push_url: string;
|
|||||||
export type PullStrategy = "merge" | "rebase" | "ff-only";
|
export type PullStrategy = "merge" | "rebase" | "ff-only";
|
||||||
export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff";
|
export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff";
|
||||||
|
|
||||||
|
export interface CloneOptions {
|
||||||
|
branch: string | null;
|
||||||
|
blobless: boolean;
|
||||||
|
customFlags: string;
|
||||||
|
shallowDepth: number | null;
|
||||||
|
shallowSince: string | null;
|
||||||
|
sparse: boolean;
|
||||||
|
sparsePaths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitFileStatus {
|
export interface GitFileStatus {
|
||||||
path: string;
|
path: string;
|
||||||
old_path: string | null;
|
old_path: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user