diff --git a/.gitignore b/.gitignore index 5d15844..035e28c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ *.log .idea .DS_Store -~ \ No newline at end of file +~ +.codex* \ No newline at end of file diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index c062ebe..87538a1 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -208,6 +208,12 @@ pub fn open_repository(path: String) -> Result { status_for_repo(&repo) } +#[tauri::command] +pub fn open_repo_in_explorer(path: String) -> Result<(), String> { + let repo = resolve_repo(&path)?; + open_path_in_file_manager(&repo) +} + #[derive(Debug, Clone, Serialize)] pub struct RepositoryBundle { pub status: GitStatus, @@ -341,6 +347,42 @@ pub fn create_branch( status_for_repo(&repo) } +#[tauri::command] +pub fn rename_branch( + path: String, + old_branch: String, + new_branch: String, +) -> Result { + let repo = resolve_repo(&path)?; + let old_branch = validate_existing_local_branch_name(&repo, &old_branch)?; + let new_branch = validate_new_branch_name(&repo, &new_branch)?; + + run_git( + &repo, + [ + "branch", + "-m", + "--", + old_branch.as_str(), + new_branch.as_str(), + ], + )?; + status_for_repo(&repo) +} + +#[tauri::command] +pub fn delete_branch(path: String, branch: String) -> Result { + let repo = resolve_repo(&path)?; + let branch = validate_existing_local_branch_name(&repo, &branch)?; + let status = status_for_repo(&repo)?; + if status.current_branch.as_deref() == Some(branch.as_str()) { + return Err("Der aktuelle Branch kann nicht geloescht werden.".to_string()); + } + + run_git(&repo, ["branch", "-d", "--", branch.as_str()])?; + status_for_repo(&repo) +} + #[tauri::command] pub fn stage_files(path: String, files: Vec) -> Result { let repo = resolve_repo(&path)?; @@ -1314,6 +1356,36 @@ fn resolve_repo(path: &str) -> Result { Ok(PathBuf::from(top_level)) } +#[cfg(windows)] +fn open_path_in_file_manager(path: &Path) -> Result<(), String> { + let native_path = path.to_string_lossy().replace('/', "\\"); + let mut command = Command::new("explorer.exe"); + command.arg(native_path); + command.creation_flags(CREATE_NO_WINDOW); + command + .spawn() + .map_err(|err| format!("Explorer konnte nicht gestartet werden: {err}"))?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn open_path_in_file_manager(path: &Path) -> Result<(), String> { + Command::new("open") + .arg(path) + .spawn() + .map_err(|err| format!("Finder konnte nicht gestartet werden: {err}"))?; + Ok(()) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn open_path_in_file_manager(path: &Path) -> Result<(), String> { + Command::new("xdg-open") + .arg(path) + .spawn() + .map_err(|err| format!("Dateimanager konnte nicht gestartet werden: {err}"))?; + Ok(()) +} + fn verify_commit(repo: &Path, commit: &str) -> Result { let commit = commit.trim(); if commit.is_empty() { @@ -2067,6 +2139,41 @@ fn validate_new_branch_name(repo: &Path, branch: &str) -> Result Ok(normalized) } +fn validate_existing_local_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 normalized = validate_branch_ref_name(branch)?; + if !ref_exists(repo, &format!("refs/heads/{normalized}"))? { + return Err(format!( + "Lokaler Branch '{normalized}' wurde nicht gefunden." + )); + } + + Ok(normalized) +} + +fn validate_branch_ref_name(branch: &str) -> Result { + let output = git_command() + .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(); + Ok(if normalized.is_empty() { + branch.to_string() + } else { + normalized + }) +} + fn ref_exists(repo: &Path, ref_name: &str) -> Result { let output = git_command() .arg("-C") @@ -3480,6 +3587,51 @@ mod tests { assert!(err.contains("existiert bereits")); } + #[test] + fn rename_branch_renames_existing_local_branch() { + let repo = init_temp_repo("rename_branch"); + commit_initial_file(&repo.path); + let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); + run_git_test(&repo.path, ["branch", "feature/old-panel"]); + + let status = rename_branch( + repo.path.to_string_lossy().to_string(), + "feature/old-panel".to_string(), + "feature/new-panel".to_string(), + ) + .unwrap(); + + assert_eq!(status.current_branch.as_deref(), Some(current.as_str())); + assert!( + !ref_exists(&repo.path, "refs/heads/feature/old-panel").unwrap(), + "old branch should be gone" + ); + assert!( + ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(), + "new branch should exist" + ); + } + + #[test] + fn delete_branch_removes_local_branch_but_rejects_current_branch() { + let repo = init_temp_repo("delete_branch"); + commit_initial_file(&repo.path); + let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); + run_git_test(&repo.path, ["branch", "stale"]); + + let status = + delete_branch(repo.path.to_string_lossy().to_string(), "stale".to_string()).unwrap(); + + assert_eq!(status.current_branch.as_deref(), Some(current.as_str())); + assert!( + !ref_exists(&repo.path, "refs/heads/stale").unwrap(), + "deleted branch should be gone" + ); + + let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err(); + assert!(err.contains("aktuelle Branch")); + } + #[test] fn apply_file_patch_stages_and_discards_selected_changes() { let repo = init_temp_repo("apply_file_patch"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4d09990..7db963e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,11 +5,12 @@ mod git; use git::{ apply_file_patch, 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_file_patch, get_remote_url, get_status, list_branches, - list_commits, 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, + delete_branch, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, + list_branches, list_commits, list_file_history, list_repository_files, merge_branch, + open_repo_in_explorer, open_repository, open_repository_bundle, pull, push, read_conflict, + rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit, + restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files, + SearchCancellationState, }; fn main() { @@ -19,10 +20,13 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ open_repository, + open_repo_in_explorer, get_status, list_branches, checkout_branch, create_branch, + rename_branch, + delete_branch, stage_files, unstage_files, restore_files, diff --git a/src/App.svelte b/src/App.svelte index 6f71d71..78d8827 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2,7 +2,7 @@ import { onDestroy, onMount, tick } from "svelte"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; - import { AlertCircle, Check, FolderOpen, GitBranch, GitMerge, LoaderCircle } from "@lucide/svelte"; + import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte"; import TitleBar from "./lib/TitleBar.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte"; @@ -16,6 +16,7 @@ import HistoryPanel from "./lib/components/HistoryPanel.svelte"; import LinePatchDialog from "./lib/components/LinePatchDialog.svelte"; import NewBranchDialog from "./lib/components/NewBranchDialog.svelte"; + import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte"; import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte"; @@ -28,6 +29,7 @@ cancelCodeSearch, applyFilePatch, createBranch, + deleteBranch, diffFileAgainstWorkingTree, compareFileToParent, getStatus, @@ -36,9 +38,11 @@ listFileHistory, listRepositoryFiles, mergeBranch, + openRepoInExplorer, openRepositoryBundle, pull, push, + renameBranch, getRemoteUrl, credLoad, credSave, @@ -81,11 +85,29 @@ } from "./lib/credentials"; type UpdateToastState = "available" | "downloading" | "installed" | "error"; + type AppView = "management" | "repository"; + + interface RepoTab { + path: string; + name: string; + branch: string | null; + ahead: number; + behind: number; + changed: number; + lastOpened: number; + } + + const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; + const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; // ── State ────────────────────────────────────────────────────────────────── let repoPath = ""; let activeRepoPath = ""; + let activeView: AppView = "management"; + let repoTabs: RepoTab[] = []; + let recentRepoPaths: string[] = []; + let repoSearch = ""; let status: GitStatus | null = null; let branches: GitBranchInfo[] = []; let commits: GitCommit[] = []; @@ -102,6 +124,7 @@ let compareTo = ""; let comparison: GitCommitComparison | null = null; let newBranchCommit: GitCommit | null = null; + let renameBranchTarget: GitBranchInfo | null = null; let compareSelectOpen = false; let compareDialogOpen = false; let selectedDiffPath = ""; @@ -147,6 +170,7 @@ $: isBusy = operation.length > 0; $: hasRepository = activeRepoPath.length > 0 && status !== null; + $: workspaceActive = activeView === "repository" && hasRepository; $: openingRepo = operation === "Opening repository"; $: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? ""; $: changedFiles = status?.files ?? []; @@ -161,10 +185,20 @@ $: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy; $: localBranches = branches.filter((b) => !b.remote); $: remoteBranches = branches.filter((b) => b.remote); + $: repoSearchTerm = repoSearch.trim().toLowerCase(); + $: openRepoRows = repoTabs.filter(repoMatchesSearch); + $: recentRepoRows = recentRepoPaths + .filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path))) + .map(repoRowFromPath) + .filter(repoMatchesSearch); + $: allRepoRows = uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths]) + .map(repoRowFromPath) + .filter(repoMatchesSearch); // ── Lifecycle ────────────────────────────────────────────────────────────── onMount(() => { + loadRepoLists(); autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL); void checkForUpdates(); }); @@ -180,7 +214,7 @@ } async function autoRefreshTick() { - if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return; + if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return; autoRefreshInFlight = true; try { // Cheap fast path: only fetch status; skip the heavy reload if nothing changed. @@ -277,11 +311,148 @@ // ── Utilities ────────────────────────────────────────────────────────────── + function repoNameFromPath(path: string): string { + return path.split(/[\\/]/).filter(Boolean).pop() ?? path; + } + + function repoKey(path: string): string { + return path.replace(/\\/g, "/").trim().toLowerCase(); + } + + function sameRepoPath(left: string, right: string): boolean { + return repoKey(left) === repoKey(right); + } + + function uniqueRepoPaths(paths: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const path of paths) { + const trimmed = path.trim(); + if (!trimmed) continue; + const key = repoKey(trimmed); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + return result; + } + + function repoRowFromPath(path: string): RepoTab { + return repoTabs.find((tab) => sameRepoPath(tab.path, path)) ?? { + path, + name: repoNameFromPath(path), + branch: null, + ahead: 0, + behind: 0, + changed: 0, + lastOpened: 0, + }; + } + + function repoMatchesSearch(repo: RepoTab): boolean { + if (!repoSearchTerm) return true; + return repo.name.toLowerCase().includes(repoSearchTerm) + || repo.path.toLowerCase().includes(repoSearchTerm) + || (repo.branch ?? "").toLowerCase().includes(repoSearchTerm); + } + + function loadRepoLists() { + try { + const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown; + const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown; + const openPaths = Array.isArray(openValue) + ? openValue.map((item) => typeof item === "string" ? item : "").filter(Boolean) + : []; + const recentPaths = Array.isArray(recentValue) + ? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean) + : []; + + repoTabs = uniqueRepoPaths(openPaths).map((path) => ({ + path, + name: repoNameFromPath(path), + branch: null, + ahead: 0, + behind: 0, + changed: 0, + lastOpened: 0, + })); + recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]); + } catch { + repoTabs = []; + recentRepoPaths = []; + } + } + + function persistRepoLists() { + try { + localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path))); + localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths)); + } catch { + // Local storage is best-effort only; the Git workflow must keep working without it. + } + } + + function rememberRecentRepo(path: string) { + recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40); + persistRepoLists(); + } + + function upsertRepoTab(path: string, nextStatus?: GitStatus | null) { + const existing = repoTabs.find((tab) => sameRepoPath(tab.path, path)); + const next: RepoTab = { + path, + name: repoNameFromPath(path), + branch: nextStatus?.current_branch ?? existing?.branch ?? null, + ahead: nextStatus?.ahead ?? existing?.ahead ?? 0, + behind: nextStatus?.behind ?? existing?.behind ?? 0, + changed: nextStatus?.files.length ?? existing?.changed ?? 0, + lastOpened: Date.now(), + }; + + repoTabs = existing + ? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab) + : [...repoTabs, next]; + rememberRecentRepo(path); + } + + function resetRepositoryState(clearActive = false) { + if (clearActive) { + activeRepoPath = ""; + repoPath = ""; + status = null; + lastStatusFingerprint = ""; + } + branches = []; + commits = []; + repoFiles = []; + selectedExplorerPath = ""; + selectedExplorerKind = "file"; + expandedExplorerPaths = new Set(); + expandedCommitHashes = new Set(); + fileHistory = []; + compareFrom = ""; + compareTo = ""; + comparison = null; + compareSelectOpen = false; + compareDialogOpen = false; + selectedDiffPath = ""; + pendingRestoreFile = null; + newBranchCommit = null; + globalSearchResults = []; + globalSearchOpen = false; + globalSearchError = ""; + resolveDialogOpen = false; + conflictTarget = ""; + conflict = null; + preparedResolutions = {}; + } + function applyStatus(nextStatus: GitStatus) { status = nextStatus; activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim(); repoPath = activeRepoPath; lastStatusFingerprint = statusFingerprint(nextStatus); + upsertRepoTab(activeRepoPath, nextStatus); } function errorToMessage(error: unknown): string { @@ -385,17 +556,10 @@ // Single backend round-trip: resolves the repo and reads status, branches, // commits and files in one pass instead of four sequential git calls. const bundle = await openRepositoryBundle(path, 100); + resetRepositoryState(false); applyStatus(bundle.status); - branches = []; commits = []; repoFiles = []; - selectedExplorerPath = ""; selectedExplorerKind = "file"; - expandedExplorerPaths = new Set(); expandedCommitHashes = new Set(); - fileHistory = []; compareFrom = ""; compareTo = ""; - comparison = null; compareSelectOpen = false; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null; - newBranchCommit = null; if (globalSearchBusy) void cancelGlobalSearch(); - globalSearchResults = []; globalSearchOpen = false; globalSearchError = ""; - resolveDialogOpen = false; conflictTarget = ""; conflict = null; - preparedResolutions = {}; + activeView = "repository"; await refreshBranchList(activeRepoPath, bundle.branches); await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshExplorerFiles(activeRepoPath, bundle.files); @@ -419,6 +583,55 @@ } } + function openRepoManagement() { + if (isBusy) return; + activeView = "management"; + } + + async function selectRepoTab(path: string) { + if (isBusy) return; + if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return; + await openRepo(path); + } + + async function closeRepoTab(path: string, event?: MouseEvent) { + event?.stopPropagation(); + if (isBusy) return; + + const index = repoTabs.findIndex((tab) => sameRepoPath(tab.path, path)); + const remaining = repoTabs.filter((tab) => !sameRepoPath(tab.path, path)); + const next = remaining[index] ?? remaining[index - 1] ?? null; + repoTabs = remaining; + persistRepoLists(); + + if (!sameRepoPath(activeRepoPath, path)) return; + if (next) { + await openRepo(next.path); + } else { + resetRepositoryState(true); + activeView = "management"; + } + } + + async function removeRepoFromManagement(path: string, event?: MouseEvent) { + event?.stopPropagation(); + if (isBusy) return; + recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path)); + persistRepoLists(); + if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) { + await closeRepoTab(path); + } + } + + async function openActiveRepoInExplorer() { + if (!activeRepoPath || isBusy) return; + try { + await openRepoInExplorer(activeRepoPath); + } catch (error) { + errorMessage = errorToMessage(error); + } + } + async function refreshRepo() { if (!activeRepoPath) { await openRepo(); return; } await runOperation("Refreshing", async () => { @@ -453,6 +666,45 @@ }); } + function renameLocalBranch(branch: GitBranchInfo) { + if (!activeRepoPath || branch.remote) return; + renameBranchTarget = branch; + } + + async function submitRenameBranch(branchName: string) { + const branch = renameBranchTarget; + const name = branchName.trim(); + if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return; + + await runOperation(`Renaming ${branch.name}`, async () => { + applyStatus(await renameBranch(activeRepoPath, branch.name, name)); + renameBranchTarget = null; + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + + async function deleteLocalBranch(branch: GitBranchInfo) { + if (!activeRepoPath || branch.remote) return; + if (branch.current) { + errorMessage = "The current branch cannot be deleted."; + return; + } + + const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`); + if (!confirmed) return; + + await runOperation(`Deleting ${branch.name}`, async () => { + applyStatus(await deleteBranch(activeRepoPath, branch.name)); + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + function openNewBranchDialog(commit: GitCommit) { if (!activeRepoPath || isBusy) return; newBranchCommit = commit; @@ -1059,21 +1311,24 @@ // ── Event handlers ───────────────────────────────────────────────────────── - function submitRepo(event: SubmitEvent) { event.preventDefault(); void openRepo(); } - function handleWindowKeydown(event: KeyboardEvent) { if (event.key === "Escape" && compareDialogOpen) closeCompareDialog(); else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; + else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); } + + function handleWindowContextMenu(event: MouseEvent) { + event.preventDefault(); + } GitLite - +
{ globalSearchOpen = true; }} onCompare={openCompareSelect} + onOpenInExplorer={openActiveRepoInExplorer} onToggleAutoRefresh={toggleAutoRefresh} />
- -
-
- - - - -
+
+ + +
+ {#each repoTabs as repo (repo.path)} +
+ + +
+ {/each} +
+ +
@@ -1145,7 +1422,7 @@ {/if} - {#if hasConflicts} + {#if workspaceActive && hasConflicts} {/if} - -
+ {#if activeView === "management"} +
+
+
+ Repository Management +

Repositories

+
+
+ +
+
+ +
+ +
+ +
+
+
+

Open repositories

+ {openRepoRows.length} +
+ {#if openRepoRows.length === 0} +
No open repositories.
+ {:else} +
+ {#each openRepoRows as repo (repo.path)} +
+ + +
+ {/each} +
+ {/if} +
+ +
+
+

Recent repositories

+ {recentRepoRows.length} +
+ {#if recentRepoRows.length === 0} +
No recent repositories.
+ {:else} +
+ {#each recentRepoRows as repo (repo.path)} +
+ + +
+ {/each} +
+ {/if} +
+ +
+
+

All repositories

+ {allRepoRows.length} +
+ {#if allRepoRows.length === 0} +
Browse for a repository to add it here.
+ {:else} +
+ {#each allRepoRows as repo (repo.path)} +
+ + +
+ {/each} +
+ {/if} +
+
+
+ {:else} + +
+ {/if}
@@ -1315,6 +1708,16 @@ /> {/if} + +{#if renameBranchTarget} + { renameBranchTarget = null; }} + /> +{/if} + {#if compareSelectOpen} header { + display: flex; + align-items: center; + gap: 8px; + min-height: 38px; + padding: 0 12px; + border-bottom: 1px solid var(--color-border-subtle); + background: rgba(255,255,255,0.045); + } + .repo-section h2 { + margin: 0; + color: var(--color-ink); + font-size: 13px; + font-weight: 800; + } + .repo-section > header span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + min-height: 20px; + border-radius: 999px; + background: rgba(94, 110, 156, 0.18); + color: var(--color-ink-dim); + font-size: 11px; + font-weight: 800; + } + + .repo-empty { + padding: 20px 24px; + color: var(--color-ink-faint); + font-size: 13px; + font-style: italic; + } + + .repo-table { + display: grid; + } + .repo-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; + min-height: 36px; + border-bottom: 1px solid rgba(255,255,255,0.035); + } + .repo-row:last-child { border-bottom: 0; } + .repo-row-main { + display: grid; + grid-template-columns: minmax(160px, 220px) minmax(220px, 1fr) minmax(160px, 360px); + justify-content: stretch; + min-height: 36px; + padding: 0 10px 0 28px; + border: 0; + border-radius: 0; + background: transparent; + text-align: left; + } + .repo-row-main:hover:not(:disabled) { + background: rgba(90, 140, 248, 0.08); + } + .repo-row-name, + .repo-row-path, + .repo-row-meta { + display: inline-flex; + align-items: center; + min-width: 0; + overflow: hidden; + color: var(--color-ink-muted); + font-size: 12px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; + } + .repo-row-path { + color: var(--color-ink-faint); + font-family: var(--font-mono); + font-size: 11.5px; + font-weight: 500; + } + .repo-row-meta { + gap: 5px; + justify-content: flex-start; + color: var(--color-ink-dim); + } + .repo-row-meta strong, + .repo-row-meta em { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 20px; + padding: 0 6px; + border-radius: 4px; + background: rgba(94, 110, 156, 0.18); + color: var(--color-ink-dim); + font-size: 10.5px; + font-style: normal; + font-weight: 800; + } + .repo-row-meta .ahead { color: #4eca76; } + .repo-row-meta .behind { color: #e0a040; } + .repo-row-icon { + min-height: 36px; + min-width: 36px; + padding: 0; + border: 0; + border-left: 1px solid rgba(255,255,255,0.035); + border-radius: 0; + background: transparent; + color: var(--color-ink-faint); + } + /* --- Notices --- */ .notice { @@ -721,6 +986,8 @@ align-items: center; gap: 6px; } + .branch-panel { position: relative; } + .branch-create-toggle { width: 26px; min-width: 26px; @@ -827,6 +1094,60 @@ font-size: 12px; } + .branch-folder-row { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + align-items: center; + justify-content: stretch; + gap: 7px; + width: 100%; + min-height: 34px; + padding: 5px 8px; + padding-left: calc(8px + var(--branch-indent, 0px)); + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--color-ink-dim); + text-align: left; + } + .branch-folder-row + .branch-folder-row, + .branch-folder-row + .branch-row, + .branch-row + .branch-folder-row { + margin-top: 3px; + } + .branch-folder-row:hover:not(:disabled) { + border-color: var(--color-border-subtle); + background: var(--color-surface-hover); + color: var(--color-ink); + } + .branch-folder-row.current { + border-color: rgba(78,202,118,0.2); + background: rgba(78,202,118,0.08); + } + .branch-folder-row svg { color: var(--color-ink-faint); } + .branch-folder-row.current svg { color: #4eca76; } + .branch-folder-name { + overflow: hidden; + color: var(--color-ink-muted); + font-size: 12.5px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; + } + .branch-folder-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + min-height: 18px; + padding: 0 6px; + border-radius: 999px; + color: var(--color-ink-dim); + background: rgba(94,110,156,0.14); + font-size: 10px; + font-weight: 800; + } + .branch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -834,6 +1155,7 @@ gap: 8px; min-height: 46px; padding: 7px 8px; + padding-left: calc(8px + var(--branch-indent, 0px)); border: 1px solid transparent; border-radius: 8px; transition: background 120ms, border-color 120ms; @@ -853,6 +1175,57 @@ .branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; } + .branch-context-menu { + position: absolute; + z-index: 120; + display: grid; + gap: 2px; + min-width: 184px; + padding: 5px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-surface); + box-shadow: 0 18px 50px rgba(0,0,0,0.35); + } + + .branch-context-menu button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + width: 100%; + min-height: 30px; + padding: 6px 8px; + border-color: transparent; + border-radius: 6px; + background: transparent; + color: var(--color-ink-muted); + font-size: 12px; + font-weight: 700; + text-align: left; + } + + .branch-context-menu button:hover:not(:disabled) { + border-color: var(--color-border-subtle); + background: rgba(255,255,255,0.06); + color: var(--color-ink); + } + + .branch-context-menu button.danger { + color: #ff9aa8; + } + + .branch-context-menu button.danger:hover:not(:disabled) { + border-color: rgba(255,92,117,0.34); + background: rgba(255,92,117,0.12); + color: #ffd0d6; + } + + .branch-context-menu button:disabled { + cursor: not-allowed; + opacity: 0.48; + } + /* --- Explorer --- */ .explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; } @@ -1113,12 +1486,25 @@ max-height: calc(100vh - 32px); overflow: auto; } + .rename-branch-dialog { + display: block; + width: min(520px, calc(100vw - 32px)); + height: auto; + max-height: calc(100vh - 32px); + overflow: auto; + } .new-branch-form { display: flex; flex-direction: column; gap: 14px; padding: 16px; } + .rename-branch-form { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; + } .new-branch-target { display: flex; align-items: center; @@ -2380,6 +2766,12 @@ .left-sidebar { grid-template-rows: minmax(200px, 1fr) minmax(240px, 1.2fr); min-height: 440px; } .top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 190px; } .repo-form { grid-template-columns: 1fr; } + .repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; } + .repo-tab.management { min-width: 0; } + .repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); } + .repo-row-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; } + .repo-row { min-height: 58px; } + .repo-row-icon { min-height: 58px; } .repo-path { display: none; } .repo-summary { height: 40px; } .change-lanes { grid-template-columns: 1fr; } diff --git a/src/lib/TitleBar.svelte b/src/lib/TitleBar.svelte index f1b6a4a..28df81b 100644 --- a/src/lib/TitleBar.svelte +++ b/src/lib/TitleBar.svelte @@ -1,7 +1,7 @@ -
+ + +
Branches @@ -127,34 +322,58 @@ {#if localBranches.length === 0}
No local branches.
{:else} - {#each localBranches as branch (branch.name)} -
checkoutOnDoubleClick(event, branch)} - title={branch.current ? "Current branch" : "Double-click to checkout"} - > -
-
+ {#if row.branch.current} + Current + {:else} +
+ + +
+ {/if} + + {/if} {/each} {/if} {/if} @@ -180,38 +399,87 @@ {#if remoteBranches.length === 0}
No remote branches.
{:else} - {#each remoteBranches as branch (branch.name)} -
checkoutOnDoubleClick(event, branch)} - title={branch.current ? "Current branch" : "Double-click to checkout"} - > -
-
+ {#if row.branch.current} + Current + {:else} +
+ + +
+ {/if} + + {/if} {/each} {/if} {/if}
{/if} + + {#if contextBranch} + + {/if}
diff --git a/src/lib/components/RenameBranchDialog.svelte b/src/lib/components/RenameBranchDialog.svelte new file mode 100644 index 0000000..51ab97a --- /dev/null +++ b/src/lib/components/RenameBranchDialog.svelte @@ -0,0 +1,77 @@ + + + diff --git a/src/lib/components/StatusPanel.svelte b/src/lib/components/StatusPanel.svelte index 34449c4..fa4396f 100644 --- a/src/lib/components/StatusPanel.svelte +++ b/src/lib/components/StatusPanel.svelte @@ -121,7 +121,7 @@