Compare commits

..
4 Commits
Author SHA1 Message Date
Christoph 106cc98afb feat(branches): add Merge Branch dialog and confirm flow
publish / Build and publish Ubuntu AppImage (release) Successful in 9m52s
publish / Build and publish Windows installer (release) Successful in 11m10s
publish / Build and publish AUR packages (release) Successful in 19m3s
Introduce a modal dialog to configure and confirm branch merges. It
replaces the old prompt-based workflow and lets users choose a merge
strategy with localized labels. Confirming the dialog runs the merge,
refreshes views, and clears the pending merge target.

- Add a merge dialog UI with strategy options and keyboard support.
- Replace prompt-based merge flow with a state-driven confirm dialog.
- Include CSS for dialog layout, responsive sizing, and styling.
2026-09-01 22:27:59 +02:00
Christoph add98f962e feat(ui): add init repository dialog
Add a modal dialog to initialize a new Git repository, replacing the
previous prompt-based flow. It integrates with the app state and invokes
the initialization operation then opens the repository on confirmation.
Also adds styles, keyboard handling, accessibility hints, a folder
browser, initial-branch input, and German/English labels with busy state.

- New dialog component with folder picker, branch input, and i18n.
- Wire dialog open/close and confirm handler to init and open actions.
- Add CSS rules for dialog layout, controls, and error/busy states.
2026-09-01 21:13:46 +02:00
Christoph 90df347e4a feat(git): add advanced clone options (shallow, sparse, flags)
Add advanced clone options support across the frontend and backend.
Users can specify a branch, shallow limits, blobless mode, sparse paths,
and custom clone flags when initiating a clone operation. The backend
validates inputs, parses custom flags without invoking a shell, and
configures sparse checkouts after a successful clone. A small parsing
dependency was added and tracked-file parsing was improved to better
handle git ls-files output.

- Introduce CloneRunOptions and validation helpers for clone inputs
- Parse custom flags with a shell-like tokenizer and block managed flags
- Support sparse checkout configuration and shallow clone constraints
2026-08-31 19:43:30 +02:00
Christoph 4c58b14691 Update version to 2026.8.10 2026-08-31 11:16:56 +02:00
13 changed files with 849 additions and 27 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.8.9", "version": "2026.8.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "2026.8.9", "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
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.8.9", "version": "2026.8.10",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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.9", "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
View File
@@ -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} />
+77
View File
@@ -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;
@@ -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));
@@ -69,7 +78,10 @@
}); });
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;
@@ -260,7 +272,22 @@
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>
@@ -298,6 +325,48 @@
<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> <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>
<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"} {#if source === "url"}
<label class="clone-dialog-field clone-url-field"> <label class="clone-dialog-field clone-url-field">
<span>{isGerman ? "Repository-URL" : "Repository URL"}</span> <span>{isGerman ? "Repository-URL" : "Repository URL"}</span>
@@ -400,11 +469,33 @@
.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 { 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-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-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 minmax(0, 1fr); grid-auto-rows: auto; align-content: stretch; gap: 14px; min-width: 0; min-height: 0; padding: 16px 18px; overflow: hidden; } .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 { 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 > 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-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-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; } .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; }
@@ -470,6 +561,8 @@
.clone-source-list { display: flex; width: max-content; min-width: 100%; padding: 0 6px; } .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 { 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-source-list button.active { box-shadow: inset 0 -2px 0 var(--color-accent); }
.clone-dialog-content { padding: 13px 12px; overflow: hidden; } .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>
@@ -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>
+119
View File
@@ -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>
+9
View File
@@ -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,
}); });
} }
+10
View File
@@ -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;