Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b04158926d | ||
|
|
eef4869bbb | ||
|
|
97fc4fc1e0 | ||
|
|
e6d22765be | ||
|
|
312727ee73 | ||
|
|
5752243e6e | ||
|
|
0bbd5ce1e8 | ||
|
|
50d9232084 | ||
|
|
357a0b7c5f | ||
|
|
ab54b9b6b2 | ||
|
|
fe392d38bf | ||
|
|
628e2f7c0b | ||
|
|
1c7c53ddf9 | ||
|
|
900159a3a9 | ||
|
|
d800417d6d | ||
|
|
405f302db9 | ||
|
|
2cc1d34fc3 | ||
|
|
089aae5f5b | ||
|
|
841d1a41b7 | ||
|
|
d57b574fe1 | ||
|
|
ec1f10d535 | ||
|
|
727461ce6c | ||
|
|
c5853a6e9b | ||
|
|
ea308c47f1 |
@@ -23,7 +23,12 @@
|
||||
"Bash(npx vite *)",
|
||||
"Bash(cargo tree *)",
|
||||
"Bash(jobs)",
|
||||
"Bash(npx svelte-check *)"
|
||||
"Bash(npx svelte-check *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(xxd)",
|
||||
"Bash(python3 -)",
|
||||
"Bash(echo \"exit: $?\")",
|
||||
"Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,4 +4,5 @@
|
||||
*.log
|
||||
.idea
|
||||
.DS_Store
|
||||
~
|
||||
~
|
||||
.codex*
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "tauri-git-lite",
|
||||
"version": "0.0.1",
|
||||
"version": "2026.7.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tauri-git-lite",
|
||||
"version": "0.0.1",
|
||||
"version": "2026.7.2",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "git-lite",
|
||||
"version": "0.0.1",
|
||||
"version": "2026.7.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -18,3 +18,24 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
# ── Build profiles ───────────────────────────────────────────────────────────
|
||||
# Dev: keep incremental compilation and emit only line-table debug info instead
|
||||
# of full debuginfo. This cuts link time noticeably (linking is the slow part of
|
||||
# a `tauri dev` recompile) while still giving panic backtraces with line numbers.
|
||||
[profile.dev]
|
||||
incremental = true
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Optimize third-party dependencies once (they are cached), so the running dev
|
||||
# build is snappy without slowing down rebuilds of our own crate.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
# Release: smaller binary (also shrinks the auto-updater download) without
|
||||
# regressing runtime performance of the code-search feature.
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
+574
-22
@@ -12,6 +12,12 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FileStatusKind {
|
||||
@@ -143,6 +149,13 @@ const EMPTY_TREE_HASH: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
||||
const SEARCH_CANCELLED_MESSAGE: &str = "Suche wurde abgebrochen.";
|
||||
static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn git_command() -> Command {
|
||||
let mut command = Command::new("git");
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
command
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SearchCancellationState {
|
||||
cancelled: Arc<Mutex<BTreeSet<String>>>,
|
||||
@@ -195,6 +208,47 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
open_path_in_file_manager(&repo)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RepositoryBundle {
|
||||
pub status: GitStatus,
|
||||
pub branches: Vec<GitBranch>,
|
||||
pub commits: Vec<GitCommit>,
|
||||
pub files: Vec<GitRepositoryFile>,
|
||||
}
|
||||
|
||||
/// Opens a repository and gathers everything the UI needs in a single call.
|
||||
///
|
||||
/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves
|
||||
/// the repo and its status only once, instead of the previous four separate
|
||||
/// commands that each re-ran `git rev-parse` and `git status`.
|
||||
#[tauri::command]
|
||||
pub async fn open_repository_bundle(
|
||||
path: String,
|
||||
commit_limit: Option<u32>,
|
||||
) -> Result<RepositoryBundle, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<RepositoryBundle, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let status = status_for_repo(&repo)?;
|
||||
let branches = branches_for_repo(&repo)?;
|
||||
let commits = commits_for_repo(&repo, commit_limit)?;
|
||||
let files = repository_files_with_status(&repo, &status)?;
|
||||
Ok(RepositoryBundle {
|
||||
status,
|
||||
branches,
|
||||
commits,
|
||||
files,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Repository konnte nicht geladen werden: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_status(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -204,8 +258,12 @@ pub fn get_status(path: String) -> Result<GitStatus, String> {
|
||||
#[tauri::command]
|
||||
pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
branches_for_repo(&repo)
|
||||
}
|
||||
|
||||
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||
let output = run_git(
|
||||
&repo,
|
||||
repo,
|
||||
[
|
||||
"for-each-ref",
|
||||
"--format=%(refname)\t%(HEAD)",
|
||||
@@ -268,6 +326,63 @@ pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_branch(
|
||||
path: String,
|
||||
branch: String,
|
||||
start_point: Option<String>,
|
||||
) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let branch = validate_new_branch_name(&repo, &branch)?;
|
||||
match start_point {
|
||||
Some(start) if !start.trim().is_empty() => {
|
||||
// Resolve the requested commit first so we fail clearly if it is gone.
|
||||
let start = verify_commit(&repo, &start)?;
|
||||
run_git(&repo, ["checkout", "-b", branch.as_str(), start.as_str()])?;
|
||||
}
|
||||
_ => {
|
||||
run_git(&repo, ["checkout", "-b", branch.as_str()])?;
|
||||
}
|
||||
}
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_branch(
|
||||
path: String,
|
||||
old_branch: String,
|
||||
new_branch: String,
|
||||
) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let old_branch = validate_existing_local_branch_name(&repo, &old_branch)?;
|
||||
let new_branch = validate_new_branch_name(&repo, &new_branch)?;
|
||||
|
||||
run_git(
|
||||
&repo,
|
||||
[
|
||||
"branch",
|
||||
"-m",
|
||||
"--",
|
||||
old_branch.as_str(),
|
||||
new_branch.as_str(),
|
||||
],
|
||||
)?;
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let branch = validate_existing_local_branch_name(&repo, &branch)?;
|
||||
let status = status_for_repo(&repo)?;
|
||||
if status.current_branch.as_deref() == Some(branch.as_str()) {
|
||||
return Err("Der aktuelle Branch kann nicht geloescht werden.".to_string());
|
||||
}
|
||||
|
||||
run_git(&repo, ["branch", "-d", "--", branch.as_str()])?;
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -312,6 +427,59 @@ pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<G
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(std::slice::from_ref(&file))?;
|
||||
|
||||
let base_args = if staged {
|
||||
&[
|
||||
"diff",
|
||||
"--cached",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--unified=3",
|
||||
][..]
|
||||
} else {
|
||||
&["diff", "--no-ext-diff", "--no-textconv", "--unified=3"][..]
|
||||
};
|
||||
let output = run_git_with_paths(&repo, base_args, &[file])?;
|
||||
Ok(String::from_utf8_lossy(&output).to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn apply_file_patch(
|
||||
path: String,
|
||||
file: String,
|
||||
patch: String,
|
||||
action: String,
|
||||
) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(std::slice::from_ref(&file))?;
|
||||
if patch.trim().is_empty() {
|
||||
return Err("Kein Patch ausgewaehlt.".to_string());
|
||||
}
|
||||
|
||||
let patch_path = write_temp_patch(&patch)?;
|
||||
let result = match action.as_str() {
|
||||
"stage" => check_apply_patch(&repo, &patch_path, &["--cached"])
|
||||
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached"])),
|
||||
"unstage" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])
|
||||
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])),
|
||||
"discard-unstaged" => check_apply_patch(&repo, &patch_path, &["--reverse"])
|
||||
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])),
|
||||
"discard-staged" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])
|
||||
.and_then(|_| check_apply_patch(&repo, &patch_path, &["--reverse"]))
|
||||
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"]))
|
||||
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])),
|
||||
_ => Err("Ungueltige Patch-Aktion.".to_string()),
|
||||
};
|
||||
|
||||
let _ = std::fs::remove_file(&patch_path);
|
||||
result?;
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -342,7 +510,7 @@ pub fn pull(
|
||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||
run_git_authenticated_output(&repo, pull_args, u, p)?
|
||||
}
|
||||
_ => Command::new("git")
|
||||
_ => git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(pull_args)
|
||||
@@ -429,7 +597,7 @@ pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
|
||||
}
|
||||
|
||||
fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
|
||||
let out = Command::new("git")
|
||||
let out = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["remote", "get-url", remote])
|
||||
@@ -452,7 +620,7 @@ fn upstream_remote_name(repo: &Path) -> Option<String> {
|
||||
if branch.is_empty() || branch == "HEAD" {
|
||||
return None;
|
||||
}
|
||||
let out = Command::new("git")
|
||||
let out = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["config", &format!("branch.{branch}.remote")])
|
||||
@@ -530,7 +698,7 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
return Err("Branch-Name darf nicht leer sein.".to_string());
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["merge", "--no-edit", branch])
|
||||
@@ -565,23 +733,34 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
#[tauri::command]
|
||||
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if verify_commit(&repo, "HEAD").is_err() {
|
||||
commits_for_repo(&repo, limit)
|
||||
}
|
||||
|
||||
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
||||
if verify_commit(repo, "HEAD").is_err() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let limit = limit.unwrap_or(100).clamp(1, 500).to_string();
|
||||
// Fetch the per-commit changed files inline via `--name-status` in a single
|
||||
// `git log` process, instead of spawning one `git diff-tree` per commit
|
||||
// (which was ~100 extra processes and the main cost of opening a repo).
|
||||
let output = run_git(
|
||||
&repo,
|
||||
repo,
|
||||
[
|
||||
"log",
|
||||
"--decorate=short",
|
||||
"--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1e",
|
||||
"--name-status",
|
||||
"-M",
|
||||
"-z",
|
||||
"--root",
|
||||
"--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f",
|
||||
"-n",
|
||||
limit.as_str(),
|
||||
],
|
||||
)?;
|
||||
|
||||
parse_commit_log(&repo, &output)
|
||||
parse_commit_log_inline(&output)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1106,7 +1285,7 @@ fn is_binary_bytes(bytes: &[u8]) -> bool {
|
||||
|
||||
fn index_stage_size(repo: &Path, stage: u8, file: &str) -> Option<u64> {
|
||||
let spec = format!(":{stage}:{file}");
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["cat-file", "-s", spec.as_str()])
|
||||
@@ -1139,7 +1318,7 @@ pub fn resolve_conflict(path: String, file: String, content: String) -> Result<G
|
||||
|
||||
fn read_index_stage(repo: &Path, stage: u8, file: &str) -> Option<String> {
|
||||
let spec = format!(":{stage}:{file}");
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["show", spec.as_str()])
|
||||
@@ -1177,6 +1356,36 @@ fn resolve_repo(path: &str) -> Result<PathBuf, String> {
|
||||
Ok(PathBuf::from(top_level))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open_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(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 open_path_in_file_manager(path: &Path) -> Result<(), String> {
|
||||
Command::new("open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map_err(|err| format!("Finder konnte nicht gestartet werden: {err}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
|
||||
Command::new("xdg-open")
|
||||
.arg(path)
|
||||
.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() {
|
||||
@@ -1221,6 +1430,13 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
||||
|
||||
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
||||
let status = status_for_repo(repo)?;
|
||||
repository_files_with_status(repo, &status)
|
||||
}
|
||||
|
||||
fn repository_files_with_status(
|
||||
repo: &Path,
|
||||
status: &GitStatus,
|
||||
) -> Result<Vec<GitRepositoryFile>, String> {
|
||||
let mut files = BTreeMap::<String, GitRepositoryFile>::new();
|
||||
|
||||
let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?;
|
||||
@@ -1325,6 +1541,10 @@ fn search_candidate_commits(
|
||||
OsString::from("--all"),
|
||||
OsString::from("--reverse"),
|
||||
OsString::from("--format=%H"),
|
||||
// Skip textconv diff drivers so git does not extract binary files
|
||||
// (e.g. .docx / Office temp "~$" lock files) to temp files, which can
|
||||
// fail with "unsupported filetype" and abort the whole search.
|
||||
OsString::from("--no-textconv"),
|
||||
];
|
||||
if !case_sensitive {
|
||||
args.push(OsString::from("-i"));
|
||||
@@ -1369,7 +1589,7 @@ fn max_parent_match_count(
|
||||
|
||||
fn read_text_blob(repo: &Path, commit: &str, file: &str) -> Result<Option<String>, String> {
|
||||
let spec = format!("{commit}:{file}");
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["show", spec.as_str()])
|
||||
@@ -1434,7 +1654,7 @@ fn first_added_match_line(
|
||||
check_search_cancelled(cancellation)?;
|
||||
let output = run_git_with_paths_cancellable(
|
||||
repo,
|
||||
&["diff", "--unified=0", parent, commit],
|
||||
&["diff", "--no-textconv", "--unified=0", parent, commit],
|
||||
&[file.to_string()],
|
||||
cancellation,
|
||||
"Git-Diff fuer Suchtreffer fehlgeschlagen",
|
||||
@@ -1555,6 +1775,71 @@ fn commit_search_metadata(repo: &Path, commit: &str) -> Result<GitSearchCommitMe
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses `git log --name-status -z` output where each commit's changed files
|
||||
/// are embedded inline (see `commits_for_repo`), so no per-commit git process is
|
||||
/// needed. Record layout: `\x1e` then eight `\x1f`-separated header fields, then
|
||||
/// git's newline, then the NUL-separated name-status entries.
|
||||
fn parse_commit_log_inline(output: &[u8]) -> Result<Vec<GitCommit>, String> {
|
||||
const FIELD_SEPARATOR: u8 = 0x1f;
|
||||
const RECORD_SEPARATOR: u8 = 0x1e;
|
||||
|
||||
let mut commits = Vec::new();
|
||||
|
||||
for record in output.split(|byte| *byte == RECORD_SEPARATOR) {
|
||||
// Skip the empty leading chunk and any stray separators left by `-z`.
|
||||
if record
|
||||
.iter()
|
||||
.all(|&byte| matches!(byte, 0 | b'\n' | b'\r' | b' ' | b'\t'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&[u8]> = record.splitn(9, |byte| *byte == FIELD_SEPARATOR).collect();
|
||||
if parts.len() < 8 {
|
||||
return Err(format!(
|
||||
"Unerwarteter Git-Log-Eintrag: {}",
|
||||
String::from_utf8_lossy(record)
|
||||
));
|
||||
}
|
||||
|
||||
// Field 8 (if present) holds the name-status list, preceded by the newline
|
||||
// git inserts between the pretty-format output and the diff.
|
||||
let mut files_bytes: &[u8] = parts.get(8).copied().unwrap_or(&[]);
|
||||
while let Some((&first, rest)) = files_bytes.split_first() {
|
||||
if matches!(first, b'\n' | b'\r') {
|
||||
files_bytes = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let refs = String::from_utf8_lossy(parts[5])
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
let parents = String::from_utf8_lossy(parts[6])
|
||||
.split_whitespace()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
|
||||
commits.push(GitCommit {
|
||||
hash: String::from_utf8_lossy(parts[0]).trim().to_string(),
|
||||
short_hash: String::from_utf8_lossy(parts[1]).trim().to_string(),
|
||||
author_name: String::from_utf8_lossy(parts[2]).to_string(),
|
||||
author_email: String::from_utf8_lossy(parts[3]).to_string(),
|
||||
date: String::from_utf8_lossy(parts[4]).trim().to_string(),
|
||||
refs,
|
||||
parents,
|
||||
summary: String::from_utf8_lossy(parts[7]).to_string(),
|
||||
files: parse_commit_files(files_bytes)?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String> {
|
||||
const FIELD_SEPARATOR: char = '\x1f';
|
||||
const RECORD_SEPARATOR: char = '\x1e';
|
||||
@@ -1822,8 +2107,75 @@ fn local_branch_name_for_remote(remote_branch: &str) -> Option<&str> {
|
||||
.filter(|local| !local.is_empty())
|
||||
}
|
||||
|
||||
fn validate_new_branch_name(repo: &Path, branch: &str) -> Result<String, String> {
|
||||
let branch = branch.trim();
|
||||
if branch.is_empty() {
|
||||
return Err("Branch-Name darf nicht leer sein.".to_string());
|
||||
}
|
||||
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["check-ref-format", "--branch", branch])
|
||||
.output()
|
||||
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let details = command_output_details(&output);
|
||||
return Err(format!("Ungueltiger Branch-Name: {details}"));
|
||||
}
|
||||
|
||||
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let normalized = if normalized.is_empty() {
|
||||
branch.to_string()
|
||||
} else {
|
||||
normalized
|
||||
};
|
||||
|
||||
if ref_exists(repo, &format!("refs/heads/{normalized}"))? {
|
||||
return Err(format!("Branch '{normalized}' existiert bereits."));
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<String, String> {
|
||||
let branch = branch.trim();
|
||||
if branch.is_empty() {
|
||||
return Err("Branch-Name darf nicht leer sein.".to_string());
|
||||
}
|
||||
|
||||
let normalized = validate_branch_ref_name(branch)?;
|
||||
if !ref_exists(repo, &format!("refs/heads/{normalized}"))? {
|
||||
return Err(format!(
|
||||
"Lokaler Branch '{normalized}' wurde nicht gefunden."
|
||||
));
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn validate_branch_ref_name(branch: &str) -> Result<String, String> {
|
||||
let output = git_command()
|
||||
.args(["check-ref-format", "--branch", branch])
|
||||
.output()
|
||||
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let details = command_output_details(&output);
|
||||
return Err(format!("Ungueltiger Branch-Name: {details}"));
|
||||
}
|
||||
|
||||
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Ok(if normalized.is_empty() {
|
||||
branch.to_string()
|
||||
} else {
|
||||
normalized
|
||||
})
|
||||
}
|
||||
|
||||
fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["show-ref", "--verify", "--quiet", ref_name])
|
||||
@@ -1992,6 +2344,45 @@ fn validate_files(files: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_temp_patch(patch: &str) -> Result<PathBuf, String> {
|
||||
let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"gitlite_patch_{}_{}.patch",
|
||||
std::process::id(),
|
||||
counter
|
||||
));
|
||||
std::fs::write(&path, patch.as_bytes())
|
||||
.map_err(|err| format!("Patch-Datei konnte nicht geschrieben werden: {err}"))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn check_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> {
|
||||
run_apply_patch_command(repo, patch_path, options, true)
|
||||
}
|
||||
|
||||
fn run_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> {
|
||||
run_apply_patch_command(repo, patch_path, options, false)
|
||||
}
|
||||
|
||||
fn run_apply_patch_command(
|
||||
repo: &Path,
|
||||
patch_path: &Path,
|
||||
options: &[&str],
|
||||
check_only: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut args = Vec::with_capacity(options.len() + 5);
|
||||
args.push(OsString::from("apply"));
|
||||
if check_only {
|
||||
args.push(OsString::from("--check"));
|
||||
}
|
||||
args.extend(options.iter().map(OsString::from));
|
||||
args.push(OsString::from("--recount"));
|
||||
args.push(OsString::from("--whitespace=nowarn"));
|
||||
args.push(patch_path.as_os_str().to_os_string());
|
||||
|
||||
run_git(repo, args).map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -2048,7 +2439,7 @@ where
|
||||
{
|
||||
let askpass = write_askpass_script()?;
|
||||
|
||||
let result = Command::new("git")
|
||||
let result = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
@@ -2157,7 +2548,7 @@ where
|
||||
let stderr_file = std::fs::File::create(&stderr_path)
|
||||
.map_err(|err| format!("Git-Fehlerdatei konnte nicht erstellt werden: {err}"))?;
|
||||
|
||||
let mut child = Command::new("git")
|
||||
let mut child = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
@@ -2218,7 +2609,7 @@ where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(args)
|
||||
@@ -2442,7 +2833,7 @@ mod tests {
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
@@ -2463,7 +2854,7 @@ mod tests {
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
@@ -2971,7 +3362,7 @@ mod tests {
|
||||
run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]);
|
||||
|
||||
// The merge is expected to fail with a conflict, so run git directly.
|
||||
let _ = Command::new("git")
|
||||
let _ = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo.path)
|
||||
.args(["merge", "--no-edit", "feature"])
|
||||
@@ -3024,7 +3415,7 @@ mod tests {
|
||||
fs::write(repo.path.join("file.txt"), "ours change\n").expect("main change");
|
||||
run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]);
|
||||
|
||||
let _ = Command::new("git")
|
||||
let _ = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo.path)
|
||||
.args(["merge", "--no-edit", "feature"])
|
||||
@@ -3065,7 +3456,7 @@ mod tests {
|
||||
fs::write(repo.path.join("img.bin"), [0u8, 7, 7]).expect("main binary");
|
||||
run_git_test(&repo.path, ["commit", "-q", "-am", "main bin"]);
|
||||
|
||||
let _ = Command::new("git")
|
||||
let _ = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo.path)
|
||||
.args(["merge", "--no-edit", "feature"])
|
||||
@@ -3169,6 +3560,167 @@ mod tests {
|
||||
assert_eq!(plan, CheckoutPlan::Local("feature/demo".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_branch_creates_and_checks_out_local_branch() {
|
||||
let repo = init_temp_repo("create_branch");
|
||||
commit_initial_file(&repo.path);
|
||||
|
||||
let status = create_branch(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"feature/new-panel".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status.current_branch.as_deref(), Some("feature/new-panel"));
|
||||
assert!(
|
||||
ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(),
|
||||
"new branch should exist"
|
||||
);
|
||||
|
||||
let err = create_branch(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"feature/new-panel".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_branch_renames_existing_local_branch() {
|
||||
let repo = init_temp_repo("rename_branch");
|
||||
commit_initial_file(&repo.path);
|
||||
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
run_git_test(&repo.path, ["branch", "feature/old-panel"]);
|
||||
|
||||
let status = rename_branch(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"feature/old-panel".to_string(),
|
||||
"feature/new-panel".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
|
||||
assert!(
|
||||
!ref_exists(&repo.path, "refs/heads/feature/old-panel").unwrap(),
|
||||
"old branch should be gone"
|
||||
);
|
||||
assert!(
|
||||
ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(),
|
||||
"new branch should exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_branch_removes_local_branch_but_rejects_current_branch() {
|
||||
let repo = init_temp_repo("delete_branch");
|
||||
commit_initial_file(&repo.path);
|
||||
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
run_git_test(&repo.path, ["branch", "stale"]);
|
||||
|
||||
let status =
|
||||
delete_branch(repo.path.to_string_lossy().to_string(), "stale".to_string()).unwrap();
|
||||
|
||||
assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
|
||||
assert!(
|
||||
!ref_exists(&repo.path, "refs/heads/stale").unwrap(),
|
||||
"deleted branch should be gone"
|
||||
);
|
||||
|
||||
let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err();
|
||||
assert!(err.contains("aktuelle Branch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_file_patch_stages_and_discards_selected_changes() {
|
||||
let repo = init_temp_repo("apply_file_patch");
|
||||
fs::write(repo.path.join("old.txt"), "one\ntwo\nthree\n")
|
||||
.expect("initial file should be written");
|
||||
run_git_test(&repo.path, ["add", "old.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "init"]);
|
||||
|
||||
fs::write(repo.path.join("old.txt"), "one\nTWO\nthree\nfour\n")
|
||||
.expect("changed file should be written");
|
||||
|
||||
let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n";
|
||||
let status = apply_file_patch(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"old.txt".to_string(),
|
||||
selected_patch.to_string(),
|
||||
"stage".to_string(),
|
||||
)
|
||||
.expect("selected line should stage");
|
||||
|
||||
assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified));
|
||||
assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified));
|
||||
assert_eq!(
|
||||
git_output_test(&repo.path, ["show", ":old.txt"]),
|
||||
"one\nTWO\nthree"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(repo.path.join("old.txt"))
|
||||
.expect("working tree should be readable")
|
||||
.replace("\r\n", "\n"),
|
||||
"one\nTWO\nthree\nfour\n"
|
||||
);
|
||||
|
||||
let unstaged_patch = get_file_patch(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"old.txt".to_string(),
|
||||
false,
|
||||
)
|
||||
.expect("unstaged patch should load");
|
||||
assert!(unstaged_patch.contains("+four"));
|
||||
|
||||
let status = apply_file_patch(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"old.txt".to_string(),
|
||||
unstaged_patch,
|
||||
"discard-unstaged".to_string(),
|
||||
)
|
||||
.expect("unstaged line should discard");
|
||||
|
||||
assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified));
|
||||
assert_eq!(status.files[0].unstaged, None);
|
||||
assert_eq!(
|
||||
fs::read_to_string(repo.path.join("old.txt"))
|
||||
.expect("working tree should be readable")
|
||||
.replace("\r\n", "\n"),
|
||||
"one\nTWO\nthree\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_file_patch_splits_distant_changes_like_interactive_diff() {
|
||||
let repo = init_temp_repo("file_patch_hunks");
|
||||
let original = (1..=30)
|
||||
.map(|line| format!("line {line}\n"))
|
||||
.collect::<String>();
|
||||
fs::write(repo.path.join("old.txt"), original).expect("initial file should be written");
|
||||
run_git_test(&repo.path, ["add", "old.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "init"]);
|
||||
|
||||
let changed = (1..=30)
|
||||
.map(|line| match line {
|
||||
5 => "line five changed\n".to_string(),
|
||||
20 => "line twenty changed\n".to_string(),
|
||||
_ => format!("line {line}\n"),
|
||||
})
|
||||
.collect::<String>();
|
||||
fs::write(repo.path.join("old.txt"), changed).expect("changed file should be written");
|
||||
|
||||
let patch = get_file_patch(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"old.txt".to_string(),
|
||||
false,
|
||||
)
|
||||
.expect("patch should load");
|
||||
let hunk_count = patch.lines().filter(|line| line.starts_with("@@ ")).count();
|
||||
|
||||
assert_eq!(hunk_count, 2, "{patch}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_to_commit_resets_branch_to_selected_commit() {
|
||||
let repo = init_temp_repo("restore_to_commit");
|
||||
|
||||
+14
-6
@@ -3,12 +3,13 @@
|
||||
mod git;
|
||||
|
||||
use git::{
|
||||
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head,
|
||||
compare_file_to_parent, cred_delete, cred_load, cred_save, diff_file_against_working_tree,
|
||||
get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
||||
list_repository_files, merge_branch, open_repository, pull, push, read_conflict,
|
||||
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, 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,
|
||||
SearchCancellationState,
|
||||
};
|
||||
|
||||
@@ -19,12 +20,18 @@ fn main() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_repository,
|
||||
open_repo_in_explorer,
|
||||
get_status,
|
||||
list_branches,
|
||||
checkout_branch,
|
||||
create_branch,
|
||||
rename_branch,
|
||||
delete_branch,
|
||||
stage_files,
|
||||
unstage_files,
|
||||
restore_files,
|
||||
get_file_patch,
|
||||
apply_file_patch,
|
||||
commit,
|
||||
pull,
|
||||
push,
|
||||
@@ -33,6 +40,7 @@ fn main() {
|
||||
restore_file_from_commit,
|
||||
merge_branch,
|
||||
list_repository_files,
|
||||
open_repository_bundle,
|
||||
list_file_history,
|
||||
compare_commits,
|
||||
compare_file_to_head,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "GitLite",
|
||||
"version": "0.0.1",
|
||||
"version": "2026.7.2",
|
||||
"identifier": "com.git-lite",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": ["nsis"],
|
||||
"icon": ["icons/icon.ico"],
|
||||
"createUpdaterArtifacts": true,
|
||||
"windows": {
|
||||
|
||||
+811
-93
File diff suppressed because it is too large
Load Diff
+1062
-5
File diff suppressed because it is too large
Load Diff
+25
-1
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Download, GitBranch, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
||||
import { Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
||||
|
||||
export let branch: string = "";
|
||||
export let ahead: number = 0;
|
||||
@@ -16,6 +16,8 @@
|
||||
export let onPush: () => void = () => {};
|
||||
export let onRefresh: () => void = () => {};
|
||||
export let onSearch: () => void = () => {};
|
||||
export let onCompare: () => void = () => {};
|
||||
export let onOpenInExplorer: () => void = () => {};
|
||||
export let onToggleAutoRefresh: () => void = () => {};
|
||||
|
||||
const win = getCurrentWindow();
|
||||
@@ -81,6 +83,17 @@
|
||||
<!-- Right: actions + window controls -->
|
||||
<div class="titlebar-right">
|
||||
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenInExplorer}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Open repository in Explorer"
|
||||
aria-label="Open repository in Explorer"
|
||||
>
|
||||
<FolderOpen size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Explorer</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onSearch}
|
||||
@@ -92,6 +105,17 @@
|
||||
<span class="tb-action-label">Search</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onCompare}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Compare commits"
|
||||
aria-label="Compare commits"
|
||||
>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Compare</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onPull}
|
||||
|
||||
@@ -1,7 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { GitBranch, GitMerge } from "@lucide/svelte";
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo } from "../types";
|
||||
|
||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||
|
||||
interface BranchFolderNode {
|
||||
kind: "folder";
|
||||
id: string;
|
||||
name: string;
|
||||
children: BranchTreeNode[];
|
||||
branchCount: number;
|
||||
current: boolean;
|
||||
folders: Map<string, BranchFolderNode>;
|
||||
}
|
||||
|
||||
interface BranchLeafNode {
|
||||
kind: "branch";
|
||||
id: string;
|
||||
branch: GitBranchInfo;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
type BranchRow = BranchFolderRow | BranchLeafRow;
|
||||
|
||||
interface BranchFolderRow {
|
||||
kind: "folder";
|
||||
id: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
branchCount: number;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
interface BranchLeafRow {
|
||||
kind: "branch";
|
||||
id: string;
|
||||
branch: GitBranchInfo;
|
||||
displayName: string;
|
||||
depth: number;
|
||||
scopeLabel: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
branches: GitBranchInfo[];
|
||||
localBranches: GitBranchInfo[];
|
||||
@@ -10,6 +49,9 @@
|
||||
isBusy: boolean;
|
||||
onCheckout: (branch: GitBranchInfo) => void;
|
||||
onMerge: (branch: GitBranchInfo) => void;
|
||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -20,16 +62,217 @@
|
||||
isBusy = false,
|
||||
onCheckout = () => {},
|
||||
onMerge = () => {},
|
||||
onCreateBranch = () => {},
|
||||
onRenameBranch = () => {},
|
||||
onDeleteBranch = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let localOpen = $state(true);
|
||||
let remoteOpen = $state(false);
|
||||
let createOpen = $state(false);
|
||||
let newBranchName = $state("");
|
||||
let createInput = $state<HTMLInputElement | null>(null);
|
||||
let panelElement = $state<HTMLElement | null>(null);
|
||||
let contextBranch = $state<GitBranchInfo | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
||||
|
||||
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
|
||||
let remoteBranchRows = $derived(buildBranchRows("remote", remoteBranches, "remote"));
|
||||
|
||||
function createFolder(id: string, name: string): BranchFolderNode {
|
||||
return {
|
||||
kind: "folder",
|
||||
id,
|
||||
name,
|
||||
children: [],
|
||||
branchCount: 0,
|
||||
current: false,
|
||||
folders: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBranchRows(scope: string, branchList: GitBranchInfo[], scopeLabel: string): BranchRow[] {
|
||||
const root = createFolder(`${scope}:root`, "");
|
||||
|
||||
for (const branch of branchList) {
|
||||
const parts = branch.name.split("/").filter(Boolean);
|
||||
const displayName = parts.length > 0 ? parts[parts.length - 1] : branch.name;
|
||||
const folderParts = parts.slice(0, -1);
|
||||
let parent = root;
|
||||
|
||||
for (let index = 0; index < folderParts.length; index += 1) {
|
||||
const folderName = folderParts[index];
|
||||
const folderPath = folderParts.slice(0, index + 1).join("/");
|
||||
let folder = parent.folders.get(folderName);
|
||||
|
||||
if (!folder) {
|
||||
folder = createFolder(`${scope}:folder:${folderPath}`, folderName);
|
||||
parent.folders.set(folderName, folder);
|
||||
parent.children.push(folder);
|
||||
}
|
||||
|
||||
folder.branchCount += 1;
|
||||
folder.current ||= branch.current;
|
||||
parent = folder;
|
||||
}
|
||||
|
||||
parent.children.push({
|
||||
kind: "branch",
|
||||
id: `${scope}:branch:${branch.name}`,
|
||||
branch,
|
||||
displayName,
|
||||
});
|
||||
}
|
||||
|
||||
sortBranchNodes(root.children);
|
||||
|
||||
const rows: BranchRow[] = [];
|
||||
flattenBranchNodes(root.children, rows, 0, scopeLabel);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function sortBranchNodes(nodes: BranchTreeNode[]) {
|
||||
nodes.sort((left, right) => {
|
||||
if (left.kind !== right.kind) return left.kind === "folder" ? -1 : 1;
|
||||
const leftName = left.kind === "folder" ? left.name : left.displayName;
|
||||
const rightName = right.kind === "folder" ? right.name : right.displayName;
|
||||
return leftName.localeCompare(rightName, undefined, { sensitivity: "base" });
|
||||
});
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "folder") sortBranchNodes(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
function flattenBranchNodes(nodes: BranchTreeNode[], rows: BranchRow[], depth: number, scopeLabel: string) {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "folder") {
|
||||
rows.push({
|
||||
kind: "folder",
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
depth,
|
||||
branchCount: node.branchCount,
|
||||
current: node.current,
|
||||
});
|
||||
|
||||
if (isBranchFolderOpen(node.id)) {
|
||||
flattenBranchNodes(node.children, rows, depth + 1, scopeLabel);
|
||||
}
|
||||
} else {
|
||||
rows.push({
|
||||
kind: "branch",
|
||||
id: node.id,
|
||||
branch: node.branch,
|
||||
displayName: node.displayName,
|
||||
depth,
|
||||
scopeLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isBranchFolderOpen(id: string) {
|
||||
return !collapsedBranchFolders.has(id);
|
||||
}
|
||||
|
||||
function toggleBranchFolder(id: string) {
|
||||
const next = new Set(collapsedBranchFolders);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
collapsedBranchFolders = next;
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
if (!hasRepository || isBusy) return;
|
||||
createOpen = true;
|
||||
queueMicrotask(() => createInput?.focus());
|
||||
}
|
||||
|
||||
function closeCreateForm() {
|
||||
createOpen = false;
|
||||
newBranchName = "";
|
||||
}
|
||||
|
||||
async function submitCreate(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = newBranchName.trim();
|
||||
if (!value || !hasRepository || isBusy) return;
|
||||
await onCreateBranch(value);
|
||||
newBranchName = "";
|
||||
createOpen = false;
|
||||
localOpen = true;
|
||||
}
|
||||
|
||||
function checkoutOnDoubleClick(event: MouseEvent, branch: GitBranchInfo) {
|
||||
if (branch.current || isBusy) return;
|
||||
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||
if (target?.closest("button")) return;
|
||||
onCheckout(branch);
|
||||
}
|
||||
|
||||
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isBusy || branch.remote) return;
|
||||
|
||||
const rect = panelElement?.getBoundingClientRect();
|
||||
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
||||
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
||||
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 92);
|
||||
|
||||
contextBranch = branch;
|
||||
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||
contextMenuY = Math.max(8, Math.min(rawY, maxY));
|
||||
}
|
||||
|
||||
function closeBranchContextMenu() {
|
||||
contextBranch = null;
|
||||
}
|
||||
|
||||
async function renameContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
await onRenameBranch(branch);
|
||||
}
|
||||
|
||||
async function deleteContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || branch.current || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
await onDeleteBranch(branch);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeBranchContextMenu();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} />
|
||||
|
||||
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Branches</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
|
||||
</div>
|
||||
<span class="pill pill-count">{branches.length}</span>
|
||||
<div class="branch-head-actions">
|
||||
<button
|
||||
class="branch-create-toggle"
|
||||
type="button"
|
||||
onclick={openCreateForm}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Create new branch"
|
||||
aria-label="Create new branch"
|
||||
>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<span class="pill pill-count">{branches.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
@@ -37,71 +280,206 @@
|
||||
{:else if branches.length === 0}
|
||||
<p class="blank-state">No branches returned.</p>
|
||||
{:else}
|
||||
<div class="overflow-auto p-2 flex flex-col gap-0">
|
||||
{#if localBranches.length > 0}
|
||||
<div class="branch-group-label">
|
||||
<span>Local</span>
|
||||
<span class="branch-group-count">{localBranches.length}</span>
|
||||
</div>
|
||||
{#each localBranches as branch (branch.name)}
|
||||
{#snippet branchCard()}
|
||||
<article class="branch-row" class:current={branch.current}>
|
||||
<div class="branch-info">
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{branch.name}</strong>
|
||||
<span>local</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{:else}
|
||||
<div class="branch-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
|
||||
Checkout
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
|
||||
<GitMerge size={15} aria-hidden="true" />
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/snippet}
|
||||
{@render branchCard()}
|
||||
{/each}
|
||||
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||
{#if createOpen}
|
||||
<form class="branch-create-form" onsubmit={submitCreate}>
|
||||
<GitBranch size={15} aria-hidden="true" />
|
||||
<input
|
||||
bind:this={createInput}
|
||||
bind:value={newBranchName}
|
||||
disabled={isBusy}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="new-branch-name"
|
||||
aria-label="New branch name"
|
||||
/>
|
||||
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title="Create branch">
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title="Cancel">
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if remoteBranches.length > 0}
|
||||
<div class="branch-group-label" style="margin-top: {localBranches.length > 0 ? '12px' : '0'}">
|
||||
<div class="branch-group">
|
||||
<button
|
||||
class="branch-group-toggle"
|
||||
type="button"
|
||||
onclick={() => { localOpen = !localOpen; }}
|
||||
aria-expanded={localOpen}
|
||||
>
|
||||
{#if localOpen}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span>Local</span>
|
||||
<span class="branch-group-count">{localBranches.length}</span>
|
||||
</button>
|
||||
|
||||
{#if localOpen}
|
||||
{#if localBranches.length === 0}
|
||||
<div class="branch-empty">No local branches.</div>
|
||||
{:else}
|
||||
{#each localBranchRows as row (row.id)}
|
||||
{#if row.kind === "folder"}
|
||||
<button
|
||||
class="branch-folder-row"
|
||||
class:current={row.current}
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
type="button"
|
||||
onclick={() => toggleBranchFolder(row.id)}
|
||||
aria-expanded={isBranchFolderOpen(row.id)}
|
||||
title={`${row.name} (${row.branchCount})`}
|
||||
>
|
||||
{#if isBranchFolderOpen(row.id)}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
<Folder size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="branch-folder-name">{row.name}</span>
|
||||
<span class="branch-folder-count">{row.branchCount}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<article
|
||||
class="branch-row"
|
||||
class:current={row.branch.current}
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
|
||||
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
|
||||
title={row.branch.current ? "Current branch" : row.branch.name}
|
||||
>
|
||||
<div class="branch-info">
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{row.displayName}</strong>
|
||||
<span>{row.scopeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if row.branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{:else}
|
||||
<div class="branch-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
|
||||
Checkout
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
|
||||
<GitMerge size={15} aria-hidden="true" />
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="branch-group">
|
||||
<button
|
||||
class="branch-group-toggle"
|
||||
type="button"
|
||||
onclick={() => { remoteOpen = !remoteOpen; }}
|
||||
aria-expanded={remoteOpen}
|
||||
>
|
||||
{#if remoteOpen}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span>Remote</span>
|
||||
<span class="branch-group-count">{remoteBranches.length}</span>
|
||||
</div>
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
<article class="branch-row" class:current={branch.current}>
|
||||
<div class="branch-info">
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{branch.name}</strong>
|
||||
<span>remote</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{:else}
|
||||
<div class="branch-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
|
||||
Checkout
|
||||
</button>
|
||||
|
||||
{#if remoteOpen}
|
||||
{#if remoteBranches.length === 0}
|
||||
<div class="branch-empty">No remote branches.</div>
|
||||
{:else}
|
||||
{#each remoteBranchRows as row (row.id)}
|
||||
{#if row.kind === "folder"}
|
||||
<button
|
||||
class="branch-folder-row"
|
||||
class:current={row.current}
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
type="button"
|
||||
onclick={() => toggleBranchFolder(row.id)}
|
||||
aria-expanded={isBranchFolderOpen(row.id)}
|
||||
title={`${row.name} (${row.branchCount})`}
|
||||
>
|
||||
{#if isBranchFolderOpen(row.id)}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
<Folder size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="branch-folder-name">{row.name}</span>
|
||||
<span class="branch-folder-count">{row.branchCount}</span>
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
|
||||
<GitMerge size={15} aria-hidden="true" />
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<article
|
||||
class="branch-row"
|
||||
class:current={row.branch.current}
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
|
||||
title={row.branch.current ? "Current branch" : row.branch.name}
|
||||
>
|
||||
<div class="branch-info">
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{row.displayName}</strong>
|
||||
<span>{row.scopeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if row.branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{:else}
|
||||
<div class="branch-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
|
||||
Checkout
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
|
||||
<GitMerge size={15} aria-hidden="true" />
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contextBranch}
|
||||
<div
|
||||
class="branch-context-menu"
|
||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label={`Actions for ${contextBranch.name}`}
|
||||
>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={deleteContextBranch}
|
||||
disabled={isBusy || contextBranch.current}
|
||||
title={contextBranch.current ? "Current branch cannot be deleted" : "Delete local branch"}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
selectedDiffPath: string;
|
||||
isBusy: boolean;
|
||||
restoreLabel?: string;
|
||||
/** When opened from a search hit, the term to highlight on matching lines. */
|
||||
highlightQuery?: string;
|
||||
onClose: () => void;
|
||||
onRestore?: () => void;
|
||||
onSelectFile: (file: GitDiffFile) => void;
|
||||
@@ -25,11 +27,25 @@
|
||||
selectedDiffPath = "",
|
||||
isBusy = false,
|
||||
restoreLabel = "",
|
||||
highlightQuery = "",
|
||||
onClose = () => {},
|
||||
onRestore = undefined,
|
||||
onSelectFile = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
// Needle = first non-empty line of the search query, lowercased for matching.
|
||||
let highlightNeedle = $derived(
|
||||
highlightQuery
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
?.toLowerCase() ?? ""
|
||||
);
|
||||
|
||||
function isMatch(text?: string): boolean {
|
||||
return highlightNeedle.length > 0 && !!text && text.toLowerCase().includes(highlightNeedle);
|
||||
}
|
||||
|
||||
let beforePane = $state<HTMLDivElement | null>(null);
|
||||
let afterPane = $state<HTMLDivElement | null>(null);
|
||||
let isSyncingSplitScroll = false;
|
||||
@@ -259,8 +275,8 @@
|
||||
{#if row.type === "span"}
|
||||
<div class="split-span split-{row.kind}">{row.text}</div>
|
||||
{:else}
|
||||
<div class="split-num" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"}>{row.leftNum ?? ""}</div>
|
||||
<div class="split-cell" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"}>{row.leftText ?? " "}</div>
|
||||
<div class="split-num" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"} class:match={isMatch(row.leftText)}>{row.leftNum ?? ""}</div>
|
||||
<div class="split-cell" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"} class:match={isMatch(row.leftText)}>{row.leftText ?? " "}</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -276,8 +292,8 @@
|
||||
{#if row.type === "span"}
|
||||
<div class="split-span split-{row.kind}">{row.text}</div>
|
||||
{:else}
|
||||
<div class="split-num" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"}>{row.rightNum ?? ""}</div>
|
||||
<div class="split-cell" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"}>{row.rightText ?? " "}</div>
|
||||
<div class="split-num" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"} class:match={isMatch(row.rightText)}>{row.rightNum ?? ""}</div>
|
||||
<div class="split-cell" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"} class:match={isMatch(row.rightText)}>{row.rightText ?? " "}</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitCommit } from "../types";
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
compareFrom: string;
|
||||
compareTo: string;
|
||||
canCompare: boolean;
|
||||
isBusy: boolean;
|
||||
operation: string;
|
||||
onCompareFromChange: (val: string) => void;
|
||||
onCompareToChange: (val: string) => void;
|
||||
onCompare: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
compareFrom = "",
|
||||
compareTo = "",
|
||||
canCompare = false,
|
||||
isBusy = false,
|
||||
operation = "",
|
||||
onCompareFromChange = () => {},
|
||||
onCompareToChange = () => {},
|
||||
onCompare = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function commitOptionLabel(item: GitCommit): string {
|
||||
return `${item.short_hash} - ${item.summary}`;
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
onCompare();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if commits.length < 2}
|
||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
||||
{:else}
|
||||
<form class="compare-form" onsubmit={handleSubmit}>
|
||||
<label class="compare-field">
|
||||
<span>From (older)</span>
|
||||
<select
|
||||
value={compareFrom}
|
||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
||||
|
||||
<label class="compare-field">
|
||||
<span>To (newer)</span>
|
||||
<select
|
||||
value={compareTo}
|
||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
||||
{#if operation === "Comparing commits"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitCompare size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Compare
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if compareFrom && compareTo && compareFrom === compareTo}
|
||||
<div class="blank-state">Select two different commits to compare.</div>
|
||||
{:else}
|
||||
<div class="blank-state">Pick two commits and run a comparison.</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { CalendarDays, FileCode, LoaderCircle, Search, User, X } from "@lucide/svelte";
|
||||
import type { GitSearchHit } from "../types";
|
||||
import {
|
||||
CalendarDays,
|
||||
FileCode,
|
||||
FileText,
|
||||
GitCompare,
|
||||
History,
|
||||
LoaderCircle,
|
||||
RotateCcw,
|
||||
Search,
|
||||
User,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import { languageIconForPath } from "../languageIcons";
|
||||
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
|
||||
import LanguageIcon from "./LanguageIcon.svelte";
|
||||
|
||||
type SearchTab = "code" | "files";
|
||||
|
||||
interface Props {
|
||||
hasRepository: boolean;
|
||||
@@ -8,9 +23,16 @@
|
||||
isSearching: boolean;
|
||||
error: string;
|
||||
results: GitSearchHit[];
|
||||
files: GitRepositoryFile[];
|
||||
fileHistory: GitCommit[];
|
||||
selectedFilePath: string;
|
||||
onClose: () => void;
|
||||
onSearch: (query: string, caseSensitive: boolean, limit: number) => void | Promise<void>;
|
||||
onCancel: () => void | Promise<void>;
|
||||
onDiff: (hit: GitSearchHit) => void | Promise<void>;
|
||||
onSelectFile: (file: GitRepositoryFile) => void | Promise<void>;
|
||||
onFileHistoryDiff: (commit: GitCommit) => void | Promise<void>;
|
||||
onFileHistoryRestore: (commit: GitCommit) => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -19,16 +41,28 @@
|
||||
isSearching = false,
|
||||
error = "",
|
||||
results = [],
|
||||
files = [],
|
||||
fileHistory = [],
|
||||
selectedFilePath = "",
|
||||
onClose = () => {},
|
||||
onSearch = () => {},
|
||||
onCancel = () => {},
|
||||
onDiff = () => {},
|
||||
onSelectFile = () => {},
|
||||
onFileHistoryDiff = () => {},
|
||||
onFileHistoryRestore = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let activeTab = $state<SearchTab>("code");
|
||||
let query = $state("");
|
||||
let caseSensitive = $state(false);
|
||||
let limit = $state(250);
|
||||
let searchedQuery = $state("");
|
||||
let searched = $state(false);
|
||||
let fileQuery = $state("");
|
||||
|
||||
const fileSearchActive = $derived(fileQuery.trim().length > 0);
|
||||
const fileSearchResults = $derived(filterFiles(files, fileQuery, 200));
|
||||
|
||||
function submit(event?: SubmitEvent) {
|
||||
event?.preventDefault();
|
||||
@@ -54,6 +88,42 @@
|
||||
function displayPath(hit: GitSearchHit): string {
|
||||
return hit.old_file ? `${hit.old_file} -> ${hit.file}` : hit.file;
|
||||
}
|
||||
|
||||
function fileName(path: string): string {
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
||||
}
|
||||
|
||||
function folderName(path: string): string {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
parts.pop();
|
||||
return parts.length > 0 ? parts.join("/") : "Repository root";
|
||||
}
|
||||
|
||||
function filterFiles(source: GitRepositoryFile[], value: string, maxResults: number): GitRepositoryFile[] {
|
||||
const terms = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (terms.length === 0) return [];
|
||||
|
||||
return source
|
||||
.filter((file) => {
|
||||
const path = file.path.toLowerCase();
|
||||
const name = fileName(file.path).toLowerCase();
|
||||
return terms.every((term) => path.includes(term) || name.includes(term));
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aName = fileName(a.path).toLowerCase();
|
||||
const bName = fileName(b.path).toLowerCase();
|
||||
const first = terms[0] ?? "";
|
||||
const aStarts = aName.startsWith(first) ? 0 : 1;
|
||||
const bStarts = bName.startsWith(first) ? 0 : 1;
|
||||
if (aStarts !== bStarts) return aStarts - bStarts;
|
||||
return a.path.localeCompare(b.path);
|
||||
})
|
||||
.slice(0, maxResults);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -61,11 +131,11 @@
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global code search" tabindex="-1">
|
||||
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Global search</span>
|
||||
<h2 class="dialog-title">Find where code was introduced</h2>
|
||||
<h2 class="dialog-title">{activeTab === "code" ? "Find where code was introduced" : "Find file history"}</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
@@ -73,102 +143,254 @@
|
||||
</header>
|
||||
|
||||
<div class="global-search-body">
|
||||
<form class="global-search-form" onsubmit={submit}>
|
||||
<label class="global-search-query">
|
||||
<span>String or function</span>
|
||||
<textarea
|
||||
bind:value={query}
|
||||
onkeydown={handleKeydown}
|
||||
disabled={!hasRepository || isBusy || isSearching}
|
||||
spellcheck="false"
|
||||
placeholder={"Paste a string, symbol, or full function body..."}
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="global-search-tabs" role="tablist" aria-label="Search mode">
|
||||
<button
|
||||
class="global-search-tab"
|
||||
class:active={activeTab === "code"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "code"}
|
||||
onclick={() => { activeTab = "code"; }}
|
||||
>
|
||||
<Search size={14} aria-hidden="true" />
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
class="global-search-tab"
|
||||
class:active={activeTab === "files"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "files"}
|
||||
onclick={() => { activeTab = "files"; }}
|
||||
>
|
||||
<FileText size={14} aria-hidden="true" />
|
||||
Files
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="global-search-options">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} />
|
||||
<span>Exact case</span>
|
||||
{#if activeTab === "code"}
|
||||
<form class="global-search-form" onsubmit={submit}>
|
||||
<label class="global-search-query">
|
||||
<span>String or function</span>
|
||||
<textarea
|
||||
bind:value={query}
|
||||
onkeydown={handleKeydown}
|
||||
disabled={!hasRepository || isBusy || isSearching}
|
||||
spellcheck="false"
|
||||
placeholder={"Paste a string, symbol, or full function body..."}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="search-limit">
|
||||
<span>Results</span>
|
||||
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
|
||||
<option value={100}>100</option>
|
||||
<option value={250}>250</option>
|
||||
<option value={500}>500</option>
|
||||
<option value={1000}>1000</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="global-search-options">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} />
|
||||
<span>Exact case</span>
|
||||
</label>
|
||||
|
||||
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
|
||||
{#if isSearching}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Search size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Search
|
||||
</button>
|
||||
<label class="search-limit">
|
||||
<span>Results</span>
|
||||
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
|
||||
<option value={100}>100</option>
|
||||
<option value={250}>250</option>
|
||||
<option value={500}>500</option>
|
||||
<option value={1000}>1000</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#if isSearching}
|
||||
<button class="btn-secondary search-cancel" type="button" onclick={onCancel}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
Cancel
|
||||
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
|
||||
{#if isSearching}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Search size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Search
|
||||
</button>
|
||||
|
||||
{#if isSearching}
|
||||
<button class="btn-secondary search-cancel" type="button" onclick={onCancel}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="global-search-results" aria-live="polite">
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">Open a repository first.</div>
|
||||
{:else if isSearching}
|
||||
<div class="blank-state">
|
||||
<LoaderCircle class="spin" size={20} aria-hidden="true" />
|
||||
Searching all branches...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="blank-state search-error">{error}</div>
|
||||
{:else if !searched}
|
||||
<div class="blank-state">Search a string or paste a complete function to find where it was added.</div>
|
||||
{:else if results.length === 0}
|
||||
<div class="blank-state">No introduction found for "{searchedQuery}".</div>
|
||||
{:else}
|
||||
<div class="search-result-head">
|
||||
<strong>{results.length}</strong>
|
||||
<span>{results.length === 1 ? "introduction" : "introductions"} found for "{searchedQuery}"</span>
|
||||
</div>
|
||||
|
||||
<div class="search-hit-list">
|
||||
{#each results as hit (`${hit.commit_hash}:${hit.file}:${hit.line_number ?? 0}`)}
|
||||
<article class="search-hit">
|
||||
<header class="search-hit-top">
|
||||
<span class="hash">{hit.short_hash}</span>
|
||||
<strong title={hit.summary}>{hit.summary || "No commit message"}</strong>
|
||||
{#if hit.matches_added > 1}
|
||||
<span class="pill pill-active">+{hit.matches_added} matches</span>
|
||||
{/if}
|
||||
<button
|
||||
class="btn-secondary search-hit-diff"
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
title={`Compare this version of ${hit.file} with the current version`}
|
||||
onclick={() => onDiff(hit)}
|
||||
>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
DIFF
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="search-hit-meta">
|
||||
<span><User size={12} aria-hidden="true" />{hit.author_name || "Unknown author"}</span>
|
||||
<span><CalendarDays size={12} aria-hidden="true" />{formatCommitDate(hit.date)}</span>
|
||||
</div>
|
||||
|
||||
<div class="search-hit-file" title={displayPath(hit)}>
|
||||
<FileCode size={14} aria-hidden="true" />
|
||||
<span>{displayPath(hit)}</span>
|
||||
{#if hit.line_number}
|
||||
<strong>:{hit.line_number}</strong>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<pre class="search-hit-line">{hit.line || searchedQuery}</pre>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{:else}
|
||||
<form class="global-search-form file-search-form" onsubmit={(event) => event.preventDefault()}>
|
||||
<label class="global-search-query">
|
||||
<span>File name or path</span>
|
||||
<input
|
||||
bind:value={fileQuery}
|
||||
disabled={!hasRepository || isBusy}
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="Search files, folders, extensions..."
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<section class="global-search-results" aria-live="polite">
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">Open a repository first.</div>
|
||||
{:else if isSearching}
|
||||
<div class="blank-state">
|
||||
<LoaderCircle class="spin" size={20} aria-hidden="true" />
|
||||
Searching all branches...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="blank-state search-error">{error}</div>
|
||||
{:else if !searched}
|
||||
<div class="blank-state">Search a string or paste a complete function to find where it was added.</div>
|
||||
{:else if results.length === 0}
|
||||
<div class="blank-state">No introduction found for "{searchedQuery}".</div>
|
||||
{:else}
|
||||
<div class="search-result-head">
|
||||
<strong>{results.length}</strong>
|
||||
<span>{results.length === 1 ? "introduction" : "introductions"} found for "{searchedQuery}"</span>
|
||||
</div>
|
||||
<section class="global-search-results file-search-results" aria-live="polite">
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">Open a repository first.</div>
|
||||
{:else if files.length === 0}
|
||||
<div class="blank-state">No files loaded for this repository.</div>
|
||||
{:else}
|
||||
<div class="file-search-split">
|
||||
<div class="file-search-column">
|
||||
{#if !fileSearchActive}
|
||||
<div class="blank-state">Search a file name or path, then choose a result to show its history.</div>
|
||||
{:else if fileSearchResults.length === 0}
|
||||
<div class="blank-state">No file found for "{fileQuery.trim()}".</div>
|
||||
{:else}
|
||||
<div class="search-result-head">
|
||||
<strong>{fileSearchResults.length}</strong>
|
||||
<span>{fileSearchResults.length === 1 ? "file" : "files"} found for "{fileQuery.trim()}"</span>
|
||||
</div>
|
||||
|
||||
<div class="search-hit-list">
|
||||
{#each results as hit (`${hit.commit_hash}:${hit.file}:${hit.line_number ?? 0}`)}
|
||||
<article class="search-hit">
|
||||
<header class="search-hit-top">
|
||||
<span class="hash">{hit.short_hash}</span>
|
||||
<strong title={hit.summary}>{hit.summary || "No commit message"}</strong>
|
||||
{#if hit.matches_added > 1}
|
||||
<span class="pill pill-active">+{hit.matches_added} matches</span>
|
||||
{/if}
|
||||
<div class="file-search-list">
|
||||
{#each fileSearchResults as file (file.path)}
|
||||
{@const languageIcon = languageIconForPath(file.path)}
|
||||
<button
|
||||
class="file-search-hit"
|
||||
class:active={selectedFilePath === file.path}
|
||||
type="button"
|
||||
disabled={isBusy || isSearching}
|
||||
title={`Show history for ${file.path}`}
|
||||
onclick={() => onSelectFile(file)}
|
||||
>
|
||||
<span class="file-search-icon">
|
||||
{#if languageIcon}
|
||||
<LanguageIcon icon={languageIcon.icon} title={languageIcon.title} />
|
||||
{:else}
|
||||
<FileText size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="file-search-main">
|
||||
<strong>{fileName(file.path)}</strong>
|
||||
<span>{folderName(file.path)}</span>
|
||||
</span>
|
||||
|
||||
{#if file.status}
|
||||
<small class={`status-badge ${file.status}`}>{file.status}</small>
|
||||
{:else if !file.tracked}
|
||||
<small class="status-badge untracked">untracked</small>
|
||||
{/if}
|
||||
|
||||
<span class="file-search-action">
|
||||
<History size={14} aria-hidden="true" />
|
||||
History
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<aside class="file-search-history" aria-label="Selected file history">
|
||||
<header class="file-search-history-head">
|
||||
<span>History</span>
|
||||
<strong title={selectedFilePath}>{selectedFilePath ? fileName(selectedFilePath) : "No file selected"}</strong>
|
||||
</header>
|
||||
|
||||
<div class="search-hit-meta">
|
||||
<span><User size={12} aria-hidden="true" />{hit.author_name || "Unknown author"}</span>
|
||||
<span><CalendarDays size={12} aria-hidden="true" />{formatCommitDate(hit.date)}</span>
|
||||
</div>
|
||||
{#if !selectedFilePath}
|
||||
<div class="blank-state">Select a file result to load its commit history.</div>
|
||||
{:else if isBusy}
|
||||
<div class="blank-state">
|
||||
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||
Loading history...
|
||||
</div>
|
||||
{:else if fileHistory.length === 0}
|
||||
<div class="blank-state">No history returned for this file.</div>
|
||||
{:else}
|
||||
<div class="file-search-history-list">
|
||||
{#each fileHistory as commit (commit.hash)}
|
||||
<article class="file-search-history-row">
|
||||
<div class="file-search-history-main">
|
||||
<span class="hash">{commit.short_hash}</span>
|
||||
<strong title={commit.summary}>{commit.summary || "No commit message"}</strong>
|
||||
<span>{commit.author_name || "Unknown author"} - {formatCommitDate(commit.date)}</span>
|
||||
</div>
|
||||
|
||||
<div class="search-hit-file" title={displayPath(hit)}>
|
||||
<FileCode size={14} aria-hidden="true" />
|
||||
<span>{displayPath(hit)}</span>
|
||||
{#if hit.line_number}
|
||||
<strong>:{hit.line_number}</strong>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<pre class="search-hit-line">{hit.line || searchedQuery}</pre>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<div class="file-search-history-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onFileHistoryDiff(commit)} disabled={isBusy}>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
Diff
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onFileHistoryRestore(commit)} disabled={isBusy}>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight, RotateCcw } from "@lucide/svelte";
|
||||
import { ChevronDown, ChevronRight, GitBranch, RotateCcw } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||
|
||||
interface GraphSegment {
|
||||
@@ -29,6 +29,7 @@
|
||||
onRestoreCommit: (commit: GitCommit) => void;
|
||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||
onToggleCommitFiles: (hash: string) => void;
|
||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -39,6 +40,7 @@
|
||||
onRestoreCommit = () => {},
|
||||
onPreviewCommitFile = () => {},
|
||||
onToggleCommitFiles = () => {},
|
||||
onCreateBranchFromCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function laneColor(col: number): string {
|
||||
@@ -220,6 +222,10 @@
|
||||
<div class="commit-actions">
|
||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<div class="commit-action-buttons">
|
||||
<button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit">
|
||||
<GitBranch size={15} aria-hidden="true" />
|
||||
Branch
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Reset current branch to this commit">
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Restore
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||
|
||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||
|
||||
interface PatchLine {
|
||||
id: string;
|
||||
text: string;
|
||||
kind: PatchLineKind;
|
||||
}
|
||||
|
||||
interface PatchHunk {
|
||||
id: string;
|
||||
header: string;
|
||||
lines: PatchLine[];
|
||||
}
|
||||
|
||||
interface ParsedPatch {
|
||||
headerLines: string[];
|
||||
hunks: PatchHunk[];
|
||||
binary: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
file: GitFileStatus;
|
||||
staged: boolean;
|
||||
patch: string;
|
||||
isBusy: boolean;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
file,
|
||||
staged = false,
|
||||
patch = "",
|
||||
isBusy = false,
|
||||
isLoading = false,
|
||||
error = "",
|
||||
onClose = () => {},
|
||||
onRefresh = () => {},
|
||||
onApply = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||
|
||||
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
||||
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||
|
||||
$effect(() => {
|
||||
parsed = parsePatch(patch);
|
||||
});
|
||||
|
||||
function parsePatch(input: string): ParsedPatch {
|
||||
const normalized = input.replace(/\r\n/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
if (lines[lines.length - 1] === "") lines.pop();
|
||||
|
||||
const headerLines: string[] = [];
|
||||
const hunks: PatchHunk[] = [];
|
||||
let current: PatchHunk | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("@@ ")) {
|
||||
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
|
||||
hunks.push(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
headerLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const kind = patchLineKind(line);
|
||||
current.lines.push({
|
||||
id: `${current.id}-line-${current.lines.length}`,
|
||||
text: line,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
headerLines,
|
||||
hunks,
|
||||
binary: /(^|\n)(Binary files|GIT binary patch|literal \d+)/.test(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
function patchLineKind(line: string): PatchLineKind {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
||||
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
|
||||
if (line.startsWith(" ")) return "context";
|
||||
return "meta";
|
||||
}
|
||||
|
||||
function linePrefix(line: PatchLine): string {
|
||||
if (line.kind === "add") return "+";
|
||||
if (line.kind === "delete") return "-";
|
||||
if (line.kind === "meta") return "\\";
|
||||
return " ";
|
||||
}
|
||||
|
||||
function lineBody(line: PatchLine): string {
|
||||
if (line.kind === "meta") return line.text;
|
||||
return line.text.slice(1);
|
||||
}
|
||||
|
||||
function buildHunkPatch(hunk: PatchHunk): string {
|
||||
return `${[...parsed.headerLines, hunk.header, ...hunk.lines.map((line) => line.text)].join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
|
||||
if (isBusy || isLoading) return;
|
||||
await onApply(action, buildHunkPatch(hunk));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label="Line patch">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">{scopeLabel}</span>
|
||||
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="line-patch-body">
|
||||
{#if isLoading}
|
||||
<div class="blank-state">
|
||||
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||
Loading patch...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="blank-state">{error}</div>
|
||||
{:else if !patch.trim()}
|
||||
<div class="blank-state">No line patch available for this file.</div>
|
||||
{:else if parsed.binary || parsed.hunks.length === 0}
|
||||
<div class="blank-state">This change cannot be split into text lines.</div>
|
||||
{:else}
|
||||
<div class="line-patch-scroll">
|
||||
{#each parsed.hunks as hunk (hunk.id)}
|
||||
<section class="line-patch-hunk">
|
||||
<div class="line-patch-hunk-head">
|
||||
<code>{hunk.header}</code>
|
||||
<div class="line-patch-hunk-actions">
|
||||
{#if staged}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
|
||||
Discard Hunk
|
||||
</button>
|
||||
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
|
||||
Unstage Hunk
|
||||
</button>
|
||||
{:else}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
|
||||
Discard Hunk
|
||||
</button>
|
||||
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
|
||||
Stage Hunk
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="line-patch-lines">
|
||||
{#each hunk.lines as line (line.id)}
|
||||
<div class={`line-patch-row ${line.kind}`}>
|
||||
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
||||
<code>{lineBody(line)}</code>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { GitBranch, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitCommit } from "../types";
|
||||
|
||||
interface Props {
|
||||
commit: GitCommit;
|
||||
isBusy: boolean;
|
||||
onCreate: (name: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commit,
|
||||
isBusy = false,
|
||||
onCreate = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let name = $state("");
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = name.trim();
|
||||
if (!value) return;
|
||||
onCreate(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">New branch</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">From commit</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="new-branch-form" onsubmit={submit}>
|
||||
<div class="new-branch-target">
|
||||
<span class="hash">{commit.short_hash}</span>
|
||||
<span class="new-branch-summary" title={commit.summary}>{commit.summary}</span>
|
||||
</div>
|
||||
|
||||
<label class="new-branch-field">
|
||||
<span>Branch name</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="feature/my-branch"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Create branch
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { GitBranch, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo } from "../types";
|
||||
|
||||
interface Props {
|
||||
branch: GitBranchInfo;
|
||||
isBusy: boolean;
|
||||
onRename: (name: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
branch,
|
||||
isBusy = false,
|
||||
onRename = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let name = $state("");
|
||||
|
||||
$effect(() => {
|
||||
name = branch.name;
|
||||
});
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = name.trim();
|
||||
if (!value || value === branch.name) return;
|
||||
onRename(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Rename branch</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="rename-branch-form" onsubmit={submit}>
|
||||
<label class="new-branch-field">
|
||||
<span>Branch name</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Rename
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
export let label = "Repository wird geöffnet";
|
||||
export let repoName = "";
|
||||
</script>
|
||||
|
||||
<div class="repo-loading" role="status" aria-live="polite">
|
||||
<div class="repo-loading-card">
|
||||
<!-- Animated git graph: nodes light up in sequence while a branch draws itself -->
|
||||
<svg class="git-graph" viewBox="0 0 120 120" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="gl-line" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#646cff" />
|
||||
<stop offset="100%" stop-color="#41d1ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- rotating dashed ring -->
|
||||
<circle class="ring" cx="60" cy="60" r="52" />
|
||||
|
||||
<!-- main trunk -->
|
||||
<path class="trunk" d="M42 22 L42 98" />
|
||||
<!-- branch forking off and merging back -->
|
||||
<path class="branch" d="M42 44 C42 62, 82 58, 82 76 L82 88" />
|
||||
|
||||
<!-- commit nodes -->
|
||||
<circle class="node n1" cx="42" cy="30" r="6" />
|
||||
<circle class="node n2" cx="42" cy="60" r="6" />
|
||||
<circle class="node n3" cx="82" cy="88" r="6" />
|
||||
<circle class="node n4" cx="42" cy="90" r="6" />
|
||||
</svg>
|
||||
|
||||
<div class="repo-loading-text">
|
||||
<span class="repo-loading-label">{label}…</span>
|
||||
{#if repoName}
|
||||
<span class="repo-loading-name" title={repoName}>{repoName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="repo-loading-bar"><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.repo-loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 400;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(6, 6, 14, 0.72);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: overlay-in 180ms ease;
|
||||
}
|
||||
|
||||
.repo-loading-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 34px 46px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 18px;
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.git-graph {
|
||||
width: 118px;
|
||||
height: 118px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.git-graph .ring {
|
||||
fill: none;
|
||||
stroke: rgba(100, 108, 255, 0.22);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 26 18;
|
||||
transform-origin: 60px 60px;
|
||||
animation: ring-spin 5s linear infinite;
|
||||
}
|
||||
|
||||
.git-graph .trunk,
|
||||
.git-graph .branch {
|
||||
fill: none;
|
||||
stroke: url(#gl-line);
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.git-graph .branch {
|
||||
stroke-dasharray: 90;
|
||||
stroke-dashoffset: 90;
|
||||
animation: branch-draw 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.git-graph .node {
|
||||
fill: var(--color-surface-alt);
|
||||
stroke: url(#gl-line);
|
||||
stroke-width: 4;
|
||||
animation: node-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
.git-graph .n1 { animation-delay: 0s; }
|
||||
.git-graph .n2 { animation-delay: 0.5s; }
|
||||
.git-graph .n3 { animation-delay: 1s; }
|
||||
.git-graph .n4 { animation-delay: 1.5s; }
|
||||
|
||||
.repo-loading-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.repo-loading-label {
|
||||
color: var(--color-ink);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.repo-loading-name {
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.repo-loading-bar {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
background: rgba(100, 108, 255, 0.16);
|
||||
}
|
||||
.repo-loading-bar span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 40%;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, transparent, var(--color-accent), transparent);
|
||||
animation: bar-slide 1.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes ring-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes bar-slide {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
@keyframes branch-draw {
|
||||
0% { stroke-dashoffset: 90; opacity: 0.35; }
|
||||
45% { stroke-dashoffset: 0; opacity: 1; }
|
||||
100% { stroke-dashoffset: 0; opacity: 1; }
|
||||
}
|
||||
@keyframes 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) {
|
||||
.git-graph .ring,
|
||||
.git-graph .branch,
|
||||
.git-graph .node,
|
||||
.repo-loading-bar span { animation: none; }
|
||||
.git-graph .branch { stroke-dashoffset: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, RotateCcw, Undo2 } from "@lucide/svelte";
|
||||
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
@@ -12,6 +12,7 @@
|
||||
onStage: (file: GitFileStatus) => void;
|
||||
onUnstage: (file: GitFileStatus) => void;
|
||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
||||
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
}
|
||||
@@ -26,6 +27,7 @@
|
||||
onStage = () => {},
|
||||
onUnstage = () => {},
|
||||
onDiscard = () => {},
|
||||
onPatch = () => {},
|
||||
onStageAll = () => {},
|
||||
onUnstageAll = () => {},
|
||||
}: Props = $props();
|
||||
@@ -38,6 +40,18 @@
|
||||
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
||||
}
|
||||
|
||||
function fileName(file: GitFileStatus): string {
|
||||
return file.old_path ? `${baseName(file.old_path)} -> ${baseName(file.path)}` : baseName(file.path);
|
||||
}
|
||||
|
||||
function canPatch(kind: FileStatusKind | null): boolean {
|
||||
return kind === "modified";
|
||||
}
|
||||
|
||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||
</script>
|
||||
@@ -90,7 +104,7 @@
|
||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<article class="file-row">
|
||||
<div class="file-title">
|
||||
<strong title={displayPath(file)}>{displayPath(file)}</strong>
|
||||
<strong title={displayPath(file)}>{fileName(file)}</strong>
|
||||
</div>
|
||||
|
||||
<div class="change-lanes">
|
||||
@@ -105,6 +119,10 @@
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
Unstage
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard
|
||||
@@ -126,6 +144,10 @@
|
||||
<Check size={14} aria-hidden="true" />
|
||||
Stage
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlertCircle,
|
||||
Download,
|
||||
LoaderCircle,
|
||||
PackageCheck,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||
|
||||
interface Props {
|
||||
state: UpdateToastState;
|
||||
version: string;
|
||||
currentVersion: string;
|
||||
progress: number;
|
||||
error: string;
|
||||
onInstall: () => void;
|
||||
onLater: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
state,
|
||||
version,
|
||||
currentVersion = "",
|
||||
progress = 0,
|
||||
error = "",
|
||||
onInstall,
|
||||
onLater,
|
||||
onDismiss,
|
||||
}: Props = $props();
|
||||
|
||||
const clampedProgress = $derived(Math.max(0, Math.min(100, Math.round(progress))));
|
||||
const title = $derived(
|
||||
state === "installed"
|
||||
? "Update installed"
|
||||
: state === "downloading"
|
||||
? "Installing update"
|
||||
: state === "error"
|
||||
? "Update failed"
|
||||
: "Update available",
|
||||
);
|
||||
const description = $derived(
|
||||
state === "installed"
|
||||
? "Restart GitLite to use the new version."
|
||||
: state === "downloading"
|
||||
? "Download and installation are running in the background."
|
||||
: state === "error"
|
||||
? (error || "The update could not be installed.")
|
||||
: "Do you want to install this version now?",
|
||||
);
|
||||
const versionLabel = $derived(
|
||||
version && currentVersion
|
||||
? `${currentVersion} -> ${version}`
|
||||
: version
|
||||
? `Version ${version}`
|
||||
: "New version",
|
||||
);
|
||||
const showProgress = $derived(state === "downloading" || state === "installed");
|
||||
const isBusy = $derived(state === "downloading");
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="update-toast"
|
||||
class:error={state === "error"}
|
||||
class:installed={state === "installed"}
|
||||
role={state === "error" ? "alert" : "status"}
|
||||
aria-live={state === "error" ? "assertive" : "polite"}
|
||||
>
|
||||
<div class="update-toast-glow" aria-hidden="true"></div>
|
||||
|
||||
<div class="update-toast-icon" class:busy={isBusy}>
|
||||
{#if state === "downloading"}
|
||||
<LoaderCircle class="spin" size={21} aria-hidden="true" />
|
||||
{:else if state === "installed"}
|
||||
<PackageCheck size={21} aria-hidden="true" />
|
||||
{:else if state === "error"}
|
||||
<AlertCircle size={21} aria-hidden="true" />
|
||||
{:else}
|
||||
<Sparkles size={21} aria-hidden="true" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="update-toast-content">
|
||||
<div class="update-toast-top">
|
||||
<div class="update-toast-copy">
|
||||
<span class="update-toast-kicker">{versionLabel}</span>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
|
||||
{#if !isBusy}
|
||||
<button class="update-toast-close" type="button" onclick={onDismiss} title="Close" aria-label="Close">
|
||||
<X size={15} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p>{description}</p>
|
||||
|
||||
{#if showProgress}
|
||||
<div
|
||||
class="update-progress"
|
||||
class:indeterminate={state === "downloading" && clampedProgress === 0}
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow={clampedProgress}
|
||||
>
|
||||
<span style={`width: ${clampedProgress}%`}></span>
|
||||
</div>
|
||||
<div class="update-progress-label">
|
||||
{state === "installed" ? "100% complete" : clampedProgress > 0 ? `${clampedProgress}%` : "Waiting for download size"}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="update-toast-actions">
|
||||
{#if state === "installed"}
|
||||
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
|
||||
Got it
|
||||
</button>
|
||||
{:else if state === "error"}
|
||||
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
|
||||
Close
|
||||
</button>
|
||||
<button class="update-toast-primary" type="button" onclick={onInstall}>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
Try again
|
||||
</button>
|
||||
{:else}
|
||||
<button class="update-toast-secondary" type="button" onclick={onLater} disabled={isBusy}>
|
||||
Later
|
||||
</button>
|
||||
<button class="update-toast-primary" type="button" onclick={onInstall} disabled={isBusy}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||
Installing
|
||||
{:else}
|
||||
<Download size={14} aria-hidden="true" />
|
||||
Install
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
GitRepositoryFile,
|
||||
GitSearchHit,
|
||||
GitStatus,
|
||||
PatchApplyAction,
|
||||
RepositoryBundle,
|
||||
StoredCredential,
|
||||
} from "./types";
|
||||
|
||||
@@ -15,6 +17,14 @@ export function openRepository(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("open_repository", { path });
|
||||
}
|
||||
|
||||
export function openRepoInExplorer(path: string): Promise<void> {
|
||||
return invoke<void>("open_repo_in_explorer", { path });
|
||||
}
|
||||
|
||||
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||
}
|
||||
|
||||
export function getStatus(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("get_status", { path });
|
||||
}
|
||||
@@ -27,6 +37,26 @@ export function checkoutBranch(path: string, branch: string): Promise<GitStatus>
|
||||
return invoke<GitStatus>("checkout_branch", { path, branch });
|
||||
}
|
||||
|
||||
export function createBranch(
|
||||
path: string,
|
||||
branch: string,
|
||||
startPoint?: string,
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("create_branch", { path, branch, startPoint: startPoint ?? null });
|
||||
}
|
||||
|
||||
export function renameBranch(
|
||||
path: string,
|
||||
oldBranch: string,
|
||||
newBranch: string,
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
||||
}
|
||||
|
||||
export function deleteBranch(path: string, branch: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("delete_branch", { path, branch });
|
||||
}
|
||||
|
||||
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("stage_files", { path, files });
|
||||
}
|
||||
@@ -43,6 +73,19 @@ export function restoreFiles(
|
||||
return invoke<GitStatus>("restore_files", { path, files, staged });
|
||||
}
|
||||
|
||||
export function getFilePatch(path: string, file: string, staged: boolean): Promise<string> {
|
||||
return invoke<string>("get_file_patch", { path, file, staged });
|
||||
}
|
||||
|
||||
export function applyFilePatch(
|
||||
path: string,
|
||||
file: string,
|
||||
patch: string,
|
||||
action: PatchApplyAction,
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("apply_file_patch", { path, file, patch, action });
|
||||
}
|
||||
|
||||
export function commit(path: string, message: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("commit", { path, message });
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface GitFileStatus {
|
||||
unstaged: FileStatusKind | null;
|
||||
}
|
||||
|
||||
export type PatchApplyAction = "stage" | "unstage" | "discard-unstaged" | "discard-staged";
|
||||
|
||||
export interface GitBranch {
|
||||
name: string;
|
||||
current: boolean;
|
||||
@@ -54,6 +56,13 @@ export interface GitRepositoryFile {
|
||||
status: FileStatusKind | null;
|
||||
}
|
||||
|
||||
export interface RepositoryBundle {
|
||||
status: GitStatus;
|
||||
branches: GitBranch[];
|
||||
commits: GitCommit[];
|
||||
files: GitRepositoryFile[];
|
||||
}
|
||||
|
||||
export interface GitDiffFile {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
|
||||
Reference in New Issue
Block a user