Compare commits

..
5 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
7 changed files with 435 additions and 190 deletions
+5 -1
View File
@@ -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 *)"
]
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "0.200.5",
"version": "2026.7.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "0.200.5",
"version": "2026.7.16",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "0.200.5",
"version": "2026.7.16",
"private": true,
"type": "module",
"scripts": {
+225 -180
View File
@@ -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(&current_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(&current_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, &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)?;
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)
}
#[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, &current_status.files, &files)?;
} else {
restore_worktree_files(&repo, &current_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(&current_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(&current_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(&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()])?;
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(&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]
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "0.200.5",
"version": "2026.7.16",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
+24 -4
View File
@@ -938,6 +938,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 +1943,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 +2558,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 +2572,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", {
@@ -3357,7 +3376,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 +3535,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,6 +3738,7 @@
{unstagedCount}
{hasRepository}
{isBusy}
{operation}
{status}
selectedFilePath={selectedExplorerPath}
onSelectFile={selectFileFromStatus}
+177 -1
View File
@@ -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,6 +9,7 @@
unstagedCount: number;
hasRepository: boolean;
isBusy: boolean;
operation: string;
status: GitStatus | null;
selectedFilePath: string;
onSelectFile: (file: GitFileStatus) => void;
@@ -25,6 +27,7 @@
unstagedCount = 0,
hasRepository = false,
isBusy = false,
operation = "",
status = null,
selectedFilePath = "",
onSelectFile = () => {},
@@ -136,7 +139,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>
@@ -278,4 +281,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>