Compare commits

...
9 Commits
Author SHA1 Message Date
Christoph Brandau 35d9fc969c feat(status-panel): enhance UI and add new functionality
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m10s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 30m34s
This update introduces several improvements to the StatusPanel component, enhancing the user interface and experience. A new function has been added to simplify file path handling, and the overlay now features a more dynamic design with animated elements for better visual feedback during operations.

- Added baseName function for improved path handling
- Enhanced status overlay with animations and new design elements
- Updated file staging and unstaging messages for clarity
2026-07-09 19:51:51 +02:00
Christoph Brandau 710c6d4a53 feat(git): refactor Git commands to use async/await pattern
This update modifies several Git command functions to utilize the async/await
pattern, enhancing performance and responsiveness during operations. The changes
allow for non-blocking execution of commands, improving the overall user experience.

- Converted multiple Git command functions to async
- Added error handling for async operations
- Updated related tests to support async execution
2026-07-09 19:17:02 +02:00
Christoph Brandau 0877d5a568 feat(status-panel): add loading overlay for ongoing operations
This update introduces a loading overlay in the StatusPanel component
to indicate ongoing operations. The overlay displays a spinner and
the current operation status, enhancing user experience during
long-running tasks.

- Added a loading overlay with a spinner for busy states
- Included operation status text to inform users of current actions
- Improved visual feedback for ongoing processes in the UI
2026-07-09 15:20:52 +02:00
Christoph Brandau f79f3dc1ec feat(recent-repos): add functionality to remove repositories from recent
This update introduces a new function to remove repositories from the
recent list. The user can now manage their recent repositories more
effectively, enhancing the overall user experience.

- Implemented removal of repositories from the recent list
- Updated UI to reflect changes in the recent repositories management
2026-07-09 15:14:29 +02:00
Christoph cf2773b6f4 Update version to 2026.7.16 2026-07-09 09:54:57 +02:00
Christoph Brandau 0ba7169efd feat(tauri): add single instance plugin and improve code formatting
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 19m14s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 26m5s
This update introduces the tauri-plugin-single-instance to ensure that
only one instance of the application can run at a time. Additionally,
the code has been reformatted for better readability, with consistent
line breaks and spacing.

- Added single instance plugin to prevent multiple app instances
- Improved code formatting for better readability and maintenance
- Updated dependencies in Cargo.toml for new plugin integration
2026-07-09 09:49:27 +02:00
Christoph Brandau c087171735 Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-07-09 09:21:53 +02:00
Christoph Brandau 2d3c30b553 feat(App): enhance repository fetching with reference and commit updates
This update improves the repository fetching process by ensuring that
the references and commit graph are refreshed after fetching remote
repositories. This change enhances the accuracy of the displayed
repository state and ensures that users have the most up-to-date
information.

- Added a new function to refresh refs and commit history
- Integrated the refresh function into the fetching workflow
- Improved status application logic for active repositories
2026-07-09 09:21:33 +02:00
Christoph Brandau c8d5553214 refactor(App): streamline repo search and row mapping functions
This update enhances the repo search functionality by passing the search term directly to the filtering functions. It also refactors the row mapping functions to accept parameters for better flexibility and clarity, improving the overall readability and maintainability of the code.

