Compare commits
8
Commits
2026.7.16
...
7ef1a2dac8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ef1a2dac8 | ||
|
|
44a5b776d7 | ||
|
|
55b4c9fc50 | ||
|
|
35d9fc969c | ||
|
|
710c6d4a53 | ||
|
|
0877d5a568 | ||
|
|
f79f3dc1ec | ||
|
|
cf2773b6f4 |
@@ -96,7 +96,11 @@
|
||||
"Bash(identify src-tauri/icons/GitCat.ico)",
|
||||
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
|
||||
"Bash(python3 *)",
|
||||
"Bash(xxd -l 16 src-tauri/icons/GitCat.ico)"
|
||||
"Bash(xxd -l 16 src-tauri/icons/GitCat.ico)",
|
||||
"Bash(pkg-config --exists openssl)",
|
||||
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
|
||||
"Bash(dpkg -L libssl3t64)",
|
||||
"Bash(grep *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "0.200.5",
|
||||
"version": "2026.7.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitty",
|
||||
"version": "0.200.5",
|
||||
"version": "2026.7.17",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "0.200.5",
|
||||
"version": "2026.7.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+225
-180
@@ -789,63 +789,75 @@ fn validate_tag_ref_name(name: &str) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(&files)?;
|
||||
pub async fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(&files)?;
|
||||
|
||||
if !files.is_empty() {
|
||||
// A file we've detected as an unstaged rename (see `detect_worktree_renames`) has
|
||||
// to be staged with both its old and new path so `git add` records it as a rename
|
||||
// instead of leaving the old path's deletion unstaged.
|
||||
let current_status = status_for_repo(&repo)?;
|
||||
let mut add_paths: Vec<String> = Vec::new();
|
||||
for file in &files {
|
||||
match find_status(¤t_status.files, file) {
|
||||
Some(entry) => {
|
||||
if let Some(old_path) = &entry.old_path {
|
||||
add_paths.push(old_path.clone());
|
||||
if !files.is_empty() {
|
||||
// A file we've detected as an unstaged rename (see `detect_worktree_renames`) has
|
||||
// to be staged with both its old and new path so `git add` records it as a rename
|
||||
// instead of leaving the old path's deletion unstaged.
|
||||
let current_status = status_for_repo(&repo)?;
|
||||
let mut add_paths: Vec<String> = Vec::new();
|
||||
for file in &files {
|
||||
match find_status(¤t_status.files, file) {
|
||||
Some(entry) => {
|
||||
if let Some(old_path) = &entry.old_path {
|
||||
add_paths.push(old_path.clone());
|
||||
}
|
||||
add_paths.push(entry.path.clone());
|
||||
}
|
||||
add_paths.push(entry.path.clone());
|
||||
None => add_paths.push(file.clone()),
|
||||
}
|
||||
None => add_paths.push(file.clone()),
|
||||
}
|
||||
run_git_with_paths(&repo, &["add"], &add_paths)?;
|
||||
}
|
||||
run_git_with_paths(&repo, &["add"], &add_paths)?;
|
||||
}
|
||||
|
||||
status_for_repo(&repo)
|
||||
status_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not stage files: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(&files)?;
|
||||
pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(&files)?;
|
||||
|
||||
if !files.is_empty() {
|
||||
let current_status = status_for_repo(&repo)?;
|
||||
unstage_selected_files(&repo, ¤t_status.files, &files)?;
|
||||
}
|
||||
|
||||
status_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not unstage files: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(&files)?;
|
||||
|
||||
if files.is_empty() {
|
||||
return status_for_repo(&repo);
|
||||
}
|
||||
|
||||
if !files.is_empty() {
|
||||
let current_status = status_for_repo(&repo)?;
|
||||
unstage_selected_files(&repo, ¤t_status.files, &files)?;
|
||||
}
|
||||
if staged {
|
||||
restore_staged_files(&repo, ¤t_status.files, &files)?;
|
||||
} else {
|
||||
restore_worktree_files(&repo, ¤t_status.files, &files)?;
|
||||
}
|
||||
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(&files)?;
|
||||
|
||||
if files.is_empty() {
|
||||
return status_for_repo(&repo);
|
||||
}
|
||||
|
||||
let current_status = status_for_repo(&repo)?;
|
||||
if staged {
|
||||
restore_staged_files(&repo, ¤t_status.files, &files)?;
|
||||
} else {
|
||||
restore_worktree_files(&repo, ¤t_status.files, &files)?;
|
||||
}
|
||||
|
||||
status_for_repo(&repo)
|
||||
status_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not restore files: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1055,47 +1067,55 @@ pub fn apply_file_patch(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if message.trim().is_empty() {
|
||||
return Err("Commit message must not be empty.".to_string());
|
||||
}
|
||||
pub async fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if message.trim().is_empty() {
|
||||
return Err("Commit message must not be empty.".to_string());
|
||||
}
|
||||
|
||||
let current_status = status_for_repo(&repo)?;
|
||||
if has_unresolved_conflicts(¤t_status) {
|
||||
return Err("Merge conflicts must be resolved before you can commit.".to_string());
|
||||
}
|
||||
let current_status = status_for_repo(&repo)?;
|
||||
if has_unresolved_conflicts(¤t_status) {
|
||||
return Err("Merge conflicts must be resolved before you can commit.".to_string());
|
||||
}
|
||||
|
||||
run_git(&repo, ["commit", "-m", message.as_str()])?;
|
||||
status_for_repo(&repo)
|
||||
run_git(&repo, ["commit", "-m", message.as_str()])?;
|
||||
status_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not commit: {err}"))?
|
||||
}
|
||||
|
||||
#[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(¤t_status) {
|
||||
return Err("Merge conflicts must be resolved before you can commit.".to_string());
|
||||
}
|
||||
|
||||
let message = message
|
||||
.map(|message| message.trim().to_string())
|
||||
.filter(|message| !message.is_empty());
|
||||
|
||||
match message {
|
||||
Some(message) => {
|
||||
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
|
||||
pub async fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> 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());
|
||||
}
|
||||
None => {
|
||||
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
|
||||
}
|
||||
}
|
||||
|
||||
status_for_repo(&repo)
|
||||
let current_status = status_for_repo(&repo)?;
|
||||
if has_unresolved_conflicts(¤t_status) {
|
||||
return Err("Merge conflicts must be resolved before you can commit.".to_string());
|
||||
}
|
||||
|
||||
let message = message
|
||||
.map(|message| message.trim().to_string())
|
||||
.filter(|message| !message.is_empty());
|
||||
|
||||
match message {
|
||||
Some(message) => {
|
||||
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
|
||||
}
|
||||
None => {
|
||||
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
|
||||
}
|
||||
}
|
||||
|
||||
status_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not amend commit: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1132,39 +1152,43 @@ pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pull(
|
||||
pub async fn pull(
|
||||
path: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
|
||||
let output = match (username.as_deref(), password.as_deref()) {
|
||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||
run_git_authenticated_output(&repo, pull_args, u, p)?
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
|
||||
let output = match (username.as_deref(), password.as_deref()) {
|
||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||
run_git_authenticated_output(&repo, pull_args, u, p)?
|
||||
}
|
||||
_ => git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(pull_args)
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
||||
};
|
||||
|
||||
if output.status.success() {
|
||||
return status_for_repo(&repo);
|
||||
}
|
||||
_ => git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(pull_args)
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
||||
};
|
||||
|
||||
if output.status.success() {
|
||||
return status_for_repo(&repo);
|
||||
}
|
||||
let status = status_for_repo(&repo)?;
|
||||
if has_unresolved_conflicts(&status) {
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
let status = status_for_repo(&repo)?;
|
||||
if has_unresolved_conflicts(&status) {
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
let details = command_output_details(&output);
|
||||
if is_auth_error(&details) {
|
||||
return Err(format!("AUTH_FAILED:{details}"));
|
||||
}
|
||||
Err(format!("Git command failed: {details}"))
|
||||
let details = command_output_details(&output);
|
||||
if is_auth_error(&details) {
|
||||
return Err(format!("AUTH_FAILED:{details}"));
|
||||
}
|
||||
Err(format!("Git command failed: {details}"))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not pull: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1203,22 +1227,26 @@ pub async fn fetch(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn push(
|
||||
pub async fn push(
|
||||
path: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let push_args = push_args_for_repo(&repo)?;
|
||||
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)?;
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let push_args = push_args_for_repo(&repo)?;
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
run_git(&repo, push_args)?;
|
||||
}
|
||||
}
|
||||
status_for_repo(&repo)
|
||||
status_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not push: {err}"))?
|
||||
}
|
||||
|
||||
// ── Credential storage (OS keychain) ────────────────────────────────────────
|
||||
@@ -1388,61 +1416,69 @@ pub fn cred_delete(key: String) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let branch = branch.trim();
|
||||
if branch.is_empty() {
|
||||
return Err("Branch name must not be empty.".to_string());
|
||||
}
|
||||
pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let branch = branch.trim();
|
||||
if branch.is_empty() {
|
||||
return Err("Branch name must not be empty.".to_string());
|
||||
}
|
||||
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["merge", "--no-edit", branch])
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["merge", "--no-edit", branch])
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return status_for_repo(&repo);
|
||||
}
|
||||
if output.status.success() {
|
||||
return status_for_repo(&repo);
|
||||
}
|
||||
|
||||
// A merge that stops on conflicts leaves unmerged paths in the work tree.
|
||||
// Surface those through the status so the UI can offer conflict resolution
|
||||
// instead of treating the conflict as a hard error.
|
||||
let status = status_for_repo(&repo)?;
|
||||
if has_unresolved_conflicts(&status) {
|
||||
return Ok(status);
|
||||
}
|
||||
// A merge that stops on conflicts leaves unmerged paths in the work tree.
|
||||
// Surface those through the status so the UI can offer conflict resolution
|
||||
// instead of treating the conflict as a hard error.
|
||||
let status = status_for_repo(&repo)?;
|
||||
if has_unresolved_conflicts(&status) {
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let details = if !stderr.trim().is_empty() {
|
||||
stderr.trim()
|
||||
} else if !stdout.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
"unknown error"
|
||||
};
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let details = if !stderr.trim().is_empty() {
|
||||
stderr.trim()
|
||||
} else if !stdout.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
"unknown error"
|
||||
};
|
||||
|
||||
Err(format!("Merge failed: {details}"))
|
||||
Err(format!("Merge failed: {details}"))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not merge: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let branch = branch.trim();
|
||||
if branch.is_empty() {
|
||||
return Err("Branch name must not be empty.".to_string());
|
||||
}
|
||||
pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let branch = branch.trim();
|
||||
if branch.is_empty() {
|
||||
return Err("Branch name must not be empty.".to_string());
|
||||
}
|
||||
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["rebase", branch])
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["rebase", branch])
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
rebase_status_or_error(&repo, output, "Rebase failed", true)
|
||||
rebase_status_or_error(&repo, output, "Rebase failed", true)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not rebase: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1494,19 +1530,23 @@ fn rebase_status_or_error(
|
||||
}
|
||||
|
||||
#[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)?;
|
||||
pub async fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> 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}"))?;
|
||||
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")
|
||||
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not cherry-pick: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -4841,12 +4881,12 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
|
||||
)]
|
||||
fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() {
|
||||
async fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() {
|
||||
let repo = init_temp_repo("pull_diverged");
|
||||
commit_initial_file(&repo.path);
|
||||
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
@@ -4876,7 +4916,9 @@ mod tests {
|
||||
],
|
||||
);
|
||||
|
||||
let status = pull(repo.path.to_string_lossy().to_string(), None, None).unwrap();
|
||||
let status = pull(repo.path.to_string_lossy().to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(status.clean, "{:?}", status.files);
|
||||
assert!(repo.path.join("remote.txt").exists());
|
||||
@@ -4925,12 +4967,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
|
||||
)]
|
||||
fn push_sets_upstream_for_branch_without_tracking_remote() {
|
||||
async fn push_sets_upstream_for_branch_without_tracking_remote() {
|
||||
let repo = init_temp_repo("push_sets_upstream_integration");
|
||||
let remote = init_bare_temp_repo("push_sets_upstream_integration_remote");
|
||||
commit_initial_file(&repo.path);
|
||||
@@ -4947,7 +4989,9 @@ mod tests {
|
||||
["remote", "add", "origin", remote.path.to_str().unwrap()],
|
||||
);
|
||||
|
||||
let status = push(repo.path.to_string_lossy().to_string(), None, None).unwrap();
|
||||
let status = push(repo.path.to_string_lossy().to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
status.upstream.as_deref(),
|
||||
@@ -5015,8 +5059,8 @@ mod tests {
|
||||
assert_eq!(contents.replace("\r\n", "\n"), "resolved\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_rejects_unresolved_merge_conflicts() {
|
||||
#[tokio::test]
|
||||
async fn commit_rejects_unresolved_merge_conflicts() {
|
||||
let repo = init_temp_repo("commit_rejects_conflicts");
|
||||
fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written");
|
||||
run_git_test(&repo.path, ["add", "file.txt"]);
|
||||
@@ -5042,6 +5086,7 @@ mod tests {
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"should not commit".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Merge conflicts"));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gitty",
|
||||
"version": "0.200.5",
|
||||
"version": "2026.7.17",
|
||||
"identifier": "com.gitty",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+87
-24
@@ -133,7 +133,8 @@
|
||||
type AppView = "management" | "repository";
|
||||
type CredentialAction = "push" | "pull" | "fetch" | "clone";
|
||||
type PendingDiscard =
|
||||
| { kind: "file"; file: GitFileStatus; staged: boolean }
|
||||
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
|
||||
| { kind: "all-changes"; files: GitFileStatus[] }
|
||||
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
|
||||
|
||||
interface RepoTab {
|
||||
@@ -631,26 +632,32 @@
|
||||
|
||||
async function autoRefreshTick() {
|
||||
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
||||
const path = activeRepoPath;
|
||||
autoRefreshInFlight = true;
|
||||
try {
|
||||
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
|
||||
const nextStatus = await getStatus(activeRepoPath);
|
||||
const nextStatus = await getStatus(path);
|
||||
// The user may have switched repos (or closed this one) while the status
|
||||
// call was in flight — applying a stale result would flash/overwrite the
|
||||
// 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(activeRepoPath, 100);
|
||||
const bundle = await openRepositoryBundle(path, 100);
|
||||
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||
const previousHeadHash = lastFileHistoryHeadHash;
|
||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||
await refreshTags(activeRepoPath, bundle.tags);
|
||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||
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);
|
||||
// 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) {
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
if (lastFileHistoryHeadHash !== previousHeadHash && sameRepoPath(path, activeRepoPath)) {
|
||||
await refreshFileHistory(path);
|
||||
}
|
||||
} catch { /* ignore transient errors */ } finally {
|
||||
autoRefreshInFlight = false;
|
||||
@@ -938,6 +945,10 @@
|
||||
return repoKey(left) === repoKey(right);
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
||||
}
|
||||
|
||||
function uniqueRepoPaths(paths: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
@@ -1939,6 +1950,21 @@
|
||||
if (!activeStillOpen) await openRepo(path);
|
||||
}
|
||||
|
||||
async function removeRepoFromRecent(path: string, event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
if (isBusy) return;
|
||||
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
|
||||
persistRepoLists();
|
||||
trackEvent("repository_removed_from_recent", {
|
||||
recent_repositories: recentRepoPaths.length,
|
||||
});
|
||||
if (!repoTabs.some((tab) => sameRepoPath(tab.path, path)) && !isFavoriteRepo(path) && repoStatusCache[repoKey(path)]) {
|
||||
const { [repoKey(path)]: _removed, ...rest } = repoStatusCache;
|
||||
repoStatusCache = rest;
|
||||
persistRepoStatusCache();
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRepoFromManagement(path: string, event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
if (isBusy) return;
|
||||
@@ -2539,7 +2565,7 @@
|
||||
const targets = files.filter((file) => file.unstaged !== null);
|
||||
if (targets.length === 0) return;
|
||||
const paths = targets.map((file) => file.path);
|
||||
await runOperation(targets.length === 1 ? `Staging ${targets[0].path}` : `Staging ${targets.length} files`, async () => {
|
||||
await runOperation(targets.length === 1 ? `Staging ${baseName(targets[0].path)}` : `Staging ${targets.length} files`, async () => {
|
||||
applyStatus(await stageFiles(activeRepoPath, paths));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
trackEvent("file_staged", {
|
||||
@@ -2553,7 +2579,7 @@
|
||||
const targets = files.filter((file) => file.staged !== null);
|
||||
if (targets.length === 0) return;
|
||||
const paths = targets.map((file) => file.path);
|
||||
await runOperation(targets.length === 1 ? `Unstaging ${targets[0].path}` : `Unstaging ${targets.length} files`, async () => {
|
||||
await runOperation(targets.length === 1 ? `Unstaging ${baseName(targets[0].path)}` : `Unstaging ${targets.length} files`, async () => {
|
||||
applyStatus(await unstageFiles(activeRepoPath, paths));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
trackEvent("file_unstaged", {
|
||||
@@ -2563,26 +2589,59 @@
|
||||
});
|
||||
}
|
||||
|
||||
function discardFile(file: GitFileStatus, staged: boolean) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
pendingDiscard = { kind: "file", file, staged };
|
||||
function discardFiles(files: GitFileStatus[], staged: boolean) {
|
||||
if (!activeRepoPath || isBusy || files.length === 0) return;
|
||||
pendingDiscard = { kind: "file", files, staged };
|
||||
trackEvent("discard_confirm_opened", {
|
||||
kind: "file",
|
||||
staged: staged ? 1 : 0,
|
||||
files: files.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function runDiscardFile(file: GitFileStatus, staged: boolean) {
|
||||
await runOperation(`Discarding ${file.path}`, async () => {
|
||||
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
|
||||
function discardChanges(files: GitFileStatus[]) {
|
||||
if (!activeRepoPath || isBusy || files.length === 0) return;
|
||||
pendingDiscard = { kind: "all-changes", files };
|
||||
trackEvent("discard_confirm_opened", {
|
||||
kind: "all",
|
||||
files: files.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function runDiscardFiles(files: GitFileStatus[], staged: boolean) {
|
||||
if (files.length === 0) return;
|
||||
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);
|
||||
trackEvent("file_discarded", {
|
||||
files: files.length,
|
||||
staged: staged ? 1 : 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Discards both the staged and unstaged changes for each given file (used
|
||||
// by "Discard all" and "Discard selected", which don't distinguish lanes).
|
||||
async function runDiscardAllChanges(files: GitFileStatus[]) {
|
||||
const stagedPaths = files.filter((file) => file.staged !== null).map((file) => file.path);
|
||||
const unstagedPaths = files.filter((file) => file.unstaged !== null).map((file) => file.path);
|
||||
if (stagedPaths.length === 0 && unstagedPaths.length === 0) return;
|
||||
await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
|
||||
let nextStatus: GitStatus | null = null;
|
||||
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);
|
||||
trackEvent("file_discarded", {
|
||||
files: files.length,
|
||||
staged: 2,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openLinePatch(file: GitFileStatus, staged: boolean) {
|
||||
if (!activeRepoPath) return;
|
||||
linePatchOpen = true;
|
||||
@@ -2718,7 +2777,9 @@
|
||||
if (!discard || !activeRepoPath || isBusy) return;
|
||||
|
||||
if (discard.kind === "file") {
|
||||
await runDiscardFile(discard.file, discard.staged);
|
||||
await runDiscardFiles(discard.files, discard.staged);
|
||||
} else if (discard.kind === "all-changes") {
|
||||
await runDiscardAllChanges(discard.files);
|
||||
} else {
|
||||
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
|
||||
}
|
||||
@@ -3357,7 +3418,7 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if operation && operation !== "Opening repository"}
|
||||
{#if operation && operation !== "Opening repository" && !hasRepository}
|
||||
<section class="notice busy" aria-live="polite">
|
||||
<LoaderCircle class="spin" size={17} aria-hidden="true" />
|
||||
<span>{operation}</span>
|
||||
@@ -3516,7 +3577,7 @@
|
||||
>
|
||||
<Star size={14} aria-hidden="true" />
|
||||
</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) => removeRepoFromRecent(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -3719,12 +3780,14 @@
|
||||
{unstagedCount}
|
||||
{hasRepository}
|
||||
{isBusy}
|
||||
{operation}
|
||||
{status}
|
||||
selectedFilePath={selectedExplorerPath}
|
||||
onSelectFile={selectFileFromStatus}
|
||||
onStage={stageFile}
|
||||
onUnstage={unstageFile}
|
||||
onDiscard={discardFile}
|
||||
onDiscard={discardFiles}
|
||||
onDiscardMany={discardChanges}
|
||||
onPatch={openLinePatch}
|
||||
onStageAll={stageAllFiles}
|
||||
onUnstageAll={unstageAllFiles}
|
||||
@@ -3901,8 +3964,8 @@
|
||||
|
||||
{#if pendingDiscard}
|
||||
<DiscardConfirmDialog
|
||||
file={pendingDiscard.file}
|
||||
staged={pendingDiscard.staged}
|
||||
files={pendingDiscard.kind === "hunk" ? [pendingDiscard.file] : pendingDiscard.files}
|
||||
staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
|
||||
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
|
||||
{isBusy}
|
||||
onConfirm={confirmDiscard}
|
||||
|
||||
+17
@@ -3188,6 +3188,23 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.discard-target-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-height: 168px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.discard-target-list .discard-target {
|
||||
max-height: none;
|
||||
}
|
||||
.discard-target-more {
|
||||
padding: 2px 2px 0;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.discard-warning-text {
|
||||
color: #ffb8bf;
|
||||
font-weight: 650;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import type { GitFileStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
file: GitFileStatus;
|
||||
staged: boolean;
|
||||
files: GitFileStatus[];
|
||||
staged: boolean | null;
|
||||
scope: "file" | "hunk";
|
||||
isBusy: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
file,
|
||||
files,
|
||||
staged = false,
|
||||
scope = "file",
|
||||
isBusy = false,
|
||||
@@ -20,10 +20,16 @@
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
|
||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
||||
function targetPath(file: GitFileStatus): string {
|
||||
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||
}
|
||||
|
||||
let count = $derived(files.length);
|
||||
let title = $derived(
|
||||
scope === "hunk" ? "Discard hunk?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
|
||||
);
|
||||
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : count > 1 ? `${count} files` : "file");
|
||||
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
|
||||
|
||||
</script>
|
||||
|
||||
@@ -46,11 +52,22 @@
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<p>
|
||||
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
|
||||
This will reset the {sourceLabel} for the {scopeLabel} below.
|
||||
</p>
|
||||
<code class="discard-target" title={targetPath}>{targetPath}</code>
|
||||
{#if count > 1}
|
||||
<ul class="discard-target-list">
|
||||
{#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<li><code class="discard-target" title={targetPath(file)}>{targetPath(file)}</code></li>
|
||||
{/each}
|
||||
{#if files.length > 8}
|
||||
<li class="discard-target-more">+{files.length - 8} more</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{:else if count === 1}
|
||||
<code class="discard-target" title={targetPath(files[0])}>{targetPath(files[0])}</code>
|
||||
{/if}
|
||||
<p class="discard-warning-text">
|
||||
This cannot be undone. If the file only exists in your working tree, it can be deleted entirely.
|
||||
This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
|
||||
import iconUrl from "../../../src-tauri/icons/icon.png";
|
||||
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
@@ -8,12 +9,14 @@
|
||||
unstagedCount: number;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
operation: string;
|
||||
status: GitStatus | null;
|
||||
selectedFilePath: string;
|
||||
onSelectFile: (file: GitFileStatus) => void;
|
||||
onStage: (files: GitFileStatus[]) => void;
|
||||
onUnstage: (files: GitFileStatus[]) => void;
|
||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
||||
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
||||
onDiscardMany: (files: GitFileStatus[]) => void;
|
||||
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
@@ -25,12 +28,14 @@
|
||||
unstagedCount = 0,
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
operation = "",
|
||||
status = null,
|
||||
selectedFilePath = "",
|
||||
onSelectFile = () => {},
|
||||
onStage = () => {},
|
||||
onUnstage = () => {},
|
||||
onDiscard = () => {},
|
||||
onDiscardMany = () => {},
|
||||
onPatch = () => {},
|
||||
onStageAll = () => {},
|
||||
onUnstageAll = () => {},
|
||||
@@ -122,6 +127,21 @@
|
||||
onUnstage(targets);
|
||||
}
|
||||
|
||||
// Discard mirrors the stage/unstage target selection: if the clicked row is
|
||||
// part of the current multi-selection, the whole selection (filtered to the
|
||||
// relevant lane) is discarded; otherwise just that one file.
|
||||
function discardStagedFromFile(file: GitFileStatus) {
|
||||
const targets = selectedUnstageTargets(file);
|
||||
if (targets.length === 0) return;
|
||||
onDiscard(targets, true);
|
||||
}
|
||||
|
||||
function discardUnstagedFromFile(file: GitFileStatus) {
|
||||
const targets = selectedStageTargets(file);
|
||||
if (targets.length === 0) return;
|
||||
onDiscard(targets, false);
|
||||
}
|
||||
|
||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||
let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
|
||||
@@ -136,7 +156,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||
<section class="panel relative grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Working tree</span>
|
||||
@@ -170,6 +190,16 @@
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
Unstage all
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm danger"
|
||||
type="button"
|
||||
onclick={() => onDiscardMany(changedFiles)}
|
||||
disabled={isBusy || changedFiles.length === 0}
|
||||
title="Discard all changes"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard all
|
||||
</button>
|
||||
{#if selectedCount > 1}
|
||||
<span class="status-selection-count">{selectedCount} selected</span>
|
||||
<button
|
||||
@@ -192,6 +222,16 @@
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
Unstage selected
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm danger"
|
||||
type="button"
|
||||
onclick={() => onDiscardMany(selectedFiles())}
|
||||
disabled={isBusy || selectedCount === 0}
|
||||
title="Discard changes in selected files"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard selected
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -238,9 +278,9 @@
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
||||
<button class="btn-sm" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Discard staged changes in ${unstageTargets.length} selected files` : "Discard staged changes"}>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard
|
||||
{unstageTargets.length > 1 ? `Discard ${unstageTargets.length}` : "Discard"}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No staged change</span>
|
||||
@@ -264,9 +304,9 @@
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
||||
<button class="btn-sm" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Discard unstaged changes in ${stageTargets.length} selected files` : "Discard unstaged changes"}>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard
|
||||
{stageTargets.length > 1 ? `Discard ${stageTargets.length}` : "Discard"}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No unstaged change</span>
|
||||
@@ -278,4 +318,177 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isBusy && hasRepository}
|
||||
<div class="status-panel-overlay" role="status" aria-live="polite">
|
||||
<div class="status-panel-overlay-card">
|
||||
<div class="status-panel-overlay-mark">
|
||||
<span class="status-panel-overlay-halo halo-one"></span>
|
||||
<span class="status-panel-overlay-halo halo-two"></span>
|
||||
<svg class="status-panel-overlay-traces" viewBox="0 0 220 220" aria-hidden="true">
|
||||
<path class="trace trace-main" d="M28 154 C72 114, 88 108, 110 110 S156 116, 192 68" />
|
||||
<path class="trace trace-branch" d="M62 74 C100 88, 126 124, 158 166" />
|
||||
<path class="trace trace-cut" d="M46 180 L174 180" />
|
||||
</svg>
|
||||
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
|
||||
</div>
|
||||
<span class="status-panel-overlay-label">{operation || "Working"}…</span>
|
||||
<div class="status-panel-overlay-bar"><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.status-panel-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(circle at 50% 38%, rgba(137, 92, 255, 0.16), transparent 55%),
|
||||
rgba(8, 9, 16, 0.58);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: status-panel-overlay-in 120ms ease;
|
||||
}
|
||||
|
||||
.status-panel-overlay-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: min(300px, calc(100% - 32px));
|
||||
padding: 26px 28px 26px;
|
||||
border: 1px solid rgba(160, 124, 255, 0.24);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(23, 26, 43, 0.9), rgba(12, 15, 27, 0.92)),
|
||||
var(--color-surface-raised);
|
||||
color: var(--color-ink);
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.status-panel-overlay-mark {
|
||||
position: relative;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.status-panel-overlay-halo {
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border: 1px solid rgba(151, 118, 255, 0.24);
|
||||
border-radius: 22px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.status-panel-overlay-halo.halo-one {
|
||||
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
.status-panel-overlay-halo.halo-two {
|
||||
inset: 16px;
|
||||
border-color: rgba(255, 109, 38, 0.26);
|
||||
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
.status-panel-overlay-traces {
|
||||
position: absolute;
|
||||
inset: -12px;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
overflow: visible;
|
||||
z-index: 0;
|
||||
}
|
||||
.status-panel-overlay-traces .trace {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 165;
|
||||
stroke-dashoffset: 165;
|
||||
filter: drop-shadow(0 0 6px rgba(151, 118, 255, 0.55));
|
||||
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
|
||||
}
|
||||
.status-panel-overlay-traces .trace-main { stroke: #9b70ff; }
|
||||
.status-panel-overlay-traces .trace-branch {
|
||||
stroke: #ff6d26;
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.status-panel-overlay-traces .trace-cut {
|
||||
stroke: rgba(255, 255, 255, 0.42);
|
||||
stroke-dasharray: 128;
|
||||
stroke-dashoffset: 128;
|
||||
animation-delay: 0.55s;
|
||||
}
|
||||
|
||||
.status-panel-overlay-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
filter:
|
||||
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5))
|
||||
drop-shadow(0 0 10px rgba(151, 118, 255, 0.28));
|
||||
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-panel-overlay-label {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--color-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-panel-overlay-bar {
|
||||
position: relative;
|
||||
width: min(190px, 100%);
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(151, 118, 255, 0.14);
|
||||
}
|
||||
.status-panel-overlay-bar span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 46%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent, #9b70ff 40%, #ff6d26 74%, transparent);
|
||||
box-shadow: 0 0 12px rgba(255, 109, 38, 0.32);
|
||||
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes status-panel-overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes status-panel-overlay-icon-float {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(-3px) scale(1.015); }
|
||||
}
|
||||
@keyframes status-panel-overlay-halo-breathe {
|
||||
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.95); }
|
||||
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
|
||||
}
|
||||
@keyframes status-panel-overlay-bar-slide {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
@keyframes status-panel-overlay-trace-draw {
|
||||
0% { stroke-dashoffset: 165; opacity: 0; }
|
||||
36% { opacity: 1; }
|
||||
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-panel-overlay,
|
||||
.status-panel-overlay-halo,
|
||||
.status-panel-overlay-traces .trace,
|
||||
.status-panel-overlay-icon,
|
||||
.status-panel-overlay-bar span { animation: none; }
|
||||
.status-panel-overlay-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user