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;