Compare commits

...
10 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
Christoph 8bec7dfc9a style(ui): refine tab and dialog close button visuals
publish / Build and publish Ubuntu AppImage (release) Successful in 9m31s
publish / Build and publish Windows installer (release) Successful in 10m28s
publish / Build and publish AUR packages (release) Successful in 19m9s
Adjust spacing and sizing for repo tabs and close controls. Add
transitions, larger hit areas, and updated hover/focus styles. Introduce
theme-aware variants and neutral dialog close styling for consistency.
Also update help overlay close to match the new behavior.

- increase close button size and tweak padding for alignment
- add transitions and inset focus/hover visuals with themes
- normalize dialog and help close appearance for consistency
2026-08-31 11:14:56 +02:00
Christoph 7950edb145 fix(clone-dialog): improve layout and scrolling
Adjust grid templates and overflow rules to improve layout.
Replace fixed heights with flexible rows so content fills space.
Hide overflow and tweak alignment to avoid double scrollbars.

- Use grid-template-rows to allocate header, controls, and list area
- Make repository list shell and list use height: 100% and overflow:auto
- Align URL field to the top to prevent unexpected scrolling
2026-08-31 10:25:07 +02:00
Christoph e3af6653cd fix(layout): account for repo tab bar in loading overlay
Adjust the fixed loading overlay to avoid overlapping the repo tab bar.
The top offset now adds the app titlebar and repo tabbar heights using
CSS calc with reasonable fallbacks. This keeps the overlay aligned with
top UI elements when a repository tab bar is present.

- Offset top by titlebar plus repo tabbar heights via CSS calc.
- Provide sensible fallback values to maintain stable layout.
2026-08-31 10:10:00 +02:00
Christoph b752c0804b style(repo-loading-overlay): adjust overlay position and backdrop
Remove the extra vertical offset so the loading overlay aligns with the
app titlebar. Update the backdrop color mix and increase blur to improve
contrast and perceived depth while the overlay is visible.

- Align overlay using the titlebar height variable instead of adding a
  fixed offset
- Use a denser color mix and stronger blur for a clearer backdrop effect
2026-08-31 09:05:10 +02:00
Christoph 8f98e79df9 feat(clone-dialog): redesign UI and group Azure DevOps repos
Rework CloneRepositoryDialog into a two-column layout with a source
sidebar and focused content area to make browsing sources easier.
Group Azure DevOps repositories by project with a new derived value
and render sticky project headers for clearer navigation.
Replace showIntegrations with selectIntegrationSource to load the
chosen integration, refresh repositories, and update labels and styles.

