add new Context Menu for file Explorer #7
Generated
+13
-13
@@ -1392,6 +1392,19 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "git_lite"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"keyring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-updater",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.18.5"
|
||||
@@ -4121,19 +4134,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "tauri_git_lite"
|
||||
name = "git_lite"
|
||||
version = "0.1.0"
|
||||
description = "Rust backend for a lightweight Git desktop client"
|
||||
edition = "2021"
|
||||
|
||||
+162
-24
@@ -214,6 +214,22 @@ pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
|
||||
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)]
|
||||
pub struct RepositoryBundle {
|
||||
pub status: GitStatus,
|
||||
@@ -786,15 +802,60 @@ pub fn list_repository_files(path: String) -> Result<Vec<GitRepositoryFile>, Str
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_file_history(
|
||||
pub async fn list_file_history(
|
||||
path: String,
|
||||
file: String,
|
||||
limit: Option<u32>,
|
||||
request_id: Option<String>,
|
||||
state: tauri::State<'_, SearchCancellationState>,
|
||||
) -> 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))?;
|
||||
|
||||
if verify_commit(&repo, "HEAD").is_err() {
|
||||
if verify_commit(repo, "HEAD").is_err() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
@@ -806,13 +867,14 @@ pub fn list_file_history(
|
||||
OsString::from("-n"),
|
||||
OsString::from(limit),
|
||||
];
|
||||
if !is_repository_folder_path(&repo, &file)? {
|
||||
if !is_repository_folder_path(repo, &file)? {
|
||||
args.push(OsString::from("--follow"));
|
||||
}
|
||||
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]
|
||||
@@ -1416,6 +1478,69 @@ fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
|
||||
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> {
|
||||
let commit = commit.trim();
|
||||
if commit.is_empty() {
|
||||
@@ -1518,7 +1643,9 @@ fn detect_worktree_renames(repo: &Path, files: &mut Vec<GitFileStatus>) {
|
||||
|
||||
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 == old_path
|
||||
&& f.staged.is_none()
|
||||
&& f.unstaged == Some(FileStatusKind::Deleted))
|
||||
&& !(f.path == new_path
|
||||
&& f.staged.is_none()
|
||||
&& f.unstaged == Some(FileStatusKind::Untracked))
|
||||
@@ -1573,11 +1700,7 @@ fn worktree_blob_hashes(repo: &Path, paths: &[String]) -> Result<Vec<(String, St
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(paths
|
||||
.iter()
|
||||
.cloned()
|
||||
.zip(hashes)
|
||||
.collect())
|
||||
Ok(paths.iter().cloned().zip(hashes).collect())
|
||||
}
|
||||
|
||||
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
||||
@@ -4052,12 +4175,8 @@ mod tests {
|
||||
run_git_test(&repo.path, ["add", "other.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "touch other"]);
|
||||
|
||||
let commits = list_file_history(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"old.txt".to_string(),
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
let commits =
|
||||
list_file_history_core(&repo.path, "old.txt".to_string(), Some(10), None).unwrap();
|
||||
|
||||
assert_eq!(commits.len(), 2);
|
||||
assert_eq!(commits[0].summary, "touch selected");
|
||||
@@ -4080,15 +4199,34 @@ mod tests {
|
||||
run_git_test(&repo.path, ["add", "."]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "src update"]);
|
||||
|
||||
let commits = list_file_history(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"src".to_string(),
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
let commits =
|
||||
list_file_history_core(&repo.path, "src".to_string(), Some(10), None).unwrap();
|
||||
|
||||
assert_eq!(commits.len(), 2);
|
||||
assert_eq!(commits[0].summary, "src update");
|
||||
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;
|
||||
|
||||
use git::{
|
||||
apply_file_patch, cancel_code_search, checkout_branch, commit, compare_commits,
|
||||
compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save,
|
||||
delete_branch, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status,
|
||||
list_branches, list_commits, list_file_history, list_repository_files, merge_branch,
|
||||
open_repo_in_explorer, open_repository, open_repository_bundle, pull, push, read_conflict,
|
||||
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
||||
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, commit,
|
||||
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, cred_delete,
|
||||
cred_load, cred_save, delete_branch, diff_file_against_working_tree, get_file_patch,
|
||||
get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
||||
list_repository_files, merge_branch, open_repo_in_explorer, open_repository,
|
||||
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch,
|
||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
||||
restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
||||
SearchCancellationState,
|
||||
};
|
||||
|
||||
@@ -21,6 +22,7 @@ fn main() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_repository,
|
||||
open_repo_in_explorer,
|
||||
open_repository_file,
|
||||
get_status,
|
||||
list_branches,
|
||||
checkout_branch,
|
||||
@@ -42,6 +44,7 @@ fn main() {
|
||||
list_repository_files,
|
||||
open_repository_bundle,
|
||||
list_file_history,
|
||||
cancel_file_history,
|
||||
compare_commits,
|
||||
compare_file_to_head,
|
||||
compare_file_to_parent,
|
||||
|
||||
+76
-17
@@ -27,6 +27,7 @@
|
||||
commit,
|
||||
compareCommits,
|
||||
cancelCodeSearch,
|
||||
cancelFileHistory,
|
||||
applyFilePatch,
|
||||
createBranch,
|
||||
deleteBranch,
|
||||
@@ -39,6 +40,7 @@
|
||||
listRepositoryFiles,
|
||||
mergeBranch,
|
||||
openRepoInExplorer,
|
||||
openRepositoryFile,
|
||||
openRepositoryBundle,
|
||||
pull,
|
||||
push,
|
||||
@@ -119,6 +121,7 @@
|
||||
let fileHistory: GitCommit[] = [];
|
||||
let fileHistoryLoading = false;
|
||||
let fileHistoryRequestId = 0;
|
||||
let activeFileHistoryRequestId = "";
|
||||
let commitMessage = "";
|
||||
let errorMessage = "";
|
||||
let operation = "";
|
||||
@@ -207,6 +210,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
||||
});
|
||||
|
||||
// ── Auto-refresh ───────────────────────────────────────────────────────────
|
||||
@@ -534,14 +538,56 @@
|
||||
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
|
||||
selectedExplorerPath = "";
|
||||
selectedExplorerKind = "file";
|
||||
cancelActiveFileHistoryLoad();
|
||||
activeFileHistoryRequestId = "";
|
||||
fileHistoryLoading = false;
|
||||
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) {
|
||||
const requestId = ++fileHistoryRequestId;
|
||||
const history = file ? await listFileHistory(path, file, 100) : [];
|
||||
if (requestId === fileHistoryRequestId) fileHistory = history;
|
||||
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 ──────────────────────────────────────────────────
|
||||
@@ -1120,26 +1166,14 @@
|
||||
// (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;
|
||||
}
|
||||
await refreshFileHistory(repo, path);
|
||||
}
|
||||
|
||||
async function selectExplorerNode(node: ExplorerNode) {
|
||||
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
||||
selectedExplorerPath = node.path;
|
||||
selectedExplorerKind = node.kind;
|
||||
await loadSelectedFileHistory(node.path);
|
||||
void loadSelectedFileHistory(node.path);
|
||||
}
|
||||
|
||||
async function selectFileFromSearch(file: GitRepositoryFile) {
|
||||
@@ -1148,7 +1182,29 @@
|
||||
selectedExplorerKind = "file";
|
||||
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||||
|
||||
await loadSelectedFileHistory(file.path);
|
||||
void loadSelectedFileHistory(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) {
|
||||
@@ -1590,6 +1646,7 @@
|
||||
onExpandAllFolders={expandAllExplorerFolders}
|
||||
onCollapseAllFolders={collapseAllExplorerFolders}
|
||||
onSelectNode={selectExplorerNode}
|
||||
onOpenFile={openFileFromExplorer}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
@@ -1618,6 +1675,8 @@
|
||||
{hasRepository}
|
||||
{isBusy}
|
||||
{status}
|
||||
selectedFilePath={selectedExplorerPath}
|
||||
onSelectFile={selectFileFromStatus}
|
||||
onStage={stageFile}
|
||||
onUnstage={unstageFile}
|
||||
onDiscard={discardFile}
|
||||
|
||||
+41
-7
@@ -12,6 +12,7 @@
|
||||
--color-surface-dim: rgba(18, 18, 30, 0.9);
|
||||
--color-surface-hover: rgba(47, 48, 78, 0.76);
|
||||
--color-surface-raised: rgba(28, 29, 48, 0.88);
|
||||
--color-surface-solid: #1c1d30;
|
||||
|
||||
--color-border: rgba(100, 108, 255, 0.28);
|
||||
--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; }
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
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 + .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 {
|
||||
display: block;
|
||||
@@ -1175,8 +1201,8 @@
|
||||
|
||||
.branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; }
|
||||
|
||||
.branch-context-menu {
|
||||
position: absolute;
|
||||
.branch-context-menu,
|
||||
.explorer-context-menu {
|
||||
z-index: 120;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@@ -1184,11 +1210,15 @@
|
||||
padding: 5px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 18px 50px rgba(0,0,0,0.35);
|
||||
background: var(--color-surface-solid);
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
@@ -1205,7 +1235,8 @@
|
||||
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);
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--color-ink);
|
||||
@@ -1221,13 +1252,16 @@
|
||||
color: #ffd0d6;
|
||||
}
|
||||
|
||||
.branch-context-menu button:disabled {
|
||||
.branch-context-menu button:disabled,
|
||||
.explorer-context-menu button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
/* --- Explorer --- */
|
||||
|
||||
.explorer-panel { position: relative; }
|
||||
|
||||
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
|
||||
.explorer-bulk-button {
|
||||
width: 26px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
||||
|
||||
@@ -23,12 +24,14 @@
|
||||
const win = getCurrentWindow();
|
||||
let isMaximized = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let appVersion = "";
|
||||
|
||||
onMount(async () => {
|
||||
isMaximized = await win.isMaximized();
|
||||
unlisten = await win.onResized(async () => {
|
||||
isMaximized = await win.isMaximized();
|
||||
});
|
||||
appVersion = await getVersion();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -58,6 +61,9 @@
|
||||
<line x1="12" y1="12" x2="12" y2="15" />
|
||||
</svg>
|
||||
<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>
|
||||
|
||||
<!-- Center: repo + branch info -->
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
}
|
||||
</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">
|
||||
<div class="section-head">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
FileVideo,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ExternalLink,
|
||||
Terminal,
|
||||
} from "@lucide/svelte";
|
||||
import { languageIconForPath } from "../languageIcons";
|
||||
@@ -34,6 +35,7 @@
|
||||
onExpandAllFolders: () => void;
|
||||
onCollapseAllFolders: () => void;
|
||||
onSelectNode: (node: ExplorerNode) => void;
|
||||
onOpenFile: (node: ExplorerNode) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -47,8 +49,13 @@
|
||||
onExpandAllFolders = () => {},
|
||||
onCollapseAllFolders = () => {},
|
||||
onSelectNode = () => {},
|
||||
onOpenFile = () => {},
|
||||
}: 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 {
|
||||
if (!next) return current;
|
||||
if (!current) return next;
|
||||
@@ -154,12 +161,39 @@
|
||||
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 visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
||||
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
|
||||
</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>
|
||||
<span class="eyebrow">Explorer</span>
|
||||
@@ -264,6 +298,7 @@
|
||||
class="explorer-select"
|
||||
type="button"
|
||||
onclick={() => onSelectNode(node)}
|
||||
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
||||
disabled={isBusy}
|
||||
title={`Show history for ${node.path}`}
|
||||
>
|
||||
@@ -279,4 +314,26 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</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}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
status: GitStatus | null;
|
||||
selectedFilePath: string;
|
||||
onSelectFile: (file: GitFileStatus) => void;
|
||||
onStage: (file: GitFileStatus) => void;
|
||||
onUnstage: (file: GitFileStatus) => void;
|
||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
||||
@@ -24,6 +26,8 @@
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
status = null,
|
||||
selectedFilePath = "",
|
||||
onSelectFile = () => {},
|
||||
onStage = () => {},
|
||||
onUnstage = () => {},
|
||||
onDiscard = () => {},
|
||||
@@ -102,9 +106,19 @@
|
||||
{:else}
|
||||
<div class="overflow-auto p-2">
|
||||
{#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">
|
||||
<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 class="change-lanes">
|
||||
|
||||
+15
-2
@@ -21,6 +21,10 @@ export function openRepoInExplorer(path: string): Promise<void> {
|
||||
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> {
|
||||
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 });
|
||||
}
|
||||
|
||||
export function listFileHistory(path: string, file: string, limit = 100): Promise<GitCommit[]> {
|
||||
return invoke<GitCommit[]>("list_file_history", { path, file, limit });
|
||||
export function listFileHistory(
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user