From 1c7c53ddf95eabcb79ccfd96f465fb079960ce22 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Wed, 1 Jul 2026 10:07:59 +0200 Subject: [PATCH 1/3] Add branch creation and file history search Implement the ability to create new local branches directly from the application's UI. This includes a new backend command to handle branch creation with validation and a frontend form in the branch panel. Additionally, extend the global search dialog to include a "Files" tab. Users can now search for files by name or path within the repository. Selecting a file in the search results displays its full commit history, with options to diff the file at a specific commit or restore it to that version. --- src-tauri/src/git.rs | 65 +++ src-tauri/src/main.rs | 11 +- src/App.svelte | 42 ++ src/app.css | 306 +++++++++++++- src/lib/components/BranchPanel.svelte | 219 +++++++--- src/lib/components/GlobalSearchDialog.svelte | 406 ++++++++++++++----- src/lib/git.ts | 4 + 7 files changed, 886 insertions(+), 167 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index eaa705f..3772d9e 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -281,6 +281,14 @@ pub fn checkout_branch(path: String, branch: String) -> Result Result { + let repo = resolve_repo(&path)?; + let branch = validate_new_branch_name(&repo, &branch)?; + run_git(&repo, ["checkout", "-b", branch.as_str()])?; + status_for_repo(&repo) +} + #[tauri::command] pub fn stage_files(path: String, files: Vec) -> Result { let repo = resolve_repo(&path)?; @@ -1839,6 +1847,38 @@ fn local_branch_name_for_remote(remote_branch: &str) -> Option<&str> { .filter(|local| !local.is_empty()) } +fn validate_new_branch_name(repo: &Path, branch: &str) -> Result { + let branch = branch.trim(); + if branch.is_empty() { + return Err("Branch-Name darf nicht leer sein.".to_string()); + } + + let output = git_command() + .arg("-C") + .arg(repo) + .args(["check-ref-format", "--branch", branch]) + .output() + .map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; + + if !output.status.success() { + let details = command_output_details(&output); + return Err(format!("Ungueltiger Branch-Name: {details}")); + } + + let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let normalized = if normalized.is_empty() { + branch.to_string() + } else { + normalized + }; + + if ref_exists(repo, &format!("refs/heads/{normalized}"))? { + return Err(format!("Branch '{normalized}' existiert bereits.")); + } + + Ok(normalized) +} + fn ref_exists(repo: &Path, ref_name: &str) -> Result { let output = git_command() .arg("-C") @@ -3186,6 +3226,31 @@ mod tests { assert_eq!(plan, CheckoutPlan::Local("feature/demo".to_string())); } + #[test] + fn create_branch_creates_and_checks_out_local_branch() { + let repo = init_temp_repo("create_branch"); + commit_initial_file(&repo.path); + + let status = create_branch( + repo.path.to_string_lossy().to_string(), + "feature/new-panel".to_string(), + ) + .unwrap(); + + assert_eq!(status.current_branch.as_deref(), Some("feature/new-panel")); + assert!( + ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(), + "new branch should exist" + ); + + let err = create_branch( + repo.path.to_string_lossy().to_string(), + "feature/new-panel".to_string(), + ) + .unwrap_err(); + assert!(err.contains("existiert bereits")); + } + #[test] fn restore_to_commit_resets_branch_to_selected_commit() { let repo = init_temp_repo("restore_to_commit"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 996a15e..dccaac0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,11 +4,11 @@ mod git; use git::{ cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head, - compare_file_to_parent, cred_delete, cred_load, cred_save, diff_file_against_working_tree, - get_remote_url, get_status, list_branches, list_commits, list_file_history, - list_repository_files, merge_branch, open_repository, pull, push, read_conflict, - resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, - restore_to_commit, search_code_introductions, stage_files, unstage_files, + compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, + diff_file_against_working_tree, get_remote_url, get_status, list_branches, list_commits, + list_file_history, list_repository_files, merge_branch, open_repository, pull, push, + read_conflict, resolve_conflict, resolve_conflict_side, restore_file_from_commit, + restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files, SearchCancellationState, }; @@ -22,6 +22,7 @@ fn main() { get_status, list_branches, checkout_branch, + create_branch, stage_files, unstage_files, restore_files, diff --git a/src/App.svelte b/src/App.svelte index a5aba5a..4df224f 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -23,6 +23,7 @@ commit, compareCommits, cancelCodeSearch, + createBranch, diffFileAgainstWorkingTree, compareFileToParent, getStatus, @@ -413,6 +414,18 @@ }); } + async function createNewBranch(branchName: string) { + const name = branchName.trim(); + if (!activeRepoPath || !name) return; + await runOperation(`Creating ${name}`, async () => { + applyStatus(await createBranch(activeRepoPath, name)); + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + async function merge(branch: GitBranchInfo) { if (!activeRepoPath || branch.current) return; await runOperation(`Merging ${branch.name}`, async () => { @@ -719,6 +732,17 @@ expandedExplorerPaths = new Set(); } + function explorerParentFolders(path: string): string[] { + const parts = normalizeExplorerPath(path).split("/").filter(Boolean); + const folders: string[] = []; + let current = ""; + for (let index = 0; index < parts.length - 1; index++) { + current = current ? `${current}/${parts[index]}` : parts[index]; + folders.push(current); + } + return folders; + } + async function selectExplorerNode(node: ExplorerNode) { if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return; selectedExplorerPath = node.path; @@ -728,6 +752,17 @@ }); } + async function selectFileFromSearch(file: GitRepositoryFile) { + if (!activeRepoPath) return; + selectedExplorerPath = file.path; + selectedExplorerKind = "file"; + expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]); + + await runOperation(`Loading ${file.path} history`, async () => { + await refreshFileHistory(activeRepoPath, file.path); + }); + } + async function restoreSelectedFileFromCommit(target: GitCommit) { if (!activeRepoPath || !selectedExplorerPath) return; const kind = selectedExplorerKind === "folder" ? "folder" : "file"; @@ -1009,6 +1044,7 @@ {isBusy} onCheckout={checkout} onMerge={merge} + onCreateBranch={createNewBranch} /> {/if} diff --git a/src/app.css b/src/app.css index 27ae071..6175575 100644 --- a/src/app.css +++ b/src/app.css @@ -715,6 +715,88 @@ /* --- Branch list --- */ + .branch-head-actions { + display: flex; + align-items: center; + gap: 6px; + } + .branch-create-toggle { + width: 26px; + min-width: 26px; + min-height: 26px; + padding: 0; + border-color: rgba(65,209,255,0.2); + border-radius: 7px; + color: var(--color-ink-dim); + background: rgba(65,209,255,0.06); + } + .branch-create-toggle:hover:not(:disabled) { + border-color: rgba(65,209,255,0.45); + color: #ffffff; + background: rgba(65,209,255,0.13); + } + + .branch-list { gap: 8px; } + + .branch-create-form { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 7px; + margin-bottom: 8px; + padding: 7px 8px; + border: 1px solid rgba(65,209,255,0.22); + border-radius: 8px; + background: linear-gradient(90deg, rgba(65,209,255,0.08), rgba(100,108,255,0.07)); + } + .branch-create-form svg { color: var(--color-accent); } + .branch-create-form input { + height: 30px; + min-width: 0; + border-radius: 7px; + font-family: var(--font-mono); + font-size: 12px; + } + .branch-create-action { + width: 28px; + min-width: 28px; + min-height: 28px; + padding: 0; + border-radius: 7px; + } + .branch-create-action.confirm { + border-color: rgba(78,202,118,0.34); + color: #6ee090; + background: rgba(78,202,118,0.11); + } + + .branch-group { display: grid; gap: 4px; min-width: 0; } + .branch-group + .branch-group { margin-top: 8px; } + + .branch-group-toggle { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + justify-content: stretch; + width: 100%; + min-height: 28px; + padding: 4px 6px; + border-color: transparent; + border-radius: 7px; + background: rgba(255,255,255,0.02); + color: var(--color-ink-faint); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.07em; + text-align: left; + text-transform: uppercase; + } + .branch-group-toggle:hover:not(:disabled) { + border-color: var(--color-border-subtle); + background: rgba(255,255,255,0.05); + color: var(--color-ink-muted); + } + .branch-group-toggle svg { color: var(--color-ink-faint); } + .branch-group-label { display: flex; align-items: center; @@ -738,6 +820,12 @@ font-size: 10px; } + .branch-empty { + padding: 8px 10px; + color: var(--color-ink-faint); + font-size: 12px; + } + .branch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -1175,11 +1263,38 @@ .global-search-body { display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); min-width: 0; min-height: 0; } + .global-search-tabs { + display: flex; + align-items: center; + gap: 6px; + padding: 9px 12px; + border-bottom: 1px solid var(--color-border-subtle); + background: rgba(7, 8, 16, 0.34); + } + .global-search-tab { + min-height: 30px; + padding: 0 12px; + border-color: transparent; + color: var(--color-ink-dim); + background: transparent; + font-size: 12px; + font-weight: 800; + } + .global-search-tab:hover:not(:disabled) { + border-color: rgba(65,209,255,0.22); + background: rgba(65,209,255,0.07); + } + .global-search-tab.active { + border-color: rgba(65,209,255,0.36); + color: #ffffff; + background: linear-gradient(135deg, rgba(100,108,255,0.26), rgba(65,209,255,0.1)); + } + .global-search-form { display: grid; gap: 10px; @@ -1206,6 +1321,11 @@ white-space: pre; overflow: auto; } + .global-search-query input { + height: 40px; + font-family: var(--font-mono); + font-size: 13px; + } .global-search-options { display: grid; @@ -1342,6 +1462,185 @@ white-space: pre; } + .file-search-form { grid-template-columns: minmax(0, 1fr); } + .file-search-results { + padding: 0; + overflow: hidden; + } + .file-search-split { + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(320px, 0.75fr); + height: 100%; + min-width: 0; + min-height: 0; + } + .file-search-column { + min-width: 0; + min-height: 0; + overflow: auto; + padding: 10px; + border-right: 1px solid var(--color-border-subtle); + } + .file-search-list { display: grid; gap: 7px; } + .file-search-hit { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + width: 100%; + min-height: 48px; + padding: 8px 10px; + border: 1px solid var(--color-border-subtle); + border-radius: 8px; + background: var(--color-surface-raised); + text-align: left; + } + .file-search-hit:hover:not(:disabled) { + border-color: rgba(65,209,255,0.3); + background: var(--color-surface-hover); + } + .file-search-hit.active { + border-color: rgba(90,140,248,0.42); + background: rgba(90,140,248,0.11); + } + .file-search-icon { + display: grid; + place-items: center; + width: 22px; + height: 22px; + color: var(--color-accent); + } + .file-search-icon .language-icon { + display: block; + width: 17px; + height: 17px; + fill: currentColor; + } + .file-search-icon .language-icon path { fill: currentColor; } + .file-search-main { + display: grid; + gap: 2px; + min-width: 0; + } + .file-search-main strong { + min-width: 0; + overflow: hidden; + color: var(--color-ink); + font-family: var(--font-mono); + font-size: 12.5px; + text-overflow: ellipsis; + white-space: nowrap; + } + .file-search-main span { + min-width: 0; + overflow: hidden; + color: var(--color-ink-faint); + font-family: var(--font-mono); + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; + } + .file-search-action { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--color-accent); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + } + .file-search-history { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-width: 0; + min-height: 0; + overflow: hidden; + background: rgba(7, 8, 16, 0.18); + } + .file-search-history-head { + display: grid; + gap: 2px; + min-width: 0; + padding: 10px 12px; + border-bottom: 1px solid var(--color-border-subtle); + background: rgba(0,0,0,0.12); + } + .file-search-history-head span { + color: var(--color-ink-faint); + font-size: 10.5px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .file-search-history-head strong { + min-width: 0; + overflow: hidden; + color: var(--color-ink); + font-family: var(--font-mono); + font-size: 12.5px; + text-overflow: ellipsis; + white-space: nowrap; + } + .file-search-history-list { + display: grid; + align-content: start; + gap: 8px; + min-width: 0; + min-height: 0; + overflow: auto; + padding: 10px; + } + .file-search-history-row { + display: grid; + gap: 8px; + min-width: 0; + padding: 9px; + border: 1px solid var(--color-border-subtle); + border-radius: 8px; + background: var(--color-surface-raised); + } + .file-search-history-main { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 5px 8px; + min-width: 0; + } + .file-search-history-main .hash { + padding: 2px 7px; + border: 1px solid rgba(90,140,248,0.22); + border-radius: 6px; + color: var(--color-accent); + background: rgba(90,140,248,0.13); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 800; + } + .file-search-history-main strong { + min-width: 0; + overflow: hidden; + color: var(--color-ink); + font-size: 12.5px; + text-overflow: ellipsis; + white-space: nowrap; + } + .file-search-history-main > span:last-child { + grid-column: 1 / -1; + overflow: hidden; + color: var(--color-ink-faint); + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; + } + .file-search-history-actions { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; + } + /* --- Credential dialog --- */ .cred-card { @@ -1929,6 +2228,11 @@ .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); } .compare-dialog .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); } .global-search-options { grid-template-columns: 1fr; } + .file-search-split { grid-template-columns: 1fr; grid-template-rows: minmax(150px, 0.9fr) minmax(220px, 1fr); } + .file-search-column { border-right: none; border-bottom: 1px solid var(--color-border-subtle); } + .file-search-hit { grid-template-columns: auto minmax(0, 1fr); align-items: start; } + .file-search-hit .status-badge, + .file-search-action { grid-column: 2; justify-self: start; } .dialog-files { border-right: none; border-bottom: 1px solid var(--color-border-subtle); } .branch-actions { flex-direction: row; justify-content: flex-start; } .tb-action-label { display: none; } diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index 616a69f..9bd6849 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -1,5 +1,5 @@
@@ -29,7 +58,19 @@ Branches

