Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312727ee73 | ||
|
|
5752243e6e | ||
|
|
0bbd5ce1e8 | ||
|
|
50d9232084 | ||
|
|
357a0b7c5f | ||
|
|
ab54b9b6b2 | ||
|
|
fe392d38bf | ||
|
|
628e2f7c0b | ||
|
|
1c7c53ddf9 | ||
|
|
900159a3a9 | ||
|
|
d800417d6d | ||
|
|
405f302db9 | ||
|
|
2cc1d34fc3 | ||
|
|
089aae5f5b | ||
|
|
841d1a41b7 | ||
|
|
d57b574fe1 | ||
|
|
ec1f10d535 |
@@ -23,7 +23,12 @@
|
|||||||
"Bash(npx vite *)",
|
"Bash(npx vite *)",
|
||||||
"Bash(cargo tree *)",
|
"Bash(cargo tree *)",
|
||||||
"Bash(jobs)",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tauri-git-lite",
|
"name": "tauri-git-lite",
|
||||||
"version": "0.0.2",
|
"version": "2026.7.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tauri-git-lite",
|
"name": "tauri-git-lite",
|
||||||
"version": "0.0.2",
|
"version": "2026.7.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "git-lite",
|
"name": "git-lite",
|
||||||
"version": "0.0.2",
|
"version": "2026.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -18,3 +18,24 @@ tauri-build = { version = "2", features = [] }
|
|||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2"
|
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
|
||||||
|
|||||||
+422
-22
@@ -12,6 +12,12 @@ use std::{
|
|||||||
time::Duration,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum FileStatusKind {
|
pub enum FileStatusKind {
|
||||||
@@ -143,6 +149,13 @@ const EMPTY_TREE_HASH: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|||||||
const SEARCH_CANCELLED_MESSAGE: &str = "Suche wurde abgebrochen.";
|
const SEARCH_CANCELLED_MESSAGE: &str = "Suche wurde abgebrochen.";
|
||||||
static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
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)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct SearchCancellationState {
|
pub struct SearchCancellationState {
|
||||||
cancelled: Arc<Mutex<BTreeSet<String>>>,
|
cancelled: Arc<Mutex<BTreeSet<String>>>,
|
||||||
@@ -195,6 +208,41 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
|||||||
status_for_repo(&repo)
|
status_for_repo(&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]
|
#[tauri::command]
|
||||||
pub fn get_status(path: String) -> Result<GitStatus, String> {
|
pub fn get_status(path: String) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -204,8 +252,12 @@ pub fn get_status(path: String) -> Result<GitStatus, String> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
|
branches_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||||
let output = run_git(
|
let output = run_git(
|
||||||
&repo,
|
repo,
|
||||||
[
|
[
|
||||||
"for-each-ref",
|
"for-each-ref",
|
||||||
"--format=%(refname)\t%(HEAD)",
|
"--format=%(refname)\t%(HEAD)",
|
||||||
@@ -268,6 +320,27 @@ pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String
|
|||||||
status_for_repo(&repo)
|
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]
|
#[tauri::command]
|
||||||
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -312,6 +385,59 @@ pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<G
|
|||||||
status_for_repo(&repo)
|
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]
|
#[tauri::command]
|
||||||
pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -342,7 +468,7 @@ pub fn pull(
|
|||||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||||
run_git_authenticated_output(&repo, pull_args, u, p)?
|
run_git_authenticated_output(&repo, pull_args, u, p)?
|
||||||
}
|
}
|
||||||
_ => Command::new("git")
|
_ => git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo)
|
.arg(&repo)
|
||||||
.args(pull_args)
|
.args(pull_args)
|
||||||
@@ -429,7 +555,7 @@ pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
|
fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
|
||||||
let out = Command::new("git")
|
let out = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
.args(["remote", "get-url", remote])
|
.args(["remote", "get-url", remote])
|
||||||
@@ -452,7 +578,7 @@ fn upstream_remote_name(repo: &Path) -> Option<String> {
|
|||||||
if branch.is_empty() || branch == "HEAD" {
|
if branch.is_empty() || branch == "HEAD" {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let out = Command::new("git")
|
let out = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
.args(["config", &format!("branch.{branch}.remote")])
|
.args(["config", &format!("branch.{branch}.remote")])
|
||||||
@@ -530,7 +656,7 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
|||||||
return Err("Branch-Name darf nicht leer sein.".to_string());
|
return Err("Branch-Name darf nicht leer sein.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = Command::new("git")
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo)
|
.arg(&repo)
|
||||||
.args(["merge", "--no-edit", branch])
|
.args(["merge", "--no-edit", branch])
|
||||||
@@ -565,23 +691,34 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
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());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let limit = limit.unwrap_or(100).clamp(1, 500).to_string();
|
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(
|
let output = run_git(
|
||||||
&repo,
|
repo,
|
||||||
[
|
[
|
||||||
"log",
|
"log",
|
||||||
"--decorate=short",
|
"--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",
|
"-n",
|
||||||
limit.as_str(),
|
limit.as_str(),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
parse_commit_log(&repo, &output)
|
parse_commit_log_inline(&output)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1106,7 +1243,7 @@ fn is_binary_bytes(bytes: &[u8]) -> bool {
|
|||||||
|
|
||||||
fn index_stage_size(repo: &Path, stage: u8, file: &str) -> Option<u64> {
|
fn index_stage_size(repo: &Path, stage: u8, file: &str) -> Option<u64> {
|
||||||
let spec = format!(":{stage}:{file}");
|
let spec = format!(":{stage}:{file}");
|
||||||
let output = Command::new("git")
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
.args(["cat-file", "-s", spec.as_str()])
|
.args(["cat-file", "-s", spec.as_str()])
|
||||||
@@ -1139,7 +1276,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> {
|
fn read_index_stage(repo: &Path, stage: u8, file: &str) -> Option<String> {
|
||||||
let spec = format!(":{stage}:{file}");
|
let spec = format!(":{stage}:{file}");
|
||||||
let output = Command::new("git")
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
.args(["show", spec.as_str()])
|
.args(["show", spec.as_str()])
|
||||||
@@ -1221,6 +1358,13 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
|||||||
|
|
||||||
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
||||||
let status = status_for_repo(repo)?;
|
let status = status_for_repo(repo)?;
|
||||||
|
repository_files_with_status(repo, &status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repository_files_with_status(
|
||||||
|
repo: &Path,
|
||||||
|
status: &GitStatus,
|
||||||
|
) -> Result<Vec<GitRepositoryFile>, String> {
|
||||||
let mut files = BTreeMap::<String, GitRepositoryFile>::new();
|
let mut files = BTreeMap::<String, GitRepositoryFile>::new();
|
||||||
|
|
||||||
let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?;
|
let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?;
|
||||||
@@ -1325,6 +1469,10 @@ fn search_candidate_commits(
|
|||||||
OsString::from("--all"),
|
OsString::from("--all"),
|
||||||
OsString::from("--reverse"),
|
OsString::from("--reverse"),
|
||||||
OsString::from("--format=%H"),
|
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 {
|
if !case_sensitive {
|
||||||
args.push(OsString::from("-i"));
|
args.push(OsString::from("-i"));
|
||||||
@@ -1369,7 +1517,7 @@ fn max_parent_match_count(
|
|||||||
|
|
||||||
fn read_text_blob(repo: &Path, commit: &str, file: &str) -> Result<Option<String>, String> {
|
fn read_text_blob(repo: &Path, commit: &str, file: &str) -> Result<Option<String>, String> {
|
||||||
let spec = format!("{commit}:{file}");
|
let spec = format!("{commit}:{file}");
|
||||||
let output = Command::new("git")
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
.args(["show", spec.as_str()])
|
.args(["show", spec.as_str()])
|
||||||
@@ -1434,7 +1582,7 @@ fn first_added_match_line(
|
|||||||
check_search_cancelled(cancellation)?;
|
check_search_cancelled(cancellation)?;
|
||||||
let output = run_git_with_paths_cancellable(
|
let output = run_git_with_paths_cancellable(
|
||||||
repo,
|
repo,
|
||||||
&["diff", "--unified=0", parent, commit],
|
&["diff", "--no-textconv", "--unified=0", parent, commit],
|
||||||
&[file.to_string()],
|
&[file.to_string()],
|
||||||
cancellation,
|
cancellation,
|
||||||
"Git-Diff fuer Suchtreffer fehlgeschlagen",
|
"Git-Diff fuer Suchtreffer fehlgeschlagen",
|
||||||
@@ -1555,6 +1703,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> {
|
fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String> {
|
||||||
const FIELD_SEPARATOR: char = '\x1f';
|
const FIELD_SEPARATOR: char = '\x1f';
|
||||||
const RECORD_SEPARATOR: char = '\x1e';
|
const RECORD_SEPARATOR: char = '\x1e';
|
||||||
@@ -1822,8 +2035,40 @@ fn local_branch_name_for_remote(remote_branch: &str) -> Option<&str> {
|
|||||||
.filter(|local| !local.is_empty())
|
.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 ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
|
fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
|
||||||
let output = Command::new("git")
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
.args(["show-ref", "--verify", "--quiet", ref_name])
|
.args(["show-ref", "--verify", "--quiet", ref_name])
|
||||||
@@ -1992,6 +2237,45 @@ fn validate_files(files: &[String]) -> Result<(), String> {
|
|||||||
Ok(())
|
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)]
|
#[cfg(unix)]
|
||||||
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -2048,7 +2332,7 @@ where
|
|||||||
{
|
{
|
||||||
let askpass = write_askpass_script()?;
|
let askpass = write_askpass_script()?;
|
||||||
|
|
||||||
let result = Command::new("git")
|
let result = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
.args(args)
|
.args(args)
|
||||||
@@ -2157,7 +2441,7 @@ where
|
|||||||
let stderr_file = std::fs::File::create(&stderr_path)
|
let stderr_file = std::fs::File::create(&stderr_path)
|
||||||
.map_err(|err| format!("Git-Fehlerdatei konnte nicht erstellt werden: {err}"))?;
|
.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("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
.args(args)
|
.args(args)
|
||||||
@@ -2218,7 +2502,7 @@ where
|
|||||||
I: IntoIterator<Item = S>,
|
I: IntoIterator<Item = S>,
|
||||||
S: AsRef<OsStr>,
|
S: AsRef<OsStr>,
|
||||||
{
|
{
|
||||||
let output = Command::new("git")
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(path)
|
.arg(path)
|
||||||
.args(args)
|
.args(args)
|
||||||
@@ -2442,7 +2726,7 @@ mod tests {
|
|||||||
I: IntoIterator<Item = S>,
|
I: IntoIterator<Item = S>,
|
||||||
S: AsRef<OsStr>,
|
S: AsRef<OsStr>,
|
||||||
{
|
{
|
||||||
let output = Command::new("git")
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
.args(args)
|
.args(args)
|
||||||
@@ -2463,7 +2747,7 @@ mod tests {
|
|||||||
I: IntoIterator<Item = S>,
|
I: IntoIterator<Item = S>,
|
||||||
S: AsRef<OsStr>,
|
S: AsRef<OsStr>,
|
||||||
{
|
{
|
||||||
let output = Command::new("git")
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
.args(args)
|
.args(args)
|
||||||
@@ -2971,7 +3255,7 @@ mod tests {
|
|||||||
run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]);
|
run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]);
|
||||||
|
|
||||||
// The merge is expected to fail with a conflict, so run git directly.
|
// The merge is expected to fail with a conflict, so run git directly.
|
||||||
let _ = Command::new("git")
|
let _ = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo.path)
|
.arg(&repo.path)
|
||||||
.args(["merge", "--no-edit", "feature"])
|
.args(["merge", "--no-edit", "feature"])
|
||||||
@@ -3024,7 +3308,7 @@ mod tests {
|
|||||||
fs::write(repo.path.join("file.txt"), "ours change\n").expect("main change");
|
fs::write(repo.path.join("file.txt"), "ours change\n").expect("main change");
|
||||||
run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]);
|
run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]);
|
||||||
|
|
||||||
let _ = Command::new("git")
|
let _ = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo.path)
|
.arg(&repo.path)
|
||||||
.args(["merge", "--no-edit", "feature"])
|
.args(["merge", "--no-edit", "feature"])
|
||||||
@@ -3065,7 +3349,7 @@ mod tests {
|
|||||||
fs::write(repo.path.join("img.bin"), [0u8, 7, 7]).expect("main binary");
|
fs::write(repo.path.join("img.bin"), [0u8, 7, 7]).expect("main binary");
|
||||||
run_git_test(&repo.path, ["commit", "-q", "-am", "main bin"]);
|
run_git_test(&repo.path, ["commit", "-q", "-am", "main bin"]);
|
||||||
|
|
||||||
let _ = Command::new("git")
|
let _ = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo.path)
|
.arg(&repo.path)
|
||||||
.args(["merge", "--no-edit", "feature"])
|
.args(["merge", "--no-edit", "feature"])
|
||||||
@@ -3169,6 +3453,122 @@ mod tests {
|
|||||||
assert_eq!(plan, CheckoutPlan::Local("feature/demo".to_string()));
|
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 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]
|
#[test]
|
||||||
fn restore_to_commit_resets_branch_to_selected_commit() {
|
fn restore_to_commit_resets_branch_to_selected_commit() {
|
||||||
let repo = init_temp_repo("restore_to_commit");
|
let repo = init_temp_repo("restore_to_commit");
|
||||||
|
|||||||
+11
-7
@@ -3,13 +3,13 @@
|
|||||||
mod git;
|
mod git;
|
||||||
|
|
||||||
use git::{
|
use git::{
|
||||||
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head,
|
apply_file_patch, cancel_code_search, checkout_branch, commit, compare_commits,
|
||||||
compare_file_to_parent, cred_delete, cred_load, cred_save, diff_file_against_working_tree,
|
compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save,
|
||||||
get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches,
|
||||||
list_repository_files, merge_branch, open_repository, pull, push, read_conflict,
|
list_commits, list_file_history, list_repository_files, merge_branch, open_repository,
|
||||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
open_repository_bundle, pull, push, read_conflict, resolve_conflict, resolve_conflict_side,
|
||||||
restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
|
||||||
SearchCancellationState,
|
stage_files, unstage_files, SearchCancellationState,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -22,9 +22,12 @@ fn main() {
|
|||||||
get_status,
|
get_status,
|
||||||
list_branches,
|
list_branches,
|
||||||
checkout_branch,
|
checkout_branch,
|
||||||
|
create_branch,
|
||||||
stage_files,
|
stage_files,
|
||||||
unstage_files,
|
unstage_files,
|
||||||
restore_files,
|
restore_files,
|
||||||
|
get_file_patch,
|
||||||
|
apply_file_patch,
|
||||||
commit,
|
commit,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
@@ -33,6 +36,7 @@ fn main() {
|
|||||||
restore_file_from_commit,
|
restore_file_from_commit,
|
||||||
merge_branch,
|
merge_branch,
|
||||||
list_repository_files,
|
list_repository_files,
|
||||||
|
open_repository_bundle,
|
||||||
list_file_history,
|
list_file_history,
|
||||||
compare_commits,
|
compare_commits,
|
||||||
compare_file_to_head,
|
compare_file_to_head,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "GitLite",
|
"productName": "GitLite",
|
||||||
"version": "0.0.2",
|
"version": "2026.7.1",
|
||||||
"identifier": "com.git-lite",
|
"identifier": "com.git-lite",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": ["nsis"],
|
||||||
"icon": ["icons/icon.ico"],
|
"icon": ["icons/icon.ico"],
|
||||||
"createUpdaterArtifacts": true,
|
"createUpdaterArtifacts": true,
|
||||||
"windows": {
|
"windows": {
|
||||||
|
|||||||
+261
-46
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount, tick } from "svelte";
|
||||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||||
import { AlertCircle, Check, FolderOpen, GitBranch, GitMerge, LoaderCircle } from "@lucide/svelte";
|
import { AlertCircle, Check, FolderOpen, GitBranch, GitMerge, LoaderCircle } from "@lucide/svelte";
|
||||||
@@ -8,12 +8,15 @@
|
|||||||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||||
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
||||||
import CompareDialog from "./lib/components/CompareDialog.svelte";
|
import CompareDialog from "./lib/components/CompareDialog.svelte";
|
||||||
import ComparePanel from "./lib/components/ComparePanel.svelte";
|
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
|
||||||
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
||||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||||
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
||||||
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
||||||
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
||||||
|
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
|
||||||
|
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
||||||
|
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
|
||||||
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
||||||
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
||||||
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
||||||
@@ -23,6 +26,8 @@
|
|||||||
commit,
|
commit,
|
||||||
compareCommits,
|
compareCommits,
|
||||||
cancelCodeSearch,
|
cancelCodeSearch,
|
||||||
|
applyFilePatch,
|
||||||
|
createBranch,
|
||||||
diffFileAgainstWorkingTree,
|
diffFileAgainstWorkingTree,
|
||||||
compareFileToParent,
|
compareFileToParent,
|
||||||
getStatus,
|
getStatus,
|
||||||
@@ -31,13 +36,14 @@
|
|||||||
listFileHistory,
|
listFileHistory,
|
||||||
listRepositoryFiles,
|
listRepositoryFiles,
|
||||||
mergeBranch,
|
mergeBranch,
|
||||||
openRepository,
|
openRepositoryBundle,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
getRemoteUrl,
|
getRemoteUrl,
|
||||||
credLoad,
|
credLoad,
|
||||||
credSave,
|
credSave,
|
||||||
credDelete,
|
credDelete,
|
||||||
|
getFilePatch,
|
||||||
readConflict,
|
readConflict,
|
||||||
resolveConflict,
|
resolveConflict,
|
||||||
resolveConflictSide,
|
resolveConflictSide,
|
||||||
@@ -62,6 +68,7 @@
|
|||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
PatchApplyAction,
|
||||||
PreparedResolution,
|
PreparedResolution,
|
||||||
StoredCredential,
|
StoredCredential,
|
||||||
} from "./lib/types";
|
} from "./lib/types";
|
||||||
@@ -94,10 +101,20 @@
|
|||||||
let compareFrom = "";
|
let compareFrom = "";
|
||||||
let compareTo = "";
|
let compareTo = "";
|
||||||
let comparison: GitCommitComparison | null = null;
|
let comparison: GitCommitComparison | null = null;
|
||||||
|
let newBranchCommit: GitCommit | null = null;
|
||||||
|
let compareSelectOpen = false;
|
||||||
let compareDialogOpen = false;
|
let compareDialogOpen = false;
|
||||||
let selectedDiffPath = "";
|
let selectedDiffPath = "";
|
||||||
|
let diffHighlightQuery = "";
|
||||||
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
|
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
|
||||||
|
let linePatchOpen = false;
|
||||||
|
let linePatchFile: GitFileStatus | null = null;
|
||||||
|
let linePatchStaged = false;
|
||||||
|
let linePatchText = "";
|
||||||
|
let linePatchLoading = false;
|
||||||
|
let linePatchError = "";
|
||||||
let globalSearchOpen = false;
|
let globalSearchOpen = false;
|
||||||
|
let lastSearchQuery = "";
|
||||||
let globalSearchResults: GitSearchHit[] = [];
|
let globalSearchResults: GitSearchHit[] = [];
|
||||||
let globalSearchBusy = false;
|
let globalSearchBusy = false;
|
||||||
let globalSearchError = "";
|
let globalSearchError = "";
|
||||||
@@ -130,6 +147,8 @@
|
|||||||
|
|
||||||
$: isBusy = operation.length > 0;
|
$: isBusy = operation.length > 0;
|
||||||
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
||||||
|
$: openingRepo = operation === "Opening repository";
|
||||||
|
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
|
||||||
$: changedFiles = status?.files ?? [];
|
$: changedFiles = status?.files ?? [];
|
||||||
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
|
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
|
||||||
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
|
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
|
||||||
@@ -161,15 +180,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function autoRefreshTick() {
|
async function autoRefreshTick() {
|
||||||
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || globalSearchOpen) return;
|
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
||||||
autoRefreshInFlight = true;
|
autoRefreshInFlight = true;
|
||||||
try {
|
try {
|
||||||
|
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
|
||||||
const nextStatus = await getStatus(activeRepoPath);
|
const nextStatus = await getStatus(activeRepoPath);
|
||||||
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
|
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
|
||||||
applyStatus(nextStatus);
|
applyStatus(nextStatus);
|
||||||
await refreshBranchList(activeRepoPath);
|
// Something changed — reload branches, commits and files in one bundled call.
|
||||||
await refreshCommitHistory(activeRepoPath);
|
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
||||||
await refreshExplorerFiles(activeRepoPath);
|
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||||
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
await refreshFileHistory(activeRepoPath);
|
await refreshFileHistory(activeRepoPath);
|
||||||
} catch { /* ignore transient errors */ } finally {
|
} catch { /* ignore transient errors */ } finally {
|
||||||
autoRefreshInFlight = false;
|
autoRefreshInFlight = false;
|
||||||
@@ -315,12 +337,12 @@
|
|||||||
|
|
||||||
// ── Refresh helpers ────────────────────────────────────────────────────────
|
// ── Refresh helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function refreshBranchList(path = activeRepoPath) {
|
async function refreshBranchList(path = activeRepoPath, prefetched?: GitBranchInfo[]) {
|
||||||
branches = await listBranches(path);
|
branches = prefetched ?? (await listBranches(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCommitHistory(path = activeRepoPath) {
|
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
|
||||||
commits = await listCommits(path, 100);
|
commits = prefetched ?? (await listCommits(path, 100));
|
||||||
const hashes = new Set(commits.map((c) => c.hash));
|
const hashes = new Set(commits.map((c) => c.hash));
|
||||||
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
|
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
|
||||||
if (compareTo && !hashes.has(compareTo)) compareTo = "";
|
if (compareTo && !hashes.has(compareTo)) compareTo = "";
|
||||||
@@ -332,8 +354,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshExplorerFiles(path = activeRepoPath) {
|
async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) {
|
||||||
repoFiles = await listRepositoryFiles(path);
|
repoFiles = prefetched ?? (await listRepositoryFiles(path));
|
||||||
const folderPaths = allExplorerFolderPaths(repoFiles);
|
const folderPaths = allExplorerFolderPaths(repoFiles);
|
||||||
expandedExplorerPaths = new Set([...expandedExplorerPaths].filter((folder) => folderPaths.has(folder)));
|
expandedExplorerPaths = new Set([...expandedExplorerPaths].filter((folder) => folderPaths.has(folder)));
|
||||||
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
|
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
|
||||||
@@ -355,20 +377,28 @@
|
|||||||
repoPath = path;
|
repoPath = path;
|
||||||
|
|
||||||
await runOperation("Opening repository", async () => {
|
await runOperation("Opening repository", async () => {
|
||||||
const nextStatus = await openRepository(path);
|
// Paint the loading overlay before the (potentially slow) git enumeration
|
||||||
applyStatus(nextStatus);
|
// starts — otherwise the first paint is deferred until the bundle resolves
|
||||||
|
// and the overlay appears to "come late".
|
||||||
|
await tick();
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
// Single backend round-trip: resolves the repo and reads status, branches,
|
||||||
|
// commits and files in one pass instead of four sequential git calls.
|
||||||
|
const bundle = await openRepositoryBundle(path, 100);
|
||||||
|
applyStatus(bundle.status);
|
||||||
branches = []; commits = []; repoFiles = [];
|
branches = []; commits = []; repoFiles = [];
|
||||||
selectedExplorerPath = ""; selectedExplorerKind = "file";
|
selectedExplorerPath = ""; selectedExplorerKind = "file";
|
||||||
expandedExplorerPaths = new Set(); expandedCommitHashes = new Set();
|
expandedExplorerPaths = new Set(); expandedCommitHashes = new Set();
|
||||||
fileHistory = []; compareFrom = ""; compareTo = "";
|
fileHistory = []; compareFrom = ""; compareTo = "";
|
||||||
comparison = null; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null;
|
comparison = null; compareSelectOpen = false; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null;
|
||||||
|
newBranchCommit = null;
|
||||||
if (globalSearchBusy) void cancelGlobalSearch();
|
if (globalSearchBusy) void cancelGlobalSearch();
|
||||||
globalSearchResults = []; globalSearchOpen = false; globalSearchError = "";
|
globalSearchResults = []; globalSearchOpen = false; globalSearchError = "";
|
||||||
resolveDialogOpen = false; conflictTarget = ""; conflict = null;
|
resolveDialogOpen = false; conflictTarget = ""; conflict = null;
|
||||||
preparedResolutions = {};
|
preparedResolutions = {};
|
||||||
await refreshBranchList(activeRepoPath);
|
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||||
await refreshCommitHistory(activeRepoPath);
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath);
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +441,37 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createNewBranch(branchName: string) {
|
||||||
|
const name = branchName.trim();
|
||||||
|
if (!activeRepoPath || !name) return;
|
||||||
|
await runOperation(`Creating ${name}`, async () => {
|
||||||
|
applyStatus(await createBranch(activeRepoPath, name));
|
||||||
|
await refreshBranchList(activeRepoPath);
|
||||||
|
await refreshCommitHistory(activeRepoPath);
|
||||||
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewBranchDialog(commit: GitCommit) {
|
||||||
|
if (!activeRepoPath || isBusy) return;
|
||||||
|
newBranchCommit = commit;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBranchFromCommit(branchName: string) {
|
||||||
|
const target = newBranchCommit;
|
||||||
|
const name = branchName.trim();
|
||||||
|
if (!activeRepoPath || !target || !name) return;
|
||||||
|
await runOperation(`Creating ${name}`, async () => {
|
||||||
|
applyStatus(await createBranch(activeRepoPath, name, target.hash));
|
||||||
|
newBranchCommit = null;
|
||||||
|
await refreshBranchList(activeRepoPath);
|
||||||
|
await refreshCommitHistory(activeRepoPath);
|
||||||
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function merge(branch: GitBranchInfo) {
|
async function merge(branch: GitBranchInfo) {
|
||||||
if (!activeRepoPath || branch.current) return;
|
if (!activeRepoPath || branch.current) return;
|
||||||
await runOperation(`Merging ${branch.name}`, async () => {
|
await runOperation(`Merging ${branch.name}`, async () => {
|
||||||
@@ -624,6 +685,77 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openLinePatch(file: GitFileStatus, staged: boolean) {
|
||||||
|
if (!activeRepoPath) return;
|
||||||
|
linePatchOpen = true;
|
||||||
|
linePatchFile = file;
|
||||||
|
linePatchStaged = staged;
|
||||||
|
linePatchText = "";
|
||||||
|
linePatchError = "";
|
||||||
|
linePatchLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
linePatchText = await getFilePatch(activeRepoPath, file.path, staged);
|
||||||
|
} catch (error) {
|
||||||
|
linePatchError = errorToMessage(error);
|
||||||
|
errorMessage = linePatchError;
|
||||||
|
} finally {
|
||||||
|
linePatchLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLinePatch() {
|
||||||
|
if (!activeRepoPath || !linePatchFile) return;
|
||||||
|
await openLinePatch(linePatchFile, linePatchStaged);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLinePatch() {
|
||||||
|
if (isBusy) return;
|
||||||
|
linePatchOpen = false;
|
||||||
|
linePatchFile = null;
|
||||||
|
linePatchText = "";
|
||||||
|
linePatchError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus): string {
|
||||||
|
switch (action) {
|
||||||
|
case "stage":
|
||||||
|
return `Staging hunk in ${file.path}`;
|
||||||
|
case "unstage":
|
||||||
|
return `Unstaging hunk in ${file.path}`;
|
||||||
|
default:
|
||||||
|
return `Discarding hunk in ${file.path}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLinePatch(action: PatchApplyAction, patch: string) {
|
||||||
|
if (!activeRepoPath || !linePatchFile || isBusy) return;
|
||||||
|
const file = linePatchFile;
|
||||||
|
operation = patchOperationLabel(action, file);
|
||||||
|
errorMessage = "";
|
||||||
|
linePatchError = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
applyStatus(await applyFilePatch(activeRepoPath, file.path, patch, action));
|
||||||
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
|
||||||
|
const updatedPatch = await getFilePatch(activeRepoPath, file.path, linePatchStaged);
|
||||||
|
if (updatedPatch.trim()) {
|
||||||
|
linePatchText = updatedPatch;
|
||||||
|
} else {
|
||||||
|
linePatchOpen = false;
|
||||||
|
linePatchFile = null;
|
||||||
|
linePatchText = "";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
linePatchError = errorToMessage(error);
|
||||||
|
errorMessage = linePatchError;
|
||||||
|
} finally {
|
||||||
|
operation = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function stageAllFiles() {
|
async function stageAllFiles() {
|
||||||
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
|
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
|
||||||
if (paths.length === 0) return;
|
if (paths.length === 0) return;
|
||||||
@@ -717,6 +849,17 @@
|
|||||||
expandedExplorerPaths = new Set();
|
expandedExplorerPaths = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function explorerParentFolders(path: string): string[] {
|
||||||
|
const parts = normalizeExplorerPath(path).split("/").filter(Boolean);
|
||||||
|
const folders: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
for (let index = 0; index < parts.length - 1; index++) {
|
||||||
|
current = current ? `${current}/${parts[index]}` : parts[index];
|
||||||
|
folders.push(current);
|
||||||
|
}
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
async function selectExplorerNode(node: ExplorerNode) {
|
async function selectExplorerNode(node: ExplorerNode) {
|
||||||
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
||||||
selectedExplorerPath = node.path;
|
selectedExplorerPath = node.path;
|
||||||
@@ -726,6 +869,17 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectFileFromSearch(file: GitRepositoryFile) {
|
||||||
|
if (!activeRepoPath) return;
|
||||||
|
selectedExplorerPath = file.path;
|
||||||
|
selectedExplorerKind = "file";
|
||||||
|
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||||||
|
|
||||||
|
await runOperation(`Loading ${file.path} history`, async () => {
|
||||||
|
await refreshFileHistory(activeRepoPath, file.path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
||||||
if (!activeRepoPath || !selectedExplorerPath) return;
|
if (!activeRepoPath || !selectedExplorerPath) return;
|
||||||
const kind = selectedExplorerKind === "folder" ? "folder" : "file";
|
const kind = selectedExplorerKind === "folder" ? "folder" : "file";
|
||||||
@@ -740,13 +894,20 @@
|
|||||||
|
|
||||||
// ── Compare ────────────────────────────────────────────────────────────────
|
// ── Compare ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openCompareSelect() {
|
||||||
|
if (!hasRepository) return;
|
||||||
|
compareSelectOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function compareSelectedCommits() {
|
async function compareSelectedCommits() {
|
||||||
if (!canCompare) return;
|
if (!canCompare) return;
|
||||||
await runOperation("Comparing commits", async () => {
|
await runOperation("Comparing commits", async () => {
|
||||||
const result = await compareCommits(activeRepoPath, compareFrom, compareTo);
|
const result = await compareCommits(activeRepoPath, compareFrom, compareTo);
|
||||||
comparison = result;
|
comparison = result;
|
||||||
selectedDiffPath = result.files[0]?.path ?? "";
|
selectedDiffPath = result.files[0]?.path ?? "";
|
||||||
|
diffHighlightQuery = "";
|
||||||
pendingRestoreFile = null;
|
pendingRestoreFile = null;
|
||||||
|
compareSelectOpen = false;
|
||||||
compareDialogOpen = true;
|
compareDialogOpen = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -757,13 +918,22 @@
|
|||||||
const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath);
|
const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath);
|
||||||
comparison = result;
|
comparison = result;
|
||||||
selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath;
|
selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath;
|
||||||
|
diffHighlightQuery = "";
|
||||||
pendingRestoreFile = null;
|
pendingRestoreFile = null;
|
||||||
compareDialogOpen = true;
|
compareDialogOpen = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCompareDialog() {
|
async function diffSearchHit(hit: GitSearchHit) {
|
||||||
if (comparison) compareDialogOpen = true;
|
if (!activeRepoPath) return;
|
||||||
|
await runOperation(`Diffing ${hit.file}`, async () => {
|
||||||
|
const result = await diffFileAgainstWorkingTree(activeRepoPath, hit.commit_hash, hit.file);
|
||||||
|
comparison = result;
|
||||||
|
selectedDiffPath = result.files[0]?.path ?? hit.file;
|
||||||
|
diffHighlightQuery = lastSearchQuery;
|
||||||
|
pendingRestoreFile = null;
|
||||||
|
compareDialogOpen = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCompareDialog() {
|
function closeCompareDialog() {
|
||||||
@@ -785,6 +955,7 @@
|
|||||||
if (!activeRepoPath || globalSearchBusy) return;
|
if (!activeRepoPath || globalSearchBusy) return;
|
||||||
const searchId = `search-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const searchId = `search-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
globalSearchId = searchId;
|
globalSearchId = searchId;
|
||||||
|
lastSearchQuery = query;
|
||||||
globalSearchBusy = true;
|
globalSearchBusy = true;
|
||||||
globalSearchError = "";
|
globalSearchError = "";
|
||||||
globalSearchResults = [];
|
globalSearchResults = [];
|
||||||
@@ -892,6 +1063,8 @@
|
|||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
|
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
|
||||||
|
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
|
||||||
|
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
|
||||||
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
|
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -917,6 +1090,7 @@
|
|||||||
onPush={pushRepo}
|
onPush={pushRepo}
|
||||||
onRefresh={refreshRepo}
|
onRefresh={refreshRepo}
|
||||||
onSearch={() => { globalSearchOpen = true; }}
|
onSearch={() => { globalSearchOpen = true; }}
|
||||||
|
onCompare={openCompareSelect}
|
||||||
onToggleAutoRefresh={toggleAutoRefresh}
|
onToggleAutoRefresh={toggleAutoRefresh}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -992,6 +1166,7 @@
|
|||||||
{isBusy}
|
{isBusy}
|
||||||
onCheckout={checkout}
|
onCheckout={checkout}
|
||||||
onMerge={merge}
|
onMerge={merge}
|
||||||
|
onCreateBranch={createNewBranch}
|
||||||
/>
|
/>
|
||||||
<ExplorerPanel
|
<ExplorerPanel
|
||||||
{repoFiles}
|
{repoFiles}
|
||||||
@@ -1007,7 +1182,7 @@
|
|||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Center: summary + status + commit + compare -->
|
<!-- Center: summary + status + commit -->
|
||||||
<section class="main-panel" aria-label="Repository status">
|
<section class="main-panel" aria-label="Repository status">
|
||||||
<div class="repo-summary">
|
<div class="repo-summary">
|
||||||
<div class="repo-meta">
|
<div class="repo-meta">
|
||||||
@@ -1035,6 +1210,7 @@
|
|||||||
onStage={stageFile}
|
onStage={stageFile}
|
||||||
onUnstage={unstageFile}
|
onUnstage={unstageFile}
|
||||||
onDiscard={discardFile}
|
onDiscard={discardFile}
|
||||||
|
onPatch={openLinePatch}
|
||||||
onStageAll={stageAllFiles}
|
onStageAll={stageAllFiles}
|
||||||
onUnstageAll={unstageAllFiles}
|
onUnstageAll={unstageAllFiles}
|
||||||
/>
|
/>
|
||||||
@@ -1050,21 +1226,6 @@
|
|||||||
onCommitMessageChange={(msg) => { commitMessage = msg; }}
|
onCommitMessageChange={(msg) => { commitMessage = msg; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComparePanel
|
|
||||||
{commits}
|
|
||||||
{hasRepository}
|
|
||||||
{isBusy}
|
|
||||||
{compareFrom}
|
|
||||||
{compareTo}
|
|
||||||
{canCompare}
|
|
||||||
{comparison}
|
|
||||||
{operation}
|
|
||||||
onCompareFromChange={(val) => { compareFrom = val; }}
|
|
||||||
onCompareToChange={(val) => { compareTo = val; }}
|
|
||||||
onCompare={compareSelectedCommits}
|
|
||||||
onOpenDialog={openCompareDialog}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Right sidebar: commit graph + file history -->
|
<!-- Right sidebar: commit graph + file history -->
|
||||||
@@ -1076,6 +1237,7 @@
|
|||||||
{expandedCommitHashes}
|
{expandedCommitHashes}
|
||||||
onRestoreCommit={restoreCommit}
|
onRestoreCommit={restoreCommit}
|
||||||
onPreviewCommitFile={previewCommitFileFromHistory}
|
onPreviewCommitFile={previewCommitFileFromHistory}
|
||||||
|
onCreateBranchFromCommit={openNewBranchDialog}
|
||||||
onToggleCommitFiles={(hash) => {
|
onToggleCommitFiles={(hash) => {
|
||||||
const next = new Set(expandedCommitHashes);
|
const next = new Set(expandedCommitHashes);
|
||||||
if (next.has(hash)) next.delete(hash); else next.add(hash);
|
if (next.has(hash)) next.delete(hash); else next.add(hash);
|
||||||
@@ -1109,16 +1271,17 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Compare diff dialog -->
|
{#if linePatchOpen && linePatchFile}
|
||||||
{#if compareDialogOpen && comparison}
|
<LinePatchDialog
|
||||||
<CompareDialog
|
file={linePatchFile}
|
||||||
{comparison}
|
staged={linePatchStaged}
|
||||||
{selectedDiffPath}
|
patch={linePatchText}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
|
isLoading={linePatchLoading}
|
||||||
onClose={closeCompareDialog}
|
error={linePatchError}
|
||||||
onRestore={restorePreviewedCommitFile}
|
onClose={closeLinePatch}
|
||||||
onSelectFile={selectDiffFile}
|
onRefresh={refreshLinePatch}
|
||||||
|
onApply={applyLinePatch}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -1129,9 +1292,56 @@
|
|||||||
isSearching={globalSearchBusy}
|
isSearching={globalSearchBusy}
|
||||||
error={globalSearchError}
|
error={globalSearchError}
|
||||||
results={globalSearchResults}
|
results={globalSearchResults}
|
||||||
|
files={repoFiles}
|
||||||
|
fileHistory={fileHistory}
|
||||||
|
selectedFilePath={selectedExplorerPath}
|
||||||
onClose={closeGlobalSearchDialog}
|
onClose={closeGlobalSearchDialog}
|
||||||
onSearch={runGlobalSearch}
|
onSearch={runGlobalSearch}
|
||||||
onCancel={cancelGlobalSearch}
|
onCancel={cancelGlobalSearch}
|
||||||
|
onDiff={diffSearchHit}
|
||||||
|
onSelectFile={selectFileFromSearch}
|
||||||
|
onFileHistoryDiff={diffSelectedFileFromCommit}
|
||||||
|
onFileHistoryRestore={restoreSelectedFileFromCommit}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Create a branch from a specific commit in the history -->
|
||||||
|
{#if newBranchCommit}
|
||||||
|
<NewBranchDialog
|
||||||
|
commit={newBranchCommit}
|
||||||
|
{isBusy}
|
||||||
|
onCreate={createBranchFromCommit}
|
||||||
|
onClose={() => { newBranchCommit = null; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Compare: pick the two commits to diff -->
|
||||||
|
{#if compareSelectOpen}
|
||||||
|
<CompareSelectDialog
|
||||||
|
{commits}
|
||||||
|
{compareFrom}
|
||||||
|
{compareTo}
|
||||||
|
{canCompare}
|
||||||
|
{isBusy}
|
||||||
|
{operation}
|
||||||
|
onCompareFromChange={(val) => { compareFrom = val; }}
|
||||||
|
onCompareToChange={(val) => { compareTo = val; }}
|
||||||
|
onCompare={compareSelectedCommits}
|
||||||
|
onClose={() => { compareSelectOpen = false; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
|
||||||
|
{#if compareDialogOpen && comparison}
|
||||||
|
<CompareDialog
|
||||||
|
{comparison}
|
||||||
|
{selectedDiffPath}
|
||||||
|
{isBusy}
|
||||||
|
highlightQuery={diffHighlightQuery}
|
||||||
|
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
|
||||||
|
onClose={closeCompareDialog}
|
||||||
|
onRestore={restorePreviewedCommitFile}
|
||||||
|
onSelectFile={selectDiffFile}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -1146,6 +1356,11 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Full-screen overlay while a repository is being opened -->
|
||||||
|
{#if openingRepo}
|
||||||
|
<RepoLoadingOverlay repoName={repoDisplayName} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Conflict resolve dialog -->
|
<!-- Conflict resolve dialog -->
|
||||||
{#if resolveDialogOpen}
|
{#if resolveDialogOpen}
|
||||||
<ResolveDialog
|
<ResolveDialog
|
||||||
|
|||||||
+492
-5
@@ -601,7 +601,7 @@
|
|||||||
|
|
||||||
.main-panel {
|
.main-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -664,7 +664,8 @@
|
|||||||
|
|
||||||
.top-section {
|
.top-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 320px;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(210px, auto);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
@@ -715,6 +716,88 @@
|
|||||||
|
|
||||||
/* --- Branch list --- */
|
/* --- Branch list --- */
|
||||||
|
|
||||||
|
.branch-head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.branch-create-toggle {
|
||||||
|
width: 26px;
|
||||||
|
min-width: 26px;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border-color: rgba(65,209,255,0.2);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
background: rgba(65,209,255,0.06);
|
||||||
|
}
|
||||||
|
.branch-create-toggle:hover:not(:disabled) {
|
||||||
|
border-color: rgba(65,209,255,0.45);
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(65,209,255,0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-list { gap: 8px; }
|
||||||
|
|
||||||
|
.branch-create-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 1px solid rgba(65,209,255,0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(90deg, rgba(65,209,255,0.08), rgba(100,108,255,0.07));
|
||||||
|
}
|
||||||
|
.branch-create-form svg { color: var(--color-accent); }
|
||||||
|
.branch-create-form input {
|
||||||
|
height: 30px;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.branch-create-action {
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.branch-create-action.confirm {
|
||||||
|
border-color: rgba(78,202,118,0.34);
|
||||||
|
color: #6ee090;
|
||||||
|
background: rgba(78,202,118,0.11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-group { display: grid; gap: 4px; min-width: 0; }
|
||||||
|
.branch-group + .branch-group { margin-top: 8px; }
|
||||||
|
|
||||||
|
.branch-group-toggle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
justify-content: stretch;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-color: transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.branch-group-toggle:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
.branch-group-toggle svg { color: var(--color-ink-faint); }
|
||||||
|
|
||||||
.branch-group-label {
|
.branch-group-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -738,6 +821,12 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branch-empty {
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.branch-row {
|
.branch-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -1005,6 +1094,60 @@
|
|||||||
width: min(1180px, calc(100vw - 32px));
|
width: min(1180px, calc(100vw - 32px));
|
||||||
height: min(840px, calc(100vh - 32px));
|
height: min(840px, calc(100vh - 32px));
|
||||||
}
|
}
|
||||||
|
.line-patch-dialog {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
width: min(1320px, calc(100vw - 32px));
|
||||||
|
height: min(860px, calc(100vh - 32px));
|
||||||
|
}
|
||||||
|
.compare-select-dialog {
|
||||||
|
display: block;
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.new-branch-dialog {
|
||||||
|
display: block;
|
||||||
|
width: min(520px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.new-branch-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.new-branch-target {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
.new-branch-summary {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.new-branch-field { display: grid; gap: 6px; }
|
||||||
|
.new-branch-field span {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.new-branch-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
||||||
.dialog-header > div:first-child { min-width: 0; }
|
.dialog-header > div:first-child { min-width: 0; }
|
||||||
@@ -1123,6 +1266,19 @@
|
|||||||
.split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; }
|
.split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; }
|
||||||
.split-cell.empty { background: rgba(0,0,0,0.06); }
|
.split-cell.empty { background: rgba(0,0,0,0.06); }
|
||||||
|
|
||||||
|
/* Search-hit highlight: amber, distinct from add (green) / del (red).
|
||||||
|
Higher specificity so it overrides the add/del backgrounds on a matched line. */
|
||||||
|
.split-diff .split-cell.match {
|
||||||
|
background: rgba(240,182,72,0.22);
|
||||||
|
color: #f3c969;
|
||||||
|
box-shadow: inset 2px 0 0 rgba(240,182,72,0.9);
|
||||||
|
}
|
||||||
|
.split-diff .split-num.match {
|
||||||
|
background: rgba(240,182,72,0.2);
|
||||||
|
color: rgba(240,182,72,0.9);
|
||||||
|
border-right-color: rgba(240,182,72,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.split-col-headers {
|
.split-col-headers {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -1160,13 +1316,146 @@
|
|||||||
|
|
||||||
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
|
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
|
||||||
|
|
||||||
|
.line-patch-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-patch-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: #0b0b14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-patch-hunk {
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-patch-hunk-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: rgba(20, 22, 36, 0.96);
|
||||||
|
}
|
||||||
|
.line-patch-hunk-head code {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.line-patch-hunk-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
.line-patch-hunk-button {
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.line-patch-hunk-button:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.line-patch-hunk-button.discard {
|
||||||
|
border-color: rgba(255, 90, 103, 0.7);
|
||||||
|
color: #ffccd1;
|
||||||
|
}
|
||||||
|
.line-patch-hunk-button.stage,
|
||||||
|
.line-patch-hunk-button.unstage {
|
||||||
|
border-color: rgba(78, 202, 118, 0.72);
|
||||||
|
color: #bff1ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-patch-lines {
|
||||||
|
min-width: max-content;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-patch-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px minmax(max-content, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 1px 10px 1px 28px;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
.line-patch-row.add {
|
||||||
|
background: rgba(78, 202, 118, 0.09);
|
||||||
|
color: #bff1ce;
|
||||||
|
}
|
||||||
|
.line-patch-row.delete {
|
||||||
|
background: rgba(255, 90, 103, 0.1);
|
||||||
|
color: #ffccd1;
|
||||||
|
}
|
||||||
|
.line-patch-row.meta {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
}
|
||||||
|
.line-patch-prefix {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.line-patch-row.add .line-patch-prefix { color: #4eca76; }
|
||||||
|
.line-patch-row.delete .line-patch-prefix { color: #ff6b7a; }
|
||||||
|
.line-patch-row code {
|
||||||
|
white-space: pre;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
.global-search-body {
|
.global-search-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-search-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: rgba(7, 8, 16, 0.34);
|
||||||
|
}
|
||||||
|
.global-search-tab {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.global-search-tab:hover:not(:disabled) {
|
||||||
|
border-color: rgba(65,209,255,0.22);
|
||||||
|
background: rgba(65,209,255,0.07);
|
||||||
|
}
|
||||||
|
.global-search-tab.active {
|
||||||
|
border-color: rgba(65,209,255,0.36);
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, rgba(100,108,255,0.26), rgba(65,209,255,0.1));
|
||||||
|
}
|
||||||
|
|
||||||
.global-search-form {
|
.global-search-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -1193,6 +1482,11 @@
|
|||||||
white-space: pre;
|
white-space: pre;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.global-search-query input {
|
||||||
|
height: 40px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.global-search-options {
|
.global-search-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1254,11 +1548,21 @@
|
|||||||
}
|
}
|
||||||
.search-hit-top {
|
.search-hit-top {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.search-hit-diff {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.search-hit-top .hash {
|
.search-hit-top .hash {
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border: 1px solid rgba(90,140,248,0.22);
|
border: 1px solid rgba(90,140,248,0.22);
|
||||||
@@ -1319,6 +1623,185 @@
|
|||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-search-form { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.file-search-results {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.file-search-split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.95fr) minmax(320px, 0.75fr);
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.file-search-column {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px;
|
||||||
|
border-right: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
.file-search-list { display: grid; gap: 7px; }
|
||||||
|
.file-search-hit {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.file-search-hit:hover:not(:disabled) {
|
||||||
|
border-color: rgba(65,209,255,0.3);
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
.file-search-hit.active {
|
||||||
|
border-color: rgba(90,140,248,0.42);
|
||||||
|
background: rgba(90,140,248,0.11);
|
||||||
|
}
|
||||||
|
.file-search-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.file-search-icon .language-icon {
|
||||||
|
display: block;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
.file-search-icon .language-icon path { fill: currentColor; }
|
||||||
|
.file-search-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.file-search-main strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-main span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-history {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(7, 8, 16, 0.18);
|
||||||
|
}
|
||||||
|
.file-search-history-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.file-search-history-head span {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.file-search-history-head strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-history-list {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.file-search-history-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
.file-search-history-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.file-search-history-main .hash {
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid rgba(90,140,248,0.22);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: rgba(90,140,248,0.13);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.file-search-history-main strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-history-main > span:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-history-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Credential dialog --- */
|
/* --- Credential dialog --- */
|
||||||
|
|
||||||
.cred-card {
|
.cred-card {
|
||||||
@@ -1860,7 +2343,6 @@
|
|||||||
|
|
||||||
@media (min-width: 1800px) {
|
@media (min-width: 1800px) {
|
||||||
.workspace { grid-template-columns: 320px minmax(0, 1fr) 680px; }
|
.workspace { grid-template-columns: 320px minmax(0, 1fr) 680px; }
|
||||||
.top-section { grid-template-columns: minmax(0, 1fr) 380px; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 1400px) {
|
||||||
@@ -1906,6 +2388,11 @@
|
|||||||
.dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
|
.dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
|
||||||
.compare-dialog .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
|
.compare-dialog .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
|
||||||
.global-search-options { grid-template-columns: 1fr; }
|
.global-search-options { grid-template-columns: 1fr; }
|
||||||
|
.file-search-split { grid-template-columns: 1fr; grid-template-rows: minmax(150px, 0.9fr) minmax(220px, 1fr); }
|
||||||
|
.file-search-column { border-right: none; border-bottom: 1px solid var(--color-border-subtle); }
|
||||||
|
.file-search-hit { grid-template-columns: auto minmax(0, 1fr); align-items: start; }
|
||||||
|
.file-search-hit .status-badge,
|
||||||
|
.file-search-action { grid-column: 2; justify-self: start; }
|
||||||
.dialog-files { border-right: none; border-bottom: 1px solid var(--color-border-subtle); }
|
.dialog-files { border-right: none; border-bottom: 1px solid var(--color-border-subtle); }
|
||||||
.branch-actions { flex-direction: row; justify-content: flex-start; }
|
.branch-actions { flex-direction: row; justify-content: flex-start; }
|
||||||
.tb-action-label { display: none; }
|
.tb-action-label { display: none; }
|
||||||
|
|||||||
+13
-1
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { Download, GitBranch, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
import { Download, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
||||||
|
|
||||||
export let branch: string = "";
|
export let branch: string = "";
|
||||||
export let ahead: number = 0;
|
export let ahead: number = 0;
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
export let onPush: () => void = () => {};
|
export let onPush: () => void = () => {};
|
||||||
export let onRefresh: () => void = () => {};
|
export let onRefresh: () => void = () => {};
|
||||||
export let onSearch: () => void = () => {};
|
export let onSearch: () => void = () => {};
|
||||||
|
export let onCompare: () => void = () => {};
|
||||||
export let onToggleAutoRefresh: () => void = () => {};
|
export let onToggleAutoRefresh: () => void = () => {};
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
@@ -92,6 +93,17 @@
|
|||||||
<span class="tb-action-label">Search</span>
|
<span class="tb-action-label">Search</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
class="tb-action"
|
class="tb-action"
|
||||||
onclick={onPull}
|
onclick={onPull}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { GitBranch, GitMerge } from "@lucide/svelte";
|
import { Check, ChevronDown, ChevronRight, GitBranch, GitMerge, Plus, X } from "@lucide/svelte";
|
||||||
import type { GitBranch as GitBranchInfo } from "../types";
|
import type { GitBranch as GitBranchInfo } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onCheckout: (branch: GitBranchInfo) => void;
|
onCheckout: (branch: GitBranchInfo) => void;
|
||||||
onMerge: (branch: GitBranchInfo) => void;
|
onMerge: (branch: GitBranchInfo) => void;
|
||||||
|
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -20,7 +21,42 @@
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
onCheckout = () => {},
|
onCheckout = () => {},
|
||||||
onMerge = () => {},
|
onMerge = () => {},
|
||||||
|
onCreateBranch = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let localOpen = $state(true);
|
||||||
|
let remoteOpen = $state(false);
|
||||||
|
let createOpen = $state(false);
|
||||||
|
let newBranchName = $state("");
|
||||||
|
let createInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||||
@@ -29,23 +65,75 @@
|
|||||||
<span class="eyebrow">Branches</span>
|
<span class="eyebrow">Branches</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
<span class="pill pill-count">{branches.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<p class="blank-state">Open a repository to list branches.</p>
|
<p class="blank-state">Open a repository to list branches.</p>
|
||||||
{:else if branches.length === 0}
|
{:else if branches.length === 0}
|
||||||
<p class="blank-state">No branches returned.</p>
|
<p class="blank-state">No branches returned.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-auto p-2 flex flex-col gap-0">
|
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||||
{#if localBranches.length > 0}
|
{#if createOpen}
|
||||||
<div class="branch-group-label">
|
<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}
|
||||||
|
|
||||||
|
<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>Local</span>
|
||||||
<span class="branch-group-count">{localBranches.length}</span>
|
<span class="branch-group-count">{localBranches.length}</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
|
{#if localOpen}
|
||||||
|
{#if localBranches.length === 0}
|
||||||
|
<div class="branch-empty">No local branches.</div>
|
||||||
|
{:else}
|
||||||
{#each localBranches as branch (branch.name)}
|
{#each localBranches as branch (branch.name)}
|
||||||
{#snippet branchCard()}
|
<article
|
||||||
<article class="branch-row" class:current={branch.current}>
|
class="branch-row"
|
||||||
|
class:current={branch.current}
|
||||||
|
ondblclick={(event) => checkoutOnDoubleClick(event, branch)}
|
||||||
|
title={branch.current ? "Current branch" : "Double-click to checkout"}
|
||||||
|
>
|
||||||
<div class="branch-info">
|
<div class="branch-info">
|
||||||
<GitBranch size={16} aria-hidden="true" />
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
@@ -67,18 +155,38 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
{/snippet}
|
|
||||||
{@render branchCard()}
|
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if remoteBranches.length > 0}
|
<div class="branch-group">
|
||||||
<div class="branch-group-label" style="margin-top: {localBranches.length > 0 ? '12px' : '0'}">
|
<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>Remote</span>
|
||||||
<span class="branch-group-count">{remoteBranches.length}</span>
|
<span class="branch-group-count">{remoteBranches.length}</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
|
{#if remoteOpen}
|
||||||
|
{#if remoteBranches.length === 0}
|
||||||
|
<div class="branch-empty">No remote branches.</div>
|
||||||
|
{:else}
|
||||||
{#each remoteBranches as branch (branch.name)}
|
{#each remoteBranches as branch (branch.name)}
|
||||||
<article class="branch-row" class:current={branch.current}>
|
<article
|
||||||
|
class="branch-row"
|
||||||
|
class:current={branch.current}
|
||||||
|
ondblclick={(event) => checkoutOnDoubleClick(event, branch)}
|
||||||
|
title={branch.current ? "Current branch" : "Double-click to checkout"}
|
||||||
|
>
|
||||||
<div class="branch-info">
|
<div class="branch-info">
|
||||||
<GitBranch size={16} aria-hidden="true" />
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
@@ -102,6 +210,8 @@
|
|||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
selectedDiffPath: string;
|
selectedDiffPath: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
restoreLabel?: string;
|
restoreLabel?: string;
|
||||||
|
/** When opened from a search hit, the term to highlight on matching lines. */
|
||||||
|
highlightQuery?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRestore?: () => void;
|
onRestore?: () => void;
|
||||||
onSelectFile: (file: GitDiffFile) => void;
|
onSelectFile: (file: GitDiffFile) => void;
|
||||||
@@ -25,11 +27,25 @@
|
|||||||
selectedDiffPath = "",
|
selectedDiffPath = "",
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
restoreLabel = "",
|
restoreLabel = "",
|
||||||
|
highlightQuery = "",
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
onRestore = undefined,
|
onRestore = undefined,
|
||||||
onSelectFile = () => {},
|
onSelectFile = () => {},
|
||||||
}: Props = $props();
|
}: 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 beforePane = $state<HTMLDivElement | null>(null);
|
||||||
let afterPane = $state<HTMLDivElement | null>(null);
|
let afterPane = $state<HTMLDivElement | null>(null);
|
||||||
let isSyncingSplitScroll = false;
|
let isSyncingSplitScroll = false;
|
||||||
@@ -259,8 +275,8 @@
|
|||||||
{#if row.type === "span"}
|
{#if row.type === "span"}
|
||||||
<div class="split-span split-{row.kind}">{row.text}</div>
|
<div class="split-span split-{row.kind}">{row.text}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="split-num" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"}>{row.leftNum ?? ""}</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"}>{row.leftText ?? " "}</div>
|
<div class="split-cell" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"} class:match={isMatch(row.leftText)}>{row.leftText ?? " "}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -276,8 +292,8 @@
|
|||||||
{#if row.type === "span"}
|
{#if row.type === "span"}
|
||||||
<div class="split-span split-{row.kind}">{row.text}</div>
|
<div class="split-span split-{row.kind}">{row.text}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="split-num" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"}>{row.rightNum ?? ""}</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"}>{row.rightText ?? " "}</div>
|
<div class="split-cell" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"} class:match={isMatch(row.rightText)}>{row.rightText ?? " "}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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">
|
<script lang="ts">
|
||||||
import { CalendarDays, FileCode, LoaderCircle, Search, User, X } from "@lucide/svelte";
|
import {
|
||||||
import type { GitSearchHit } from "../types";
|
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 {
|
interface Props {
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
@@ -8,9 +23,16 @@
|
|||||||
isSearching: boolean;
|
isSearching: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
results: GitSearchHit[];
|
results: GitSearchHit[];
|
||||||
|
files: GitRepositoryFile[];
|
||||||
|
fileHistory: GitCommit[];
|
||||||
|
selectedFilePath: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSearch: (query: string, caseSensitive: boolean, limit: number) => void | Promise<void>;
|
onSearch: (query: string, caseSensitive: boolean, limit: number) => void | Promise<void>;
|
||||||
onCancel: () => 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 {
|
let {
|
||||||
@@ -19,16 +41,28 @@
|
|||||||
isSearching = false,
|
isSearching = false,
|
||||||
error = "",
|
error = "",
|
||||||
results = [],
|
results = [],
|
||||||
|
files = [],
|
||||||
|
fileHistory = [],
|
||||||
|
selectedFilePath = "",
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
onSearch = () => {},
|
onSearch = () => {},
|
||||||
onCancel = () => {},
|
onCancel = () => {},
|
||||||
|
onDiff = () => {},
|
||||||
|
onSelectFile = () => {},
|
||||||
|
onFileHistoryDiff = () => {},
|
||||||
|
onFileHistoryRestore = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let activeTab = $state<SearchTab>("code");
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
let caseSensitive = $state(false);
|
let caseSensitive = $state(false);
|
||||||
let limit = $state(250);
|
let limit = $state(250);
|
||||||
let searchedQuery = $state("");
|
let searchedQuery = $state("");
|
||||||
let searched = $state(false);
|
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) {
|
function submit(event?: SubmitEvent) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
@@ -54,6 +88,42 @@
|
|||||||
function displayPath(hit: GitSearchHit): string {
|
function displayPath(hit: GitSearchHit): string {
|
||||||
return hit.old_file ? `${hit.old_file} -> ${hit.file}` : hit.file;
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -61,11 +131,11 @@
|
|||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
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">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Global search</span>
|
<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>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||||
<X size={18} aria-hidden="true" />
|
<X size={18} aria-hidden="true" />
|
||||||
@@ -73,6 +143,32 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="global-search-body">
|
<div class="global-search-body">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{#if activeTab === "code"}
|
||||||
<form class="global-search-form" onsubmit={submit}>
|
<form class="global-search-form" onsubmit={submit}>
|
||||||
<label class="global-search-query">
|
<label class="global-search-query">
|
||||||
<span>String or function</span>
|
<span>String or function</span>
|
||||||
@@ -148,6 +244,16 @@
|
|||||||
{#if hit.matches_added > 1}
|
{#if hit.matches_added > 1}
|
||||||
<span class="pill pill-active">+{hit.matches_added} matches</span>
|
<span class="pill pill-active">+{hit.matches_added} matches</span>
|
||||||
{/if}
|
{/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>
|
</header>
|
||||||
|
|
||||||
<div class="search-hit-meta">
|
<div class="search-hit-meta">
|
||||||
@@ -169,6 +275,122 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 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="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>
|
||||||
|
|
||||||
|
{#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="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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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";
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||||
|
|
||||||
interface GraphSegment {
|
interface GraphSegment {
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
onRestoreCommit: (commit: GitCommit) => void;
|
onRestoreCommit: (commit: GitCommit) => void;
|
||||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||||
onToggleCommitFiles: (hash: string) => void;
|
onToggleCommitFiles: (hash: string) => void;
|
||||||
|
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
onRestoreCommit = () => {},
|
onRestoreCommit = () => {},
|
||||||
onPreviewCommitFile = () => {},
|
onPreviewCommitFile = () => {},
|
||||||
onToggleCommitFiles = () => {},
|
onToggleCommitFiles = () => {},
|
||||||
|
onCreateBranchFromCommit = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function laneColor(col: number): string {
|
function laneColor(col: number): string {
|
||||||
@@ -220,6 +222,10 @@
|
|||||||
<div class="commit-actions">
|
<div class="commit-actions">
|
||||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||||
<div class="commit-action-buttons">
|
<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">
|
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Reset current branch to this commit">
|
||||||
<RotateCcw size={15} aria-hidden="true" />
|
<RotateCcw size={15} aria-hidden="true" />
|
||||||
Restore
|
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,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">
|
<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";
|
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
onStage: (file: GitFileStatus) => void;
|
onStage: (file: GitFileStatus) => void;
|
||||||
onUnstage: (file: GitFileStatus) => void;
|
onUnstage: (file: GitFileStatus) => void;
|
||||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
||||||
|
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||||
onStageAll: () => void;
|
onStageAll: () => void;
|
||||||
onUnstageAll: () => void;
|
onUnstageAll: () => void;
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
onStage = () => {},
|
onStage = () => {},
|
||||||
onUnstage = () => {},
|
onUnstage = () => {},
|
||||||
onDiscard = () => {},
|
onDiscard = () => {},
|
||||||
|
onPatch = () => {},
|
||||||
onStageAll = () => {},
|
onStageAll = () => {},
|
||||||
onUnstageAll = () => {},
|
onUnstageAll = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -38,6 +40,18 @@
|
|||||||
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
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 hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||||
</script>
|
</script>
|
||||||
@@ -90,7 +104,7 @@
|
|||||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||||
<article class="file-row">
|
<article class="file-row">
|
||||||
<div class="file-title">
|
<div class="file-title">
|
||||||
<strong title={displayPath(file)}>{displayPath(file)}</strong>
|
<strong title={displayPath(file)}>{fileName(file)}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="change-lanes">
|
<div class="change-lanes">
|
||||||
@@ -105,6 +119,10 @@
|
|||||||
<Undo2 size={14} aria-hidden="true" />
|
<Undo2 size={14} aria-hidden="true" />
|
||||||
Unstage
|
Unstage
|
||||||
</button>
|
</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" />
|
||||||
|
Lines
|
||||||
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
Discard
|
Discard
|
||||||
@@ -126,6 +144,10 @@
|
|||||||
<Check size={14} aria-hidden="true" />
|
<Check size={14} aria-hidden="true" />
|
||||||
Stage
|
Stage
|
||||||
</button>
|
</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" />
|
||||||
|
Lines
|
||||||
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
Discard
|
Discard
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
PatchApplyAction,
|
||||||
|
RepositoryBundle,
|
||||||
StoredCredential,
|
StoredCredential,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -15,6 +17,10 @@ export function openRepository(path: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("open_repository", { path });
|
return invoke<GitStatus>("open_repository", { 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> {
|
export function getStatus(path: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("get_status", { path });
|
return invoke<GitStatus>("get_status", { path });
|
||||||
}
|
}
|
||||||
@@ -27,6 +33,14 @@ export function checkoutBranch(path: string, branch: string): Promise<GitStatus>
|
|||||||
return invoke<GitStatus>("checkout_branch", { path, branch });
|
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 stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("stage_files", { path, files });
|
return invoke<GitStatus>("stage_files", { path, files });
|
||||||
}
|
}
|
||||||
@@ -43,6 +57,19 @@ export function restoreFiles(
|
|||||||
return invoke<GitStatus>("restore_files", { path, files, staged });
|
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> {
|
export function commit(path: string, message: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("commit", { path, message });
|
return invoke<GitStatus>("commit", { path, message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface GitFileStatus {
|
|||||||
unstaged: FileStatusKind | null;
|
unstaged: FileStatusKind | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PatchApplyAction = "stage" | "unstage" | "discard-unstaged" | "discard-staged";
|
||||||
|
|
||||||
export interface GitBranch {
|
export interface GitBranch {
|
||||||
name: string;
|
name: string;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
@@ -54,6 +56,13 @@ export interface GitRepositoryFile {
|
|||||||
status: FileStatusKind | null;
|
status: FileStatusKind | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RepositoryBundle {
|
||||||
|
status: GitStatus;
|
||||||
|
branches: GitBranch[];
|
||||||
|
commits: GitCommit[];
|
||||||
|
files: GitRepositoryFile[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitDiffFile {
|
export interface GitDiffFile {
|
||||||
path: string;
|
path: string;
|
||||||
old_path: string | null;
|
old_path: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user