Features/extend git features #20

Merged
Christoph merged 6 commits from features/extend_git_features into main 2026-07-06 19:07:49 +00:00
11 changed files with 1164 additions and 52 deletions
+7 -1
View File
@@ -79,7 +79,13 @@
"Bash(rustfmt --edition 2024 --check src/git.rs)", "Bash(rustfmt --edition 2024 --check src/git.rs)",
"Bash(rustfmt --edition 2024 --check src/badge.rs)", "Bash(rustfmt --edition 2024 --check src/badge.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.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)"
] ]
} }
} }
+314
View File
@@ -48,6 +48,7 @@ pub struct GitStatus {
pub files: Vec<GitFileStatus>, pub files: Vec<GitFileStatus>,
pub clean: bool, pub clean: bool,
pub rebase_in_progress: bool, pub rebase_in_progress: bool,
pub cherry_pick_in_progress: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -57,6 +58,16 @@ pub struct GitBranch {
pub remote: bool, 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<String>,
pub date: String,
pub annotated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitStash { pub struct GitStash {
pub selector: String, pub selector: String,
@@ -245,6 +256,7 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
pub struct RepositoryBundle { pub struct RepositoryBundle {
pub status: GitStatus, pub status: GitStatus,
pub branches: Vec<GitBranch>, pub branches: Vec<GitBranch>,
pub tags: Vec<GitTag>,
pub stashes: Vec<GitStash>, pub stashes: Vec<GitStash>,
pub commits: Vec<GitCommit>, pub commits: Vec<GitCommit>,
pub files: Vec<GitRepositoryFile>, pub files: Vec<GitRepositoryFile>,
@@ -287,12 +299,14 @@ pub async fn open_repository_bundle(
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?; let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?; let branches = branches_for_repo(&repo)?;
let tags = tags_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?; let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?; let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?; let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle { Ok(RepositoryBundle {
status, status,
branches, branches,
tags,
stashes, stashes,
commits, commits,
files, files,
@@ -320,6 +334,63 @@ pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
stashes_for_repo(&repo) stashes_for_repo(&repo)
} }
#[tauri::command]
pub fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
let repo = resolve_repo(&path)?;
tags_for_repo(&repo)
}
fn tags_for_repo(repo: &Path) -> Result<Vec<GitTag>, 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<Vec<GitBranch>, String> { fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
let output = run_git( let output = run_git(
repo, repo,
@@ -570,6 +641,123 @@ pub fn delete_branch(
status_for_repo(&repo) status_for_repo(&repo)
} }
#[tauri::command]
pub fn create_tag(
path: String,
name: String,
target: Option<String>,
message: Option<String>,
) -> Result<Vec<GitTag>, 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<Vec<GitTag>, 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<String>,
password: Option<String>,
) -> 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<String, String> {
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<String, String> {
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<String, String> {
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] #[tauri::command]
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> { pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -852,6 +1040,63 @@ pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
status_for_repo(&repo) status_for_repo(&repo)
} }
#[tauri::command]
pub fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
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(&current_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<Option<String>, 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<GitStatus, String> {
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] #[tauri::command]
pub fn pull( pub fn pull(
path: String, path: String,
@@ -1214,6 +1459,68 @@ fn rebase_status_or_error(
Err(format!("{context}: {}", command_output_details(&output))) Err(format!("{context}: {}", command_output_details(&output)))
} }
#[tauri::command]
pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
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<GitStatus, String> {
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<GitStatus, String> {
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<GitStatus, String> {
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] #[tauri::command]
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> { pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -2049,6 +2356,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
clean: files.is_empty(), clean: files.is_empty(),
files, files,
rebase_in_progress: rebase_in_progress(repo), 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") 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 { fn git_path_exists(repo: &Path, name: &str) -> bool {
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else { let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
return false; return false;
@@ -2245,6 +2557,7 @@ fn clone_repository_core(
let repo = resolve_repo(&target.to_string_lossy())?; let repo = resolve_repo(&target.to_string_lossy())?;
let status = status_for_repo(&repo)?; let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?; let branches = branches_for_repo(&repo)?;
let tags = tags_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?; let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?; let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?; let files = repository_files_with_status(&repo, &status)?;
@@ -2252,6 +2565,7 @@ fn clone_repository_core(
Ok(RepositoryBundle { Ok(RepositoryBundle {
status, status,
branches, branches,
tags,
stashes, stashes,
commits, commits,
files, files,
+22 -10
View File
@@ -5,17 +5,19 @@ mod git;
use badge::set_sync_badge; use badge::set_sync_badge;
use git::{ use git::{
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history, SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
checkout_branch, clone_repository, commit, commit_ai_generate, commit_ai_load, 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, 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, compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status, delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_patch,
list_branches, list_commits, list_file_history, list_repository_files, list_stashes, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle, list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
open_repository_file, pull, push, read_conflict, rebase_abort, rebase_branch, rebase_continue, open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit, push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
restore_files, restore_to_commit, search_code_introductions, stage_files, stash_apply, resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
stash_drop, stash_pop, stash_push, unstage_files, restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop,
stash_pop, stash_push, undo_last_commit, unstage_files,
}; };
fn main() { fn main() {
@@ -36,6 +38,13 @@ fn main() {
create_branch, create_branch,
rename_branch, rename_branch,
delete_branch, delete_branch,
list_tags,
create_tag,
delete_tag,
push_tag,
cherry_pick_commit,
cherry_pick_continue,
cherry_pick_abort,
stage_files, stage_files,
unstage_files, unstage_files,
stash_push, stash_push,
@@ -46,6 +55,9 @@ fn main() {
get_file_patch, get_file_patch,
apply_file_patch, apply_file_patch,
commit, commit,
amend_commit,
undo_last_commit,
last_commit_message,
commit_ai_status, commit_ai_status,
commit_ai_load, commit_ai_load,
commit_ai_local_models, commit_ai_local_models,
+366 -19
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount, tick } from "svelte"; import { onDestroy, onMount, tick } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; 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 TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
@@ -28,7 +28,11 @@
import UpdateToast from "./lib/components/UpdateToast.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte";
import { import {
amendCommit,
checkoutBranch, checkoutBranch,
cherryPickAbort,
cherryPickCommit,
cherryPickContinue,
cloneRepository, cloneRepository,
commit, commit,
commitAiGenerate, commitAiGenerate,
@@ -40,13 +44,17 @@
cancelFileHistory, cancelFileHistory,
applyFilePatch, applyFilePatch,
createBranch, createBranch,
createTag,
deleteBranch, deleteBranch,
deleteTag,
diffFileAgainstWorkingTree, diffFileAgainstWorkingTree,
compareFileToParent, compareFileToParent,
fetchRemote, fetchRemote,
getStatus, getStatus,
lastCommitMessage,
listBranches, listBranches,
listStashes, listStashes,
listTags,
listCommits, listCommits,
listFileHistory, listFileHistory,
listRepositoryFiles, listRepositoryFiles,
@@ -56,6 +64,7 @@
openRepositoryBundle, openRepositoryBundle,
pull, pull,
push, push,
pushTag,
renameBranch, renameBranch,
rebaseAbort, rebaseAbort,
rebaseBranch, rebaseBranch,
@@ -78,6 +87,7 @@
stashDrop, stashDrop,
stashPop, stashPop,
stashPush, stashPush,
undoLastCommit,
unstageFiles, unstageFiles,
} from "./lib/git"; } from "./lib/git";
@@ -97,6 +107,7 @@
GitSearchHit, GitSearchHit,
GitStash, GitStash,
GitStatus, GitStatus,
GitTag,
LocalModelOption, LocalModelOption,
PatchApplyAction, PatchApplyAction,
PreparedResolution, PreparedResolution,
@@ -135,6 +146,7 @@
const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1"; const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1"; const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
@@ -153,6 +165,9 @@
let activeView: AppView = "management"; let activeView: AppView = "management";
let repoTabs: RepoTab[] = []; let repoTabs: RepoTab[] = [];
let recentRepoPaths: string[] = []; 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<string, RepoTab> = {};
let repoSearch = ""; let repoSearch = "";
let cloneDialogOpen = false; let cloneDialogOpen = false;
let cloneDialogError = ""; let cloneDialogError = "";
@@ -160,6 +175,7 @@
let pendingClone: CloneRequest | null = null; let pendingClone: CloneRequest | null = null;
let status: GitStatus | null = null; let status: GitStatus | null = null;
let branches: GitBranchInfo[] = []; let branches: GitBranchInfo[] = [];
let tags: GitTag[] = [];
let stashes: GitStash[] = []; let stashes: GitStash[] = [];
let commits: GitCommit[] = []; let commits: GitCommit[] = [];
let repoFiles: GitRepositoryFile[] = []; let repoFiles: GitRepositoryFile[] = [];
@@ -173,6 +189,8 @@
let activeFileHistoryRequestId = ""; let activeFileHistoryRequestId = "";
let lastFileHistoryHeadHash = ""; let lastFileHistoryHeadHash = "";
let commitMessage = ""; let commitMessage = "";
let amendMode = false;
let preAmendDraftMessage = "";
let lastLocalAiGeneratedMessage = ""; let lastLocalAiGeneratedMessage = "";
let commitAiPhase: CommitAiPhase = "idle"; let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false; let commitAiGenerating = false;
@@ -220,10 +238,16 @@
let lastStatusFingerprint = ""; let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000; const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined; let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
const BACKGROUND_REPO_STATUS_INTERVAL = 30_000;
const BACKGROUND_REPO_STATUS_BATCH_SIZE = 4;
const BACKGROUND_FETCH_INTERVAL = 180_000; const BACKGROUND_FETCH_INTERVAL = 180_000;
const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000; const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000;
const BACKGROUND_FETCH_OTHER_BATCH_SIZE = 2;
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined; let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false; let backgroundFetchInFlight = false;
let backgroundRepoStatusInFlight = false;
let backgroundRepoStatusIndex = 0;
let lastRepoSwitchAt = 0; let lastRepoSwitchAt = 0;
let updateToastOpen = false; let updateToastOpen = false;
let updateToastState: UpdateToastState = "available"; let updateToastState: UpdateToastState = "available";
@@ -260,9 +284,23 @@
$: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted"); $: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted");
$: hasConflicts = conflictedFiles.length > 0; $: hasConflicts = conflictedFiles.length > 0;
$: rebaseInProgress = status?.rebase_in_progress ?? false; $: 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 $: commitBlockReason = rebaseInProgress
? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase." ? "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 : hasConflicts
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "conflict must" : "conflicts must"} be resolved before committing.` ? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "conflict must" : "conflicts must"} be resolved before committing.`
: ""; : "";
@@ -285,13 +323,16 @@
onMount(() => { onMount(() => {
loadRepoLists(); loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL); autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
backgroundRepoStatusTimer = setInterval(() => { void backgroundRepoStatusTick(false); }, BACKGROUND_REPO_STATUS_INTERVAL);
backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL); backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL);
void backgroundRepoStatusTick(false);
void checkForUpdates(); void checkForUpdates();
void initCommitAi(); void initCommitAi();
}); });
onDestroy(() => { onDestroy(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer); if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer);
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer); if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
if (commitAiPollTimer) clearInterval(commitAiPollTimer); if (commitAiPollTimer) clearInterval(commitAiPollTimer);
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer); if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
@@ -336,11 +377,14 @@
// Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/ // Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/
// Push buttons instead, not as a background popup. // Push buttons instead, not as a background popup.
async function backgroundFetchTick() { async function backgroundFetchTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || backgroundFetchInFlight) return; if (!autoRefreshEnabled) return;
if (Date.now() - lastRepoSwitchAt < BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) return;
if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
&& !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try { try {
await fetchRemote(activeRepoPath); await fetchRemote(activeRepoPath);
applyStatus(await getStatus(activeRepoPath));
} catch { } catch {
// ignore — see comment above // ignore — see comment above
} finally { } finally {
@@ -348,6 +392,86 @@
} }
} }
void backgroundRepoStatusTick(true);
}
async function backgroundFetchRepo(path: string) {
if (!autoRefreshEnabled || !path || backgroundFetchInFlight) return;
backgroundFetchInFlight = true;
try {
await fetchRemote(path);
const nextStatus = await getStatus(path);
if (sameRepoPath(path, activeRepoPath)) applyStatus(nextStatus);
else updateRepoManagementStatus(path, nextStatus);
} catch {
// 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() { async function autoRefreshTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return; if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
autoRefreshInFlight = true; autoRefreshInFlight = true;
@@ -360,6 +484,7 @@
const bundle = await openRepositoryBundle(activeRepoPath, 100); const bundle = await openRepositoryBundle(activeRepoPath, 100);
const previousHeadHash = lastFileHistoryHeadHash; const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(activeRepoPath, bundle.branches);
await refreshTags(activeRepoPath, bundle.tags);
await refreshStashes(activeRepoPath, bundle.stashes); await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); await refreshExplorerFiles(activeRepoPath, bundle.files);
@@ -620,7 +745,13 @@
} }
function repoRowFromPath(path: string): RepoTab { 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, path,
name: repoNameFromPath(path), name: repoNameFromPath(path),
branch: null, branch: null,
@@ -640,6 +771,11 @@
function loadRepoLists() { function loadRepoLists() {
try { try {
const cacheValue = JSON.parse(localStorage.getItem(REPO_STATUS_CACHE_KEY) ?? "{}") as unknown;
repoStatusCache = cacheValue && typeof cacheValue === "object" && !Array.isArray(cacheValue)
? cacheValue as Record<string, RepoTab>
: {};
const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown; const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown;
const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown; const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown;
const openPaths = Array.isArray(openValue) const openPaths = Array.isArray(openValue)
@@ -649,19 +785,19 @@
? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean) ? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
: []; : [];
repoTabs = uniqueRepoPaths(openPaths).map((path) => ({ // Seed from the last-known status cache so tabs show real data immediately
path, // on startup, instead of blank until the background poll catches up.
name: repoNameFromPath(path), repoTabs = uniqueRepoPaths(openPaths).map((path) => {
branch: null, const cached = repoStatusCache[repoKey(path)];
ahead: 0, return cached
behind: 0, ? { ...cached, path, name: repoNameFromPath(path) }
changed: 0, : { path, name: repoNameFromPath(path), branch: null, ahead: 0, behind: 0, changed: 0, lastOpened: 0 };
lastOpened: 0, });
}));
recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]); recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]);
} catch { } catch {
repoTabs = []; repoTabs = [];
recentRepoPaths = []; 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 { function defaultAiSettings(): AiSettings {
return { return {
provider: "openai", provider: "openai",
@@ -831,6 +975,16 @@
? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab) ? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab)
: [...repoTabs, next]; : [...repoTabs, next];
rememberRecentRepo(path); 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) { function resetRepositoryState(clearActive = false) {
@@ -941,6 +1095,10 @@
branches = prefetched ?? (await listBranches(path)); branches = prefetched ?? (await listBranches(path));
} }
async function refreshTags(path = activeRepoPath, prefetched?: GitTag[]) {
tags = prefetched ?? (await listTags(path));
}
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) { async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
stashes = prefetched ?? (await listStashes(path)); stashes = prefetched ?? (await listStashes(path));
} }
@@ -1039,10 +1197,12 @@
if (globalSearchBusy) void cancelGlobalSearch(); if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository"; activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(activeRepoPath, bundle.branches);
await refreshTags(activeRepoPath, bundle.tags);
await refreshStashes(activeRepoPath, bundle.stashes); await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); await refreshExplorerFiles(activeRepoPath, bundle.files);
lastRepoSwitchAt = Date.now(); lastRepoSwitchAt = Date.now();
void backgroundFetchRepo(activeRepoPath);
}); });
} }
@@ -1106,6 +1266,7 @@
if (globalSearchBusy) void cancelGlobalSearch(); if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository"; activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(activeRepoPath, bundle.branches);
await refreshTags(activeRepoPath, bundle.tags);
await refreshStashes(activeRepoPath, bundle.stashes); await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); await refreshExplorerFiles(activeRepoPath, bundle.files);
@@ -1151,6 +1312,7 @@
function openRepoManagement() { function openRepoManagement() {
if (isBusy) return; if (isBusy) return;
activeView = "management"; activeView = "management";
void backgroundRepoStatusTick(false);
} }
async function selectRepoTab(path: string) { async function selectRepoTab(path: string) {
@@ -1183,6 +1345,11 @@
if (isBusy) return; if (isBusy) return;
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path)); recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
persistRepoLists(); persistRepoLists();
if (repoStatusCache[repoKey(path)]) {
const { [repoKey(path)]: _removed, ...rest } = repoStatusCache;
repoStatusCache = rest;
persistRepoStatusCache();
}
if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) { if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) {
await closeRepoTab(path); await closeRepoTab(path);
} }
@@ -1327,7 +1494,7 @@
} }
async function rebaseOnto(branch: GitBranchInfo) { 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 () => { await runOperation(`Rebasing onto ${branch.name}`, async () => {
applyStatus(await rebaseBranch(activeRepoPath, branch.name)); applyStatus(await rebaseBranch(activeRepoPath, branch.name));
await refreshBranchList(activeRepoPath); 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. // Resolve the keychain key (host/org) for the active repo's remote.
async function currentCredKey(): Promise<string | null> { async function currentCredKey(): Promise<string | null> {
if (!activeRepoPath) return null; if (!activeRepoPath) return null;
@@ -1774,7 +2019,8 @@
async function commitChanges() { async function commitChanges() {
const message = commitMessage.trim(); const message = commitMessage.trim();
if (!message || !activeRepoPath) return; if (!activeRepoPath) return;
if (!amendMode && !message) return;
if (hasConflicts) { if (hasConflicts) {
errorMessage = "Resolve all conflicts before committing."; errorMessage = "Resolve all conflicts before committing.";
return; return;
@@ -1783,6 +2029,26 @@
errorMessage = "A rebase is in progress. Use Rebase continue or abort the rebase."; errorMessage = "A rebase is in progress. Use Rebase continue or abort the rebase.";
return; 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 () => { await runOperation("Committing", async () => {
applyStatus(await commit(activeRepoPath, message)); applyStatus(await commit(activeRepoPath, message));
commitMessage = ""; 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 ───────────────────────────────────────────────────────── // ── Commit restore ─────────────────────────────────────────────────────────
async function restoreCommit(target: GitCommit) { async function restoreCommit(target: GitCommit) {
@@ -2201,7 +2511,7 @@
</section> </section>
{/if} {/if}
{#if workspaceActive && hasConflicts && !rebaseInProgress} {#if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
<section class="notice conflict" role="alert"> <section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" /> <GitMerge size={17} aria-hidden="true" />
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span> <span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
@@ -2228,6 +2538,25 @@
</section> </section>
{/if} {/if}
{#if workspaceActive && cherryPickInProgress}
<section class="notice rebase" role="status">
<Cherry size={17} aria-hidden="true" />
<span>
Cherry-pick in progress.
{#if hasConflicts}
Resolve conflicts, then continue.
{:else}
Continue when the index is ready, or abort to return to the previous state.
{/if}
</span>
{#if hasConflicts}
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
{/if}
<button type="button" onclick={continueCherryPick} disabled={isBusy || hasConflicts}>Continue</button>
<button type="button" onclick={abortCherryPick} disabled={isBusy}>Abort</button>
</section>
{/if}
{#if activeView === "management"} {#if activeView === "management"}
<section class="repo-management" aria-label="Repository Management"> <section class="repo-management" aria-label="Repository Management">
<div class="repo-management-head"> <div class="repo-management-head">
@@ -2305,7 +2634,12 @@
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}> <button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
<span class="repo-row-name">{repo.name}</span> <span class="repo-row-name">{repo.name}</span>
<span class="repo-row-path">{repo.path}</span> <span class="repo-row-path">{repo.path}</span>
<span class="repo-row-meta quiet">recent</span> <span class="repo-row-meta">
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">recent</em>{/if}
{#if repo.ahead > 0}<em class="ahead">{repo.ahead}</em>{/if}
{#if repo.behind > 0}<em class="behind">{repo.behind}</em>{/if}
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
</span>
</button> </button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}> <button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
<X size={14} aria-hidden="true" /> <X size={14} aria-hidden="true" />
@@ -2331,7 +2665,10 @@
<span class="repo-row-name">{repo.name}</span> <span class="repo-row-name">{repo.name}</span>
<span class="repo-row-path">{repo.path}</span> <span class="repo-row-path">{repo.path}</span>
<span class="repo-row-meta"> <span class="repo-row-meta">
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em>known repo</em>{/if} {#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">known repo</em>{/if}
{#if repo.ahead > 0}<em class="ahead">{repo.ahead}</em>{/if}
{#if repo.behind > 0}<em class="behind">{repo.behind}</em>{/if}
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
</span> </span>
</button> </button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}> <button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}>
@@ -2354,6 +2691,7 @@
{branches} {branches}
{localBranches} {localBranches}
{remoteBranches} {remoteBranches}
{tags}
{hasRepository} {hasRepository}
{isBusy} {isBusy}
onCheckout={checkout} onCheckout={checkout}
@@ -2362,6 +2700,9 @@
onCreateBranch={createNewBranch} onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch} onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch} onDeleteBranch={deleteLocalBranch}
onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag}
/> />
<StashPanel <StashPanel
{stashes} {stashes}
@@ -2451,10 +2792,14 @@
commitAiProvider={aiSettings.provider} commitAiProvider={aiSettings.provider}
{commitAiPhase} {commitAiPhase}
{commitAiGenerating} {commitAiGenerating}
{canAmend}
{amendMode}
onCommit={commitChanges} onCommit={commitChanges}
onCommitMessageChange={updateCommitMessage} onCommitMessageChange={updateCommitMessage}
onGenerateCommitMessage={generateCommitMessageWithAi} onGenerateCommitMessage={generateCommitMessageWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }} onOpenAiSettings={() => { aiSettingsOpen = true; }}
onToggleAmend={toggleAmendMode}
onUndoLastCommit={undoLastCommitChange}
/> />
</div> </div>
</section> </section>
@@ -2483,6 +2828,7 @@
{commits} {commits}
{localBranchNames} {localBranchNames}
activeBranch={status?.current_branch ?? ""} activeBranch={status?.current_branch ?? ""}
activeUpstream={status?.upstream ?? ""}
repositoryKey={activeRepoPath} repositoryKey={activeRepoPath}
{hasRepository} {hasRepository}
{isBusy} {isBusy}
@@ -2490,6 +2836,7 @@
onRestoreCommit={restoreCommit} onRestoreCommit={restoreCommit}
onPreviewCommitFile={previewCommitFileFromHistory} onPreviewCommitFile={previewCommitFileFromHistory}
onCreateBranchFromCommit={openNewBranchDialog} onCreateBranchFromCommit={openNewBranchDialog}
onCherryPickCommit={cherryPickFromCommit}
onToggleCommitFiles={(hash) => { onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes); const next = new Set(expandedCommitHashes);
if (next.has(hash)) next.delete(hash); else next.add(hash); if (next.has(hash)) next.delete(hash); else next.add(hash);
+97 -2
View File
@@ -833,8 +833,8 @@
font-style: normal; font-style: normal;
font-weight: 800; font-weight: 800;
} }
.repo-row-meta .ahead { color: #4eca76; } .repo-row-meta .ahead { color: #e0a040; background: rgba(224,160,64,0.13); }
.repo-row-meta .behind { color: #e0a040; } .repo-row-meta .behind { color: #7aacff; background: rgba(122,172,255,0.13); }
.repo-row-icon { .repo-row-icon {
min-height: 36px; min-height: 36px;
min-width: 36px; min-width: 36px;
@@ -1550,6 +1550,12 @@
font-size: 10px; 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 { .branch-empty {
padding: 8px 10px; padding: 8px 10px;
color: var(--color-ink-faint); color: var(--color-ink-faint);
@@ -1801,6 +1807,56 @@
resize: none; resize: none;
overflow: auto; 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 { .commit-actions-row {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
@@ -2185,6 +2241,15 @@
.graph-svg path.hidden-branch { .graph-svg path.hidden-branch {
opacity: 0.08; 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 { .graph-dot {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
@@ -2202,6 +2267,14 @@
opacity: 0.16; opacity: 0.16;
box-shadow: none; 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.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
.graph-dot.merge { .graph-dot.merge {
width: 15px; width: 15px;
@@ -2247,6 +2320,22 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 { .graph-hover-branches svg {
flex: 0 0 auto; flex: 0 0 auto;
color: #76d995; color: #76d995;
@@ -2260,6 +2349,12 @@
transition: background 120ms ease; transition: background 120ms ease;
} }
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); } .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 .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 { opacity: 1; stroke-width: 2.65; }
.graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; } .graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; }
+183 -5
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte"; import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types"; import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode; type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -45,6 +45,7 @@
branches: GitBranchInfo[]; branches: GitBranchInfo[];
localBranches: GitBranchInfo[]; localBranches: GitBranchInfo[];
remoteBranches: GitBranchInfo[]; remoteBranches: GitBranchInfo[];
tags: GitTag[];
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void; onCheckout: (branch: GitBranchInfo) => void;
@@ -53,12 +54,16 @@
onCreateBranch: (branchName: string) => void | Promise<void>; onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>; onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>; onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
} }
let { let {
branches = [], branches = [],
localBranches = [], localBranches = [],
remoteBranches = [], remoteBranches = [],
tags = [],
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
onCheckout = () => {}, onCheckout = () => {},
@@ -67,17 +72,28 @@
onCreateBranch = () => {}, onCreateBranch = () => {},
onRenameBranch = () => {}, onRenameBranch = () => {},
onDeleteBranch = () => {}, onDeleteBranch = () => {},
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
}: Props = $props(); }: Props = $props();
let localOpen = $state(true); let localOpen = $state(true);
let remoteOpen = $state(false); let remoteOpen = $state(false);
let tagsOpen = $state(false);
let createOpen = $state(false); let createOpen = $state(false);
let newBranchName = $state(""); let newBranchName = $state("");
let createInput = $state<HTMLInputElement | null>(null); let createInput = $state<HTMLInputElement | null>(null);
let tagCreateOpen = $state(false);
let newTagName = $state("");
let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null);
let panelElement = $state<HTMLElement | null>(null); let panelElement = $state<HTMLElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null); let contextBranch = $state<GitBranchInfo | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
let contextTag = $state<GitTag | null>(null);
let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set()); let collapsedBranchFolders = $state<Set<string>>(new Set());
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local")); let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
@@ -270,12 +286,74 @@
await onRebase(branch); await onRebase(branch);
} }
function openTagCreateForm() {
if (!hasRepository || isBusy) return;
tagCreateOpen = true;
queueMicrotask(() => tagCreateInput?.focus());
}
function closeTagCreateForm() {
tagCreateOpen = false;
newTagName = "";
newTagMessage = "";
}
async function submitCreateTag(event: SubmitEvent) {
event.preventDefault();
const name = newTagName.trim();
if (!name || !hasRepository || isBusy) return;
await onCreateTag(name, newTagMessage.trim());
newTagName = "";
newTagMessage = "";
tagCreateOpen = false;
tagsOpen = true;
}
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
contextTag = tag;
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
}
function closeTagContextMenu() {
contextTag = null;
}
async function pushContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onPushTag(tag);
}
async function deleteContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onDeleteTag(tag);
}
function closeAllContextMenus() {
closeBranchContextMenu();
closeTagContextMenu();
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu(); if (event.key === "Escape") closeAllContextMenus();
} }
</script> </script>
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} /> <svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches"> <section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
<div class="section-head"> <div class="section-head">
@@ -300,7 +378,7 @@
{#if !hasRepository} {#if !hasRepository}
<p class="blank-state">Open a repository to list branches.</p> <p class="blank-state">Open a repository to list branches.</p>
{:else if branches.length === 0} {:else if branches.length === 0 && tags.length === 0}
<p class="blank-state">No branches returned.</p> <p class="blank-state">No branches returned.</p>
{:else} {:else}
<div class="branch-list overflow-auto p-2 flex flex-col gap-0"> <div class="branch-list overflow-auto p-2 flex flex-col gap-0">
@@ -458,6 +536,86 @@
{/if} {/if}
{/if} {/if}
</div> </div>
<div class="branch-group">
<div class="tag-group-head">
<button
class="branch-group-toggle"
type="button"
onclick={() => { tagsOpen = !tagsOpen; }}
aria-expanded={tagsOpen}
>
{#if tagsOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Tags</span>
<span class="branch-group-count">{tags.length}</span>
</button>
<button
class="branch-create-toggle"
type="button"
onclick={openTagCreateForm}
disabled={!hasRepository || isBusy}
title="Create new tag"
aria-label="Create new tag"
>
<Plus size={13} aria-hidden="true" />
</button>
</div>
{#if tagCreateOpen}
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
<TagIcon size={15} aria-hidden="true" />
<input
bind:this={tagCreateInput}
bind:value={newTagName}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="v1.0.0"
aria-label="New tag name"
/>
<input
bind:value={newTagMessage}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="Message (optional)"
aria-label="Tag message"
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if tagsOpen}
{#if tags.length === 0}
<div class="branch-empty">No tags.</div>
{:else}
{#each tags as tag (tag.name)}
<article
class="branch-row"
oncontextmenu={(event) => openTagContextMenu(event, tag)}
title={tag.message ?? tag.name}
>
<div class="branch-info">
<TagIcon size={16} aria-hidden="true" />
<div>
<strong>{tag.name}</strong>
<span>{tag.short_hash}</span>
</div>
</div>
</article>
{/each}
{/if}
{/if}
</div>
</div> </div>
{/if} {/if}
@@ -499,4 +657,24 @@
</button> </button>
</div> </div>
{/if} {/if}
{#if contextTag}
<div
class="branch-context-menu"
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextTag.name}`}
>
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
<Upload size={14} aria-hidden="true" />
Push to remote
</button>
<div class="menu-separator" role="separator"></div>
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
<Trash2 size={14} aria-hidden="true" />
Delete
</button>
</div>
{/if}
</section> </section>
+35 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Check, LoaderCircle, Settings, Sparkles } from "@lucide/svelte"; import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types"; import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props { interface Props {
@@ -13,10 +13,14 @@
commitAiProvider: CommitAiProvider; commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase; commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean; commitAiGenerating: boolean;
canAmend: boolean;
amendMode: boolean;
onCommit: () => void; onCommit: () => void;
onCommitMessageChange: (msg: string) => void; onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void; onGenerateCommitMessage: () => void;
onOpenAiSettings: () => void; onOpenAiSettings: () => void;
onToggleAmend: (checked: boolean) => void;
onUndoLastCommit: () => void;
} }
let { let {
@@ -30,10 +34,14 @@
commitAiProvider = "local", commitAiProvider = "local",
commitAiPhase = "idle", commitAiPhase = "idle",
commitAiGenerating = false, commitAiGenerating = false,
canAmend = false,
amendMode = false,
onCommit = () => {}, onCommit = () => {},
onCommitMessageChange = () => {}, onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {}, onGenerateCommitMessage = () => {},
onOpenAiSettings = () => {}, onOpenAiSettings = () => {},
onToggleAmend = () => {},
onUndoLastCommit = () => {},
}: Props = $props(); }: Props = $props();
function handleSubmit(event: SubmitEvent) { function handleSubmit(event: SubmitEvent) {
@@ -77,14 +85,37 @@
{#if commitBlockReason} {#if commitBlockReason}
<p class="commit-block-reason">{commitBlockReason}</p> <p class="commit-block-reason">{commitBlockReason}</p>
{/if} {/if}
{#if canAmend}
<div class="commit-amend-row">
<label class="commit-amend-toggle">
<input
type="checkbox"
checked={amendMode}
disabled={isBusy}
onchange={(e) => onToggleAmend((e.target as HTMLInputElement).checked)}
/>
Amend last commit
</label>
<button
class="btn-secondary commit-undo-button"
type="button"
onclick={onUndoLastCommit}
disabled={isBusy}
title="Undo the last commit — its changes come back as uncommitted changes (not pushed yet, so safe)"
>
<RotateCcw size={13} aria-hidden="true" />
Undo last commit
</button>
</div>
{/if}
<div class="commit-actions-row flex-shrink-0"> <div class="commit-actions-row flex-shrink-0">
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}> <button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || (amendMode ? "Amend the last commit" : "Commit staged changes")}>
{#if operation === "Committing"} {#if operation === "Committing" || operation === "Amending"}
<LoaderCircle class="spin" size={16} aria-hidden="true" /> <LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else} {:else}
<Check size={16} aria-hidden="true" /> <Check size={16} aria-hidden="true" />
{/if} {/if}
Commit {amendMode ? "Amend" : "Commit"}
</button> </button>
<button <button
class="btn-secondary commit-ai-button" class="btn-secondary commit-ai-button"
+1 -1
View File
@@ -35,7 +35,7 @@
let username = $state(""); let username = $state("");
let password = $state(""); let password = $state("");
let showPassword = $state(false); let showPassword = $state(false);
let saveSession = $state(false); let saveSession = $state(true);
let expiresAt = $state(""); let expiresAt = $state("");
let canSubmit = $derived( let canSubmit = $derived(
+70 -8
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte"; import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types"; import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment { interface GraphSegment {
@@ -32,6 +32,7 @@
commits: GitCommit[]; commits: GitCommit[];
localBranchNames: string[]; localBranchNames: string[];
activeBranch: string; activeBranch: string;
activeUpstream: string;
repositoryKey: string; repositoryKey: string;
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
@@ -40,12 +41,14 @@
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void; onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void; onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void; onCreateBranchFromCommit: (commit: GitCommit) => void;
onCherryPickCommit: (commit: GitCommit) => void;
} }
let { let {
commits = [], commits = [],
localBranchNames = [], localBranchNames = [],
activeBranch = "", activeBranch = "",
activeUpstream = "",
repositoryKey = "", repositoryKey = "",
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
@@ -54,6 +57,7 @@
onPreviewCommitFile = () => {}, onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {}, onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {}, onCreateBranchFromCommit = () => {},
onCherryPickCommit = () => {},
}: Props = $props(); }: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set()); let hiddenGraphBranches = $state<Set<string>>(new Set());
@@ -199,7 +203,13 @@
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
let graphBranchNameSet = $derived(new Set(graphBranchNames));
function branchIsVisible(branch: string): boolean { function branchIsVisible(branch: string): boolean {
if (branch === activeUpstream && activeUpstream) {
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
}
return !hiddenGraphBranches.has(branch); return !hiddenGraphBranches.has(branch);
} }
@@ -208,7 +218,7 @@
} }
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] { function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
const directBranches = localBranchRefs(commit); const directBranches = graphBranchRefs(commit);
if (directBranches.length > 0) return directBranches; if (directBranches.length > 0) return directBranches;
const containingBranches = row?.branchLabels ?? []; const containingBranches = row?.branchLabels ?? [];
@@ -217,7 +227,7 @@
} }
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string { function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
const directBranches = localBranchRefs(commit); const directBranches = graphBranchRefs(commit);
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`; if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
const containingBranches = row?.branchLabels ?? []; const containingBranches = row?.branchLabels ?? [];
@@ -229,8 +239,17 @@
return segment.branches.length === 0 || segment.branches.some(branchIsVisible); return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
} }
function syncClassForBranches(labels: string[]): "" | "ahead" | "behind" {
if (!activeBranch || !activeUpstream) return "";
const hasLocal = labels.includes(activeBranch);
const hasRemote = labels.includes(activeUpstream);
if (hasLocal && !hasRemote) return "ahead";
if (hasRemote && !hasLocal) return "behind";
return "";
}
function branchesAreVisible(branches: string[]): boolean { function branchesAreVisible(branches: string[]): boolean {
if (localBranchNames.length === 0) return true; if (graphBranchNames.length === 0) return true;
return branches.some(branchIsVisible); return branches.some(branchIsVisible);
} }
@@ -262,7 +281,7 @@
const membership = new Map<string, Set<string>>(); const membership = new Map<string, Set<string>>();
for (const commit of items) { for (const commit of items) {
for (const branch of localBranchRefs(commit)) { for (const branch of graphBranchRefs(commit)) {
const stack = [commit.hash]; const stack = [commit.hash];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -287,7 +306,7 @@
return new Map( return new Map(
items.map((commit) => [ items.map((commit) => [
commit.hash, commit.hash,
localBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)), graphBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
]), ]),
); );
} }
@@ -380,6 +399,13 @@
await onRestoreCommit(commit); await onRestoreCommit(commit);
} }
async function cherryPickContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onCherryPickCommit(commit);
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
closeCommitContextMenu(); closeCommitContextMenu();
@@ -398,6 +424,18 @@
return labels; return labels;
} }
function graphBranchRefs(commit: GitCommit): string[] {
const seen = new Set<string>();
const labels: string[] = [];
for (const ref of commit.refs) {
const label = refLabel(ref);
if (!graphBranchNameSet.has(label) || seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels;
}
function visibleRefs(commit: GitCommit): string[] { function visibleRefs(commit: GitCommit): string[] {
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref))); return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
} }
@@ -431,7 +469,7 @@
$effect(() => { $effect(() => {
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch) const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
? activeBranch ? activeBranch
: (localBranchNames[0] ?? ""); : (graphBranchNames[0] ?? "");
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`; const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
if (!defaultBranch) { if (!defaultBranch) {
@@ -503,8 +541,11 @@
{@const row = graphRows[rowIndex]} {@const row = graphRows[rowIndex]}
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)} {@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
{@const otherRefs = visibleRefs(item)} {@const otherRefs = visibleRefs(item)}
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
<article <article
class="commit-row graph-row" class="commit-row graph-row"
class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"}
class:merge-row={item.parents.length > 1} class:merge-row={item.parents.length > 1}
class:root-row={item.parents.length === 0} class:root-row={item.parents.length === 0}
class:tip-row={item.refs.length > 0} class:tip-row={item.refs.length > 0}
@@ -515,6 +556,8 @@
{#each row.top as seg} {#each row.top as seg}
<path <path
class:hidden-branch={!segmentIsVisible(seg)} class:hidden-branch={!segmentIsVisible(seg)}
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
d={graphPath(seg, 0, 50)} d={graphPath(seg, 0, 50)}
stroke={seg.color} stroke={seg.color}
stroke-width="2.2" stroke-width="2.2"
@@ -524,6 +567,8 @@
{#each row.bottom as seg} {#each row.bottom as seg}
<path <path
class:hidden-branch={!segmentIsVisible(seg)} class:hidden-branch={!segmentIsVisible(seg)}
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
d={graphPath(seg, 50, 100)} d={graphPath(seg, 50, 100)}
stroke={seg.color} stroke={seg.color}
stroke-width="2.2" stroke-width="2.2"
@@ -536,13 +581,20 @@
class:merge={item.parents.length > 1} class:merge={item.parents.length > 1}
class:tip={item.refs.length > 0} class:tip={item.refs.length > 0}
class:hidden-branch={!rowGraphIsVisible(row)} class:hidden-branch={!rowGraphIsVisible(row)}
class:ahead={rowSyncClass === "ahead"}
class:behind={rowSyncClass === "behind"}
title={commitHoverTitle(item, row)} title={commitHoverTitle(item, row)}
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`} style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
></span> ></span>
{#if hoverBranchRefs.length > 0} {#if hoverBranchRefs.length > 0}
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}> <div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
{#each hoverBranchRefs as branch} {#each hoverBranchRefs as branch}
<span title={branch}> <span
class:remote={branch === activeUpstream}
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
title={branch}
>
<GitBranch size={10} aria-hidden="true" /> <GitBranch size={10} aria-hidden="true" />
{branch} {branch}
</span> </span>
@@ -656,6 +708,16 @@
<RotateCcw size={14} aria-hidden="true" /> <RotateCcw size={14} aria-hidden="true" />
Restore Restore
</button> </button>
<button
type="button"
role="menuitem"
onclick={cherryPickContextCommit}
disabled={isBusy}
title="Apply this commit's changes on top of the current branch"
>
<Cherry size={14} aria-hidden="true" />
Cherry-pick
</button>
</div> </div>
{/if} {/if}
</section> </section>
+56
View File
@@ -12,6 +12,7 @@ import type {
GitSearchHit, GitSearchHit,
GitStash, GitStash,
GitStatus, GitStatus,
GitTag,
LocalModelOption, LocalModelOption,
PatchApplyAction, PatchApplyAction,
RepositoryBundle, RepositoryBundle,
@@ -94,6 +95,49 @@ export function deleteBranch(path: string, branch: string, force = false): Promi
return invoke<GitStatus>("delete_branch", { path, branch, force }); return invoke<GitStatus>("delete_branch", { path, branch, force });
} }
export function listTags(path: string): Promise<GitTag[]> {
return invoke<GitTag[]>("list_tags", { path });
}
export function createTag(
path: string,
name: string,
target?: string,
message?: string,
): Promise<GitTag[]> {
return invoke<GitTag[]>("create_tag", {
path,
name,
target: target?.trim() ? target.trim() : null,
message: message?.trim() ? message.trim() : null,
});
}
export function deleteTag(path: string, name: string): Promise<GitTag[]> {
return invoke<GitTag[]>("delete_tag", { path, name });
}
export function pushTag(
path: string,
name: string,
username?: string,
password?: string,
): Promise<void> {
return invoke<void>("push_tag", { path, name, username: username ?? null, password: password ?? null });
}
export function cherryPickCommit(path: string, commit: string): Promise<GitStatus> {
return invoke<GitStatus>("cherry_pick_commit", { path, commit });
}
export function cherryPickContinue(path: string): Promise<GitStatus> {
return invoke<GitStatus>("cherry_pick_continue", { path });
}
export function cherryPickAbort(path: string): Promise<GitStatus> {
return invoke<GitStatus>("cherry_pick_abort", { path });
}
export function stageFiles(path: string, files: string[]): Promise<GitStatus> { export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
return invoke<GitStatus>("stage_files", { path, files }); return invoke<GitStatus>("stage_files", { path, files });
} }
@@ -127,6 +171,18 @@ export function commit(path: string, message: string): Promise<GitStatus> {
return invoke<GitStatus>("commit", { path, message }); return invoke<GitStatus>("commit", { path, message });
} }
export function amendCommit(path: string, message?: string): Promise<GitStatus> {
return invoke<GitStatus>("amend_commit", { path, message: message?.trim() ? message.trim() : null });
}
export function lastCommitMessage(path: string): Promise<string | null> {
return invoke<string | null>("last_commit_message", { path });
}
export function undoLastCommit(path: string): Promise<GitStatus> {
return invoke<GitStatus>("undo_last_commit", { path });
}
export function stashPush( export function stashPush(
path: string, path: string,
message?: string, message?: string,
+11
View File
@@ -42,6 +42,7 @@ export interface GitStatus {
files: GitFileStatus[]; files: GitFileStatus[];
clean: boolean; clean: boolean;
rebase_in_progress: boolean; rebase_in_progress: boolean;
cherry_pick_in_progress: boolean;
} }
export interface GitFileStatus { export interface GitFileStatus {
@@ -59,6 +60,15 @@ export interface GitBranch {
remote: boolean; remote: boolean;
} }
export interface GitTag {
name: string;
hash: string;
short_hash: string;
message: string | null;
date: string;
annotated: boolean;
}
export interface GitStash { export interface GitStash {
selector: string; selector: string;
index: number; index: number;
@@ -95,6 +105,7 @@ export interface GitRepositoryFile {
export interface RepositoryBundle { export interface RepositoryBundle {
status: GitStatus; status: GitStatus;
branches: GitBranch[]; branches: GitBranch[];
tags: GitTag[];
stashes: GitStash[]; stashes: GitStash[];
commits: GitCommit[]; commits: GitCommit[];
files: GitRepositoryFile[]; files: GitRepositoryFile[];