diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7d9e6ce..4520a74 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -79,7 +79,13 @@ "Bash(rustfmt --edition 2024 --check src/git.rs)", "Bash(rustfmt --edition 2024 --check src/badge.rs)", "Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)", - "Bash(pkg-config --list-all)" + "Bash(pkg-config --list-all)", + "Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata src/git.rs)", + "Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata /mnt/d/Development/GitLite/src-tauri/src/git.rs)", + "Bash(rustc --edition 2021 --crate-type bin -o /dev/null --emit=metadata src/main.rs)", + "Bash(grep -B1 \"^error\\\\[E0432\\\\]\\\\|^error$\")", + "Bash(grep -v \"^--$\")", + "Bash(grep \"^error$\" -A2)" ] } } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index a3a67c8..e0a376e 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -48,6 +48,7 @@ pub struct GitStatus { pub files: Vec, pub clean: bool, pub rebase_in_progress: bool, + pub cherry_pick_in_progress: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -57,6 +58,16 @@ pub struct GitBranch { pub remote: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitTag { + pub name: String, + pub hash: String, + pub short_hash: String, + pub message: Option, + pub date: String, + pub annotated: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitStash { pub selector: String, @@ -245,6 +256,7 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> { pub struct RepositoryBundle { pub status: GitStatus, pub branches: Vec, + pub tags: Vec, pub stashes: Vec, pub commits: Vec, pub files: Vec, @@ -287,12 +299,14 @@ pub async fn open_repository_bundle( let repo = resolve_repo(&path)?; let status = status_for_repo(&repo)?; let branches = branches_for_repo(&repo)?; + let tags = tags_for_repo(&repo)?; let stashes = stashes_for_repo(&repo)?; let commits = commits_for_repo(&repo, commit_limit)?; let files = repository_files_with_status(&repo, &status)?; Ok(RepositoryBundle { status, branches, + tags, stashes, commits, files, @@ -320,6 +334,63 @@ pub fn list_stashes(path: String) -> Result, String> { stashes_for_repo(&repo) } +#[tauri::command] +pub fn list_tags(path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + tags_for_repo(&repo) +} + +fn tags_for_repo(repo: &Path) -> Result, String> { + let output = run_git( + repo, + [ + "for-each-ref", + "--sort=-creatordate", + "refs/tags", + "--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(*objectname)%00%(*objectname:short)%00%(contents:subject)%00%(creatordate:iso-strict)", + ], + )?; + let text = String::from_utf8_lossy(&output); + let mut tags = Vec::new(); + + for line in text.lines() { + let mut parts = line.splitn(7, '\0'); + let name = parts.next().unwrap_or_default().trim(); + let object_hash = parts.next().unwrap_or_default().trim(); + let object_short = parts.next().unwrap_or_default().trim(); + let deref_hash = parts.next().unwrap_or_default().trim(); + let deref_short = parts.next().unwrap_or_default().trim(); + let subject = parts.next().unwrap_or_default().trim(); + let date = parts.next().unwrap_or_default().trim(); + + if name.is_empty() { + continue; + } + + // Annotated tags are their own object with a `taggerdate`/subject and + // dereference (`*...`) to the commit they point at; lightweight tags + // point straight at the commit, so the dereferenced fields are empty. + let annotated = !deref_hash.is_empty(); + let hash = if annotated { deref_hash } else { object_hash }; + let short_hash = if annotated { deref_short } else { object_short }; + + tags.push(GitTag { + name: name.to_string(), + hash: hash.to_string(), + short_hash: short_hash.to_string(), + message: if annotated && !subject.is_empty() { + Some(subject.to_string()) + } else { + None + }, + date: date.to_string(), + annotated, + }); + } + + Ok(tags) +} + fn branches_for_repo(repo: &Path) -> Result, String> { let output = run_git( repo, @@ -570,6 +641,123 @@ pub fn delete_branch( status_for_repo(&repo) } +#[tauri::command] +pub fn create_tag( + path: String, + name: String, + target: Option, + message: Option, +) -> Result, String> { + let repo = resolve_repo(&path)?; + let name = validate_new_tag_name(&repo, &name)?; + let target = match target { + Some(target) if !target.trim().is_empty() => verify_commit(&repo, &target)?, + _ => verify_commit(&repo, "HEAD")?, + }; + let message = message + .map(|message| message.trim().to_string()) + .filter(|message| !message.is_empty()); + + match message { + Some(message) => { + run_git( + &repo, + ["tag", "-a", name.as_str(), "-m", message.as_str(), target.as_str()], + )?; + } + None => { + run_git(&repo, ["tag", name.as_str(), target.as_str()])?; + } + } + + tags_for_repo(&repo) +} + +#[tauri::command] +pub fn delete_tag(path: String, name: String) -> Result, String> { + let repo = resolve_repo(&path)?; + let name = validate_existing_tag_name(&repo, &name)?; + run_git(&repo, ["tag", "-d", name.as_str()])?; + tags_for_repo(&repo) +} + +#[tauri::command] +pub fn push_tag( + path: String, + name: String, + username: Option, + password: Option, +) -> Result<(), String> { + let repo = resolve_repo(&path)?; + let name = validate_existing_tag_name(&repo, &name)?; + let remote = initial_push_remote_name(&repo)?; + let tag_ref = format!("refs/tags/{name}"); + let push_args = ["push", remote.as_str(), tag_ref.as_str()]; + + match (username.as_deref(), password.as_deref()) { + (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { + run_git_authenticated(&repo, push_args, u, p)?; + } + _ => { + run_git(&repo, push_args)?; + } + } + + Ok(()) +} + +fn validate_new_tag_name(repo: &Path, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("Tag name must not be empty.".to_string()); + } + + let normalized = validate_tag_ref_name(name)?; + if ref_exists(repo, &format!("refs/tags/{normalized}"))? { + return Err(format!("Tag '{normalized}' already exists.")); + } + + Ok(normalized) +} + +fn validate_existing_tag_name(repo: &Path, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("Tag name must not be empty.".to_string()); + } + + let normalized = validate_tag_ref_name(name)?; + if !ref_exists(repo, &format!("refs/tags/{normalized}"))? { + return Err(format!("Tag '{normalized}' was not found.")); + } + + Ok(normalized) +} + +fn validate_tag_ref_name(name: &str) -> Result { + let output = git_command() + .args(["check-ref-format", "--allow-onelevel", &format!("refs/tags/{name}")]) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + + if !output.status.success() { + let details = command_output_details(&output); + return Err(format!("Invalid tag name: {details}")); + } + + let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let normalized = normalized + .strip_prefix("refs/tags/") + .map(str::to_string) + .unwrap_or(normalized); + + Ok(if normalized.is_empty() { + name.to_string() + } else { + normalized + }) +} + #[tauri::command] pub fn stage_files(path: String, files: Vec) -> Result { let repo = resolve_repo(&path)?; @@ -852,6 +1040,63 @@ pub fn commit(path: String, message: String) -> Result { status_for_repo(&repo) } +#[tauri::command] +pub fn amend_commit(path: String, message: Option) -> Result { + let repo = resolve_repo(&path)?; + if verify_commit(&repo, "HEAD").is_err() { + return Err("There is no commit to amend.".to_string()); + } + + let current_status = status_for_repo(&repo)?; + if has_unresolved_conflicts(¤t_status) { + return Err("Merge conflicts must be resolved before you can commit.".to_string()); + } + + let message = message + .map(|message| message.trim().to_string()) + .filter(|message| !message.is_empty()); + + match message { + Some(message) => { + run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?; + } + None => { + run_git(&repo, ["commit", "--amend", "--no-edit"])?; + } + } + + status_for_repo(&repo) +} + +#[tauri::command] +pub fn last_commit_message(path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + if verify_commit(&repo, "HEAD").is_err() { + return Ok(None); + } + + let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?; + let message = String::from_utf8_lossy(&output).trim_end().to_string(); + Ok(if message.is_empty() { None } else { Some(message) }) +} + +#[tauri::command] +pub fn undo_last_commit(path: String) -> Result { + let repo = resolve_repo(&path)?; + if verify_commit(&repo, "HEAD").is_err() { + return Err("There is no commit to undo.".to_string()); + } + if verify_commit(&repo, "HEAD~1").is_err() { + return Err("This is the first commit; there is nothing to undo to.".to_string()); + } + + // Mixed reset: moves HEAD back one commit and unstages the difference, but + // leaves the working tree files untouched, so the undone commit's changes + // reappear as ordinary uncommitted changes instead of being discarded. + run_git(&repo, ["reset", "HEAD~1"])?; + status_for_repo(&repo) +} + #[tauri::command] pub fn pull( path: String, @@ -1214,6 +1459,68 @@ fn rebase_status_or_error( Err(format!("{context}: {}", command_output_details(&output))) } +#[tauri::command] +pub fn cherry_pick_commit(path: String, commit: String) -> Result { + let repo = resolve_repo(&path)?; + let commit_hash = verify_commit(&repo, &commit)?; + + let output = git_command() + .arg("-C") + .arg(&repo) + .args(["cherry-pick", commit_hash.as_str()]) + .env("GIT_EDITOR", "true") + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + + cherry_pick_status_or_error(&repo, output, "Cherry-pick failed") +} + +#[tauri::command] +pub fn cherry_pick_continue(path: String) -> Result { + let repo = resolve_repo(&path)?; + if !cherry_pick_in_progress(&repo) { + return Err("No cherry-pick is currently in progress.".to_string()); + } + + let output = git_command() + .arg("-C") + .arg(&repo) + .args(["cherry-pick", "--continue"]) + .env("GIT_EDITOR", "true") + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + + cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed") +} + +#[tauri::command] +pub fn cherry_pick_abort(path: String) -> Result { + let repo = resolve_repo(&path)?; + if !cherry_pick_in_progress(&repo) { + return Err("No cherry-pick is currently in progress.".to_string()); + } + + run_git(&repo, ["cherry-pick", "--abort"])?; + status_for_repo(&repo) +} + +fn cherry_pick_status_or_error( + repo: &Path, + output: Output, + context: &str, +) -> Result { + if output.status.success() { + return status_for_repo(repo); + } + + let status = status_for_repo(repo)?; + if has_unresolved_conflicts(&status) || status.cherry_pick_in_progress { + return Ok(status); + } + + Err(format!("{context}: {}", command_output_details(&output))) +} + #[tauri::command] pub fn list_commits(path: String, limit: Option) -> Result, String> { let repo = resolve_repo(&path)?; @@ -2049,6 +2356,7 @@ fn status_for_repo(repo: &Path) -> Result { clean: files.is_empty(), files, rebase_in_progress: rebase_in_progress(repo), + cherry_pick_in_progress: cherry_pick_in_progress(repo), }) } @@ -2056,6 +2364,10 @@ fn rebase_in_progress(repo: &Path) -> bool { git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply") } +fn cherry_pick_in_progress(repo: &Path) -> bool { + git_path_exists(repo, "CHERRY_PICK_HEAD") +} + fn git_path_exists(repo: &Path, name: &str) -> bool { let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else { return false; @@ -2245,6 +2557,7 @@ fn clone_repository_core( let repo = resolve_repo(&target.to_string_lossy())?; let status = status_for_repo(&repo)?; let branches = branches_for_repo(&repo)?; + let tags = tags_for_repo(&repo)?; let stashes = stashes_for_repo(&repo)?; let commits = commits_for_repo(&repo, commit_limit)?; let files = repository_files_with_status(&repo, &status)?; @@ -2252,6 +2565,7 @@ fn clone_repository_core( Ok(RepositoryBundle { status, branches, + tags, stashes, commits, files, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f3f059c..5c77c94 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,17 +5,19 @@ mod git; use badge::set_sync_badge; use git::{ - SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history, - checkout_branch, clone_repository, commit, commit_ai_generate, commit_ai_load, + SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search, + cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit, + cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head, - compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch, - diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status, - list_branches, list_commits, list_file_history, list_repository_files, list_stashes, - merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle, - open_repository_file, pull, push, read_conflict, rebase_abort, rebase_branch, rebase_continue, - rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit, - restore_files, restore_to_commit, search_code_introductions, stage_files, stash_apply, - stash_drop, stash_pop, stash_push, unstage_files, + compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save, + delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_patch, + get_remote_url, get_status, last_commit_message, list_branches, list_commits, + list_file_history, list_repository_files, list_stashes, list_tags, merge_branch, + open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull, + push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, + resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, + restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, + stash_pop, stash_push, undo_last_commit, unstage_files, }; fn main() { @@ -36,6 +38,13 @@ fn main() { create_branch, rename_branch, delete_branch, + list_tags, + create_tag, + delete_tag, + push_tag, + cherry_pick_commit, + cherry_pick_continue, + cherry_pick_abort, stage_files, unstage_files, stash_push, @@ -46,6 +55,9 @@ fn main() { get_file_patch, apply_file_patch, commit, + amend_commit, + undo_last_commit, + last_commit_message, commit_ai_status, commit_ai_load, commit_ai_local_models, diff --git a/src/App.svelte b/src/App.svelte index 05a696a..ce13e70 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, BookOpen, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte"; + import { AlertCircle, BookOpen, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte"; import TitleBar from "./lib/TitleBar.svelte"; import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; @@ -28,7 +28,11 @@ import UpdateToast from "./lib/components/UpdateToast.svelte"; import { + amendCommit, checkoutBranch, + cherryPickAbort, + cherryPickCommit, + cherryPickContinue, cloneRepository, commit, commitAiGenerate, @@ -40,13 +44,17 @@ cancelFileHistory, applyFilePatch, createBranch, + createTag, deleteBranch, + deleteTag, diffFileAgainstWorkingTree, compareFileToParent, fetchRemote, getStatus, + lastCommitMessage, listBranches, listStashes, + listTags, listCommits, listFileHistory, listRepositoryFiles, @@ -56,6 +64,7 @@ openRepositoryBundle, pull, push, + pushTag, renameBranch, rebaseAbort, rebaseBranch, @@ -78,6 +87,7 @@ stashDrop, stashPop, stashPush, + undoLastCommit, unstageFiles, } from "./lib/git"; @@ -97,6 +107,7 @@ GitSearchHit, GitStash, GitStatus, + GitTag, LocalModelOption, PatchApplyAction, PreparedResolution, @@ -135,6 +146,7 @@ const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; + const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1"; const AI_SETTINGS_KEY = "gitlite.aiSettings.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1"; @@ -153,6 +165,9 @@ let activeView: AppView = "management"; let repoTabs: RepoTab[] = []; let recentRepoPaths: string[] = []; + // Last-seen branch/ahead/behind/changed for repos that are known (recent/all) + // but not currently open as a tab — keyed by normalized path (repoKey). + let repoStatusCache: Record = {}; let repoSearch = ""; let cloneDialogOpen = false; let cloneDialogError = ""; @@ -160,6 +175,7 @@ let pendingClone: CloneRequest | null = null; let status: GitStatus | null = null; let branches: GitBranchInfo[] = []; + let tags: GitTag[] = []; let stashes: GitStash[] = []; let commits: GitCommit[] = []; let repoFiles: GitRepositoryFile[] = []; @@ -173,6 +189,8 @@ let activeFileHistoryRequestId = ""; let lastFileHistoryHeadHash = ""; let commitMessage = ""; + let amendMode = false; + let preAmendDraftMessage = ""; let lastLocalAiGeneratedMessage = ""; let commitAiPhase: CommitAiPhase = "idle"; let commitAiGenerating = false; @@ -220,10 +238,16 @@ let lastStatusFingerprint = ""; const AUTO_REFRESH_INTERVAL = 4000; let autoRefreshTimer: ReturnType | undefined; + const BACKGROUND_REPO_STATUS_INTERVAL = 30_000; + const BACKGROUND_REPO_STATUS_BATCH_SIZE = 4; const BACKGROUND_FETCH_INTERVAL = 180_000; const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000; + const BACKGROUND_FETCH_OTHER_BATCH_SIZE = 2; let backgroundFetchTimer: ReturnType | undefined; + let backgroundRepoStatusTimer: ReturnType | undefined; let backgroundFetchInFlight = false; + let backgroundRepoStatusInFlight = false; + let backgroundRepoStatusIndex = 0; let lastRepoSwitchAt = 0; let updateToastOpen = false; let updateToastState: UpdateToastState = "available"; @@ -260,9 +284,23 @@ $: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted"); $: hasConflicts = conflictedFiles.length > 0; $: rebaseInProgress = status?.rebase_in_progress ?? false; - $: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !isBusy; + $: cherryPickInProgress = status?.cherry_pick_in_progress ?? false; + // Amending/undoing is only offered while the last commit hasn't reached a + // remote yet: no upstream at all, or the branch is still ahead of it. + $: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress + && (!status?.upstream || (status?.ahead ?? 0) > 0); + // Safety net: if the last commit gets pushed elsewhere (or a conflict/rebase starts) + // while amend mode is active, drop out of it instead of leaving a stale, hidden toggle. + $: if (!canAmend && amendMode) { + amendMode = false; + commitMessage = preAmendDraftMessage; + preAmendDraftMessage = ""; + } + $: canCommit = hasRepository && (amendMode || stagedCount > 0) && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress && !isBusy; $: commitBlockReason = rebaseInProgress ? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase." + : cherryPickInProgress + ? "A cherry-pick is in progress. Resolve conflicts and use Cherry-pick continue or abort it." : hasConflicts ? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "conflict must" : "conflicts must"} be resolved before committing.` : ""; @@ -285,13 +323,16 @@ onMount(() => { loadRepoLists(); autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL); + backgroundRepoStatusTimer = setInterval(() => { void backgroundRepoStatusTick(false); }, BACKGROUND_REPO_STATUS_INTERVAL); backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL); + void backgroundRepoStatusTick(false); void checkForUpdates(); void initCommitAi(); }); onDestroy(() => { if (autoRefreshTimer) clearInterval(autoRefreshTimer); + if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer); if (backgroundFetchTimer) clearInterval(backgroundFetchTimer); if (commitAiPollTimer) clearInterval(commitAiPollTimer); if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer); @@ -336,18 +377,101 @@ // Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/ // Push buttons instead, not as a background popup. async function backgroundFetchTick() { - if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || backgroundFetchInFlight) return; - if (Date.now() - lastRepoSwitchAt < BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) return; + if (!autoRefreshEnabled) return; + + if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight + && !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) { + backgroundFetchInFlight = true; + try { + await fetchRemote(activeRepoPath); + applyStatus(await getStatus(activeRepoPath)); + } catch { + // ignore — see comment above + } finally { + backgroundFetchInFlight = false; + } + } + + void backgroundRepoStatusTick(true); + } + + async function backgroundFetchRepo(path: string) { + if (!autoRefreshEnabled || !path || backgroundFetchInFlight) return; + backgroundFetchInFlight = true; try { - await fetchRemote(activeRepoPath); + await fetchRemote(path); + const nextStatus = await getStatus(path); + if (sameRepoPath(path, activeRepoPath)) applyStatus(nextStatus); + else updateRepoManagementStatus(path, nextStatus); } catch { - // ignore — see comment above + // ignore; manual Fetch/Pull surfaces auth or network problems } finally { backgroundFetchInFlight = false; } } + // Keeps the repo list's branch/ahead/behind up to date for every *other* open + // tab, not just the active one, so switching to the management view (or just + // glancing at the tab bar) shows current data without an explicit fetch. + // One repo per tick, round-robin: spreads the git subprocess cost over time + // instead of firing N fetches at once when many repos are open. + // Every repo we know about besides the active one: open tabs (minus the active + // tab) plus recent repos that aren't currently open — the same universe the + // Recent/All repositories lists in Repository Management draw from. + function knownRepoPathsForBackground(): string[] { + return uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths]) + .filter((path) => !(activeView === "repository" && sameRepoPath(path, activeRepoPath))); + } + + function updateRepoManagementStatus(path: string, nextStatus: GitStatus) { + const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path)); + const cached = repoStatusCache[repoKey(path)]; + const row: RepoTab = { + path, + name: repoNameFromPath(path), + branch: nextStatus.current_branch, + ahead: nextStatus.ahead, + behind: nextStatus.behind, + changed: nextStatus.files.length, + lastOpened: openTab?.lastOpened ?? cached?.lastOpened ?? 0, + }; + + if (openTab) { + repoTabs = repoTabs.map((tab) => sameRepoPath(tab.path, path) ? row : tab); + } + cacheRepoStatus(row); + } + + async function backgroundRepoStatusTick(fetchFirst: boolean) { + if (!autoRefreshEnabled || backgroundRepoStatusInFlight) return; + const others = knownRepoPathsForBackground(); + if (others.length === 0) return; + + backgroundRepoStatusInFlight = true; + try { + // A handful per tick, sequentially (not in parallel) — enough to fill in + // a long recent-repos list within a few minutes instead of an hour, while + // still never running more than one `git fetch` subprocess at a time. + const batchSize = fetchFirst ? BACKGROUND_FETCH_OTHER_BATCH_SIZE : BACKGROUND_REPO_STATUS_BATCH_SIZE; + for (let step = 0; step < Math.min(batchSize, others.length); step++) { + if (backgroundRepoStatusIndex >= others.length) backgroundRepoStatusIndex = 0; + const path = others[backgroundRepoStatusIndex]; + backgroundRepoStatusIndex += 1; + + try { + if (fetchFirst) await fetchRemote(path); + const nextStatus = await getStatus(path); + updateRepoManagementStatus(path, nextStatus); + } catch { + // ignore this repo — same rationale as the active-repo background fetch above + } + } + } finally { + backgroundRepoStatusInFlight = false; + } + } + async function autoRefreshTick() { if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return; autoRefreshInFlight = true; @@ -360,6 +484,7 @@ const bundle = await openRepositoryBundle(activeRepoPath, 100); const previousHeadHash = lastFileHistoryHeadHash; await refreshBranchList(activeRepoPath, bundle.branches); + await refreshTags(activeRepoPath, bundle.tags); await refreshStashes(activeRepoPath, bundle.stashes); await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshExplorerFiles(activeRepoPath, bundle.files); @@ -620,7 +745,13 @@ } function repoRowFromPath(path: string): RepoTab { - return repoTabs.find((tab) => sameRepoPath(tab.path, path)) ?? { + const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path)); + if (openTab) return openTab; + + const cached = repoStatusCache[repoKey(path)]; + if (cached) return { ...cached, path, name: repoNameFromPath(path) }; + + return { path, name: repoNameFromPath(path), branch: null, @@ -640,6 +771,11 @@ function loadRepoLists() { try { + const cacheValue = JSON.parse(localStorage.getItem(REPO_STATUS_CACHE_KEY) ?? "{}") as unknown; + repoStatusCache = cacheValue && typeof cacheValue === "object" && !Array.isArray(cacheValue) + ? cacheValue as Record + : {}; + 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) @@ -649,19 +785,19 @@ ? 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, - })); + // Seed from the last-known status cache so tabs show real data immediately + // on startup, instead of blank until the background poll catches up. + repoTabs = uniqueRepoPaths(openPaths).map((path) => { + const cached = repoStatusCache[repoKey(path)]; + return cached + ? { ...cached, path, name: repoNameFromPath(path) } + : { path, name: repoNameFromPath(path), branch: null, ahead: 0, behind: 0, changed: 0, lastOpened: 0 }; + }); recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]); } catch { repoTabs = []; recentRepoPaths = []; + repoStatusCache = {}; } } @@ -674,6 +810,14 @@ } } + function persistRepoStatusCache() { + try { + localStorage.setItem(REPO_STATUS_CACHE_KEY, JSON.stringify(repoStatusCache)); + } catch { + // Local storage is best-effort only; the Git workflow must keep working without it. + } + } + function defaultAiSettings(): AiSettings { return { provider: "openai", @@ -831,6 +975,16 @@ ? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab) : [...repoTabs, next]; rememberRecentRepo(path); + cacheRepoStatus(next); + } + + // Keeps last-known branch/ahead/behind/changed around under the repo's + // normalized path, independent of repoTabs — so a closed tab (or a repo + // that's only ever shown up in "recent") still displays real data instead + // of the "known repo" placeholder in the Recent/All repositories lists. + function cacheRepoStatus(row: RepoTab) { + repoStatusCache = { ...repoStatusCache, [repoKey(row.path)]: row }; + persistRepoStatusCache(); } function resetRepositoryState(clearActive = false) { @@ -941,6 +1095,10 @@ branches = prefetched ?? (await listBranches(path)); } + async function refreshTags(path = activeRepoPath, prefetched?: GitTag[]) { + tags = prefetched ?? (await listTags(path)); + } + async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) { stashes = prefetched ?? (await listStashes(path)); } @@ -1039,10 +1197,12 @@ if (globalSearchBusy) void cancelGlobalSearch(); activeView = "repository"; await refreshBranchList(activeRepoPath, bundle.branches); + await refreshTags(activeRepoPath, bundle.tags); await refreshStashes(activeRepoPath, bundle.stashes); await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshExplorerFiles(activeRepoPath, bundle.files); lastRepoSwitchAt = Date.now(); + void backgroundFetchRepo(activeRepoPath); }); } @@ -1106,6 +1266,7 @@ if (globalSearchBusy) void cancelGlobalSearch(); activeView = "repository"; await refreshBranchList(activeRepoPath, bundle.branches); + await refreshTags(activeRepoPath, bundle.tags); await refreshStashes(activeRepoPath, bundle.stashes); await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshExplorerFiles(activeRepoPath, bundle.files); @@ -1151,6 +1312,7 @@ function openRepoManagement() { if (isBusy) return; activeView = "management"; + void backgroundRepoStatusTick(false); } async function selectRepoTab(path: string) { @@ -1183,6 +1345,11 @@ if (isBusy) return; recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path)); persistRepoLists(); + if (repoStatusCache[repoKey(path)]) { + const { [repoKey(path)]: _removed, ...rest } = repoStatusCache; + repoStatusCache = rest; + persistRepoStatusCache(); + } if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) { await closeRepoTab(path); } @@ -1327,7 +1494,7 @@ } async function rebaseOnto(branch: GitBranchInfo) { - if (!activeRepoPath || branch.current || rebaseInProgress) return; + if (!activeRepoPath || branch.current || rebaseInProgress || cherryPickInProgress) return; await runOperation(`Rebasing onto ${branch.name}`, async () => { applyStatus(await rebaseBranch(activeRepoPath, branch.name)); await refreshBranchList(activeRepoPath); @@ -1366,6 +1533,84 @@ }); } + async function createNewTag(name: string, message: string) { + const trimmed = name.trim(); + if (!activeRepoPath || !trimmed) return; + await runOperation(`Creating tag ${trimmed}`, async () => { + await refreshTags(activeRepoPath, await createTag(activeRepoPath, trimmed, undefined, message)); + }); + } + + async function deleteLocalTag(tag: GitTag) { + if (!activeRepoPath || isBusy) return; + const confirmed = window.confirm(`Delete tag '${tag.name}'?\n\nThis only removes the local tag, not any copy already pushed to a remote.`); + if (!confirmed) return; + + await runOperation(`Deleting tag ${tag.name}`, async () => { + await refreshTags(activeRepoPath, await deleteTag(activeRepoPath, tag.name)); + }); + } + + async function pushLocalTag(tag: GitTag) { + if (!activeRepoPath || isBusy) return; + const key = await currentCredKey(); + const stored = await loadStoredCredential(key); + const credential = stored && !isCredentialExpired(stored) ? stored : null; + + await runOperation(`Pushing tag ${tag.name}`, async () => { + try { + await pushTag(activeRepoPath, tag.name, credential?.username, credential?.password); + } catch (error) { + const message = errorToMessage(error); + throw new Error( + isAuthError(message) + ? `Sign in via the Push button first, then retry pushing tag '${tag.name}'.` + : stripAuthPrefix(message), + ); + } + }); + } + + async function cherryPickFromCommit(commit: GitCommit) { + if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return; + await runOperation(`Cherry-picking ${commit.short_hash}`, async () => { + applyStatus(await cherryPickCommit(activeRepoPath, commit.hash)); + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + + async function continueCherryPick() { + if (!activeRepoPath || !cherryPickInProgress || hasConflicts) return; + await runOperation("Continuing cherry-pick", async () => { + applyStatus(await cherryPickContinue(activeRepoPath)); + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + + async function abortCherryPick() { + if (!activeRepoPath || !cherryPickInProgress) return; + const confirmed = window.confirm("Abort the current cherry-pick and return to the previous state?"); + if (!confirmed) return; + + await runOperation("Aborting cherry-pick", async () => { + applyStatus(await cherryPickAbort(activeRepoPath)); + preparedResolutions = {}; + resolveDialogOpen = false; + conflict = null; + conflictTarget = ""; + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + // Resolve the keychain key (host/org) for the active repo's remote. async function currentCredKey(): Promise { if (!activeRepoPath) return null; @@ -1774,7 +2019,8 @@ async function commitChanges() { const message = commitMessage.trim(); - if (!message || !activeRepoPath) return; + if (!activeRepoPath) return; + if (!amendMode && !message) return; if (hasConflicts) { errorMessage = "Resolve all conflicts before committing."; return; @@ -1783,6 +2029,26 @@ errorMessage = "A rebase is in progress. Use Rebase continue or abort the rebase."; return; } + if (cherryPickInProgress) { + errorMessage = "A cherry-pick is in progress. Use Cherry-pick continue or abort it."; + return; + } + + if (amendMode) { + await runOperation("Amending", async () => { + applyStatus(await amendCommit(activeRepoPath, message)); + commitMessage = ""; + amendMode = false; + preAmendDraftMessage = ""; + lastLocalAiGeneratedMessage = ""; + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + return; + } + await runOperation("Committing", async () => { applyStatus(await commit(activeRepoPath, message)); commitMessage = ""; @@ -1794,6 +2060,50 @@ }); } + // Only offered when the last commit hasn't reached a remote yet (no upstream, + // or the branch is ahead of it) — amending/undoing a pushed commit rewrites + // history other clones already have, which needs a force-push to reconcile. + async function toggleAmendMode(checked: boolean) { + if (!activeRepoPath || isBusy) return; + if (!checked) { + amendMode = false; + commitMessage = preAmendDraftMessage; + preAmendDraftMessage = ""; + return; + } + if (!canAmend) return; + + try { + const message = await lastCommitMessage(activeRepoPath); + preAmendDraftMessage = commitMessage; + commitMessage = message ?? ""; + amendMode = true; + } catch (error) { + errorMessage = errorToMessage(error); + } + } + + async function undoLastCommitChange() { + if (!activeRepoPath || !canAmend || isBusy) return; + const confirmed = window.confirm( + "Undo the last commit?\n\nIts changes come back as uncommitted changes in the working tree — nothing is discarded.", + ); + if (!confirmed) return; + + await runOperation("Undoing last commit", async () => { + applyStatus(await undoLastCommit(activeRepoPath)); + if (amendMode) { + amendMode = false; + commitMessage = preAmendDraftMessage; + preAmendDraftMessage = ""; + } + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + // ── Commit restore ───────────────────────────────────────────────────────── async function restoreCommit(target: GitCommit) { @@ -2201,7 +2511,7 @@ {/if} - {#if workspaceActive && hasConflicts && !rebaseInProgress} + {#if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress} {/if} + {#if workspaceActive && cherryPickInProgress} +
+
+ {/if} + {#if activeView === "management"}
@@ -2305,7 +2634,12 @@
@@ -2483,6 +2828,7 @@ {commits} {localBranchNames} activeBranch={status?.current_branch ?? ""} + activeUpstream={status?.upstream ?? ""} repositoryKey={activeRepoPath} {hasRepository} {isBusy} @@ -2490,6 +2836,7 @@ onRestoreCommit={restoreCommit} onPreviewCommitFile={previewCommitFileFromHistory} onCreateBranchFromCommit={openNewBranchDialog} + onCherryPickCommit={cherryPickFromCommit} onToggleCommitFiles={(hash) => { const next = new Set(expandedCommitHashes); if (next.has(hash)) next.delete(hash); else next.add(hash); diff --git a/src/app.css b/src/app.css index 8f4b380..ce7abb8 100644 --- a/src/app.css +++ b/src/app.css @@ -833,8 +833,8 @@ font-style: normal; font-weight: 800; } - .repo-row-meta .ahead { color: #4eca76; } - .repo-row-meta .behind { color: #e0a040; } + .repo-row-meta .ahead { color: #e0a040; background: rgba(224,160,64,0.13); } + .repo-row-meta .behind { color: #7aacff; background: rgba(122,172,255,0.13); } .repo-row-icon { min-height: 36px; min-width: 36px; @@ -1550,6 +1550,12 @@ font-size: 10px; } + .tag-group-head { display: flex; align-items: center; gap: 6px; } + .tag-group-head .branch-group-toggle { flex: 1; min-width: 0; } + .tag-group-head .branch-create-toggle { flex-shrink: 0; } + + .tag-create-form { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto auto; } + .branch-empty { padding: 8px 10px; color: var(--color-ink-faint); @@ -1801,6 +1807,56 @@ resize: none; overflow: auto; } + .commit-amend-row { + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 auto; + gap: 8px; + min-width: 0; + } + .commit-amend-toggle { + display: flex; + align-items: center; + gap: 6px; + color: var(--color-ink-dim); + font-size: 12px; + white-space: nowrap; + } + .commit-amend-toggle input { + appearance: none; + flex: 0 0 auto; + width: 14px; + height: 14px; + min-width: 14px; + min-height: 14px; + padding: 0; + border: 1px solid rgba(94,110,156,0.42); + border-radius: 4px; + background: rgba(13,14,26,0.78); + box-shadow: none; + cursor: pointer; + } + .commit-amend-toggle input:checked { + border-color: rgba(65,209,255,0.62); + background: + linear-gradient(135deg, rgba(65,209,255,0.92), rgba(100,108,255,0.92)); + box-shadow: inset 0 0 0 2px rgba(13,14,26,0.82); + } + .commit-amend-toggle input:focus { + box-shadow: 0 0 0 2px rgba(65,209,255,0.2); + } + .commit-amend-toggle input:disabled { + cursor: not-allowed; + opacity: 0.5; + } + .commit-undo-button { + flex: 0 0 auto; + min-height: 26px; + padding: 0 8px; + font-size: 11px; + } + .commit-actions-row { display: flex; flex: 0 0 auto; @@ -2185,6 +2241,15 @@ .graph-svg path.hidden-branch { opacity: 0.08; } + .graph-svg path.graph-segment-ahead { + stroke: #e0a040; + filter: drop-shadow(0 0 3px rgba(224,160,64,0.24)); + } + .graph-svg path.graph-segment-behind { + stroke: #7aacff; + stroke-dasharray: 4 4; + filter: drop-shadow(0 0 3px rgba(122,172,255,0.24)); + } .graph-dot { position: absolute; z-index: 2; @@ -2202,6 +2267,14 @@ opacity: 0.16; box-shadow: none; } + .graph-dot.ahead { + background: #e0a040; + box-shadow: 0 0 0 1px rgba(224,160,64,0.42), 0 0 10px rgba(224,160,64,0.16); + } + .graph-dot.behind { + background: #7aacff; + box-shadow: 0 0 0 1px rgba(122,172,255,0.46), 0 0 10px rgba(122,172,255,0.18); + } .graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); } .graph-dot.merge { width: 15px; @@ -2247,6 +2320,22 @@ text-overflow: ellipsis; white-space: nowrap; } + .graph-hover-branches span.remote, + .branch-filter-option.remote { + border-color: rgba(122,172,255,0.32); + color: #bcd2ff; + background: rgba(31,43,72,0.88); + } + .graph-hover-branches span.ahead { + border-color: rgba(224,160,64,0.42); + color: #ffd99a; + background: rgba(58,42,20,0.94); + } + .graph-hover-branches span.behind { + border-color: rgba(122,172,255,0.46); + color: #c7dbff; + background: rgba(25,39,70,0.94); + } .graph-hover-branches svg { flex: 0 0 auto; color: #76d995; @@ -2260,6 +2349,12 @@ transition: background 120ms ease; } .graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); } + .graph-row.graph-ahead-row .commit-body { + box-shadow: inset 3px 0 0 rgba(224,160,64,0.72); + } + .graph-row.graph-behind-row .commit-body { + box-shadow: inset 3px 0 0 rgba(122,172,255,0.72); + } .graph-row:hover .commit-body { background: rgba(37,40,62,0.54); } .graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; } .graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; } diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index 746f96a..dcec2f0 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -1,6 +1,6 @@ - +
@@ -300,7 +378,7 @@ {#if !hasRepository}

Open a repository to list branches.

- {:else if branches.length === 0} + {:else if branches.length === 0 && tags.length === 0}

No branches returned.

{:else}
@@ -458,6 +536,86 @@ {/if} {/if}
+ +
+
+ + +
+ + {#if tagCreateOpen} +
+
{/if} @@ -499,4 +657,24 @@ {/if} + + {#if contextTag} + + {/if}
diff --git a/src/lib/components/CommitPanel.svelte b/src/lib/components/CommitPanel.svelte index e6c4777..c7c4bed 100644 --- a/src/lib/components/CommitPanel.svelte +++ b/src/lib/components/CommitPanel.svelte @@ -1,5 +1,5 @@