Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f0904e839 | ||
|
|
e3df75cc38 | ||
|
|
794680a696 | ||
|
|
18476d9d92 | ||
|
|
3a8c114d82 | ||
|
|
0edf2819dc | ||
|
|
5d34147b41 | ||
|
|
e18f8f1040 | ||
|
|
310a0fb09a |
@@ -28,7 +28,13 @@
|
|||||||
"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/**)",
|
||||||
|
"Bash(sudo apt install -y libdbus-1-dev pkg-config)",
|
||||||
|
"Bash(dpkg -l)",
|
||||||
|
"Bash(apt list *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tauri-git-lite",
|
"name": "tauri-git-lite",
|
||||||
"version": "2026.7.2",
|
"version": "2026.7.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tauri-git-lite",
|
"name": "tauri-git-lite",
|
||||||
"version": "2026.7.2",
|
"version": "2026.7.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "git-lite",
|
"name": "git-lite",
|
||||||
"version": "2026.7.2",
|
"version": "2026.7.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+13
-13
@@ -1392,6 +1392,19 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "git_lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"keyring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-updater",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glib"
|
name = "glib"
|
||||||
version = "0.18.5"
|
version = "0.18.5"
|
||||||
@@ -4121,19 +4134,6 @@ dependencies = [
|
|||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tauri_git_lite"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"keyring",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tauri",
|
|
||||||
"tauri-build",
|
|
||||||
"tauri-plugin-dialog",
|
|
||||||
"tauri-plugin-updater",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tauri_git_lite"
|
name = "git_lite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Rust backend for a lightweight Git desktop client"
|
description = "Rust backend for a lightweight Git desktop client"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+386
-31
@@ -214,6 +214,22 @@ pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
|
|||||||
open_path_in_file_manager(&repo)
|
open_path_in_file_manager(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
validate_files(std::slice::from_ref(&file))?;
|
||||||
|
let file_path = resolve_repo_child_path(&repo, &file)?;
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
return Err(format!("Datei '{file}' existiert im Working Tree nicht."));
|
||||||
|
}
|
||||||
|
if !file_path.is_file() {
|
||||||
|
return Err(format!("'{file}' ist keine Datei."));
|
||||||
|
}
|
||||||
|
|
||||||
|
reveal_path_in_file_manager(&file_path)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct RepositoryBundle {
|
pub struct RepositoryBundle {
|
||||||
pub status: GitStatus,
|
pub status: GitStatus,
|
||||||
@@ -389,7 +405,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)
|
||||||
@@ -770,15 +802,60 @@ pub fn list_repository_files(path: String) -> Result<Vec<GitRepositoryFile>, Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_file_history(
|
pub async fn list_file_history(
|
||||||
path: String,
|
path: String,
|
||||||
file: String,
|
file: String,
|
||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
|
request_id: Option<String>,
|
||||||
|
state: tauri::State<'_, SearchCancellationState>,
|
||||||
) -> Result<Vec<GitCommit>, String> {
|
) -> Result<Vec<GitCommit>, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let state = state.inner().clone();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let request_id = request_id
|
||||||
|
.map(|id| id.trim().to_string())
|
||||||
|
.filter(|id| !id.is_empty());
|
||||||
|
let cancellation = request_id.as_ref().map(|request_id| SearchCancellation {
|
||||||
|
state: state.clone(),
|
||||||
|
search_id: request_id.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = list_file_history_core(&repo, file, limit, cancellation.as_ref());
|
||||||
|
|
||||||
|
if let Some(request_id) = request_id.as_deref() {
|
||||||
|
let _ = state.clear(request_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Dateihistorie konnte nicht geladen werden: {err}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cancel_file_history(
|
||||||
|
request_id: String,
|
||||||
|
state: tauri::State<'_, SearchCancellationState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let request_id = request_id.trim();
|
||||||
|
if request_id.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.cancel(request_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_file_history_core(
|
||||||
|
repo: &Path,
|
||||||
|
file: String,
|
||||||
|
limit: Option<u32>,
|
||||||
|
cancellation: Option<&SearchCancellation>,
|
||||||
|
) -> Result<Vec<GitCommit>, String> {
|
||||||
|
check_search_cancelled(cancellation)?;
|
||||||
validate_files(std::slice::from_ref(&file))?;
|
validate_files(std::slice::from_ref(&file))?;
|
||||||
|
|
||||||
if verify_commit(&repo, "HEAD").is_err() {
|
if verify_commit(repo, "HEAD").is_err() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,13 +867,14 @@ pub fn list_file_history(
|
|||||||
OsString::from("-n"),
|
OsString::from("-n"),
|
||||||
OsString::from(limit),
|
OsString::from(limit),
|
||||||
];
|
];
|
||||||
if !is_repository_folder_path(&repo, &file)? {
|
if !is_repository_folder_path(repo, &file)? {
|
||||||
args.push(OsString::from("--follow"));
|
args.push(OsString::from("--follow"));
|
||||||
}
|
}
|
||||||
args.extend([OsString::from("--"), OsString::from(file)]);
|
args.extend([OsString::from("--"), OsString::from(file)]);
|
||||||
let output = run_git(&repo, args)?;
|
let output = run_git_cancellable(repo, args, cancellation, "Git-Dateihistorie fehlgeschlagen")?;
|
||||||
|
check_search_cancelled(cancellation)?;
|
||||||
|
|
||||||
parse_commit_log(&repo, &output)
|
parse_commit_log(repo, &output)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -972,7 +1050,21 @@ pub fn restore_to_commit(path: String, commit: String) -> Result<GitStatus, Stri
|
|||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
let commit_hash = verify_commit(&repo, &commit)?;
|
let commit_hash = verify_commit(&repo, &commit)?;
|
||||||
|
|
||||||
run_git(&repo, ["reset", "--hard", commit_hash.as_str()])?;
|
// Non-destructive: bring the working tree back to how it looked at `commit` without
|
||||||
|
// moving the branch pointer (unlike `git reset --hard`, which would rewrite history and
|
||||||
|
// hide any newer commits from the log). The result lands as ordinary unstaged changes
|
||||||
|
// that the user reviews in the status panel and stages/commits or discards explicitly.
|
||||||
|
run_git(
|
||||||
|
&repo,
|
||||||
|
[
|
||||||
|
"restore",
|
||||||
|
"--source",
|
||||||
|
commit_hash.as_str(),
|
||||||
|
"--worktree",
|
||||||
|
"--",
|
||||||
|
".",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1386,6 +1478,69 @@ fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_repo_child_path(repo: &Path, child: &str) -> Result<PathBuf, String> {
|
||||||
|
let child_path = Path::new(child);
|
||||||
|
if child_path.is_absolute()
|
||||||
|
|| child_path.components().any(|component| {
|
||||||
|
matches!(
|
||||||
|
component,
|
||||||
|
std::path::Component::ParentDir
|
||||||
|
| std::path::Component::RootDir
|
||||||
|
| std::path::Component::Prefix(_)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err("Dateipfad muss innerhalb des Repositorys liegen.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = repo.join(child_path);
|
||||||
|
let repo = repo
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|err| format!("Repository-Pfad konnte nicht aufgeloest werden: {err}"))?;
|
||||||
|
let candidate = candidate
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|err| format!("Dateipfad konnte nicht aufgeloest werden: {err}"))?;
|
||||||
|
|
||||||
|
if !candidate.starts_with(&repo) {
|
||||||
|
return Err("Dateipfad liegt ausserhalb des Repositorys.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
|
||||||
|
let native_path = path.to_string_lossy().replace('/', "\\");
|
||||||
|
let mut command = Command::new("explorer.exe");
|
||||||
|
command.arg(format!("/select,{native_path}"));
|
||||||
|
command.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
command
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("Explorer konnte nicht gestartet werden: {err}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
|
||||||
|
Command::new("open")
|
||||||
|
.arg("-R")
|
||||||
|
.arg(path)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("Finder konnte nicht gestartet werden: {err}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
|
||||||
|
// No universal "select this file" flag across Linux file managers; open its folder instead.
|
||||||
|
let target = path.parent().unwrap_or(path);
|
||||||
|
Command::new("xdg-open")
|
||||||
|
.arg(target)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("Dateimanager konnte nicht gestartet werden: {err}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> {
|
fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> {
|
||||||
let commit = commit.trim();
|
let commit = commit.trim();
|
||||||
if commit.is_empty() {
|
if commit.is_empty() {
|
||||||
@@ -1415,7 +1570,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 +1584,125 @@ 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 +2482,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 +2917,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)
|
||||||
@@ -3722,7 +4050,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restore_to_commit_resets_branch_to_selected_commit() {
|
fn restore_to_commit_leaves_branch_untouched_and_stages_change_as_unstaged() {
|
||||||
let repo = init_temp_repo("restore_to_commit");
|
let repo = init_temp_repo("restore_to_commit");
|
||||||
commit_initial_file(&repo.path);
|
commit_initial_file(&repo.path);
|
||||||
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||||
@@ -3730,6 +4058,7 @@ mod tests {
|
|||||||
fs::write(repo.path.join("old.txt"), "second\n").expect("second file should be written");
|
fs::write(repo.path.join("old.txt"), "second\n").expect("second file should be written");
|
||||||
run_git_test(&repo.path, ["add", "old.txt"]);
|
run_git_test(&repo.path, ["add", "old.txt"]);
|
||||||
run_git_test(&repo.path, ["commit", "-q", "-m", "second"]);
|
run_git_test(&repo.path, ["commit", "-q", "-m", "second"]);
|
||||||
|
let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||||
|
|
||||||
let status = restore_to_commit(
|
let status = restore_to_commit(
|
||||||
repo.path.to_string_lossy().to_string(),
|
repo.path.to_string_lossy().to_string(),
|
||||||
@@ -3739,9 +4068,20 @@ mod tests {
|
|||||||
let current_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
let current_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||||
let contents = fs::read_to_string(repo.path.join("old.txt")).unwrap();
|
let contents = fs::read_to_string(repo.path.join("old.txt")).unwrap();
|
||||||
|
|
||||||
assert_eq!(current_commit, first_commit);
|
// The branch must stay exactly where it was: no commit is rewritten or hidden.
|
||||||
|
assert_eq!(current_commit, second_commit);
|
||||||
|
assert_ne!(current_commit, first_commit);
|
||||||
|
// The old content lands in the worktree as a reviewable, unstaged change.
|
||||||
assert_eq!(contents.replace("\r\n", "\n"), "original\n");
|
assert_eq!(contents.replace("\r\n", "\n"), "original\n");
|
||||||
assert!(status.clean, "{:?}", status.files);
|
assert!(!status.clean, "{:?}", status.files);
|
||||||
|
assert_eq!(
|
||||||
|
status
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "old.txt")
|
||||||
|
.and_then(|f| f.unstaged),
|
||||||
|
Some(FileStatusKind::Modified)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3835,12 +4175,8 @@ mod tests {
|
|||||||
run_git_test(&repo.path, ["add", "other.txt"]);
|
run_git_test(&repo.path, ["add", "other.txt"]);
|
||||||
run_git_test(&repo.path, ["commit", "-q", "-m", "touch other"]);
|
run_git_test(&repo.path, ["commit", "-q", "-m", "touch other"]);
|
||||||
|
|
||||||
let commits = list_file_history(
|
let commits =
|
||||||
repo.path.to_string_lossy().to_string(),
|
list_file_history_core(&repo.path, "old.txt".to_string(), Some(10), None).unwrap();
|
||||||
"old.txt".to_string(),
|
|
||||||
Some(10),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(commits.len(), 2);
|
assert_eq!(commits.len(), 2);
|
||||||
assert_eq!(commits[0].summary, "touch selected");
|
assert_eq!(commits[0].summary, "touch selected");
|
||||||
@@ -3863,15 +4199,34 @@ mod tests {
|
|||||||
run_git_test(&repo.path, ["add", "."]);
|
run_git_test(&repo.path, ["add", "."]);
|
||||||
run_git_test(&repo.path, ["commit", "-q", "-m", "src update"]);
|
run_git_test(&repo.path, ["commit", "-q", "-m", "src update"]);
|
||||||
|
|
||||||
let commits = list_file_history(
|
let commits =
|
||||||
repo.path.to_string_lossy().to_string(),
|
list_file_history_core(&repo.path, "src".to_string(), Some(10), None).unwrap();
|
||||||
"src".to_string(),
|
|
||||||
Some(10),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(commits.len(), 2);
|
assert_eq!(commits.len(), 2);
|
||||||
assert_eq!(commits[0].summary, "src update");
|
assert_eq!(commits[0].summary, "src update");
|
||||||
assert_eq!(commits[1].summary, "src initial");
|
assert_eq!(commits[1].summary, "src initial");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_file_history_can_be_cancelled() {
|
||||||
|
let repo = init_temp_repo("file_history_cancelled");
|
||||||
|
commit_initial_file(&repo.path);
|
||||||
|
|
||||||
|
let state = SearchCancellationState::default();
|
||||||
|
state
|
||||||
|
.cancel("file-history-test")
|
||||||
|
.expect("cancel flag should be set");
|
||||||
|
|
||||||
|
let result = list_file_history_core(
|
||||||
|
&repo.path,
|
||||||
|
"old.txt".to_string(),
|
||||||
|
Some(10),
|
||||||
|
Some(&SearchCancellation {
|
||||||
|
state,
|
||||||
|
search_id: "file-history-test".to_string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-7
@@ -3,13 +3,14 @@
|
|||||||
mod git;
|
mod git;
|
||||||
|
|
||||||
use git::{
|
use git::{
|
||||||
apply_file_patch, cancel_code_search, checkout_branch, commit, compare_commits,
|
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, commit,
|
||||||
compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save,
|
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, cred_delete,
|
||||||
delete_branch, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status,
|
cred_load, cred_save, delete_branch, diff_file_against_working_tree, get_file_patch,
|
||||||
list_branches, list_commits, list_file_history, list_repository_files, merge_branch,
|
get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
||||||
open_repo_in_explorer, open_repository, open_repository_bundle, pull, push, read_conflict,
|
list_repository_files, merge_branch, open_repo_in_explorer, open_repository,
|
||||||
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch,
|
||||||
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
||||||
|
restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
||||||
SearchCancellationState,
|
SearchCancellationState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ fn main() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_repository,
|
open_repository,
|
||||||
open_repo_in_explorer,
|
open_repo_in_explorer,
|
||||||
|
open_repository_file,
|
||||||
get_status,
|
get_status,
|
||||||
list_branches,
|
list_branches,
|
||||||
checkout_branch,
|
checkout_branch,
|
||||||
@@ -42,6 +44,7 @@ fn main() {
|
|||||||
list_repository_files,
|
list_repository_files,
|
||||||
open_repository_bundle,
|
open_repository_bundle,
|
||||||
list_file_history,
|
list_file_history,
|
||||||
|
cancel_file_history,
|
||||||
compare_commits,
|
compare_commits,
|
||||||
compare_file_to_head,
|
compare_file_to_head,
|
||||||
compare_file_to_parent,
|
compare_file_to_parent,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "GitLite",
|
"productName": "GitLite",
|
||||||
"version": "2026.7.2",
|
"version": "2026.7.5",
|
||||||
"identifier": "com.git-lite",
|
"identifier": "com.git-lite",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+87
-8
@@ -27,6 +27,7 @@
|
|||||||
commit,
|
commit,
|
||||||
compareCommits,
|
compareCommits,
|
||||||
cancelCodeSearch,
|
cancelCodeSearch,
|
||||||
|
cancelFileHistory,
|
||||||
applyFilePatch,
|
applyFilePatch,
|
||||||
createBranch,
|
createBranch,
|
||||||
deleteBranch,
|
deleteBranch,
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
listRepositoryFiles,
|
listRepositoryFiles,
|
||||||
mergeBranch,
|
mergeBranch,
|
||||||
openRepoInExplorer,
|
openRepoInExplorer,
|
||||||
|
openRepositoryFile,
|
||||||
openRepositoryBundle,
|
openRepositoryBundle,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
@@ -117,6 +119,9 @@
|
|||||||
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 activeFileHistoryRequestId = "";
|
||||||
let commitMessage = "";
|
let commitMessage = "";
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
let operation = "";
|
let operation = "";
|
||||||
@@ -205,6 +210,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||||
|
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Auto-refresh ───────────────────────────────────────────────────────────
|
// ── Auto-refresh ───────────────────────────────────────────────────────────
|
||||||
@@ -532,12 +538,56 @@
|
|||||||
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
|
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
|
||||||
selectedExplorerPath = "";
|
selectedExplorerPath = "";
|
||||||
selectedExplorerKind = "file";
|
selectedExplorerKind = "file";
|
||||||
|
cancelActiveFileHistoryLoad();
|
||||||
|
activeFileHistoryRequestId = "";
|
||||||
|
fileHistoryLoading = false;
|
||||||
fileHistory = [];
|
fileHistory = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCancellationMessage(message: string): boolean {
|
||||||
|
return message.toLowerCase().includes("abgebrochen");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelActiveFileHistoryLoad() {
|
||||||
|
const requestId = activeFileHistoryRequestId;
|
||||||
|
if (!requestId) return;
|
||||||
|
void cancelFileHistory(requestId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
|
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
|
||||||
fileHistory = file ? await listFileHistory(path, file, 100) : [];
|
const requestId = ++fileHistoryRequestId;
|
||||||
|
cancelActiveFileHistoryLoad();
|
||||||
|
|
||||||
|
if (!path || !file) {
|
||||||
|
activeFileHistoryRequestId = "";
|
||||||
|
fileHistoryLoading = false;
|
||||||
|
fileHistory = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyRequestId = `file-history-${requestId}-${Date.now()}`;
|
||||||
|
activeFileHistoryRequestId = historyRequestId;
|
||||||
|
fileHistoryLoading = true;
|
||||||
|
fileHistory = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const history = await listFileHistory(path, file, 100, historyRequestId);
|
||||||
|
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
|
||||||
|
fileHistory = history;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorToMessage(error);
|
||||||
|
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
|
||||||
|
fileHistory = [];
|
||||||
|
if (!isCancellationMessage(message)) errorMessage = message;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
|
||||||
|
activeFileHistoryRequestId = "";
|
||||||
|
fileHistoryLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Repository operations ──────────────────────────────────────────────────
|
// ── Repository operations ──────────────────────────────────────────────────
|
||||||
@@ -1047,7 +1097,7 @@
|
|||||||
|
|
||||||
async function restoreCommit(target: GitCommit) {
|
async function restoreCommit(target: GitCommit) {
|
||||||
if (!activeRepoPath) return;
|
if (!activeRepoPath) return;
|
||||||
const confirmed = window.confirm(`Reset current branch to ${target.short_hash}?\n\nThis moves the current branch and discards tracked local changes.`);
|
const confirmed = window.confirm(`Restore working tree to ${target.short_hash}?\n\nThis brings back the files from that commit as unstaged changes so you can review and commit them. No commit is removed and the branch stays where it is.`);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
await runOperation(`Restoring ${target.short_hash}`, async () => {
|
await runOperation(`Restoring ${target.short_hash}`, async () => {
|
||||||
applyStatus(await restoreToCommit(activeRepoPath, target.hash));
|
applyStatus(await restoreToCommit(activeRepoPath, target.hash));
|
||||||
@@ -1112,13 +1162,18 @@
|
|||||||
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) {
|
||||||
|
await refreshFileHistory(repo, path);
|
||||||
|
}
|
||||||
|
|
||||||
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 () => {
|
void loadSelectedFileHistory(node.path);
|
||||||
await refreshFileHistory(activeRepoPath, node.path);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectFileFromSearch(file: GitRepositoryFile) {
|
async function selectFileFromSearch(file: GitRepositoryFile) {
|
||||||
@@ -1127,9 +1182,29 @@
|
|||||||
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 () => {
|
void loadSelectedFileHistory(file.path);
|
||||||
await refreshFileHistory(activeRepoPath, file.path);
|
}
|
||||||
});
|
|
||||||
|
function selectFileFromStatus(file: GitFileStatus) {
|
||||||
|
if (!activeRepoPath) return;
|
||||||
|
selectedExplorerPath = file.path;
|
||||||
|
selectedExplorerKind = "file";
|
||||||
|
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||||||
|
|
||||||
|
void loadSelectedFileHistory(file.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFileFromExplorer(node: ExplorerNode) {
|
||||||
|
if (!activeRepoPath || node.kind !== "file") return;
|
||||||
|
selectedExplorerPath = node.path;
|
||||||
|
selectedExplorerKind = "file";
|
||||||
|
void loadSelectedFileHistory(node.path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openRepositoryFile(activeRepoPath, node.path);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = errorToMessage(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
||||||
@@ -1571,6 +1646,7 @@
|
|||||||
onExpandAllFolders={expandAllExplorerFolders}
|
onExpandAllFolders={expandAllExplorerFolders}
|
||||||
onCollapseAllFolders={collapseAllExplorerFolders}
|
onCollapseAllFolders={collapseAllExplorerFolders}
|
||||||
onSelectNode={selectExplorerNode}
|
onSelectNode={selectExplorerNode}
|
||||||
|
onOpenFile={openFileFromExplorer}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -1599,6 +1675,8 @@
|
|||||||
{hasRepository}
|
{hasRepository}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
{status}
|
{status}
|
||||||
|
selectedFilePath={selectedExplorerPath}
|
||||||
|
onSelectFile={selectFileFromStatus}
|
||||||
onStage={stageFile}
|
onStage={stageFile}
|
||||||
onUnstage={unstageFile}
|
onUnstage={unstageFile}
|
||||||
onDiscard={discardFile}
|
onDiscard={discardFile}
|
||||||
@@ -1642,6 +1720,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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+117
-7
@@ -12,6 +12,7 @@
|
|||||||
--color-surface-dim: rgba(18, 18, 30, 0.9);
|
--color-surface-dim: rgba(18, 18, 30, 0.9);
|
||||||
--color-surface-hover: rgba(47, 48, 78, 0.76);
|
--color-surface-hover: rgba(47, 48, 78, 0.76);
|
||||||
--color-surface-raised: rgba(28, 29, 48, 0.88);
|
--color-surface-raised: rgba(28, 29, 48, 0.88);
|
||||||
|
--color-surface-solid: #1c1d30;
|
||||||
|
|
||||||
--color-border: rgba(100, 108, 255, 0.28);
|
--color-border: rgba(100, 108, 255, 0.28);
|
||||||
--color-border-subtle: rgba(255, 255, 255, 0.08);
|
--color-border-subtle: rgba(255, 255, 255, 0.08);
|
||||||
@@ -274,6 +275,15 @@
|
|||||||
}
|
}
|
||||||
.titlebar-brand svg { color: #ffd343; filter: drop-shadow(0 0 10px rgba(255,211,67,0.3)); flex-shrink: 0; }
|
.titlebar-brand svg { color: #ffd343; filter: drop-shadow(0 0 10px rgba(255,211,67,0.3)); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.tb-version {
|
||||||
|
margin-left: 1px;
|
||||||
|
color: rgba(255,255,255,0.32);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
.titlebar-info {
|
.titlebar-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -943,6 +953,22 @@
|
|||||||
|
|
||||||
.file-row { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
.file-row { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||||
.file-row + .file-row { margin-top: 6px; }
|
.file-row + .file-row { margin-top: 6px; }
|
||||||
|
.file-row.selected { border-color: rgba(90,140,248,0.36); background: rgba(90,140,248,0.1); }
|
||||||
|
|
||||||
|
.file-title-button {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.file-title-button:hover:not(:disabled) {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.file-title strong {
|
.file-title strong {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -1175,8 +1201,8 @@
|
|||||||
|
|
||||||
.branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; }
|
.branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; }
|
||||||
|
|
||||||
.branch-context-menu {
|
.branch-context-menu,
|
||||||
position: absolute;
|
.explorer-context-menu {
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -1184,11 +1210,15 @@
|
|||||||
padding: 5px;
|
padding: 5px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface-solid);
|
||||||
box-shadow: 0 18px 50px rgba(0,0,0,0.35);
|
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-context-menu button {
|
.branch-context-menu { position: absolute; }
|
||||||
|
.explorer-context-menu { position: fixed; }
|
||||||
|
|
||||||
|
.branch-context-menu button,
|
||||||
|
.explorer-context-menu button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -1205,7 +1235,8 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-context-menu button:hover:not(:disabled) {
|
.branch-context-menu button:hover:not(:disabled),
|
||||||
|
.explorer-context-menu button:hover:not(:disabled) {
|
||||||
border-color: var(--color-border-subtle);
|
border-color: var(--color-border-subtle);
|
||||||
background: rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.06);
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
@@ -1221,13 +1252,16 @@
|
|||||||
color: #ffd0d6;
|
color: #ffd0d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-context-menu button:disabled {
|
.branch-context-menu button:disabled,
|
||||||
|
.explorer-context-menu button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.48;
|
opacity: 0.48;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Explorer --- */
|
/* --- Explorer --- */
|
||||||
|
|
||||||
|
.explorer-panel { position: relative; }
|
||||||
|
|
||||||
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
|
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
|
||||||
.explorer-bulk-button {
|
.explorer-bulk-button {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
@@ -1371,6 +1405,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; }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
import { Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
||||||
|
|
||||||
@@ -23,12 +24,14 @@
|
|||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
let isMaximized = false;
|
let isMaximized = false;
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
|
let appVersion = "";
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
isMaximized = await win.isMaximized();
|
isMaximized = await win.isMaximized();
|
||||||
unlisten = await win.onResized(async () => {
|
unlisten = await win.onResized(async () => {
|
||||||
isMaximized = await win.isMaximized();
|
isMaximized = await win.isMaximized();
|
||||||
});
|
});
|
||||||
|
appVersion = await getVersion();
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -58,6 +61,9 @@
|
|||||||
<line x1="12" y1="12" x2="12" y2="15" />
|
<line x1="12" y1="12" x2="12" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
<span data-tauri-drag-region>GitLite</span>
|
<span data-tauri-drag-region>GitLite</span>
|
||||||
|
{#if appVersion}
|
||||||
|
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: repo + branch info -->
|
<!-- Center: repo + branch info -->
|
||||||
|
|||||||
@@ -252,7 +252,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} />
|
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} />
|
||||||
|
|
||||||
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
FileVideo,
|
FileVideo,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
ExternalLink,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { languageIconForPath } from "../languageIcons";
|
import { languageIconForPath } from "../languageIcons";
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
onExpandAllFolders: () => void;
|
onExpandAllFolders: () => void;
|
||||||
onCollapseAllFolders: () => void;
|
onCollapseAllFolders: () => void;
|
||||||
onSelectNode: (node: ExplorerNode) => void;
|
onSelectNode: (node: ExplorerNode) => void;
|
||||||
|
onOpenFile: (node: ExplorerNode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -47,8 +49,13 @@
|
|||||||
onExpandAllFolders = () => {},
|
onExpandAllFolders = () => {},
|
||||||
onCollapseAllFolders = () => {},
|
onCollapseAllFolders = () => {},
|
||||||
onSelectNode = () => {},
|
onSelectNode = () => {},
|
||||||
|
onOpenFile = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let contextNode = $state<ExplorerNode | null>(null);
|
||||||
|
let contextMenuX = $state(0);
|
||||||
|
let contextMenuY = $state(0);
|
||||||
|
|
||||||
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
||||||
if (!next) return current;
|
if (!next) return current;
|
||||||
if (!current) return next;
|
if (!current) return next;
|
||||||
@@ -154,12 +161,39 @@
|
|||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openFileContextMenu(event: MouseEvent, node: ExplorerNode) {
|
||||||
|
if (node.kind !== "file") return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
contextNode = node;
|
||||||
|
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
|
||||||
|
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 56));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFileContextMenu() {
|
||||||
|
contextNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openContextFile() {
|
||||||
|
const node = contextNode;
|
||||||
|
if (!node || node.kind !== "file") return;
|
||||||
|
closeFileContextMenu();
|
||||||
|
onOpenFile(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") closeFileContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
||||||
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
||||||
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
|
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
|
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
|
||||||
|
|
||||||
|
<section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Explorer</span>
|
<span class="eyebrow">Explorer</span>
|
||||||
@@ -264,6 +298,7 @@
|
|||||||
class="explorer-select"
|
class="explorer-select"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => onSelectNode(node)}
|
onclick={() => onSelectNode(node)}
|
||||||
|
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
title={`Show history for ${node.path}`}
|
title={`Show history for ${node.path}`}
|
||||||
>
|
>
|
||||||
@@ -279,4 +314,26 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if contextNode}
|
||||||
|
<div
|
||||||
|
class="explorer-context-menu"
|
||||||
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={`Actions for ${contextNode.path}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={openContextFile}
|
||||||
|
disabled={contextNode.status === "deleted"}
|
||||||
|
title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} aria-hidden="true" />
|
||||||
|
Open in Explorer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -226,7 +226,7 @@
|
|||||||
<GitBranch size={15} aria-hidden="true" />
|
<GitBranch size={15} aria-hidden="true" />
|
||||||
Branch
|
Branch
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Reset current branch to this commit">
|
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Bring this commit's files into your working tree as unstaged changes (no history is changed)">
|
||||||
<RotateCcw size={15} aria-hidden="true" />
|
<RotateCcw size={15} aria-hidden="true" />
|
||||||
Restore
|
Restore
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
status: GitStatus | null;
|
status: GitStatus | null;
|
||||||
|
selectedFilePath: string;
|
||||||
|
onSelectFile: (file: GitFileStatus) => void;
|
||||||
onStage: (file: GitFileStatus) => void;
|
onStage: (file: GitFileStatus) => void;
|
||||||
onUnstage: (file: GitFileStatus) => void;
|
onUnstage: (file: GitFileStatus) => void;
|
||||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
||||||
@@ -24,6 +26,8 @@
|
|||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
status = null,
|
status = null,
|
||||||
|
selectedFilePath = "",
|
||||||
|
onSelectFile = () => {},
|
||||||
onStage = () => {},
|
onStage = () => {},
|
||||||
onUnstage = () => {},
|
onUnstage = () => {},
|
||||||
onDiscard = () => {},
|
onDiscard = () => {},
|
||||||
@@ -102,9 +106,19 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-auto p-2">
|
<div class="overflow-auto p-2">
|
||||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||||
<article class="file-row">
|
<article
|
||||||
|
class="file-row"
|
||||||
|
class:selected={selectedFilePath === file.path}
|
||||||
|
>
|
||||||
<div class="file-title">
|
<div class="file-title">
|
||||||
<strong title={displayPath(file)}>{fileName(file)}</strong>
|
<button
|
||||||
|
class="file-title-button"
|
||||||
|
type="button"
|
||||||
|
onclick={() => onSelectFile(file)}
|
||||||
|
title={`Select ${displayPath(file)} in Explorer`}
|
||||||
|
>
|
||||||
|
<strong>{fileName(file)}</strong>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="change-lanes">
|
<div class="change-lanes">
|
||||||
|
|||||||
+15
-2
@@ -21,6 +21,10 @@ export function openRepoInExplorer(path: string): Promise<void> {
|
|||||||
return invoke<void>("open_repo_in_explorer", { path });
|
return invoke<void>("open_repo_in_explorer", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function openRepositoryFile(path: string, file: string): Promise<void> {
|
||||||
|
return invoke<void>("open_repository_file", { path, file });
|
||||||
|
}
|
||||||
|
|
||||||
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
||||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||||
}
|
}
|
||||||
@@ -143,8 +147,17 @@ export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]>
|
|||||||
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listFileHistory(path: string, file: string, limit = 100): Promise<GitCommit[]> {
|
export function listFileHistory(
|
||||||
return invoke<GitCommit[]>("list_file_history", { path, file, limit });
|
path: string,
|
||||||
|
file: string,
|
||||||
|
limit = 100,
|
||||||
|
requestId?: string,
|
||||||
|
): Promise<GitCommit[]> {
|
||||||
|
return invoke<GitCommit[]>("list_file_history", { path, file, limit, requestId: requestId ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelFileHistory(requestId: string): Promise<void> {
|
||||||
|
return invoke<void>("cancel_file_history", { requestId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compareCommits(
|
export function compareCommits(
|
||||||
|
|||||||
Reference in New Issue
Block a user