- Introduce source sidebar and refreshed dialog layout
- Add azureRepositoryGroups and project grouping UI
- Rename flow to selectIntegrationSource and improve loading logic
2026-08-31 08:43:00 +02:00
Christoph f0a1d89152 Update version to 2026.8.9 2026-08-30 20:26:46 +02:00
15 changed files with 1022 additions and 88 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "2026.8.8",
"version": "2026.8.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.8.8",
"version": "2026.8.10",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "2026.8.8",
"version": "2026.8.10",
"private": true,
"type": "module",
"scripts": {
+1
View File
@@ -1478,6 +1478,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"shlex",
"sysinfo",
"tauri",
"tauri-build",
+1
View File
@@ -24,6 +24,7 @@ tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] }
log = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
shlex = "2"
[build-dependencies]
tauri-build = { version = "2", features = [] }
+325 -4
View File
@@ -632,8 +632,24 @@ pub async fn clone_repository(
username: Option<String>,
password: Option<String>,
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> {
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(
&remote_url,
&parent_path,
@@ -641,6 +657,7 @@ pub async fn clone_repository(
username.as_deref(),
password.as_deref(),
commit_limit,
&clone_options,
)
})
.await
@@ -5405,8 +5422,20 @@ fn repository_files_with_status(
) -> Result<Vec<GitRepositoryFile>, String> {
let mut files = BTreeMap::<String, GitRepositoryFile>::new();
let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?;
for path in parse_nul_paths(&tracked_output) {
let tracked_output = run_git(repo, ["ls-files", "-z", "-t", "--cached", "--deleted"])?;
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);
files.insert(
path.clone(),
@@ -5434,6 +5463,17 @@ fn repository_files_with_status(
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(
remote_url: &str,
parent_path: &str,
@@ -5441,9 +5481,16 @@ fn clone_repository_core(
username: Option<&str>,
password: Option<&str>,
commit_limit: Option<u32>,
clone_options: &CloneRunOptions,
) -> Result<RepositoryBundle, String> {
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 lfs_warning = if verify_commit(&repo, "HEAD").is_ok() {
@@ -5564,7 +5611,21 @@ fn run_git_clone(
target: &Path,
username: Option<&str>,
password: Option<&str>,
clone_options: &CloneRunOptions,
) -> 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 has_explicit_credentials = matches!(
(username, password),
@@ -5573,8 +5634,24 @@ fn run_git_clone(
if has_explicit_credentials {
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
.arg("clone")
.arg("--")
.arg(remote_url)
.arg(target)
@@ -5601,6 +5678,9 @@ fn run_git_clone(
let output = output?;
if output.status.success() {
if !sparse_paths.is_empty() {
configure_sparse_checkout(target, &sparse_paths)?;
}
return Ok(());
}
@@ -5612,6 +5692,104 @@ fn run_git_clone(
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> {
let normalized = normalize_git_path(path);
if repo.join(path).is_dir() {
@@ -7795,6 +7973,7 @@ mod tests {
None,
None,
Some(100),
&CloneRunOptions::default(),
)
.expect("repository should clone");
@@ -7812,6 +7991,146 @@ mod tests {
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]
fn clone_repository_core_supports_empty_repository() {
let source = init_bare_temp_repo("empty_clone_source");
@@ -7824,6 +8143,7 @@ mod tests {
None,
None,
Some(100),
&CloneRunOptions::default(),
)
.expect("empty repository should clone");
@@ -7882,6 +8202,7 @@ mod tests {
None,
None,
Some(100),
&CloneRunOptions::default(),
)
.expect("LFS repository should clone");
let cloned_repo = parent.path.join("local-copy");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.8.8",
"version": "2026.8.10",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
+55 -13
View File
@@ -26,6 +26,8 @@
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
import ExplorerPanel from "./lib/components/ExplorerPanel.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 RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
@@ -148,6 +150,7 @@
AppLanguage,
AppTheme,
AnalyticsSettings,
CloneOptions,
CustomThemeColors,
ConflictFile,
DetectedExternalTool,
@@ -167,6 +170,7 @@
GitFileStatus,
GitIgnoreKind,
GitLfsStatus,
MergeStrategy,
GitRepositoryFile,
GitRemote,
PullStrategy,
@@ -233,6 +237,7 @@
remoteUrl: string;
parentPath: string;
directoryName: string;
cloneOptions?: CloneOptions;
}
interface ErrorAutoHideState {
@@ -311,6 +316,7 @@
let repoStatusCache: Record<string, RepoTab> = {};
let repoSearch = "";
let cloneDialogOpen = false;
let initRepositoryDialogOpen = false;
let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
let selectedRemote = localStorage.getItem("gitlite.selectedRemote") || "";
let remoteActionForceWithLease = false;
@@ -387,6 +393,7 @@
let comparisonFromLabel = "";
let comparisonToLabel = "";
let newBranchCommit: GitCommit | null = null;
let mergeBranchTarget: GitBranchInfo | null = null;
let renameBranchTarget: GitBranchInfo | null = null;
let deleteBranchTarget: GitBranchInfo | null = null;
let deleteBranchForce = false;
@@ -719,7 +726,7 @@
if (pendingStartupCloneRequest) {
const request = pendingStartupCloneRequest;
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;
}
const path = pendingStartupRepoPath;
@@ -2462,12 +2469,13 @@
key?: string | null,
fromStore = false,
credentialMode: CredentialMode = "credentials",
cloneOptions: CloneOptions = { branch: null, blobless: false, customFlags: "", shallowDepth: null, shallowSince: null, sparse: false, sparsePaths: [] },
) {
if (isBusy) return;
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
if (!parentPath) { errorMessage = "Select a destination folder."; return; }
const request: CloneRequest = { remoteUrl, parentPath, directoryName };
const request: CloneRequest = { remoteUrl, parentPath, directoryName, cloneOptions };
pendingClone = request;
const credentialKey = key === undefined ? orgKeyFromUrl(remoteUrl) : key;
@@ -2483,7 +2491,7 @@
credDialogOpen = true;
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;
}
}
@@ -2499,6 +2507,7 @@
username,
password,
COMMIT_HISTORY_PAGE_SIZE + 1,
cloneOptions,
);
resetRepositoryState(false);
await applyRepositoryBundle(bundle.status.repo_path, bundle);
@@ -2555,9 +2564,9 @@
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;
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() {
@@ -3201,11 +3210,15 @@
async function merge(branch: GitBranchInfo) {
if (!activeRepoPath || branch.current) return;
const strategy = (window.prompt("Merge strategy: default, squash, ff-only, or no-ff", "default") ?? "").trim();
if (!strategy) return;
if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; }
mergeBranchTarget = branch;
}
async function confirmMerge(strategy: MergeStrategy) {
const branch = mergeBranchTarget;
if (!activeRepoPath || !branch || branch.current) return;
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);
trackEvent("branch_merged", {
remote: branch.remote ? 1 : 0,
@@ -3877,6 +3890,7 @@
key,
false,
mode,
pendingClone.cloneOptions,
);
}
}
@@ -3921,10 +3935,17 @@
}
async function initializeRepository() {
const selected = await openDialog({ directory: true, multiple: false, title: "Choose an empty or existing folder" });
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); });
initRepositoryDialogOpen = true;
}
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) {
@@ -5912,6 +5933,17 @@
/>
{/if}
{#if mergeBranchTarget}
<MergeBranchDialog
branch={mergeBranchTarget}
currentBranch={status?.current_branch ?? ""}
{isBusy}
language={appLanguage}
onMerge={confirmMerge}
onClose={() => { if (!isBusy) mergeBranchTarget = null; }}
/>
{/if}
{#if commitNoteTarget}
<CommitNoteDialog
commit={commitNoteTarget}
@@ -6085,6 +6117,16 @@
/>
{/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 -->
{#if openingRepo}
<RepoLoadingOverlay repoName={repoDisplayName} />
+123 -12
View File
@@ -3878,6 +3878,20 @@
max-height: calc(100vh - 32px);
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 {
display: block;
width: min(520px, calc(100vw - 32px));
@@ -4172,6 +4186,69 @@
gap: 14px;
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 {
display: flex;
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 {
min-height: 35px;
height: 35px;
padding: 0 31px 0 15px;
padding: 0 34px 0 15px;
gap: 6px;
font-size: 12.5px;
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 {
position: absolute;
top: 8px;
right: 6px;
width: 19px;
min-width: 19px;
max-width: 19px;
height: 19px;
min-height: 19px;
max-height: 19px;
top: 6px;
right: 5px;
width: 23px;
min-width: 23px;
max-width: 23px;
height: 23px;
min-height: 23px;
max-height: 23px;
margin: 0;
border-radius: 2px;
border-radius: 3px;
color: var(--color-ink-faint);
background: transparent;
box-shadow: none;
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; }
@@ -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-close:hover:not(:disabled),
.repo-tab-close:focus-visible:not(:disabled) {
color: #e1848b;
background: transparent;
color: #ffffff;
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 {
@@ -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 {
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;
}
+212 -42
View File
@@ -4,7 +4,7 @@
import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte";
import { listIntegrationRepositories } from "../git";
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";
@@ -13,7 +13,7 @@
error: string;
language: AppLanguage;
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;
}
@@ -45,6 +45,15 @@
let repositoryScrollbarDragScrollTop = 0;
let repositoryScrollbarFrame: number | undefined;
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 configuredSources = $derived(configuredIntegrationSources(integrations));
@@ -55,9 +64,24 @@
if (!query) return activeRepositories;
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 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(() => {
const nextError = error || browseError;
@@ -230,10 +254,9 @@
}
}
function showIntegrations() {
function selectIntegrationSource(integrationSource: GitIntegrationSource) {
source = "integrations";
const nextSource = configuredSources.find((candidate) => candidate.id === selectedSourceId) ?? configuredSources[0];
if (nextSource) void loadRepositories(nextSource);
void loadRepositories(integrationSource);
}
function showUrlInput() {
@@ -249,26 +272,104 @@
function submit(event: SubmitEvent) {
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>
<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">
<header class="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>
<header class="dialog-header clone-dialog-header">
<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>
</header>
<form class="clone-dialog-form" onsubmit={submit}>
<div class="clone-source-tabs" 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} />URL</button>
<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-dialog-layout">
<aside class="clone-source-nav" aria-label={isGerman ? "Repository-Quellen" : "Repository sources"}>
<div class="clone-source-heading">{isGerman ? "Quelle" : "Source"}</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>
<section class="clone-dialog-content">
<div class="clone-dialog-title">
<span>{source === "integrations" ? (activeSource?.label ?? "Integration") : "URL"}</span>
<h3>{isGerman ? "Repository klonen" : "Clone a Repo"}</h3>
</div>
<div class="clone-target-grid">
<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">
<span>{isGerman ? "Remote-URL" : "Remote URL"}</span>
<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>
@@ -277,11 +378,8 @@
{#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>
{: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">
<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>
</div>
<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>
{: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>
{: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}
{#each filteredRepositories as repository (repository.id)}
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
@@ -334,35 +445,65 @@
</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>
{#if visibleError}<div class="clone-dialog-error" role="alert">{visibleError}</div>{/if}
</section>
</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>
</div>
</div>
<style>
.clone-repository-dialog { width: min(760px, calc(100vw - 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-source-tabs button { min-height: 36px; border-color: transparent; color: var(--color-ink-dim); background: transparent; font-size: 11px; font-weight: 800; }
.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-source-tabs button.active :global(svg) { color: var(--color-accent); }
.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; }
.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); }
.integration-provider-tabs { display: flex; gap: 5px; overflow-x: auto; }
.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; }
.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-repository-dialog { width: min(900px, calc(100vw - 32px)); height: min(660px, calc(100vh - 32px)); }
.clone-dialog-header { min-height: 52px; padding: 0 16px 0 20px; }
.clone-dialog-header h2 { margin: 0; color: var(--color-ink); font-size: 15px; font-weight: 650; }
.clone-dialog-form { grid-template-rows: minmax(0, 1fr) auto; gap: 0; height: calc(100% - 53px); padding: 0; }
.clone-dialog-layout { display: grid; grid-template-columns: 205px minmax(0, 1fr); min-height: 0; }
.clone-source-nav { min-width: 0; padding: 12px 0; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.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; }
.clone-source-list { display: grid; gap: 2px; }
.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; }
.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 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 input { height: 34px; padding-left: 31px; font-size: 11px; }
.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 { min-height: 160px; max-height: 248px; padding-right: 8px; overflow: auto; scrollbar-width: none; border-radius: inherit; background: transparent; }
.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: 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-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; }
@@ -371,28 +512,57 @@
.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: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: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-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.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: 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 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 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-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 strong, .integration-empty strong { color: var(--color-ink); font-size: 11px; }
.repository-state-error strong { color: #e86060; }
.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 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 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 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; }
@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-target-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(150px, .72fr); gap: 10px; }
.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>
+2 -1
View File
@@ -2116,7 +2116,8 @@
.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 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-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>
+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>
+3 -3
View File
@@ -33,7 +33,7 @@
<style>
.repo-loading {
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;
bottom: 0;
left: 0;
@@ -47,8 +47,8 @@
color-mix(in srgb, var(--color-accent) 13%, transparent),
transparent 38%
),
var(--app-dialog-backdrop);
backdrop-filter: blur(5px);
color-mix(in srgb, var(--app-dialog-bg) 78%, #05070a 22%);
backdrop-filter: blur(9px);
animation: overlay-in 180ms ease;
}
+9
View File
@@ -4,6 +4,7 @@ import type {
AiReviewResult,
AiCommitPlan,
CommitAiProvider,
CloneOptions,
ConflictFile,
DetectedExternalTool,
ExternalToolCommand,
@@ -80,6 +81,7 @@ export function cloneRepository(
username?: string,
password?: string,
commitLimit = 100,
options: CloneOptions = { branch: null, blobless: false, customFlags: "", shallowDepth: null, shallowSince: null, sparse: false, sparsePaths: [] },
): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("clone_repository", {
remoteUrl,
@@ -88,6 +90,13 @@ export function cloneRepository(
username: username ?? null,
password: password ?? null,
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 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 {
path: string;
old_path: string | null;