Refs

- {branches.length} +
+ + {branches.length} +
{#if !hasRepository} @@ -37,71 +78,123 @@ {:else if branches.length === 0}

No branches returned.

{:else} -
- {#if localBranches.length > 0} -
- Local - {localBranches.length} -
- {#each localBranches as branch (branch.name)} - {#snippet branchCard()} -
-
-
- {#if branch.current} - Current - {:else} -
- - -
- {/if} -
- {/snippet} - {@render branchCard()} - {/each} +
+ {#if createOpen} +
+
{/if}
diff --git a/src/lib/components/GlobalSearchDialog.svelte b/src/lib/components/GlobalSearchDialog.svelte index a7e69e4..b88e42d 100644 --- a/src/lib/components/GlobalSearchDialog.svelte +++ b/src/lib/components/GlobalSearchDialog.svelte @@ -1,6 +1,21 @@
{ if (e.target === e.currentTarget) onClose(); }} > -
diff --git a/src/lib/git.ts b/src/lib/git.ts index e334664..d082125 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -27,6 +27,10 @@ export function checkoutBranch(path: string, branch: string): Promise return invoke("checkout_branch", { path, branch }); } +export function createBranch(path: string, branch: string): Promise { + return invoke("create_branch", { path, branch }); +} + export function stageFiles(path: string, files: string[]): Promise { return invoke("stage_files", { path, files }); } -- 2.54.0 From 628e2f7c0bce9c344c9427f33eb1ba3f615aab2d Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Wed, 1 Jul 2026 10:40:22 +0200 Subject: [PATCH 2/3] Add repository opening loading overlay Introduce a full-screen, animated overlay to provide visual feedback when the application is opening a repository. This prevents perceived delays and informs the user about the ongoing operation. Also, enhance build performance and binary size by configuring Cargo profiles: - Development: Enable incremental compilation, use line-tables-only debug info, and optimize third-party dependencies to speed up rebuilds and improve runtime snappiness during development. - Release: Apply aggressive optimizations (opt-level 3, thin LTO, single codegen unit, stripping) for smaller, faster binaries. --- src-tauri/Cargo.toml | 21 +++ src/App.svelte | 8 + src/lib/components/RepoLoadingOverlay.svelte | 175 +++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 src/lib/components/RepoLoadingOverlay.svelte diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 52fbb2a..486da8d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,3 +18,24 @@ tauri-build = { version = "2", features = [] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" + +# ── Build profiles ─────────────────────────────────────────────────────────── +# Dev: keep incremental compilation and emit only line-table debug info instead +# of full debuginfo. This cuts link time noticeably (linking is the slow part of +# a `tauri dev` recompile) while still giving panic backtraces with line numbers. +[profile.dev] +incremental = true +debug = "line-tables-only" + +# Optimize third-party dependencies once (they are cached), so the running dev +# build is snappy without slowing down rebuilds of our own crate. +[profile.dev.package."*"] +opt-level = 2 + +# Release: smaller binary (also shrinks the auto-updater download) without +# regressing runtime performance of the code-search feature. +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = true diff --git a/src/App.svelte b/src/App.svelte index 4df224f..06f28b5 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -14,6 +14,7 @@ import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte"; import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte"; import HistoryPanel from "./lib/components/HistoryPanel.svelte"; + import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte"; @@ -133,6 +134,8 @@ $: isBusy = operation.length > 0; $: hasRepository = activeRepoPath.length > 0 && status !== null; + $: openingRepo = operation === "Opening repository"; + $: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? ""; $: changedFiles = status?.files ?? []; $: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0; $: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0; @@ -1207,6 +1210,11 @@ /> {/if} + +{#if openingRepo} + +{/if} + {#if resolveDialogOpen} + export let label = "Repository wird geöffnet"; + export let repoName = ""; + + +
+
+ + + +
+ {label}… + {#if repoName} + {repoName} + {/if} +
+ +
+
+
+ + -- 2.54.0 From fe392d38bf43b50b900ae97c1877d86c87de24eb Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Wed, 1 Jul 2026 11:39:22 +0200 Subject: [PATCH 3/3] Optimize repository loading and enhance Git UI Consolidate multiple Git backend calls into a single bundle to reduce overhead when opening and auto-refreshing repositories. This significantly improves performance by fetching status, branches, commits, and files in one optimized operation, instead of re-running 'git rev-parse' and 'git status' multiple times. Also, streamline commit history loading by fetching file changes inline via 'git log --name-status -z', eliminating expensive per-commit 'git diff-tree' processes. Additionally, introduce the ability to create new branches from a specific commit in the history and refactor the commit comparison feature into a dedicated dialog. The status panel now displays concise file names. --- .claude/settings.local.json | 7 +- src-tauri/src/git.rs | 151 +++++++++++++++++- src-tauri/src/main.rs | 4 +- src/App.svelte | 128 ++++++++++----- src/app.css | 55 ++++++- src/lib/TitleBar.svelte | 14 +- src/lib/components/CompareSelectDialog.svelte | 108 +++++++++++++ src/lib/components/HistoryPanel.svelte | 8 +- src/lib/components/NewBranchDialog.svelte | 79 +++++++++ src/lib/components/StatusPanel.svelte | 10 +- src/lib/git.ts | 13 +- src/lib/types.ts | 7 + 12 files changed, 528 insertions(+), 56 deletions(-) create mode 100644 src/lib/components/CompareSelectDialog.svelte create mode 100644 src/lib/components/NewBranchDialog.svelte diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 22f46cf..a499b36 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,12 @@ "Bash(npx vite *)", "Bash(cargo tree *)", "Bash(jobs)", - "Bash(npx svelte-check *)" + "Bash(npx svelte-check *)", + "Bash(git log *)", + "Bash(xxd)", + "Bash(python3 -)", + "Bash(echo \"exit: $?\")", + "Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)" ] } } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 3772d9e..cdd23e6 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -208,6 +208,41 @@ pub fn open_repository(path: String) -> Result { status_for_repo(&repo) } +#[derive(Debug, Clone, Serialize)] +pub struct RepositoryBundle { + pub status: GitStatus, + pub branches: Vec, + pub commits: Vec, + pub files: Vec, +} + +/// Opens a repository and gathers everything the UI needs in a single call. +/// +/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves +/// the repo and its status only once, instead of the previous four separate +/// commands that each re-ran `git rev-parse` and `git status`. +#[tauri::command] +pub async fn open_repository_bundle( + path: String, + commit_limit: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + let status = status_for_repo(&repo)?; + let branches = branches_for_repo(&repo)?; + let commits = commits_for_repo(&repo, commit_limit)?; + let files = repository_files_with_status(&repo, &status)?; + Ok(RepositoryBundle { + status, + branches, + commits, + files, + }) + }) + .await + .map_err(|err| format!("Repository konnte nicht geladen werden: {err}"))? +} + #[tauri::command] pub fn get_status(path: String) -> Result { let repo = resolve_repo(&path)?; @@ -217,8 +252,12 @@ pub fn get_status(path: String) -> Result { #[tauri::command] pub fn list_branches(path: String) -> Result, String> { let repo = resolve_repo(&path)?; + branches_for_repo(&repo) +} + +fn branches_for_repo(repo: &Path) -> Result, String> { let output = run_git( - &repo, + repo, [ "for-each-ref", "--format=%(refname)\t%(HEAD)", @@ -282,10 +321,23 @@ pub fn checkout_branch(path: String, branch: String) -> Result Result { +pub fn create_branch( + path: String, + branch: String, + start_point: Option, +) -> Result { let repo = resolve_repo(&path)?; let branch = validate_new_branch_name(&repo, &branch)?; - run_git(&repo, ["checkout", "-b", branch.as_str()])?; + match start_point { + Some(start) if !start.trim().is_empty() => { + // Resolve the requested commit first so we fail clearly if it is gone. + let start = verify_commit(&repo, &start)?; + run_git(&repo, ["checkout", "-b", branch.as_str(), start.as_str()])?; + } + _ => { + run_git(&repo, ["checkout", "-b", branch.as_str()])?; + } + } status_for_repo(&repo) } @@ -586,23 +638,34 @@ pub fn merge_branch(path: String, branch: String) -> Result { #[tauri::command] pub fn list_commits(path: String, limit: Option) -> Result, String> { let repo = resolve_repo(&path)?; - if verify_commit(&repo, "HEAD").is_err() { + commits_for_repo(&repo, limit) +} + +fn commits_for_repo(repo: &Path, limit: Option) -> Result, String> { + if verify_commit(repo, "HEAD").is_err() { return Ok(Vec::new()); } let limit = limit.unwrap_or(100).clamp(1, 500).to_string(); + // Fetch the per-commit changed files inline via `--name-status` in a single + // `git log` process, instead of spawning one `git diff-tree` per commit + // (which was ~100 extra processes and the main cost of opening a repo). let output = run_git( - &repo, + repo, [ "log", "--decorate=short", - "--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1e", + "--name-status", + "-M", + "-z", + "--root", + "--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f", "-n", limit.as_str(), ], )?; - parse_commit_log(&repo, &output) + parse_commit_log_inline(&output) } #[tauri::command] @@ -1242,6 +1305,13 @@ fn status_for_repo(repo: &Path) -> Result { fn repository_files(repo: &Path) -> Result, String> { let status = status_for_repo(repo)?; + repository_files_with_status(repo, &status) +} + +fn repository_files_with_status( + repo: &Path, + status: &GitStatus, +) -> Result, String> { let mut files = BTreeMap::::new(); let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?; @@ -1580,6 +1650,71 @@ fn commit_search_metadata(repo: &Path, commit: &str) -> Result Result, String> { + const FIELD_SEPARATOR: u8 = 0x1f; + const RECORD_SEPARATOR: u8 = 0x1e; + + let mut commits = Vec::new(); + + for record in output.split(|byte| *byte == RECORD_SEPARATOR) { + // Skip the empty leading chunk and any stray separators left by `-z`. + if record + .iter() + .all(|&byte| matches!(byte, 0 | b'\n' | b'\r' | b' ' | b'\t')) + { + continue; + } + + let parts: Vec<&[u8]> = record.splitn(9, |byte| *byte == FIELD_SEPARATOR).collect(); + if parts.len() < 8 { + return Err(format!( + "Unerwarteter Git-Log-Eintrag: {}", + String::from_utf8_lossy(record) + )); + } + + // Field 8 (if present) holds the name-status list, preceded by the newline + // git inserts between the pretty-format output and the diff. + let mut files_bytes: &[u8] = parts.get(8).copied().unwrap_or(&[]); + while let Some((&first, rest)) = files_bytes.split_first() { + if matches!(first, b'\n' | b'\r') { + files_bytes = rest; + } else { + break; + } + } + + let refs = String::from_utf8_lossy(parts[5]) + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToString::to_string) + .collect(); + let parents = String::from_utf8_lossy(parts[6]) + .split_whitespace() + .map(ToString::to_string) + .collect(); + + commits.push(GitCommit { + hash: String::from_utf8_lossy(parts[0]).trim().to_string(), + short_hash: String::from_utf8_lossy(parts[1]).trim().to_string(), + author_name: String::from_utf8_lossy(parts[2]).to_string(), + author_email: String::from_utf8_lossy(parts[3]).to_string(), + date: String::from_utf8_lossy(parts[4]).trim().to_string(), + refs, + parents, + summary: String::from_utf8_lossy(parts[7]).to_string(), + files: parse_commit_files(files_bytes)?, + }); + } + + Ok(commits) +} + fn parse_commit_log(repo: &Path, output: &[u8]) -> Result, String> { const FIELD_SEPARATOR: char = '\x1f'; const RECORD_SEPARATOR: char = '\x1e'; @@ -3234,6 +3369,7 @@ mod tests { let status = create_branch( repo.path.to_string_lossy().to_string(), "feature/new-panel".to_string(), + None, ) .unwrap(); @@ -3246,6 +3382,7 @@ mod tests { let err = create_branch( repo.path.to_string_lossy().to_string(), "feature/new-panel".to_string(), + None, ) .unwrap_err(); assert!(err.contains("existiert bereits")); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index dccaac0..a0ce927 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,7 +6,8 @@ use git::{ cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, diff_file_against_working_tree, get_remote_url, get_status, list_branches, list_commits, - list_file_history, list_repository_files, merge_branch, open_repository, pull, push, + list_file_history, list_repository_files, merge_branch, open_repository, + open_repository_bundle, pull, push, read_conflict, resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files, SearchCancellationState, @@ -34,6 +35,7 @@ fn main() { restore_file_from_commit, merge_branch, list_repository_files, + open_repository_bundle, list_file_history, compare_commits, compare_file_to_head, diff --git a/src/App.svelte b/src/App.svelte index 06f28b5..4248692 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,5 +1,5 @@ @@ -972,6 +1009,7 @@ onPush={pushRepo} onRefresh={refreshRepo} onSearch={() => { globalSearchOpen = true; }} + onCompare={openCompareSelect} onToggleAutoRefresh={toggleAutoRefresh} /> @@ -1063,7 +1101,7 @@ /> - +
@@ -1106,21 +1144,6 @@ onCommitMessageChange={(msg) => { commitMessage = msg; }} />
- - { compareFrom = val; }} - onCompareToChange={(val) => { compareTo = val; }} - onCompare={compareSelectedCommits} - onOpenDialog={openCompareDialog} - />
@@ -1132,6 +1155,7 @@ {expandedCommitHashes} onRestoreCommit={restoreCommit} onPreviewCommitFile={previewCommitFileFromHistory} + onCreateBranchFromCommit={openNewBranchDialog} onToggleCommitFiles={(hash) => { const next = new Set(expandedCommitHashes); if (next.has(hash)) next.delete(hash); else next.add(hash); @@ -1185,6 +1209,32 @@ /> {/if} + +{#if newBranchCommit} + { newBranchCommit = null; }} + /> +{/if} + + +{#if compareSelectOpen} + { compareFrom = val; }} + onCompareToChange={(val) => { compareTo = val; }} + onCompare={compareSelectedCommits} + onClose={() => { compareSelectOpen = false; }} + /> +{/if} + {#if compareDialogOpen && comparison} div:first-child { min-width: 0; } @@ -2182,7 +2232,6 @@ @media (min-width: 1800px) { .workspace { grid-template-columns: 320px minmax(0, 1fr) 680px; } - .top-section { grid-template-columns: minmax(0, 1fr) 380px; } } @media (max-width: 1400px) { diff --git a/src/lib/TitleBar.svelte b/src/lib/TitleBar.svelte index a8ec072..f1b6a4a 100644 --- a/src/lib/TitleBar.svelte +++ b/src/lib/TitleBar.svelte @@ -1,7 +1,7 @@ + + diff --git a/src/lib/components/HistoryPanel.svelte b/src/lib/components/HistoryPanel.svelte index 1b1cc7f..2edc4b0 100644 --- a/src/lib/components/HistoryPanel.svelte +++ b/src/lib/components/HistoryPanel.svelte @@ -1,5 +1,5 @@ + + diff --git a/src/lib/components/StatusPanel.svelte b/src/lib/components/StatusPanel.svelte index ba498bd..c1f90db 100644 --- a/src/lib/components/StatusPanel.svelte +++ b/src/lib/components/StatusPanel.svelte @@ -38,6 +38,14 @@ return file.old_path ? `${file.old_path} -> ${file.path}` : file.path; } + function baseName(path: string): string { + return path.split(/[\\/]/).filter(Boolean).pop() ?? path; + } + + function fileName(file: GitFileStatus): string { + return file.old_path ? `${baseName(file.old_path)} -> ${baseName(file.path)}` : baseName(file.path); + } + let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null)); let hasStaged = $derived(changedFiles.some((f) => f.staged !== null)); @@ -90,7 +98,7 @@ {#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
- {displayPath(file)} + {fileName(file)}
diff --git a/src/lib/git.ts b/src/lib/git.ts index d082125..f091a45 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -8,6 +8,7 @@ import type { GitRepositoryFile, GitSearchHit, GitStatus, + RepositoryBundle, StoredCredential, } from "./types"; @@ -15,6 +16,10 @@ export function openRepository(path: string): Promise { return invoke("open_repository", { path }); } +export function openRepositoryBundle(path: string, commitLimit = 100): Promise { + return invoke("open_repository_bundle", { path, commitLimit }); +} + export function getStatus(path: string): Promise { return invoke("get_status", { path }); } @@ -27,8 +32,12 @@ export function checkoutBranch(path: string, branch: string): Promise return invoke("checkout_branch", { path, branch }); } -export function createBranch(path: string, branch: string): Promise { - return invoke("create_branch", { path, branch }); +export function createBranch( + path: string, + branch: string, + startPoint?: string, +): Promise { + return invoke("create_branch", { path, branch, startPoint: startPoint ?? null }); } export function stageFiles(path: string, files: string[]): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 92831e8..d58ab36 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -54,6 +54,13 @@ export interface GitRepositoryFile { status: FileStatusKind | null; } +export interface RepositoryBundle { + status: GitStatus; + branches: GitBranch[]; + commits: GitCommit[]; + files: GitRepositoryFile[]; +} + export interface GitDiffFile { path: string; old_path: string | null; -- 2.54.0