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 }); }