- Simplified search and mapping logic for repositories
- Improved parameter handling in helper functions
2026-07-09 09:01:42 +02:00
10 changed files with 626 additions and 225 deletions
+5 -1
View File
@@ -96,7 +96,11 @@
"Bash(identify src-tauri/icons/GitCat.ico)", "Bash(identify src-tauri/icons/GitCat.ico)",
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")", "Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
"Bash(python3 *)", "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 *)"
] ]
} }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gitty", "name": "gitty",
"version": "0.200.5", "version": "2026.7.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "0.200.5", "version": "2026.7.16",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitty", "name": "gitty",
"version": "0.200.5", "version": "2026.7.16",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+126 -9
View File
@@ -2543,6 +2543,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-aptabase", "tauri-plugin-aptabase",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-single-instance",
"tauri-plugin-updater", "tauri-plugin-updater",
"tokio", "tokio",
] ]
@@ -3420,7 +3421,7 @@ dependencies = [
"security-framework 2.11.1", "security-framework 2.11.1",
"security-framework 3.7.0", "security-framework 3.7.0",
"windows-sys 0.60.2", "windows-sys 0.60.2",
"zbus", "zbus 4.4.0",
"zeroize", "zeroize",
] ]
@@ -5895,7 +5896,7 @@ dependencies = [
"rand 0.8.6", "rand 0.8.6",
"serde", "serde",
"sha2", "sha2",
"zbus", "zbus 4.4.0",
] ]
[[package]] [[package]]
@@ -7066,6 +7067,21 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus 5.17.0",
]
[[package]] [[package]]
name = "tauri-plugin-updater" name = "tauri-plugin-updater"
version = "2.10.1" version = "2.10.1"
@@ -9126,9 +9142,44 @@ dependencies = [
"uds_windows", "uds_windows",
"windows-sys 0.52.0", "windows-sys 0.52.0",
"xdg-home", "xdg-home",
"zbus_macros", "zbus_macros 4.4.0",
"zbus_names", "zbus_names 3.0.0",
"zvariant", "zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid 1.23.4",
"windows-sys 0.61.2",
"winnow 1.0.3",
"zbus_macros 5.17.0",
"zbus_names 4.3.3",
"zvariant 5.13.0",
] ]
[[package]] [[package]]
@@ -9141,7 +9192,22 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.118", "syn 2.0.118",
"zvariant_utils", "zvariant_utils 2.1.0",
]
[[package]]
name = "zbus_macros"
version = "5.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"zbus_names 4.3.3",
"zvariant 5.13.0",
"zvariant_utils 3.5.0",
] ]
[[package]] [[package]]
@@ -9152,7 +9218,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [ dependencies = [
"serde", "serde",
"static_assertions", "static_assertions",
"zvariant", "zvariant 4.2.0",
]
[[package]]
name = "zbus_names"
version = "4.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2"
dependencies = [
"serde",
"winnow 1.0.3",
"zvariant 5.13.0",
] ]
[[package]] [[package]]
@@ -9313,7 +9390,21 @@ dependencies = [
"enumflags2", "enumflags2",
"serde", "serde",
"static_assertions", "static_assertions",
"zvariant_derive", "zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.3",
"zvariant_derive 5.13.0",
"zvariant_utils 3.5.0",
] ]
[[package]] [[package]]
@@ -9326,7 +9417,20 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.118", "syn 2.0.118",
"zvariant_utils", "zvariant_utils 2.1.0",
]
[[package]]
name = "zvariant_derive"
version = "5.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"zvariant_utils 3.5.0",
] ]
[[package]] [[package]]
@@ -9339,3 +9443,16 @@ dependencies = [
"quote", "quote",
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "zvariant_utils"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.118",
"winnow 1.0.3",
]
+1
View File
@@ -26,6 +26,7 @@ tokio = "1.52.3"
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
# ── Build profiles ─────────────────────────────────────────────────────────── # ── Build profiles ───────────────────────────────────────────────────────────
+245 -184
View File
@@ -681,7 +681,14 @@ pub fn create_tag(
Some(message) => { Some(message) => {
run_git( run_git(
&repo, &repo,
["tag", "-a", name.as_str(), "-m", message.as_str(), target.as_str()], [
"tag",
"-a",
name.as_str(),
"-m",
message.as_str(),
target.as_str(),
],
)?; )?;
} }
None => { None => {
@@ -755,7 +762,11 @@ fn validate_existing_tag_name(repo: &Path, name: &str) -> Result<String, String>
fn validate_tag_ref_name(name: &str) -> Result<String, String> { fn validate_tag_ref_name(name: &str) -> Result<String, String> {
let output = git_command() let output = git_command()
.args(["check-ref-format", "--allow-onelevel", &format!("refs/tags/{name}")]) .args([
"check-ref-format",
"--allow-onelevel",
&format!("refs/tags/{name}"),
])
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
@@ -778,63 +789,75 @@ fn validate_tag_ref_name(name: &str) -> Result<String, String> {
} }
#[tauri::command] #[tauri::command]
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> { pub async fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
validate_files(&files)?; let repo = resolve_repo(&path)?;
validate_files(&files)?;
if !files.is_empty() { if !files.is_empty() {
// A file we've detected as an unstaged rename (see `detect_worktree_renames`) has // 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 // 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. // instead of leaving the old path's deletion unstaged.
let current_status = status_for_repo(&repo)?; let current_status = status_for_repo(&repo)?;
let mut add_paths: Vec<String> = Vec::new(); let mut add_paths: Vec<String> = Vec::new();
for file in &files { for file in &files {
match find_status(&current_status.files, file) { match find_status(&current_status.files, file) {
Some(entry) => { Some(entry) => {
if let Some(old_path) = &entry.old_path { if let Some(old_path) = &entry.old_path {
add_paths.push(old_path.clone()); 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] #[tauri::command]
pub fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> { pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
validate_files(&files)?; let repo = resolve_repo(&path)?;
validate_files(&files)?;
if !files.is_empty() {
let current_status = status_for_repo(&repo)?;
unstage_selected_files(&repo, &current_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)?; let current_status = status_for_repo(&repo)?;
unstage_selected_files(&repo, &current_status.files, &files)?; if staged {
} restore_staged_files(&repo, &current_status.files, &files)?;
} else {
restore_worktree_files(&repo, &current_status.files, &files)?;
}
status_for_repo(&repo) status_for_repo(&repo)
} })
.await
#[tauri::command] .map_err(|err| format!("Could not restore files: {err}"))?
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, &current_status.files, &files)?;
} else {
restore_worktree_files(&repo, &current_status.files, &files)?;
}
status_for_repo(&repo)
} }
#[tauri::command] #[tauri::command]
@@ -1044,47 +1067,55 @@ pub fn apply_file_patch(
} }
#[tauri::command] #[tauri::command]
pub fn commit(path: String, message: String) -> Result<GitStatus, String> { pub async fn commit(path: String, message: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
if message.trim().is_empty() { let repo = resolve_repo(&path)?;
return Err("Commit message must not be empty.".to_string()); if message.trim().is_empty() {
} return Err("Commit message must not be empty.".to_string());
}
let current_status = status_for_repo(&repo)?; let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) { if has_unresolved_conflicts(&current_status) {
return Err("Merge conflicts must be resolved before you can commit.".to_string()); return Err("Merge conflicts must be resolved before you can commit.".to_string());
} }
run_git(&repo, ["commit", "-m", message.as_str()])?; run_git(&repo, ["commit", "-m", message.as_str()])?;
status_for_repo(&repo) status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not commit: {err}"))?
} }
#[tauri::command] #[tauri::command]
pub fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> { pub async fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
if verify_commit(&repo, "HEAD").is_err() { let repo = resolve_repo(&path)?;
return Err("There is no commit to amend.".to_string()); if verify_commit(&repo, "HEAD").is_err() {
} return Err("There is no commit to amend.".to_string());
let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) {
return Err("Merge conflicts must be resolved before you can commit.".to_string());
}
let message = message
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty());
match message {
Some(message) => {
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
} }
None => {
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
}
}
status_for_repo(&repo) let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) {
return Err("Merge conflicts must be resolved before you can commit.".to_string());
}
let message = message
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty());
match message {
Some(message) => {
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
}
None => {
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
}
}
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not amend commit: {err}"))?
} }
#[tauri::command] #[tauri::command]
@@ -1096,7 +1127,11 @@ pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?; let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?;
let message = String::from_utf8_lossy(&output).trim_end().to_string(); let message = String::from_utf8_lossy(&output).trim_end().to_string();
Ok(if message.is_empty() { None } else { Some(message) }) Ok(if message.is_empty() {
None
} else {
Some(message)
})
} }
#[tauri::command] #[tauri::command]
@@ -1117,39 +1152,43 @@ pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
} }
#[tauri::command] #[tauri::command]
pub fn pull( pub async fn pull(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"]; let repo = resolve_repo(&path)?;
let output = match (username.as_deref(), password.as_deref()) { let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { let output = match (username.as_deref(), password.as_deref()) {
run_git_authenticated_output(&repo, pull_args, u, p)? (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() { let status = status_for_repo(&repo)?;
return status_for_repo(&repo); if has_unresolved_conflicts(&status) {
} return Ok(status);
}
let status = status_for_repo(&repo)?; let details = command_output_details(&output);
if has_unresolved_conflicts(&status) { if is_auth_error(&details) {
return Ok(status); return Err(format!("AUTH_FAILED:{details}"));
} }
Err(format!("Git command failed: {details}"))
let details = command_output_details(&output); })
if is_auth_error(&details) { .await
return Err(format!("AUTH_FAILED:{details}")); .map_err(|err| format!("Could not pull: {err}"))?
}
Err(format!("Git command failed: {details}"))
} }
#[tauri::command] #[tauri::command]
@@ -1188,22 +1227,26 @@ pub async fn fetch(
} }
#[tauri::command] #[tauri::command]
pub fn push( pub async fn push(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let push_args = push_args_for_repo(&repo)?; let repo = resolve_repo(&path)?;
match (username.as_deref(), password.as_deref()) { let push_args = push_args_for_repo(&repo)?;
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { match (username.as_deref(), password.as_deref()) {
run_git_authenticated(&repo, push_args, u, p)?; (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated(&repo, push_args, u, p)?;
}
_ => {
run_git(&repo, push_args)?;
}
} }
_ => { status_for_repo(&repo)
run_git(&repo, push_args)?; })
} .await
} .map_err(|err| format!("Could not push: {err}"))?
status_for_repo(&repo)
} }
// ── Credential storage (OS keychain) ──────────────────────────────────────── // ── Credential storage (OS keychain) ────────────────────────────────────────
@@ -1373,61 +1416,69 @@ pub fn cred_delete(key: String) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> { pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let branch = branch.trim(); let repo = resolve_repo(&path)?;
if branch.is_empty() { let branch = branch.trim();
return Err("Branch name must not be empty.".to_string()); if branch.is_empty() {
} return Err("Branch name must not be empty.".to_string());
}
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(["merge", "--no-edit", branch]) .args(["merge", "--no-edit", branch])
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() { if output.status.success() {
return status_for_repo(&repo); return status_for_repo(&repo);
} }
// A merge that stops on conflicts leaves unmerged paths in the work tree. // 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 // Surface those through the status so the UI can offer conflict resolution
// instead of treating the conflict as a hard error. // instead of treating the conflict as a hard error.
let status = status_for_repo(&repo)?; let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) { if has_unresolved_conflicts(&status) {
return Ok(status); return Ok(status);
} }
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let details = if !stderr.trim().is_empty() { let details = if !stderr.trim().is_empty() {
stderr.trim() stderr.trim()
} else if !stdout.trim().is_empty() { } else if !stdout.trim().is_empty() {
stdout.trim() stdout.trim()
} else { } else {
"unknown error" "unknown error"
}; };
Err(format!("Merge failed: {details}")) Err(format!("Merge failed: {details}"))
})
.await
.map_err(|err| format!("Could not merge: {err}"))?
} }
#[tauri::command] #[tauri::command]
pub fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> { pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let branch = branch.trim(); let repo = resolve_repo(&path)?;
if branch.is_empty() { let branch = branch.trim();
return Err("Branch name must not be empty.".to_string()); if branch.is_empty() {
} return Err("Branch name must not be empty.".to_string());
}
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(["rebase", branch]) .args(["rebase", branch])
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .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] #[tauri::command]
@@ -1479,19 +1530,23 @@ fn rebase_status_or_error(
} }
#[tauri::command] #[tauri::command]
pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> { pub async fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let commit_hash = verify_commit(&repo, &commit)?; let repo = resolve_repo(&path)?;
let commit_hash = verify_commit(&repo, &commit)?;
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(["cherry-pick", commit_hash.as_str()]) .args(["cherry-pick", commit_hash.as_str()])
.env("GIT_EDITOR", "true") .env("GIT_EDITOR", "true")
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .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] #[tauri::command]
@@ -4826,12 +4881,12 @@ mod tests {
})); }));
} }
#[test] #[tokio::test]
#[cfg_attr( #[cfg_attr(
windows, windows,
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error" 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"); let repo = init_temp_repo("pull_diverged");
commit_initial_file(&repo.path); commit_initial_file(&repo.path);
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
@@ -4861,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!(status.clean, "{:?}", status.files);
assert!(repo.path.join("remote.txt").exists()); assert!(repo.path.join("remote.txt").exists());
@@ -4910,12 +4967,12 @@ mod tests {
); );
} }
#[test] #[tokio::test]
#[cfg_attr( #[cfg_attr(
windows, windows,
ignore = "Git for Windows can fail local push tests with a sh signal pipe error" 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 repo = init_temp_repo("push_sets_upstream_integration");
let remote = init_bare_temp_repo("push_sets_upstream_integration_remote"); let remote = init_bare_temp_repo("push_sets_upstream_integration_remote");
commit_initial_file(&repo.path); commit_initial_file(&repo.path);
@@ -4932,7 +4989,9 @@ mod tests {
["remote", "add", "origin", remote.path.to_str().unwrap()], ["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!( assert_eq!(
status.upstream.as_deref(), status.upstream.as_deref(),
@@ -5000,8 +5059,8 @@ mod tests {
assert_eq!(contents.replace("\r\n", "\n"), "resolved\n"); assert_eq!(contents.replace("\r\n", "\n"), "resolved\n");
} }
#[test] #[tokio::test]
fn commit_rejects_unresolved_merge_conflicts() { async fn commit_rejects_unresolved_merge_conflicts() {
let repo = init_temp_repo("commit_rejects_conflicts"); let repo = init_temp_repo("commit_rejects_conflicts");
fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written"); fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written");
run_git_test(&repo.path, ["add", "file.txt"]); run_git_test(&repo.path, ["add", "file.txt"]);
@@ -5027,6 +5086,7 @@ mod tests {
repo.path.to_string_lossy().to_string(), repo.path.to_string_lossy().to_string(),
"should not commit".to_string(), "should not commit".to_string(),
) )
.await
.unwrap_err(); .unwrap_err();
assert!(err.contains("Merge conflicts")); assert!(err.contains("Merge conflicts"));
@@ -5544,7 +5604,8 @@ mod tests {
fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() { fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() {
let repo = init_temp_repo("file_blame"); let repo = init_temp_repo("file_blame");
commit_initial_file(&repo.path); commit_initial_file(&repo.path);
fs::write(repo.path.join("old.txt"), "original\ntwo\n").expect("tracked file should change"); fs::write(repo.path.join("old.txt"), "original\ntwo\n")
.expect("tracked file should change");
run_git_test(&repo.path, ["add", "old.txt"]); run_git_test(&repo.path, ["add", "old.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]); run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]);
+15 -7
View File
@@ -19,7 +19,7 @@ use git::{
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop, restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
stash_push, undo_last_commit, unstage_files, stash_push, undo_last_commit, unstage_files,
}; };
use tauri::Manager; use tauri::{Manager, AppHandle};
#[tauri::command] #[tauri::command]
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
@@ -43,13 +43,21 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
#[cfg(desktop)]
let _ = app.get_webview_window("main")
.expect("no main window")
.set_focus();
}))
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_aptabase::Builder::new("A-SH-1344793789").with_options( .plugin(
tauri_plugin_aptabase::InitOptions { tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
host: Some("https://aptabase.cbsk-tech.de".to_string()), .with_options(tauri_plugin_aptabase::InitOptions {
flush_interval:None host: Some("https://aptabase.cbsk-tech.de".to_string()),
} flush_interval: None,
).build()) })
.build(),
)
.manage(SearchCancellationState::default()) .manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new()) .manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty", "productName": "Gitty",
"version": "0.200.5", "version": "2026.7.16",
"identifier": "com.gitty", "identifier": "com.gitty",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+53 -19
View File
@@ -377,14 +377,14 @@
$: localBranchNames = localBranches.map((b) => b.name); $: localBranchNames = localBranches.map((b) => b.name);
$: remoteBranches = branches.filter((b) => b.remote); $: remoteBranches = branches.filter((b) => b.remote);
$: repoSearchTerm = repoSearch.trim().toLowerCase(); $: repoSearchTerm = repoSearch.trim().toLowerCase();
$: openRepoRows = repoTabs.filter(repoMatchesSearch); $: openRepoRows = repoTabs.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
$: recentRepoRows = recentRepoPaths $: recentRepoRows = recentRepoPaths
.filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path))) .filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
.map(repoRowFromPath) .map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
.filter(repoMatchesSearch); .filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
$: favoriteRepoRows = favoriteRepoPaths $: favoriteRepoRows = favoriteRepoPaths
.map(repoRowFromPath) .map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
.filter(repoMatchesSearch); .filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed); $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed);
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed; $: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
@@ -539,6 +539,7 @@
try { try {
await fetchRemote(activeRepoPath); await fetchRemote(activeRepoPath);
applyStatus(await getStatus(activeRepoPath)); applyStatus(await getStatus(activeRepoPath));
await refreshRefsAndCommitGraph(activeRepoPath);
} catch { } catch {
// ignore — see comment above // ignore — see comment above
} finally { } finally {
@@ -556,8 +557,10 @@
try { try {
await fetchRemote(path); await fetchRemote(path);
const nextStatus = await getStatus(path); const nextStatus = await getStatus(path);
if (sameRepoPath(path, activeRepoPath)) applyStatus(nextStatus); if (sameRepoPath(path, activeRepoPath)) {
else updateRepoManagementStatus(path, nextStatus); applyStatus(nextStatus);
await refreshRefsAndCommitGraph(path);
} else updateRepoManagementStatus(path, nextStatus);
} catch { } catch {
// ignore; manual Fetch/Pull surfaces auth or network problems // ignore; manual Fetch/Pull surfaces auth or network problems
} finally { } finally {
@@ -935,6 +938,10 @@
return repoKey(left) === repoKey(right); return repoKey(left) === repoKey(right);
} }
function baseName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function uniqueRepoPaths(paths: string[]): string[] { function uniqueRepoPaths(paths: string[]): string[] {
const seen = new Set<string>(); const seen = new Set<string>();
const result: string[] = []; const result: string[] = [];
@@ -949,11 +956,11 @@
return result; return result;
} }
function repoRowFromPath(path: string): RepoTab { function repoRowFromPath(path: string, tabs = repoTabs, statusCache = repoStatusCache): RepoTab {
const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path)); const openTab = tabs.find((tab) => sameRepoPath(tab.path, path));
if (openTab) return openTab; if (openTab) return openTab;
const cached = repoStatusCache[repoKey(path)]; const cached = statusCache[repoKey(path)];
if (cached) return { ...cached, path, name: repoNameFromPath(path) }; if (cached) return { ...cached, path, name: repoNameFromPath(path) };
return { return {
@@ -967,11 +974,11 @@
}; };
} }
function repoMatchesSearch(repo: RepoTab): boolean { function repoMatchesSearch(repo: RepoTab, searchTerm = repoSearchTerm): boolean {
if (!repoSearchTerm) return true; if (!searchTerm) return true;
return repo.name.toLowerCase().includes(repoSearchTerm) return repo.name.toLowerCase().includes(searchTerm)
|| repo.path.toLowerCase().includes(repoSearchTerm) || repo.path.toLowerCase().includes(searchTerm)
|| (repo.branch ?? "").toLowerCase().includes(repoSearchTerm); || (repo.branch ?? "").toLowerCase().includes(searchTerm);
} }
function loadRepoLists() { function loadRepoLists() {
@@ -1629,6 +1636,16 @@
} }
} }
async function refreshRefsAndCommitGraph(path = activeRepoPath) {
const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(path);
await refreshTags(path);
await refreshCommitHistory(path);
if (lastFileHistoryHeadHash !== previousHeadHash) {
await refreshFileHistory(path);
}
}
function isCancellationMessage(message: string): boolean { function isCancellationMessage(message: string): boolean {
return message.toLowerCase().includes("cancelled"); return message.toLowerCase().includes("cancelled");
} }
@@ -1926,6 +1943,21 @@
if (!activeStillOpen) await openRepo(path); 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) { async function removeRepoFromManagement(path: string, event?: MouseEvent) {
event?.stopPropagation(); event?.stopPropagation();
if (isBusy) return; if (isBusy) return;
@@ -2329,6 +2361,7 @@
errorMessage = ""; errorMessage = "";
await runOperation("Fetching", async () => { await runOperation("Fetching", async () => {
applyStatus(await fetchRemote(activeRepoPath, username, password)); applyStatus(await fetchRemote(activeRepoPath, username, password));
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("repository_fetched", { trackEvent("repository_fetched", {
from_stored_credential: fromStore ? 1 : 0, from_stored_credential: fromStore ? 1 : 0,
ahead: status?.ahead ?? 0, ahead: status?.ahead ?? 0,
@@ -2525,7 +2558,7 @@
const targets = files.filter((file) => file.unstaged !== null); const targets = files.filter((file) => file.unstaged !== null);
if (targets.length === 0) return; if (targets.length === 0) return;
const paths = targets.map((file) => file.path); 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)); applyStatus(await stageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
trackEvent("file_staged", { trackEvent("file_staged", {
@@ -2539,7 +2572,7 @@
const targets = files.filter((file) => file.staged !== null); const targets = files.filter((file) => file.staged !== null);
if (targets.length === 0) return; if (targets.length === 0) return;
const paths = targets.map((file) => file.path); 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)); applyStatus(await unstageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
trackEvent("file_unstaged", { trackEvent("file_unstaged", {
@@ -3343,7 +3376,7 @@
</section> </section>
{/if} {/if}
{#if operation && operation !== "Opening repository"} {#if operation && operation !== "Opening repository" && !hasRepository}
<section class="notice busy" aria-live="polite"> <section class="notice busy" aria-live="polite">
<LoaderCircle class="spin" size={17} aria-hidden="true" /> <LoaderCircle class="spin" size={17} aria-hidden="true" />
<span>{operation}</span> <span>{operation}</span>
@@ -3502,7 +3535,7 @@
> >
<Star size={14} aria-hidden="true" /> <Star size={14} aria-hidden="true" />
</button> </button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}> <button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromRecent(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
<X size={14} aria-hidden="true" /> <X size={14} aria-hidden="true" />
</button> </button>
</div> </div>
@@ -3705,6 +3738,7 @@
{unstagedCount} {unstagedCount}
{hasRepository} {hasRepository}
{isBusy} {isBusy}
{operation}
{status} {status}
selectedFilePath={selectedExplorerPath} selectedFilePath={selectedExplorerPath}
onSelectFile={selectFileFromStatus} onSelectFile={selectFileFromStatus}
+177 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte"; import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types"; import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
interface Props { interface Props {
@@ -8,6 +9,7 @@
unstagedCount: number; unstagedCount: number;
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
operation: string;
status: GitStatus | null; status: GitStatus | null;
selectedFilePath: string; selectedFilePath: string;
onSelectFile: (file: GitFileStatus) => void; onSelectFile: (file: GitFileStatus) => void;
@@ -25,6 +27,7 @@
unstagedCount = 0, unstagedCount = 0,
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
operation = "",
status = null, status = null,
selectedFilePath = "", selectedFilePath = "",
onSelectFile = () => {}, onSelectFile = () => {},
@@ -136,7 +139,7 @@
}); });
</script> </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 class="section-head">
<div> <div>
<span class="eyebrow">Working tree</span> <span class="eyebrow">Working tree</span>
@@ -278,4 +281,177 @@
{/each} {/each}
</div> </div>
{/if} {/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> </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>