Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fa57eea6f | ||
|
|
444a7acadd | ||
|
|
ac7cacd687 | ||
|
|
80dc66366d |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.8.1",
|
||||
"version": "2026.8.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitty",
|
||||
"version": "2026.8.1",
|
||||
"version": "2026.8.2",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.8.1",
|
||||
"version": "2026.8.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+297
-105
@@ -382,13 +382,13 @@ fn check_search_cancelled(cancellation: Option<&SearchCancellation>) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn init_repository(path: String, initial_branch: Option<String>) -> Result<GitStatus, String> {
|
||||
let path = PathBuf::from(path.trim());
|
||||
if path.as_os_str().is_empty() {
|
||||
@@ -416,13 +416,13 @@ pub fn init_repository(path: String, initial_branch: Option<String>) -> Result<G
|
||||
status_for_repo(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
open_path_in_file_manager(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(std::slice::from_ref(&file))?;
|
||||
@@ -448,6 +448,56 @@ pub struct RepositoryBundle {
|
||||
pub files: Vec<GitRepositoryFile>,
|
||||
}
|
||||
|
||||
async fn run_git_task<T, F>(context: &'static str, task: F) -> Result<T, String>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> Result<T, String> + Send + 'static,
|
||||
{
|
||||
tauri::async_runtime::spawn_blocking(task)
|
||||
.await
|
||||
.map_err(|error| format!("{context}: {error}"))?
|
||||
}
|
||||
|
||||
fn join_git_worker<T>(name: &str, result: thread::Result<Result<T, String>>) -> Result<T, String> {
|
||||
result.map_err(|_| format!("The {name} Git worker stopped unexpectedly."))?
|
||||
}
|
||||
|
||||
fn repository_bundle_for_repo(
|
||||
repo: &Path,
|
||||
commit_limit: Option<u32>,
|
||||
) -> Result<RepositoryBundle, String> {
|
||||
// Status is needed by the file tree. Once it is available, all remaining
|
||||
// reads are independent and can run concurrently. Each worker only starts
|
||||
// read-only Git processes, so this is safe while cutting the former
|
||||
// branches -> tags -> stashes -> commits -> files waterfall down to the
|
||||
// duration of its slowest member.
|
||||
let status = status_for_repo(repo)?;
|
||||
let (branches, tags, stashes, commits, files) = thread::scope(|scope| -> Result<_, String> {
|
||||
let branches = scope.spawn(|| branches_for_repo(repo));
|
||||
let tags = scope.spawn(|| tags_for_repo(repo));
|
||||
let stashes = scope.spawn(|| stashes_for_repo(repo));
|
||||
let commits = scope.spawn(|| commits_for_repo(repo, commit_limit));
|
||||
let files = scope.spawn(|| repository_files_with_status(repo, &status));
|
||||
|
||||
Ok((
|
||||
join_git_worker("branch", branches.join())?,
|
||||
join_git_worker("tag", tags.join())?,
|
||||
join_git_worker("stash", stashes.join())?,
|
||||
join_git_worker("history", commits.join())?,
|
||||
join_git_worker("file tree", files.join())?,
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(RepositoryBundle {
|
||||
status,
|
||||
branches,
|
||||
tags,
|
||||
stashes,
|
||||
commits,
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clone_repository(
|
||||
remote_url: String,
|
||||
@@ -481,40 +531,32 @@ pub async fn open_repository_bundle(
|
||||
path: String,
|
||||
commit_limit: Option<u32>,
|
||||
) -> Result<RepositoryBundle, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<RepositoryBundle, String> {
|
||||
run_git_task("Could not load repository", move || {
|
||||
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,
|
||||
})
|
||||
repository_bundle_for_repo(&repo, commit_limit)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not load repository: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_status(path: String) -> Result<GitStatus, String> {
|
||||
pub async fn get_status(path: String) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not refresh repository status", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
status_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
||||
pub async fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
||||
run_git_task("Could not load branches", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
branches_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn list_remotes(path: String) -> Result<Vec<GitRemote>, String> {
|
||||
log::info!(target: "gitty::remote", "list_remotes invoked: path={path:?}");
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -535,7 +577,7 @@ pub fn list_remotes(path: String) -> Result<Vec<GitRemote>, String> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn add_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let name = validate_remote_name(&repo, &name, false)?;
|
||||
@@ -544,7 +586,7 @@ pub fn add_remote(path: String, name: String, url: String) -> Result<Vec<GitRemo
|
||||
list_remotes(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn update_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let name = validate_remote_name(&repo, &name, true)?;
|
||||
@@ -553,7 +595,7 @@ pub fn update_remote(path: String, name: String, url: String) -> Result<Vec<GitR
|
||||
list_remotes(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn remove_remote(path: String, name: String) -> Result<Vec<GitRemote>, String> {
|
||||
log::info!(target: "gitty::remote", "remove_remote invoked: path={path:?}, name={name:?}");
|
||||
let result = (|| {
|
||||
@@ -574,7 +616,7 @@ pub fn remove_remote(path: String, name: String) -> Result<Vec<GitRemote>, Strin
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn set_branch_upstream(
|
||||
path: String,
|
||||
branch: String,
|
||||
@@ -607,7 +649,7 @@ pub fn set_branch_upstream(
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_remote_branch(
|
||||
path: String,
|
||||
remote: String,
|
||||
@@ -634,15 +676,21 @@ pub fn delete_remote_branch(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
||||
pub async fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
||||
run_git_task("Could not load stashes", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
stashes_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
|
||||
pub async fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
|
||||
run_git_task("Could not load tags", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
tags_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn tags_for_repo(repo: &Path) -> Result<Vec<GitTag>, String> {
|
||||
@@ -790,11 +838,12 @@ fn parse_stash_subject(subject: &str) -> (Option<String>, String) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stash_push(
|
||||
pub async fn stash_push(
|
||||
path: String,
|
||||
message: Option<String>,
|
||||
include_untracked: bool,
|
||||
) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not stash changes", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let trimmed_message = message.unwrap_or_default().trim().to_string();
|
||||
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
|
||||
@@ -808,21 +857,32 @@ pub fn stash_push(
|
||||
|
||||
run_git(&repo, args)?;
|
||||
status_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
|
||||
pub async fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not apply stash", move || {
|
||||
run_stash_update(path, "apply", selector)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stash_pop(path: String, selector: String) -> Result<GitStatus, String> {
|
||||
pub async fn stash_pop(path: String, selector: String) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not pop stash", move || {
|
||||
run_stash_update(path, "pop", selector)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stash_drop(path: String, selector: String) -> Result<GitStatus, String> {
|
||||
pub async fn stash_drop(path: String, selector: String) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not drop stash", move || {
|
||||
run_stash_update(path, "drop", selector)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn run_stash_update(path: String, action: &str, selector: String) -> Result<GitStatus, String> {
|
||||
@@ -859,7 +919,14 @@ fn validate_stash_selector(selector: &str) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
pub async fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not check out branch", move || {
|
||||
checkout_branch_core(path, branch)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn checkout_branch_core(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let branch = branch.trim().to_string();
|
||||
if branch.is_empty() {
|
||||
@@ -885,7 +952,18 @@ pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_branch(
|
||||
pub async fn create_branch(
|
||||
path: String,
|
||||
branch: String,
|
||||
start_point: Option<String>,
|
||||
) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not create branch", move || {
|
||||
create_branch_core(path, branch, start_point)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn create_branch_core(
|
||||
path: String,
|
||||
branch: String,
|
||||
start_point: Option<String>,
|
||||
@@ -906,7 +984,18 @@ pub fn create_branch(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_branch(
|
||||
pub async fn rename_branch(
|
||||
path: String,
|
||||
old_branch: String,
|
||||
new_branch: String,
|
||||
) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not rename branch", move || {
|
||||
rename_branch_core(path, old_branch, new_branch)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn rename_branch_core(
|
||||
path: String,
|
||||
old_branch: String,
|
||||
new_branch: String,
|
||||
@@ -929,7 +1018,18 @@ pub fn rename_branch(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_branch(
|
||||
pub async fn delete_branch(
|
||||
path: String,
|
||||
branch: String,
|
||||
force: Option<bool>,
|
||||
) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not delete branch", move || {
|
||||
delete_branch_core(path, branch, force)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn delete_branch_core(
|
||||
path: String,
|
||||
branch: String,
|
||||
force: Option<bool>,
|
||||
@@ -1070,13 +1170,43 @@ fn worktrees_for_repo(repo: &Path) -> Result<Vec<GitWorktree>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
|
||||
pub async fn list_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
|
||||
run_git_task("Could not load worktrees", move || {
|
||||
list_worktrees_core(path)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn list_worktrees_core(path: String) -> Result<Vec<GitWorktree>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
worktrees_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_worktree(
|
||||
pub async fn add_worktree(
|
||||
path: String,
|
||||
worktree_path: String,
|
||||
branch: Option<String>,
|
||||
new_branch: Option<String>,
|
||||
start_point: Option<String>,
|
||||
detached: Option<bool>,
|
||||
lock: Option<bool>,
|
||||
) -> Result<Vec<GitWorktree>, String> {
|
||||
run_git_task("Could not add worktree", move || {
|
||||
add_worktree_core(
|
||||
path,
|
||||
worktree_path,
|
||||
branch,
|
||||
new_branch,
|
||||
start_point,
|
||||
detached,
|
||||
lock,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn add_worktree_core(
|
||||
path: String,
|
||||
worktree_path: String,
|
||||
branch: Option<String>,
|
||||
@@ -1136,7 +1266,18 @@ pub fn add_worktree(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_worktree(
|
||||
pub async fn remove_worktree(
|
||||
path: String,
|
||||
worktree_path: String,
|
||||
force: Option<bool>,
|
||||
) -> Result<Vec<GitWorktree>, String> {
|
||||
run_git_task("Could not remove worktree", move || {
|
||||
remove_worktree_core(path, worktree_path, force)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn remove_worktree_core(
|
||||
path: String,
|
||||
worktree_path: String,
|
||||
force: Option<bool>,
|
||||
@@ -1168,7 +1309,18 @@ pub fn remove_worktree(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn move_worktree(
|
||||
pub async fn move_worktree(
|
||||
path: String,
|
||||
worktree_path: String,
|
||||
destination: String,
|
||||
) -> Result<Vec<GitWorktree>, String> {
|
||||
run_git_task("Could not move worktree", move || {
|
||||
move_worktree_core(path, worktree_path, destination)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn move_worktree_core(
|
||||
path: String,
|
||||
worktree_path: String,
|
||||
destination: String,
|
||||
@@ -1191,7 +1343,18 @@ pub fn move_worktree(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn lock_worktree(
|
||||
pub async fn lock_worktree(
|
||||
path: String,
|
||||
worktree_path: String,
|
||||
reason: Option<String>,
|
||||
) -> Result<Vec<GitWorktree>, String> {
|
||||
run_git_task("Could not lock worktree", move || {
|
||||
lock_worktree_core(path, worktree_path, reason)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn lock_worktree_core(
|
||||
path: String,
|
||||
worktree_path: String,
|
||||
reason: Option<String>,
|
||||
@@ -1211,28 +1374,59 @@ pub fn lock_worktree(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn unlock_worktree(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
|
||||
pub async fn unlock_worktree(
|
||||
path: String,
|
||||
worktree_path: String,
|
||||
) -> Result<Vec<GitWorktree>, String> {
|
||||
run_git_task("Could not unlock worktree", move || {
|
||||
unlock_worktree_core(path, worktree_path)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn unlock_worktree_core(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
run_git(&repo, ["worktree", "unlock", "--", worktree_path.as_str()])?;
|
||||
worktrees_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn prune_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
|
||||
pub async fn prune_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
|
||||
run_git_task("Could not prune worktrees", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
run_git(&repo, ["worktree", "prune"])?;
|
||||
worktrees_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn repair_worktree(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
|
||||
pub async fn repair_worktree(
|
||||
path: String,
|
||||
worktree_path: String,
|
||||
) -> Result<Vec<GitWorktree>, String> {
|
||||
run_git_task("Could not repair worktree", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?;
|
||||
worktrees_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_tag(
|
||||
pub async fn create_tag(
|
||||
path: String,
|
||||
name: String,
|
||||
target: Option<String>,
|
||||
message: Option<String>,
|
||||
) -> Result<Vec<GitTag>, String> {
|
||||
run_git_task("Could not create tag", move || {
|
||||
create_tag_core(path, name, target, message)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn create_tag_core(
|
||||
path: String,
|
||||
name: String,
|
||||
target: Option<String>,
|
||||
@@ -1271,20 +1465,24 @@ pub fn create_tag(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
|
||||
pub async fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
|
||||
run_git_task("Could not delete tag", move || {
|
||||
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)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn push_tag(
|
||||
pub async fn push_tag(
|
||||
path: String,
|
||||
name: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
run_git_task("Could not push tag", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let name = validate_existing_tag_name(&repo, &name)?;
|
||||
let remote = initial_push_remote_name(&repo)?;
|
||||
@@ -1301,6 +1499,8 @@ pub fn push_tag(
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn validate_new_tag_name(repo: &Path, name: &str) -> Result<String, String> {
|
||||
@@ -1435,7 +1635,7 @@ pub async fn restore_files(
|
||||
.map_err(|err| format!("Could not restore files: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(std::slice::from_ref(&file))?;
|
||||
@@ -1866,7 +2066,7 @@ pub async fn commit_ai_review(
|
||||
parse_ai_review(&raw)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn apply_file_patch(
|
||||
path: String,
|
||||
file: String,
|
||||
@@ -1951,7 +2151,7 @@ pub async fn amend_commit(path: String, message: Option<String>) -> Result<GitSt
|
||||
.map_err(|err| format!("Could not amend commit: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if verify_commit(&repo, "HEAD").is_err() {
|
||||
@@ -1967,7 +2167,7 @@ pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if verify_commit(&repo, "HEAD").is_err() {
|
||||
@@ -2146,7 +2346,7 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
|
||||
|
||||
/// Returns the remote URL used for auth key derivation (upstream remote of the
|
||||
/// current branch, falling back to `origin`, then the first configured remote).
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let remote = upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string());
|
||||
@@ -2301,7 +2501,7 @@ fn push_args_for_repo_to(
|
||||
])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
|
||||
let entry = cred_entry(&key)?;
|
||||
match entry.get_password() {
|
||||
@@ -2315,7 +2515,7 @@ pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn cred_save(
|
||||
key: String,
|
||||
username: String,
|
||||
@@ -2336,7 +2536,7 @@ pub fn cred_save(
|
||||
.map_err(|err| format!("Saving to keychain failed: {err}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn cred_delete(key: String) -> Result<(), String> {
|
||||
let entry = cred_entry(&key)?;
|
||||
match entry.delete_credential() {
|
||||
@@ -2402,7 +2602,7 @@ pub async fn merge_branch(
|
||||
.map_err(|err| format!("Could not merge: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn merge_continue(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if !merge_in_progress(&repo) {
|
||||
@@ -2415,7 +2615,7 @@ pub fn merge_continue(path: String) -> Result<GitStatus, String> {
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn merge_abort(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if !merge_in_progress(&repo) {
|
||||
@@ -2425,7 +2625,7 @@ pub fn merge_abort(path: String) -> Result<GitStatus, String> {
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn revert_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let commit = verify_commit(&repo, &commit)?;
|
||||
@@ -2470,7 +2670,7 @@ pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, St
|
||||
.map_err(|err| format!("Could not rebase: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn list_interactive_rebase_commits(
|
||||
path: String,
|
||||
base: String,
|
||||
@@ -2550,7 +2750,7 @@ pub async fn start_interactive_rebase(
|
||||
.map_err(|err| format!("Could not run interactive rebase: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if !rebase_in_progress(&repo) {
|
||||
@@ -2580,7 +2780,7 @@ pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if !rebase_in_progress(&repo) {
|
||||
@@ -2592,7 +2792,7 @@ pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn list_reflog(path: String, limit: Option<u32>) -> Result<Vec<ReflogEntry>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let limit = limit.unwrap_or(250).clamp(1, 1_000).to_string();
|
||||
@@ -2610,7 +2810,7 @@ pub fn list_reflog(path: String, limit: Option<u32>) -> Result<Vec<ReflogEntry>,
|
||||
parse_reflog(&output)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn restore_reflog_entry(
|
||||
path: String,
|
||||
commit: String,
|
||||
@@ -2870,7 +3070,7 @@ pub async fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatu
|
||||
.map_err(|err| format!("Could not cherry-pick: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn cherry_pick_continue(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if !cherry_pick_in_progress(&repo) {
|
||||
@@ -2888,7 +3088,7 @@ pub fn cherry_pick_continue(path: String) -> Result<GitStatus, String> {
|
||||
cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn cherry_pick_abort(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if !cherry_pick_in_progress(&repo) {
|
||||
@@ -2917,13 +3117,16 @@ fn cherry_pick_status_or_error(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_commits(
|
||||
pub async fn list_commits(
|
||||
path: String,
|
||||
limit: Option<u32>,
|
||||
skip: Option<u32>,
|
||||
) -> Result<Vec<GitCommit>, String> {
|
||||
run_git_task("Could not load commit history", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
commit_page_for_repo(&repo, limit, skip)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
||||
@@ -2968,9 +3171,12 @@ fn commit_page_for_repo(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_repository_files(path: String) -> Result<Vec<GitRepositoryFile>, String> {
|
||||
pub async fn list_repository_files(path: String) -> Result<Vec<GitRepositoryFile>, String> {
|
||||
run_git_task("Could not load repository files", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
repository_files(&repo)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -3020,7 +3226,7 @@ pub fn cancel_file_history(
|
||||
|
||||
const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000";
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn get_file_blame(path: String, file: String) -> Result<GitBlameResult, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(std::slice::from_ref(&file))?;
|
||||
@@ -3318,7 +3524,7 @@ fn search_code_introductions_core(
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn restore_to_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let commit_hash = verify_commit(&repo, &commit)?;
|
||||
@@ -3341,7 +3547,7 @@ pub fn restore_to_commit(path: String, commit: String) -> Result<GitStatus, Stri
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn restore_file_from_commit(
|
||||
path: String,
|
||||
commit: String,
|
||||
@@ -3359,7 +3565,7 @@ pub fn restore_file_from_commit(
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn compare_commits(
|
||||
path: String,
|
||||
from: String,
|
||||
@@ -3417,7 +3623,7 @@ pub fn compare_commits(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn diff_file_against_working_tree(
|
||||
path: String,
|
||||
commit: String,
|
||||
@@ -3463,7 +3669,7 @@ pub fn diff_file_against_working_tree(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn compare_file_to_head(
|
||||
path: String,
|
||||
commit: String,
|
||||
@@ -3525,7 +3731,7 @@ pub fn compare_file_to_head(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn compare_file_to_parent(
|
||||
path: String,
|
||||
commit: String,
|
||||
@@ -3599,7 +3805,7 @@ pub fn compare_file_to_parent(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn read_conflict(path: String, file: String) -> Result<ConflictFile, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(std::slice::from_ref(&file))?;
|
||||
@@ -3635,7 +3841,7 @@ pub fn read_conflict(path: String, file: String) -> Result<ConflictFile, String>
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn resolve_conflict_side(
|
||||
path: String,
|
||||
file: String,
|
||||
@@ -3677,7 +3883,7 @@ fn index_stage_size(repo: &Path, stage: u8, file: &str) -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn resolve_conflict(path: String, file: String, content: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(std::slice::from_ref(&file))?;
|
||||
@@ -4073,21 +4279,7 @@ fn clone_repository_core(
|
||||
run_git_clone(remote_url.trim(), &target, username, password)?;
|
||||
|
||||
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)?;
|
||||
|
||||
Ok(RepositoryBundle {
|
||||
status,
|
||||
branches,
|
||||
tags,
|
||||
stashes,
|
||||
commits,
|
||||
files,
|
||||
})
|
||||
repository_bundle_for_repo(&repo, commit_limit)
|
||||
}
|
||||
|
||||
fn clone_target_path(
|
||||
@@ -6747,7 +6939,7 @@ mod tests {
|
||||
let repo = init_temp_repo("create_branch");
|
||||
commit_initial_file(&repo.path);
|
||||
|
||||
let status = create_branch(
|
||||
let status = create_branch_core(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"feature/new-panel".to_string(),
|
||||
None,
|
||||
@@ -6760,7 +6952,7 @@ mod tests {
|
||||
"new branch should exist"
|
||||
);
|
||||
|
||||
let err = create_branch(
|
||||
let err = create_branch_core(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"feature/new-panel".to_string(),
|
||||
None,
|
||||
@@ -6776,7 +6968,7 @@ mod tests {
|
||||
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
run_git_test(&repo.path, ["branch", "feature/old-panel"]);
|
||||
|
||||
let status = rename_branch(
|
||||
let status = rename_branch_core(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"feature/old-panel".to_string(),
|
||||
"feature/new-panel".to_string(),
|
||||
@@ -6801,7 +6993,7 @@ mod tests {
|
||||
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
run_git_test(&repo.path, ["branch", "stale"]);
|
||||
|
||||
let status = delete_branch(
|
||||
let status = delete_branch_core(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"stale".to_string(),
|
||||
None,
|
||||
@@ -6815,7 +7007,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let err =
|
||||
delete_branch(repo.path.to_string_lossy().to_string(), current, None).unwrap_err();
|
||||
delete_branch_core(repo.path.to_string_lossy().to_string(), current, None).unwrap_err();
|
||||
assert!(err.contains("current branch"));
|
||||
}
|
||||
|
||||
@@ -6831,7 +7023,7 @@ mod tests {
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "feature"]);
|
||||
run_git_test(&repo.path, ["checkout", "-q", current.as_str()]);
|
||||
|
||||
let err = delete_branch(
|
||||
let err = delete_branch_core(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"feature/unmerged".to_string(),
|
||||
Some(false),
|
||||
@@ -6839,7 +7031,7 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(err.contains("not fully merged"));
|
||||
|
||||
delete_branch(
|
||||
delete_branch_core(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"feature/unmerged".to_string(),
|
||||
Some(true),
|
||||
@@ -7216,7 +7408,7 @@ mod tests {
|
||||
commit_initial_file(&repo.path);
|
||||
run_git_test(&repo.path, ["branch", "feature"]);
|
||||
|
||||
let rows = add_worktree(
|
||||
let rows = add_worktree_core(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
destination.path.to_string_lossy().to_string(),
|
||||
Some("feature".to_string()),
|
||||
@@ -7234,7 +7426,7 @@ mod tests {
|
||||
assert!(!linked.is_main);
|
||||
assert!(linked.clean);
|
||||
|
||||
let rows = lock_worktree(
|
||||
let rows = lock_worktree_core(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
destination.path.to_string_lossy().to_string(),
|
||||
Some("test lock".to_string()),
|
||||
@@ -7247,13 +7439,13 @@ mod tests {
|
||||
assert!(linked.locked);
|
||||
assert_eq!(linked.lock_reason.as_deref(), Some("test lock"));
|
||||
|
||||
unlock_worktree(
|
||||
unlock_worktree_core(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
destination.path.to_string_lossy().to_string(),
|
||||
)
|
||||
.expect("worktree should unlock");
|
||||
|
||||
let rows = remove_worktree(
|
||||
let rows = remove_worktree_core(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
destination.path.to_string_lossy().to_string(),
|
||||
Some(false),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gitty",
|
||||
"version": "2026.8.1",
|
||||
"version": "2026.8.2",
|
||||
"identifier": "com.gitty",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+175
-163
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { AlertCircle, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
||||
import { beginFrontendShutdown } from "./lib/telemetry";
|
||||
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
||||
@@ -151,6 +153,7 @@
|
||||
RebaseCommit,
|
||||
RebasePlanItem,
|
||||
ReflogEntry,
|
||||
RepositoryBundle,
|
||||
StoredCredential,
|
||||
} from "./lib/types";
|
||||
|
||||
@@ -202,6 +205,15 @@
|
||||
clearIfCurrent: (message: string) => void;
|
||||
}
|
||||
|
||||
interface RepositoryRefreshOptions {
|
||||
branches?: boolean;
|
||||
tags?: boolean;
|
||||
stashes?: boolean;
|
||||
commits?: boolean;
|
||||
files?: boolean;
|
||||
fileHistory?: boolean;
|
||||
}
|
||||
|
||||
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
||||
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
||||
const FAVORITE_REPOS_KEY = "gitlite.favoriteRepos.v1";
|
||||
@@ -370,12 +382,13 @@
|
||||
const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000;
|
||||
const BACKGROUND_FETCH_OTHER_BATCH_SIZE = 2;
|
||||
const STARTUP_SPLASH_MIN_VISIBLE_MS = 850;
|
||||
const STARTUP_FETCH_MAX_WAIT_MS = 20_000;
|
||||
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let backgroundFetchInFlight = false;
|
||||
let backgroundRepoStatusInFlight = false;
|
||||
let backgroundRepoStatusIndex = 0;
|
||||
let appShuttingDown = false;
|
||||
let unlistenCloseRequested: (() => void) | undefined;
|
||||
let lastRepoSwitchAt = 0;
|
||||
let updateToastOpen = false;
|
||||
let updateToastState: UpdateToastState = "available";
|
||||
@@ -476,21 +489,44 @@
|
||||
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
void runStartupSequence();
|
||||
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
|
||||
window.addEventListener("beforeunload", handleAppShutdown);
|
||||
window.addEventListener("pagehide", handleAppShutdown);
|
||||
try {
|
||||
void getCurrentWindow().onCloseRequested(() => handleAppShutdown()).then((unlisten) => {
|
||||
if (appShuttingDown) unlisten();
|
||||
else unlistenCloseRequested = unlisten;
|
||||
}).catch(() => {
|
||||
// Browser preview has no Tauri window; DOM lifecycle events still cover it.
|
||||
});
|
||||
} catch {
|
||||
// getCurrentWindow itself throws synchronously in a plain browser preview.
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
handleAppShutdown();
|
||||
themeMediaQuery?.removeEventListener("change", handleSystemThemeChange);
|
||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||
if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer);
|
||||
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
||||
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
||||
window.removeEventListener("beforeunload", handleAppShutdown);
|
||||
window.removeEventListener("pagehide", handleAppShutdown);
|
||||
unlistenCloseRequested?.();
|
||||
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
|
||||
Object.values(errorAutoHideStates).forEach((state) => {
|
||||
if (state?.timer) clearTimeout(state.timer);
|
||||
});
|
||||
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
||||
// Do not invoke backend cancellation here: Windows may already be shutting
|
||||
// down, and starting another IPC/Git operation is precisely what we avoid.
|
||||
});
|
||||
|
||||
function handleAppShutdown() {
|
||||
if (appShuttingDown) return;
|
||||
appShuttingDown = true;
|
||||
beginFrontendShutdown();
|
||||
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = undefined; }
|
||||
if (backgroundRepoStatusTimer) { clearInterval(backgroundRepoStatusTimer); backgroundRepoStatusTimer = undefined; }
|
||||
if (backgroundFetchTimer) { clearInterval(backgroundFetchTimer); backgroundFetchTimer = undefined; }
|
||||
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -528,6 +564,7 @@
|
||||
}
|
||||
|
||||
function startBackgroundTimers() {
|
||||
if (appShuttingDown) return;
|
||||
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
||||
backgroundRepoStatusTimer = setInterval(() => { void backgroundRepoStatusTick(false); }, BACKGROUND_REPO_STATUS_INTERVAL);
|
||||
backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL);
|
||||
@@ -544,16 +581,15 @@
|
||||
|
||||
try {
|
||||
await waitForStartupPaint();
|
||||
await Promise.race([
|
||||
fetchOpenRepositoriesDuringStartup(),
|
||||
wait(STARTUP_FETCH_MAX_WAIT_MS),
|
||||
]);
|
||||
} finally {
|
||||
const remainingSplashTime = STARTUP_SPLASH_MIN_VISIBLE_MS - (performance.now() - startupStartedAt);
|
||||
if (remainingSplashTime > 0) await wait(remainingSplashTime);
|
||||
|
||||
await closeStartupSplashscreen();
|
||||
startBackgroundTimers();
|
||||
// Remote access can take seconds (offline networks, SSH negotiation,
|
||||
// credential helpers). It must never hold the startup screen hostage.
|
||||
void fetchOpenRepositoriesDuringStartup();
|
||||
void backgroundRepoStatusTick(false);
|
||||
}
|
||||
}
|
||||
@@ -630,7 +666,7 @@
|
||||
// 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) return;
|
||||
if (appShuttingDown || !autoRefreshEnabled) return;
|
||||
|
||||
if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
|
||||
&& !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
|
||||
@@ -650,7 +686,7 @@
|
||||
}
|
||||
|
||||
async function backgroundFetchRepo(path: string) {
|
||||
if (!autoRefreshEnabled || !path || backgroundFetchInFlight) return;
|
||||
if (appShuttingDown || !autoRefreshEnabled || !path || backgroundFetchInFlight) return;
|
||||
|
||||
backgroundFetchInFlight = true;
|
||||
try {
|
||||
@@ -700,7 +736,7 @@
|
||||
}
|
||||
|
||||
async function backgroundRepoStatusTick(fetchFirst: boolean) {
|
||||
if (!autoRefreshEnabled || backgroundRepoStatusInFlight) return;
|
||||
if (appShuttingDown || !autoRefreshEnabled || backgroundRepoStatusInFlight) return;
|
||||
const others = knownRepoPathsForBackground();
|
||||
if (others.length === 0) return;
|
||||
|
||||
@@ -711,6 +747,7 @@
|
||||
// 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 (appShuttingDown) break;
|
||||
if (backgroundRepoStatusIndex >= others.length) backgroundRepoStatusIndex = 0;
|
||||
const path = others[backgroundRepoStatusIndex];
|
||||
backgroundRepoStatusIndex += 1;
|
||||
@@ -729,7 +766,7 @@
|
||||
}
|
||||
|
||||
async function autoRefreshTick() {
|
||||
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || globalSearchOpen || helpOpen) return;
|
||||
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || globalSearchOpen || helpOpen) return;
|
||||
const path = activeRepoPath;
|
||||
autoRefreshInFlight = true;
|
||||
try {
|
||||
@@ -740,23 +777,14 @@
|
||||
// now-active repo's name and data with this one's.
|
||||
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
|
||||
applyStatus(nextStatus);
|
||||
// Something changed — reload branches, commits and files in one bundled call.
|
||||
const bundle = await openRepositoryBundle(path, Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length) + 1);
|
||||
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||
const previousHeadHash = lastFileHistoryHeadHash;
|
||||
await refreshBranchList(path, bundle.branches);
|
||||
await refreshTags(path, bundle.tags);
|
||||
await refreshStashes(path, bundle.stashes);
|
||||
await refreshCommitHistory(path, bundle.commits);
|
||||
await refreshExplorerFiles(path, bundle.files);
|
||||
await applyRepositoryBundle(path, bundle);
|
||||
// File history reflects `git log`, which only changes when HEAD actually moves
|
||||
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
|
||||
// working-tree/status change (staging, edits) doesn't keep re-fetching and
|
||||
// flickering the currently viewed file's history.
|
||||
if (lastFileHistoryHeadHash !== previousHeadHash && sameRepoPath(path, activeRepoPath)) {
|
||||
await refreshFileHistory(path);
|
||||
}
|
||||
} catch { /* ignore transient errors */ } finally {
|
||||
autoRefreshInFlight = false;
|
||||
}
|
||||
@@ -1089,10 +1117,7 @@
|
||||
aiCommitSplitOpen = false;
|
||||
aiCommitPlan = null;
|
||||
commitMessage = "";
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("ai_commit_split_applied", { groups: plan.groups.length, files: allFiles.length });
|
||||
});
|
||||
commitAiSplitting = false;
|
||||
@@ -1870,15 +1895,21 @@
|
||||
// ── Refresh helpers ────────────────────────────────────────────────────────
|
||||
|
||||
async function refreshBranchList(path = activeRepoPath, prefetched?: GitBranchInfo[]) {
|
||||
branches = prefetched ?? (await listBranches(path));
|
||||
const nextBranches = prefetched ?? (await listBranches(path));
|
||||
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||
branches = nextBranches;
|
||||
}
|
||||
|
||||
async function refreshTags(path = activeRepoPath, prefetched?: GitTag[]) {
|
||||
tags = prefetched ?? (await listTags(path));
|
||||
const nextTags = prefetched ?? (await listTags(path));
|
||||
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||
tags = nextTags;
|
||||
}
|
||||
|
||||
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
|
||||
stashes = prefetched ?? (await listStashes(path));
|
||||
const nextStashes = prefetched ?? (await listStashes(path));
|
||||
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||
stashes = nextStashes;
|
||||
}
|
||||
|
||||
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
|
||||
@@ -1935,7 +1966,9 @@
|
||||
}
|
||||
|
||||
async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) {
|
||||
repoFiles = prefetched ?? (await listRepositoryFiles(path));
|
||||
const nextFiles = prefetched ?? (await listRepositoryFiles(path));
|
||||
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||
repoFiles = nextFiles;
|
||||
const folderPaths = allExplorerFolderPaths(repoFiles);
|
||||
expandedExplorerPaths = new Set([...expandedExplorerPaths].filter((folder) => folderPaths.has(folder)));
|
||||
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
|
||||
@@ -1945,11 +1978,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRepositoryViews(
|
||||
path = activeRepoPath,
|
||||
options: RepositoryRefreshOptions = {},
|
||||
) {
|
||||
const tasks: Promise<void>[] = [];
|
||||
if (options.branches ?? true) tasks.push(refreshBranchList(path));
|
||||
if (options.tags ?? false) tasks.push(refreshTags(path));
|
||||
if (options.stashes ?? false) tasks.push(refreshStashes(path));
|
||||
if (options.commits ?? true) tasks.push(refreshCommitHistory(path));
|
||||
if (options.files ?? true) tasks.push(refreshExplorerFiles(path));
|
||||
|
||||
// These reads do not depend on each other. Starting them together removes
|
||||
// several IPC/Git-process waterfalls after every user operation.
|
||||
await Promise.all(tasks);
|
||||
|
||||
// Explorer refresh may invalidate the selected path, so file history runs
|
||||
// after the parallel group rather than racing a disappearing selection.
|
||||
if (options.fileHistory ?? true) await refreshFileHistory(path);
|
||||
}
|
||||
|
||||
async function applyRepositoryBundle(
|
||||
path: string,
|
||||
bundle: RepositoryBundle,
|
||||
forceFileHistory = false,
|
||||
) {
|
||||
const previousHeadHash = lastFileHistoryHeadHash;
|
||||
applyStatus(bundle.status);
|
||||
const resolvedPath = activeRepoPath || path;
|
||||
await Promise.all([
|
||||
refreshBranchList(resolvedPath, bundle.branches),
|
||||
refreshTags(resolvedPath, bundle.tags),
|
||||
refreshStashes(resolvedPath, bundle.stashes),
|
||||
refreshCommitHistory(resolvedPath, bundle.commits),
|
||||
refreshExplorerFiles(resolvedPath, bundle.files),
|
||||
]);
|
||||
|
||||
if (forceFileHistory || lastFileHistoryHeadHash !== previousHeadHash) {
|
||||
await refreshFileHistory(resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRepositorySnapshot(path = activeRepoPath, forceFileHistory = false) {
|
||||
const bundle = await openRepositoryBundle(
|
||||
path,
|
||||
Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length) + 1,
|
||||
);
|
||||
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||
await applyRepositoryBundle(path, bundle, forceFileHistory);
|
||||
}
|
||||
|
||||
async function refreshRefsAndCommitGraph(path = activeRepoPath) {
|
||||
const previousHeadHash = lastFileHistoryHeadHash;
|
||||
await refreshBranchList(path);
|
||||
await refreshTags(path);
|
||||
await refreshCommitHistory(path);
|
||||
await Promise.all([
|
||||
refreshBranchList(path),
|
||||
refreshTags(path),
|
||||
refreshCommitHistory(path),
|
||||
]);
|
||||
if (lastFileHistoryHeadHash !== previousHeadHash && fileHistoryDialogOpen) {
|
||||
await refreshFileHistory(path);
|
||||
}
|
||||
@@ -2025,14 +2110,9 @@
|
||||
const bundle = await openRepositoryBundle(path, COMMIT_HISTORY_PAGE_SIZE + 1);
|
||||
if (requestId !== repoOpenRequestId) return;
|
||||
resetRepositoryState(false);
|
||||
applyStatus(bundle.status);
|
||||
await applyRepositoryBundle(path, bundle);
|
||||
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();
|
||||
trackEvent("repository_opened", {
|
||||
changed_files: bundle.status.files.length,
|
||||
@@ -2099,14 +2179,9 @@
|
||||
COMMIT_HISTORY_PAGE_SIZE + 1,
|
||||
);
|
||||
resetRepositoryState(false);
|
||||
applyStatus(bundle.status);
|
||||
await applyRepositoryBundle(bundle.status.repo_path, bundle);
|
||||
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);
|
||||
cloneDialogOpen = false;
|
||||
pendingClone = null;
|
||||
if (credDialogAction === "clone") {
|
||||
@@ -2325,12 +2400,7 @@
|
||||
async function refreshRepo() {
|
||||
if (!activeRepoPath) { await openRepo(); return; }
|
||||
await runOperation("Refreshing", async () => {
|
||||
applyStatus(await getStatus(activeRepoPath));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshStashes(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositorySnapshot(activeRepoPath, true);
|
||||
trackEvent("repository_refreshed", {
|
||||
changed_files: status?.files.length ?? 0,
|
||||
});
|
||||
@@ -2341,10 +2411,7 @@
|
||||
if (!activeRepoPath || branch.current) return;
|
||||
await runOperation(`Checking out ${branch.name}`, async () => {
|
||||
applyStatus(await checkoutBranch(activeRepoPath, branch.name));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("branch_checked_out", {
|
||||
remote: branch.remote ? 1 : 0,
|
||||
});
|
||||
@@ -2356,10 +2423,7 @@
|
||||
if (!activeRepoPath || !name) return;
|
||||
await runOperation(`Creating ${name}`, async () => {
|
||||
applyStatus(await createBranch(activeRepoPath, name));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("branch_created");
|
||||
});
|
||||
}
|
||||
@@ -2378,10 +2442,7 @@
|
||||
await runOperation(`Renaming ${branch.name}`, async () => {
|
||||
applyStatus(await renameBranch(activeRepoPath, branch.name, name));
|
||||
renameBranchTarget = null;
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("branch_renamed");
|
||||
});
|
||||
}
|
||||
@@ -2429,10 +2490,7 @@
|
||||
applyStatus(await deleteBranch(activeRepoPath, branch.name, deleteBranchForce));
|
||||
deleteBranchTarget = null;
|
||||
deleteBranchForce = false;
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("branch_deleted", {
|
||||
force: forceDelete ? 1 : 0,
|
||||
});
|
||||
@@ -2608,10 +2666,7 @@
|
||||
await runOperation(`Creating ${name}`, async () => {
|
||||
applyStatus(await createBranch(activeRepoPath, name, target.hash));
|
||||
newBranchCommit = null;
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("branch_created_from_commit");
|
||||
});
|
||||
}
|
||||
@@ -2623,10 +2678,7 @@
|
||||
if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; }
|
||||
await runOperation(`Merging ${branch.name}`, async () => {
|
||||
applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("branch_merged", {
|
||||
remote: branch.remote ? 1 : 0,
|
||||
});
|
||||
@@ -2637,10 +2689,7 @@
|
||||
if (!activeRepoPath || branch.current || rebaseInProgress || cherryPickInProgress) return;
|
||||
await runOperation(`Rebasing onto ${branch.name}`, async () => {
|
||||
applyStatus(await rebaseBranch(activeRepoPath, branch.name));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("branch_rebased", {
|
||||
remote: branch.remote ? 1 : 0,
|
||||
});
|
||||
@@ -2651,10 +2700,7 @@
|
||||
if (!activeRepoPath || !rebaseInProgress || hasConflicts) return;
|
||||
await runOperation("Continuing rebase", async () => {
|
||||
applyStatus(await rebaseContinue(activeRepoPath));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("rebase_continued");
|
||||
});
|
||||
}
|
||||
@@ -2670,10 +2716,7 @@
|
||||
resolveDialogOpen = false;
|
||||
conflict = null;
|
||||
conflictTarget = "";
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("rebase_aborted");
|
||||
});
|
||||
}
|
||||
@@ -2717,10 +2760,7 @@
|
||||
await runOperation("Starting interactive rebase", async () => {
|
||||
applyStatus(await startInteractiveRebase(activeRepoPath, interactiveRebaseBase, plan));
|
||||
interactiveRebaseOpen = false;
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("interactive_rebase_started", { commits: plan.length });
|
||||
});
|
||||
if (interactiveRebaseOpen && errorMessage) interactiveRebaseError = errorMessage;
|
||||
@@ -2762,10 +2802,7 @@
|
||||
await runOperation("Restoring reflog entry", async () => {
|
||||
applyStatus(await restoreReflogEntry(activeRepoPath, entry.hash, branch.trim()));
|
||||
reflogOpen = false;
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("reflog_recovered");
|
||||
});
|
||||
if (reflogOpen && errorMessage) reflogError = errorMessage;
|
||||
@@ -2818,10 +2855,7 @@
|
||||
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);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("commit_cherry_picked");
|
||||
});
|
||||
}
|
||||
@@ -2830,10 +2864,7 @@
|
||||
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);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("cherry_pick_continued");
|
||||
});
|
||||
}
|
||||
@@ -2849,10 +2880,7 @@
|
||||
resolveDialogOpen = false;
|
||||
conflict = null;
|
||||
conflictTarget = "";
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("cherry_pick_aborted");
|
||||
});
|
||||
}
|
||||
@@ -2928,10 +2956,7 @@
|
||||
errorMessage = "";
|
||||
await runOperation("Pulling", async () => {
|
||||
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("repository_pulled", {
|
||||
from_stored_credential: fromStore ? 1 : 0,
|
||||
changed_files: status?.files.length ?? 0,
|
||||
@@ -2970,9 +2995,7 @@
|
||||
await runOperation("Pushing", async () => {
|
||||
applyStatus(await push(activeRepoPath, username, password, remoteActionForceWithLease, selectedRemote || undefined));
|
||||
remoteActionForceWithLease = false;
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, { files: false });
|
||||
trackEvent("repository_pushed", {
|
||||
from_stored_credential: fromStore ? 1 : 0,
|
||||
changed_files: status?.files.length ?? 0,
|
||||
@@ -2996,10 +3019,7 @@
|
||||
|
||||
await runOperation("Pulling before push", async () => {
|
||||
applyStatus(await pull(activeRepoPath, username, password));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
});
|
||||
|
||||
if (errorMessage) {
|
||||
@@ -3016,9 +3036,7 @@
|
||||
|
||||
await runOperation("Pushing after pull", async () => {
|
||||
applyStatus(await push(activeRepoPath, username, password));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, { files: false });
|
||||
trackEvent("repository_pushed_after_pull", {
|
||||
from_stored_credential: fromStore ? 1 : 0,
|
||||
changed_files: status?.files.length ?? 0,
|
||||
@@ -3112,18 +3130,24 @@
|
||||
if (!activeRepoPath || !window.confirm(`Revert commit ${commit.short_hash} (${commit.summary}) with a new commit?`)) return;
|
||||
await runOperation(`Reverting ${commit.short_hash}`, async () => {
|
||||
applyStatus(await revertCommit(activeRepoPath, commit.hash));
|
||||
await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, { branches: false });
|
||||
});
|
||||
}
|
||||
|
||||
async function continueMerge() {
|
||||
if (!activeRepoPath) return;
|
||||
await runOperation("Continuing merge", async () => { applyStatus(await mergeContinue(activeRepoPath)); await refreshCommitHistory(activeRepoPath); });
|
||||
await runOperation("Continuing merge", async () => {
|
||||
applyStatus(await mergeContinue(activeRepoPath));
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function abortMerge() {
|
||||
if (!activeRepoPath || !window.confirm("Abort the current merge and restore the pre-merge state?")) return;
|
||||
await runOperation("Aborting merge", async () => { applyStatus(await mergeAbort(activeRepoPath)); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); });
|
||||
await runOperation("Aborting merge", async () => {
|
||||
applyStatus(await mergeAbort(activeRepoPath));
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPruneRepo() {
|
||||
@@ -3180,9 +3204,11 @@
|
||||
const stashedFiles = changedFiles.length;
|
||||
await runOperation("Stashing changes", async () => {
|
||||
applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
|
||||
await refreshStashes(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, {
|
||||
branches: false,
|
||||
stashes: true,
|
||||
commits: false,
|
||||
});
|
||||
trackEvent("stash_saved", {
|
||||
include_untracked: includeUntracked ? 1 : 0,
|
||||
changed_files: stashedFiles,
|
||||
@@ -3194,9 +3220,11 @@
|
||||
if (!activeRepoPath) return;
|
||||
await runOperation(`Applying ${stash.selector}`, async () => {
|
||||
applyStatus(await stashApply(activeRepoPath, stash.selector));
|
||||
await refreshStashes(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, {
|
||||
branches: false,
|
||||
stashes: true,
|
||||
commits: false,
|
||||
});
|
||||
trackEvent("stash_applied");
|
||||
});
|
||||
}
|
||||
@@ -3205,9 +3233,11 @@
|
||||
if (!activeRepoPath) return;
|
||||
await runOperation(`Popping ${stash.selector}`, async () => {
|
||||
applyStatus(await stashPop(activeRepoPath, stash.selector));
|
||||
await refreshStashes(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, {
|
||||
branches: false,
|
||||
stashes: true,
|
||||
commits: false,
|
||||
});
|
||||
trackEvent("stash_popped");
|
||||
});
|
||||
}
|
||||
@@ -3278,8 +3308,7 @@
|
||||
const paths = files.map((file) => file.path);
|
||||
await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
|
||||
applyStatus(await restoreFiles(activeRepoPath, paths, staged));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
|
||||
trackEvent("file_discarded", {
|
||||
files: files.length,
|
||||
staged: staged ? 1 : 0,
|
||||
@@ -3298,8 +3327,7 @@
|
||||
if (stagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, stagedPaths, true);
|
||||
if (unstagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, unstagedPaths, false);
|
||||
if (nextStatus) applyStatus(nextStatus);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
|
||||
trackEvent("file_discarded", {
|
||||
files: files.length,
|
||||
staged: 2,
|
||||
@@ -3400,8 +3428,7 @@
|
||||
|
||||
try {
|
||||
applyStatus(await applyFilePatch(activeRepoPath, file.path, patch, action));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
|
||||
|
||||
const updatedPatch = await getFilePatch(activeRepoPath, file.path, staged);
|
||||
if (updatedPatch.trim()) {
|
||||
@@ -3507,10 +3534,7 @@
|
||||
amendMode = false;
|
||||
preAmendDraftMessage = "";
|
||||
lastLocalAiGeneratedMessage = "";
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("commit_created", { amend: 1 });
|
||||
});
|
||||
return;
|
||||
@@ -3521,10 +3545,7 @@
|
||||
applyStatus(await commit(activeRepoPath, message));
|
||||
commitMessage = "";
|
||||
lastLocalAiGeneratedMessage = "";
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("commit_created", { amend: 0, staged_files: trackedStagedCount });
|
||||
});
|
||||
}
|
||||
@@ -3566,10 +3587,7 @@
|
||||
commitMessage = preAmendDraftMessage;
|
||||
preAmendDraftMessage = "";
|
||||
}
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("commit_undone");
|
||||
});
|
||||
}
|
||||
@@ -3582,10 +3600,7 @@
|
||||
if (!confirmed) return;
|
||||
await runOperation(`Restoring ${target.short_hash}`, async () => {
|
||||
applyStatus(await restoreToCommit(activeRepoPath, target.hash));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("commit_restored");
|
||||
});
|
||||
}
|
||||
@@ -3596,8 +3611,7 @@
|
||||
if (!confirmed) return false;
|
||||
await runOperation(`Restoring ${file.path}`, async () => {
|
||||
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, file.path));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
|
||||
trackEvent("commit_file_restored");
|
||||
});
|
||||
return !errorMessage;
|
||||
@@ -3742,8 +3756,7 @@
|
||||
if (!confirmed) return;
|
||||
await runOperation(`Restoring ${selectedExplorerPath}`, async () => {
|
||||
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, selectedExplorerPath));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
|
||||
trackEvent("selected_file_restored_from_commit", {
|
||||
kind,
|
||||
});
|
||||
@@ -3935,8 +3948,7 @@
|
||||
}
|
||||
preparedResolutions = {};
|
||||
if (nextStatus) applyStatus(nextStatus);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
|
||||
|
||||
const remaining = (nextStatus?.files ?? status?.files ?? []).filter(
|
||||
(f) => f.staged === "conflicted" || f.unstaged === "conflicted",
|
||||
|
||||
@@ -5,6 +5,11 @@ type TelemetryLevel = "info" | "warn" | "error";
|
||||
const MAX_MESSAGE_LENGTH = 2_048;
|
||||
let telemetryEnabled = false;
|
||||
let configuration: Promise<unknown> = Promise.resolve();
|
||||
let frontendShuttingDown = false;
|
||||
|
||||
export function beginFrontendShutdown() {
|
||||
frontendShuttingDown = true;
|
||||
}
|
||||
|
||||
// Telemetry must never contain repository contents or identifying local data.
|
||||
function sanitize(message: string): string {
|
||||
@@ -42,6 +47,9 @@ function randomHex(bytes: number): string {
|
||||
}
|
||||
|
||||
export async function tracedInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
if (frontendShuttingDown) {
|
||||
throw new Error("Application is shutting down");
|
||||
}
|
||||
if (!telemetryEnabled) return invoke<T>(command, args);
|
||||
const startedAtMs = Date.now();
|
||||
const started = performance.now();
|
||||
|
||||
Reference in New Issue
Block a user