Merge pull request 'add Loading Spinner when file History is loading' (#5) from bug/blockingUI into master
publish / publish-tauri (, windows-latest) (release) Successful in 8m44s
publish / publish-tauri (, windows-latest) (release) Successful in 8m44s
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -28,7 +28,10 @@
|
|||||||
"Bash(xxd)",
|
"Bash(xxd)",
|
||||||
"Bash(python3 -)",
|
"Bash(python3 -)",
|
||||||
"Bash(echo \"exit: $?\")",
|
"Bash(echo \"exit: $?\")",
|
||||||
"Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)"
|
"Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)",
|
||||||
|
"Bash(sudo -n true)",
|
||||||
|
"Bash(rustc --version)",
|
||||||
|
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+200
-9
@@ -389,7 +389,23 @@ pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String
|
|||||||
validate_files(&files)?;
|
validate_files(&files)?;
|
||||||
|
|
||||||
if !files.is_empty() {
|
if !files.is_empty() {
|
||||||
run_git_with_paths(&repo, &["add"], &files)?;
|
// 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());
|
||||||
|
}
|
||||||
|
None => add_paths.push(file.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_git_with_paths(&repo, &["add"], &add_paths)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
@@ -1415,7 +1431,8 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
|||||||
"--untracked-files=all",
|
"--untracked-files=all",
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
let (branch, files) = parse_status_output(&output)?;
|
let (branch, mut files) = parse_status_output(&output)?;
|
||||||
|
detect_worktree_renames(repo, &mut files);
|
||||||
|
|
||||||
Ok(GitStatus {
|
Ok(GitStatus {
|
||||||
repo_path: repo.to_string_lossy().to_string(),
|
repo_path: repo.to_string_lossy().to_string(),
|
||||||
@@ -1428,6 +1445,127 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `git status` only auto-detects renames between HEAD and the index (staged changes).
|
||||||
|
// A file renamed on disk but not yet `git add`ed shows up as a plain delete + untracked
|
||||||
|
// pair instead. We detect that case ourselves by comparing content hashes: if an unstaged
|
||||||
|
// deletion and an untracked file share the same blob hash (and the match is unambiguous),
|
||||||
|
// we merge them into a single unstaged "renamed" entry, mirroring how git reports staged
|
||||||
|
// renames. This is intentionally content-hash based (not similarity-based) so it never
|
||||||
|
// mutates the repository's real index.
|
||||||
|
const WORKTREE_RENAME_DETECTION_LIMIT: usize = 300;
|
||||||
|
|
||||||
|
fn detect_worktree_renames(repo: &Path, files: &mut Vec<GitFileStatus>) {
|
||||||
|
let deleted_paths: Vec<String> = files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted))
|
||||||
|
.map(|f| f.path.clone())
|
||||||
|
.collect();
|
||||||
|
let untracked_paths: Vec<String> = files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Untracked))
|
||||||
|
.map(|f| f.path.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if deleted_paths.is_empty()
|
||||||
|
|| untracked_paths.is_empty()
|
||||||
|
|| deleted_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT
|
||||||
|
|| untracked_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(deleted_hashes) = index_blob_hashes(repo, &deleted_paths) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(untracked_hashes) = worktree_blob_hashes(repo, &untracked_paths) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut hash_to_deleted: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||||
|
for (path, hash) in &deleted_hashes {
|
||||||
|
hash_to_deleted.entry(hash).or_default().push(path);
|
||||||
|
}
|
||||||
|
let mut hash_to_untracked: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||||
|
for (path, hash) in &untracked_hashes {
|
||||||
|
hash_to_untracked.entry(hash).or_default().push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut renames: Vec<(String, String)> = Vec::new();
|
||||||
|
for (hash, olds) in &hash_to_deleted {
|
||||||
|
if olds.len() != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(news) = hash_to_untracked.get(hash) {
|
||||||
|
if news.len() == 1 {
|
||||||
|
renames.push((olds[0].to_string(), news[0].to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (old_path, new_path) in renames {
|
||||||
|
files.retain(|f| {
|
||||||
|
!(f.path == old_path && f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted))
|
||||||
|
&& !(f.path == new_path
|
||||||
|
&& f.staged.is_none()
|
||||||
|
&& f.unstaged == Some(FileStatusKind::Untracked))
|
||||||
|
});
|
||||||
|
files.push(GitFileStatus {
|
||||||
|
path: new_path,
|
||||||
|
old_path: Some(old_path),
|
||||||
|
staged: None,
|
||||||
|
unstaged: Some(FileStatusKind::Renamed),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batched via `git ls-files -s -z` (one process for every deleted path) rather than one
|
||||||
|
// `git rev-parse` call per file, since this runs on every status refresh.
|
||||||
|
fn index_blob_hashes(repo: &Path, paths: &[String]) -> Result<Vec<(String, String)>, String> {
|
||||||
|
let mut args: Vec<OsString> = vec![
|
||||||
|
OsString::from("ls-files"),
|
||||||
|
OsString::from("-s"),
|
||||||
|
OsString::from("-z"),
|
||||||
|
OsString::from("--"),
|
||||||
|
];
|
||||||
|
args.extend(paths.iter().map(OsString::from));
|
||||||
|
let output = run_git(repo, args)?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for entry in output.split(|byte| *byte == 0).filter(|e| !e.is_empty()) {
|
||||||
|
let text = String::from_utf8_lossy(entry);
|
||||||
|
let Some((meta, path)) = text.split_once('\t') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(hash) = meta.split_whitespace().nth(1) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
result.push((path.to_string(), hash.to_string()));
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batched via `git hash-object --stdin-paths` (one process for every untracked path).
|
||||||
|
fn worktree_blob_hashes(repo: &Path, paths: &[String]) -> Result<Vec<(String, String)>, String> {
|
||||||
|
let stdin_data = paths.join("\n");
|
||||||
|
let output = run_git_with_stdin(
|
||||||
|
repo,
|
||||||
|
["hash-object", "--stdin-paths"],
|
||||||
|
stdin_data.as_bytes(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let hashes: Vec<String> = String::from_utf8_lossy(&output)
|
||||||
|
.lines()
|
||||||
|
.map(|line| line.trim().to_string())
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(paths
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.zip(hashes)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
||||||
let status = status_for_repo(repo)?;
|
let status = status_for_repo(repo)?;
|
||||||
repository_files_with_status(repo, &status)
|
repository_files_with_status(repo, &status)
|
||||||
@@ -2207,13 +2345,22 @@ fn restore_worktree_files(
|
|||||||
|
|
||||||
for file in files {
|
for file in files {
|
||||||
let status = find_status(statuses, file);
|
let status = find_status(statuses, file);
|
||||||
if matches!(
|
match status.and_then(|entry| entry.unstaged) {
|
||||||
status.and_then(|entry| entry.unstaged),
|
Some(FileStatusKind::Untracked) => clean_paths.push(file.clone()),
|
||||||
Some(FileStatusKind::Untracked)
|
// An unstaged rename (see `detect_worktree_renames`) has no index entry for the
|
||||||
) {
|
// new path, so `git restore` can't act on it directly: restore the original
|
||||||
clean_paths.push(file.clone());
|
// content at the old path and drop the untracked new file instead.
|
||||||
} else {
|
Some(FileStatusKind::Renamed) => {
|
||||||
restore_paths.push(file.clone());
|
if let Some(entry) = status {
|
||||||
|
if let Some(old_path) = entry.old_path.clone() {
|
||||||
|
restore_paths.push(old_path);
|
||||||
|
}
|
||||||
|
clean_paths.push(entry.path.clone());
|
||||||
|
} else {
|
||||||
|
restore_paths.push(file.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => restore_paths.push(file.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2633,6 +2780,50 @@ where
|
|||||||
Err(format!("{context}: {details}"))
|
Err(format!("{context}: {details}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_git_with_stdin<I, S>(repo: &Path, args: I, stdin_data: &[u8]) -> Result<Vec<u8>, String>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<OsStr>,
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let mut child = git_command()
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
|
||||||
|
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
stdin
|
||||||
|
.write_all(stdin_data)
|
||||||
|
.map_err(|err| format!("Eingabe konnte nicht an Git gesendet werden: {err}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = child
|
||||||
|
.wait_with_output()
|
||||||
|
.map_err(|err| format!("Git-Ausgabe konnte nicht gelesen werden: {err}"))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(output.stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
"unbekannter Fehler"
|
||||||
|
};
|
||||||
|
|
||||||
|
Err(format!("Git-Befehl fehlgeschlagen: {details}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec<GitFileStatus>), String> {
|
fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec<GitFileStatus>), String> {
|
||||||
let entries: Vec<&[u8]> = output
|
let entries: Vec<&[u8]> = output
|
||||||
.split(|byte| *byte == 0)
|
.split(|byte| *byte == 0)
|
||||||
|
|||||||
+27
-7
@@ -117,6 +117,8 @@
|
|||||||
let expandedExplorerPaths = new Set<string>();
|
let expandedExplorerPaths = new Set<string>();
|
||||||
let expandedCommitHashes = new Set<string>();
|
let expandedCommitHashes = new Set<string>();
|
||||||
let fileHistory: GitCommit[] = [];
|
let fileHistory: GitCommit[] = [];
|
||||||
|
let fileHistoryLoading = false;
|
||||||
|
let fileHistoryRequestId = 0;
|
||||||
let commitMessage = "";
|
let commitMessage = "";
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
let operation = "";
|
let operation = "";
|
||||||
@@ -537,7 +539,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
|
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
|
||||||
fileHistory = file ? await listFileHistory(path, file, 100) : [];
|
const requestId = ++fileHistoryRequestId;
|
||||||
|
const history = file ? await listFileHistory(path, file, 100) : [];
|
||||||
|
if (requestId === fileHistoryRequestId) fileHistory = history;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Repository operations ──────────────────────────────────────────────────
|
// ── Repository operations ──────────────────────────────────────────────────
|
||||||
@@ -1112,13 +1116,30 @@
|
|||||||
return folders;
|
return folders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loads history for a selected explorer node without blocking the rest of the UI
|
||||||
|
// (isBusy/runOperation would disable every button in the app while this awaits).
|
||||||
|
// A request id guards against a slower, stale request overwriting a newer selection.
|
||||||
|
async function loadSelectedFileHistory(path: string, repo = activeRepoPath) {
|
||||||
|
const requestId = ++fileHistoryRequestId;
|
||||||
|
fileHistoryLoading = true;
|
||||||
|
try {
|
||||||
|
const history = await listFileHistory(repo, path, 100);
|
||||||
|
if (requestId === fileHistoryRequestId) fileHistory = history;
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === fileHistoryRequestId) {
|
||||||
|
fileHistory = [];
|
||||||
|
errorMessage = errorToMessage(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === fileHistoryRequestId) fileHistoryLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function selectExplorerNode(node: ExplorerNode) {
|
async function selectExplorerNode(node: ExplorerNode) {
|
||||||
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
||||||
selectedExplorerPath = node.path;
|
selectedExplorerPath = node.path;
|
||||||
selectedExplorerKind = node.kind;
|
selectedExplorerKind = node.kind;
|
||||||
await runOperation(`Loading ${node.path} history`, async () => {
|
await loadSelectedFileHistory(node.path);
|
||||||
await refreshFileHistory(activeRepoPath, node.path);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectFileFromSearch(file: GitRepositoryFile) {
|
async function selectFileFromSearch(file: GitRepositoryFile) {
|
||||||
@@ -1127,9 +1148,7 @@
|
|||||||
selectedExplorerKind = "file";
|
selectedExplorerKind = "file";
|
||||||
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||||||
|
|
||||||
await runOperation(`Loading ${file.path} history`, async () => {
|
await loadSelectedFileHistory(file.path);
|
||||||
await refreshFileHistory(activeRepoPath, file.path);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
||||||
@@ -1642,6 +1661,7 @@
|
|||||||
selectedExplorerLabel={selectedExplorerPath ? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history` : "File history"}
|
selectedExplorerLabel={selectedExplorerPath ? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history` : "File history"}
|
||||||
{hasRepository}
|
{hasRepository}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
|
isLoading={fileHistoryLoading}
|
||||||
onDiff={diffSelectedFileFromCommit}
|
onDiff={diffSelectedFileFromCommit}
|
||||||
onRestore={restoreSelectedFileFromCommit}
|
onRestore={restoreSelectedFileFromCommit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+76
@@ -1371,6 +1371,82 @@
|
|||||||
.file-history-actions .commit-action-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; justify-content: stretch; }
|
.file-history-actions .commit-action-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; justify-content: stretch; }
|
||||||
.file-history-actions .commit-action-buttons button { min-width: 0; justify-content: center; padding-inline: 6px; }
|
.file-history-actions .commit-action-buttons button { min-width: 0; justify-content: center; padding-inline: 6px; }
|
||||||
|
|
||||||
|
/* --- File history loading (scoped, non-blocking) --- */
|
||||||
|
|
||||||
|
.file-history-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 140px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-history-loading-graph { width: 56px; height: 56px; overflow: visible; }
|
||||||
|
|
||||||
|
.file-history-loading-graph .fhl-ring {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(100, 108, 255, 0.22);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-dasharray: 26 18;
|
||||||
|
transform-origin: 60px 60px;
|
||||||
|
animation: fhl-ring-spin 5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-history-loading-graph .fhl-trunk,
|
||||||
|
.file-history-loading-graph .fhl-branch {
|
||||||
|
fill: none;
|
||||||
|
stroke: url(#fhl-line);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-history-loading-graph .fhl-branch {
|
||||||
|
stroke-dasharray: 90;
|
||||||
|
stroke-dashoffset: 90;
|
||||||
|
animation: fhl-branch-draw 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-history-loading-graph .fhl-node {
|
||||||
|
fill: var(--color-surface-alt);
|
||||||
|
stroke: url(#fhl-line);
|
||||||
|
stroke-width: 5;
|
||||||
|
animation: fhl-node-pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.file-history-loading-graph .fhl-n1 { animation-delay: 0s; }
|
||||||
|
.file-history-loading-graph .fhl-n2 { animation-delay: 0.5s; }
|
||||||
|
.file-history-loading-graph .fhl-n3 { animation-delay: 1s; }
|
||||||
|
.file-history-loading-graph .fhl-n4 { animation-delay: 1.5s; }
|
||||||
|
|
||||||
|
.file-history-loading-label {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fhl-ring-spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes fhl-branch-draw {
|
||||||
|
0% { stroke-dashoffset: 90; opacity: 0.35; }
|
||||||
|
45% { stroke-dashoffset: 0; opacity: 1; }
|
||||||
|
100% { stroke-dashoffset: 0; opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes fhl-node-pulse {
|
||||||
|
0%, 100% { fill: var(--color-surface-alt); filter: none; }
|
||||||
|
50% {
|
||||||
|
fill: var(--color-primary);
|
||||||
|
filter: drop-shadow(0 0 6px rgba(100, 108, 255, 0.8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.file-history-loading-graph .fhl-ring,
|
||||||
|
.file-history-loading-graph .fhl-branch,
|
||||||
|
.file-history-loading-graph .fhl-node { animation: none; }
|
||||||
|
.file-history-loading-graph .fhl-branch { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Git graph --- */
|
/* --- Git graph --- */
|
||||||
|
|
||||||
.graph-list { padding: 0; }
|
.graph-list { padding: 0; }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
selectedExplorerLabel: string;
|
selectedExplorerLabel: string;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
onDiff: (commit: GitCommit) => void;
|
onDiff: (commit: GitCommit) => void;
|
||||||
onRestore: (commit: GitCommit) => void;
|
onRestore: (commit: GitCommit) => void;
|
||||||
}
|
}
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
selectedExplorerLabel = "File history",
|
selectedExplorerLabel = "File history",
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
isLoading = false,
|
||||||
onDiff = () => {},
|
onDiff = () => {},
|
||||||
onRestore = () => {},
|
onRestore = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -89,6 +91,25 @@
|
|||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">No repository loaded.</div>
|
||||||
{:else if !selectedExplorerPath}
|
{:else if !selectedExplorerPath}
|
||||||
<div class="blank-state">Select a file in Explorer.</div>
|
<div class="blank-state">Select a file in Explorer.</div>
|
||||||
|
{:else if isLoading}
|
||||||
|
<div class="file-history-loading" role="status" aria-live="polite">
|
||||||
|
<svg class="file-history-loading-graph" viewBox="0 0 120 120" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fhl-line" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#646cff" />
|
||||||
|
<stop offset="100%" stop-color="#41d1ff" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle class="fhl-ring" cx="60" cy="60" r="52" />
|
||||||
|
<path class="fhl-trunk" d="M42 22 L42 98" />
|
||||||
|
<path class="fhl-branch" d="M42 44 C42 62, 82 58, 82 76 L82 88" />
|
||||||
|
<circle class="fhl-node fhl-n1" cx="42" cy="30" r="6" />
|
||||||
|
<circle class="fhl-node fhl-n2" cx="42" cy="60" r="6" />
|
||||||
|
<circle class="fhl-node fhl-n3" cx="82" cy="88" r="6" />
|
||||||
|
<circle class="fhl-node fhl-n4" cx="42" cy="90" r="6" />
|
||||||
|
</svg>
|
||||||
|
<span class="file-history-loading-label">Loading history…</span>
|
||||||
|
</div>
|
||||||
{:else if fileHistory.length === 0}
|
{:else if fileHistory.length === 0}
|
||||||
<div class="blank-state">No history returned for this selection.</div>
|
<div class="blank-state">No history returned for this selection.</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
Reference in New Issue
Block a user