Compare commits

...
17 Commits
Author SHA1 Message Date
Christoph 312727ee73 Merge pull request 'Implement interactive hunk staging and discarding' (#3) from Features/lineStageDiscard into master
publish / publish-tauri (, windows-latest) (release) Successful in 8m48s
Reviewed-on: #3
2026-07-01 12:48:55 +00:00
Christoph Brandau 5752243e6e Implement interactive hunk staging and discarding
Introduce a new dialog that allows users to view file changes as individual hunks and selectively stage, unstage, or discard them. This provides more granular control over modifications, similar to `git add -p`.

Enhance branch panel usability by allowing double-click to checkout a branch.
2026-07-01 14:47:36 +02:00
Christoph Brandau 0bbd5ce1e8 Limit Tauri bundle targets to NSIS
publish / publish-tauri (, windows-latest) (release) Successful in 9m11s
2026-07-01 13:03:45 +02:00
Christoph 50d9232084 Update version to 2026.7.1 2026-07-01 11:47:09 +02:00
Christoph 357a0b7c5f Update version to 2026.07.1
publish / publish-tauri (, windows-latest) (release) Failing after 7m21s
2026-07-01 11:42:53 +02:00
Christoph ab54b9b6b2 Merge pull request 'Features/extend search' (#2) from Features/extendSearch into master
publish / publish-tauri (, windows-latest) (release) Failing after 2m24s
Reviewed-on: #2
2026-07-01 09:40:53 +00:00
Christoph Brandau fe392d38bf Optimize repository loading and enhance Git UI
Consolidate multiple Git backend calls into a single bundle to reduce
overhead when opening and auto-refreshing repositories. This significantly
improves performance by fetching status, branches, commits, and files
in one optimized operation, instead of re-running 'git rev-parse' and
'git status' multiple times.

Also, streamline commit history loading by fetching file changes inline
via 'git log --name-status -z', eliminating expensive per-commit
'git diff-tree' processes.

Additionally, introduce the ability to create new branches from a
specific commit in the history and refactor the commit comparison
feature into a dedicated dialog. The status panel now displays concise
file names.
2026-07-01 11:39:22 +02:00
Christoph Brandau 628e2f7c0b Add repository opening loading overlay
Introduce a full-screen, animated overlay to provide visual feedback
when the application is opening a repository. This prevents perceived
delays and informs the user about the ongoing operation.

Also, enhance build performance and binary size by configuring
Cargo profiles:
- Development: Enable incremental compilation, use line-tables-only
  debug info, and optimize third-party dependencies to speed up
  rebuilds and improve runtime snappiness during development.
- Release: Apply aggressive optimizations (opt-level 3, thin LTO,
  single codegen unit, stripping) for smaller, faster binaries.
2026-07-01 10:40:22 +02:00
Christoph Brandau 1c7c53ddf9 Add branch creation and file history search
Implement the ability to create new local branches directly from the application's UI. This includes a new backend command to handle branch creation with validation and a frontend form in the branch panel.

Additionally, extend the global search dialog to include a "Files" tab. Users can now search for files by name or path within the repository. Selecting a file in the search results displays its full commit history, with options to diff the file at a specific commit or restore it to that version.
2026-07-01 10:07:59 +02:00
Christoph 900159a3a9 Update version to 0.1.0 2026-06-30 20:36:09 +02:00
Christoph d800417d6d Merge pull request 'Improve global search: Add diffing from results with query highlighting' (#1) from FixSearch into master
publish / publish-tauri (, windows-latest) (release) Successful in 6m34s
Reviewed-on: #1
2026-06-30 18:33:59 +00:00
Christoph Brandau 405f302db9 Improve global search: Add diffing from results with query highlighting
Users can now open a diff for a specific search hit directly from the global search results. The original search query will be highlighted within the diff view, making it easier to locate the relevant changes.

Additionally, add `--no-textconv` to git commands used in search and diff operations. This prevents failures when git encounters binary files that textconv drivers might otherwise attempt to process.
2026-06-30 20:32:59 +02:00
Christoph 2cc1d34fc3 Update version to 0.0.6 2026-06-30 19:19:22 +02:00
Christoph Brandau 089aae5f5b Merge remote-tracking branch 'origin/master'
publish / publish-tauri (, windows-latest) (release) Successful in 7m17s
2026-06-30 19:17:25 +02:00
Christoph Brandau 841d1a41b7 Suppress Git console window on Windows
When executing Git commands on Windows, a console window would briefly flash. This change applies the CREATE_NO_WINDOW flag to prevent this, providing a smoother user experience.
2026-06-30 19:17:17 +02:00
Christoph d57b574fe1 Update version to 0.0.5 2026-06-30 17:27:18 +02:00
Christoph ec1f10d535 Update version to 0.0.3
publish / publish-tauri (, windows-latest) (release) Successful in 6m45s
2026-06-30 16:58:36 +02:00
21 changed files with 2352 additions and 245 deletions
+6 -1
View File
@@ -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)"
] ]
} }
} }
+2 -2
View File
@@ -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
View File
@@ -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": {
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+173 -63
View File
@@ -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,7 +65,19 @@
<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>
<span class="pill pill-count">{branches.length}</span> <div class="branch-head-actions">
<button
class="branch-create-toggle"
type="button"
onclick={openCreateForm}
disabled={!hasRepository || isBusy}
title="Create new branch"
aria-label="Create new branch"
>
<Plus size={14} aria-hidden="true" />
</button>
<span class="pill pill-count">{branches.length}</span>
</div>
</div> </div>
{#if !hasRepository} {#if !hasRepository}
@@ -37,71 +85,133 @@
{: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}>
<span>Local</span> <GitBranch size={15} aria-hidden="true" />
<span class="branch-group-count">{localBranches.length}</span> <input
</div> bind:this={createInput}
{#each localBranches as branch (branch.name)} bind:value={newBranchName}
{#snippet branchCard()} disabled={isBusy}
<article class="branch-row" class:current={branch.current}> autocomplete="off"
<div class="branch-info"> spellcheck="false"
<GitBranch size={16} aria-hidden="true" /> placeholder="new-branch-name"
<div> aria-label="New branch name"
<strong>{branch.name}</strong> />
<span>local</span> <button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title="Create branch">
</div> <Check size={14} aria-hidden="true" />
</div> </button>
{#if branch.current} <button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title="Cancel">
<span class="pill pill-active">Current</span> <X size={14} aria-hidden="true" />
{:else} </button>
<div class="branch-actions"> </form>
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/snippet}
{@render branchCard()}
{/each}
{/if} {/if}
{#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={() => { localOpen = !localOpen; }}
aria-expanded={localOpen}
>
{#if localOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Local</span>
<span class="branch-group-count">{localBranches.length}</span>
</button>
{#if localOpen}
{#if localBranches.length === 0}
<div class="branch-empty">No local branches.</div>
{:else}
{#each localBranches as branch (branch.name)}
<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">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>local</span>
</div>
</div>
{#if branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/each}
{/if}
{/if}
</div>
<div class="branch-group">
<button
class="branch-group-toggle"
type="button"
onclick={() => { remoteOpen = !remoteOpen; }}
aria-expanded={remoteOpen}
>
{#if remoteOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Remote</span> <span>Remote</span>
<span class="branch-group-count">{remoteBranches.length}</span> <span class="branch-group-count">{remoteBranches.length}</span>
</div> </button>
{#each remoteBranches as branch (branch.name)}
<article class="branch-row" class:current={branch.current}> {#if remoteOpen}
<div class="branch-info"> {#if remoteBranches.length === 0}
<GitBranch size={16} aria-hidden="true" /> <div class="branch-empty">No remote branches.</div>
<div> {:else}
<strong>{branch.name}</strong> {#each remoteBranches as branch (branch.name)}
<span>remote</span> <article
</div> class="branch-row"
</div> class:current={branch.current}
{#if branch.current} ondblclick={(event) => checkoutOnDoubleClick(event, branch)}
<span class="pill pill-active">Current</span> title={branch.current ? "Current branch" : "Double-click to checkout"}
{:else} >
<div class="branch-actions"> <div class="branch-info">
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}> <GitBranch size={16} aria-hidden="true" />
Checkout <div>
</button> <strong>{branch.name}</strong>
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current"> <span>remote</span>
<GitMerge size={15} aria-hidden="true" /> </div>
Merge </div>
</button> {#if branch.current}
</div> <span class="pill pill-active">Current</span>
{/if} {:else}
</article> <div class="branch-actions">
{/each} <button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
{/if} Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/each}
{/if}
{/if}
</div>
</div> </div>
{/if} {/if}
</section> </section>
+20 -4
View File
@@ -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>
+310 -88
View File
@@ -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,102 +143,254 @@
</header> </header>
<div class="global-search-body"> <div class="global-search-body">
<form class="global-search-form" onsubmit={submit}> <div class="global-search-tabs" role="tablist" aria-label="Search mode">
<label class="global-search-query"> <button
<span>String or function</span> class="global-search-tab"
<textarea class:active={activeTab === "code"}
bind:value={query} type="button"
onkeydown={handleKeydown} role="tab"
disabled={!hasRepository || isBusy || isSearching} aria-selected={activeTab === "code"}
spellcheck="false" onclick={() => { activeTab = "code"; }}
placeholder={"Paste a string, symbol, or full function body..."} >
></textarea> <Search size={14} aria-hidden="true" />
</label> Code
</button>
<button
class="global-search-tab"
class:active={activeTab === "files"}
type="button"
role="tab"
aria-selected={activeTab === "files"}
onclick={() => { activeTab = "files"; }}
>
<FileText size={14} aria-hidden="true" />
Files
</button>
</div>
<div class="global-search-options"> {#if activeTab === "code"}
<label class="check-row"> <form class="global-search-form" onsubmit={submit}>
<input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} /> <label class="global-search-query">
<span>Exact case</span> <span>String or function</span>
<textarea
bind:value={query}
onkeydown={handleKeydown}
disabled={!hasRepository || isBusy || isSearching}
spellcheck="false"
placeholder={"Paste a string, symbol, or full function body..."}
></textarea>
</label> </label>
<label class="search-limit"> <div class="global-search-options">
<span>Results</span> <label class="check-row">
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}> <input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} />
<option value={100}>100</option> <span>Exact case</span>
<option value={250}>250</option> </label>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
</label>
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}> <label class="search-limit">
{#if isSearching} <span>Results</span>
<LoaderCircle class="spin" size={16} aria-hidden="true" /> <select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
{:else} <option value={100}>100</option>
<Search size={16} aria-hidden="true" /> <option value={250}>250</option>
{/if} <option value={500}>500</option>
Search <option value={1000}>1000</option>
</button> </select>
</label>
{#if isSearching} <button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
<button class="btn-secondary search-cancel" type="button" onclick={onCancel}> {#if isSearching}
<X size={16} aria-hidden="true" /> <LoaderCircle class="spin" size={16} aria-hidden="true" />
Cancel {:else}
<Search size={16} aria-hidden="true" />
{/if}
Search
</button> </button>
{#if isSearching}
<button class="btn-secondary search-cancel" type="button" onclick={onCancel}>
<X size={16} aria-hidden="true" />
Cancel
</button>
{/if}
</div>
</form>
<section class="global-search-results" aria-live="polite">
{#if !hasRepository}
<div class="blank-state">Open a repository first.</div>
{:else if isSearching}
<div class="blank-state">
<LoaderCircle class="spin" size={20} aria-hidden="true" />
Searching all branches...
</div>
{:else if error}
<div class="blank-state search-error">{error}</div>
{:else if !searched}
<div class="blank-state">Search a string or paste a complete function to find where it was added.</div>
{:else if results.length === 0}
<div class="blank-state">No introduction found for "{searchedQuery}".</div>
{:else}
<div class="search-result-head">
<strong>{results.length}</strong>
<span>{results.length === 1 ? "introduction" : "introductions"} found for "{searchedQuery}"</span>
</div>
<div class="search-hit-list">
{#each results as hit (`${hit.commit_hash}:${hit.file}:${hit.line_number ?? 0}`)}
<article class="search-hit">
<header class="search-hit-top">
<span class="hash">{hit.short_hash}</span>
<strong title={hit.summary}>{hit.summary || "No commit message"}</strong>
{#if hit.matches_added > 1}
<span class="pill pill-active">+{hit.matches_added} matches</span>
{/if}
<button
class="btn-secondary search-hit-diff"
type="button"
disabled={isBusy}
title={`Compare this version of ${hit.file} with the current version`}
onclick={() => onDiff(hit)}
>
<GitCompare size={14} aria-hidden="true" />
DIFF
</button>
</header>
<div class="search-hit-meta">
<span><User size={12} aria-hidden="true" />{hit.author_name || "Unknown author"}</span>
<span><CalendarDays size={12} aria-hidden="true" />{formatCommitDate(hit.date)}</span>
</div>
<div class="search-hit-file" title={displayPath(hit)}>
<FileCode size={14} aria-hidden="true" />
<span>{displayPath(hit)}</span>
{#if hit.line_number}
<strong>:{hit.line_number}</strong>
{/if}
</div>
<pre class="search-hit-line">{hit.line || searchedQuery}</pre>
</article>
{/each}
</div>
{/if} {/if}
</div> </section>
</form> {:else}
<form class="global-search-form file-search-form" onsubmit={(event) => event.preventDefault()}>
<label class="global-search-query">
<span>File name or path</span>
<input
bind:value={fileQuery}
disabled={!hasRepository || isBusy}
spellcheck="false"
autocomplete="off"
placeholder="Search files, folders, extensions..."
/>
</label>
</form>
<section class="global-search-results" aria-live="polite"> <section class="global-search-results file-search-results" aria-live="polite">
{#if !hasRepository} {#if !hasRepository}
<div class="blank-state">Open a repository first.</div> <div class="blank-state">Open a repository first.</div>
{:else if isSearching} {:else if files.length === 0}
<div class="blank-state"> <div class="blank-state">No files loaded for this repository.</div>
<LoaderCircle class="spin" size={20} aria-hidden="true" /> {:else}
Searching all branches... <div class="file-search-split">
</div> <div class="file-search-column">
{:else if error} {#if !fileSearchActive}
<div class="blank-state search-error">{error}</div> <div class="blank-state">Search a file name or path, then choose a result to show its history.</div>
{:else if !searched} {:else if fileSearchResults.length === 0}
<div class="blank-state">Search a string or paste a complete function to find where it was added.</div> <div class="blank-state">No file found for "{fileQuery.trim()}".</div>
{:else if results.length === 0} {:else}
<div class="blank-state">No introduction found for "{searchedQuery}".</div> <div class="search-result-head">
{:else} <strong>{fileSearchResults.length}</strong>
<div class="search-result-head"> <span>{fileSearchResults.length === 1 ? "file" : "files"} found for "{fileQuery.trim()}"</span>
<strong>{results.length}</strong> </div>
<span>{results.length === 1 ? "introduction" : "introductions"} found for "{searchedQuery}"</span>
</div>
<div class="search-hit-list"> <div class="file-search-list">
{#each results as hit (`${hit.commit_hash}:${hit.file}:${hit.line_number ?? 0}`)} {#each fileSearchResults as file (file.path)}
<article class="search-hit"> {@const languageIcon = languageIconForPath(file.path)}
<header class="search-hit-top"> <button
<span class="hash">{hit.short_hash}</span> class="file-search-hit"
<strong title={hit.summary}>{hit.summary || "No commit message"}</strong> class:active={selectedFilePath === file.path}
{#if hit.matches_added > 1} type="button"
<span class="pill pill-active">+{hit.matches_added} matches</span> disabled={isBusy || isSearching}
{/if} 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> </header>
<div class="search-hit-meta"> {#if !selectedFilePath}
<span><User size={12} aria-hidden="true" />{hit.author_name || "Unknown author"}</span> <div class="blank-state">Select a file result to load its commit history.</div>
<span><CalendarDays size={12} aria-hidden="true" />{formatCommitDate(hit.date)}</span> {:else if isBusy}
</div> <div class="blank-state">
<LoaderCircle class="spin" size={18} aria-hidden="true" />
Loading history...
</div>
{:else if fileHistory.length === 0}
<div class="blank-state">No history returned for this file.</div>
{:else}
<div class="file-search-history-list">
{#each fileHistory as commit (commit.hash)}
<article class="file-search-history-row">
<div class="file-search-history-main">
<span class="hash">{commit.short_hash}</span>
<strong title={commit.summary}>{commit.summary || "No commit message"}</strong>
<span>{commit.author_name || "Unknown author"} - {formatCommitDate(commit.date)}</span>
</div>
<div class="search-hit-file" title={displayPath(hit)}> <div class="file-search-history-actions">
<FileCode size={14} aria-hidden="true" /> <button class="btn-sm" type="button" onclick={() => onFileHistoryDiff(commit)} disabled={isBusy}>
<span>{displayPath(hit)}</span> <GitCompare size={14} aria-hidden="true" />
{#if hit.line_number} Diff
<strong>:{hit.line_number}</strong> </button>
{/if} <button class="btn-sm" type="button" onclick={() => onFileHistoryRestore(commit)} disabled={isBusy}>
</div> <RotateCcw size={14} aria-hidden="true" />
Restore
<pre class="search-hit-line">{hit.line || searchedQuery}</pre> </button>
</article> </div>
{/each} </article>
</div> {/each}
{/if} </div>
</section> {/if}
</aside>
</div>
{/if}
</section>
{/if}
</div> </div>
</div> </div>
</div> </div>
+7 -1
View File
@@ -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
+189
View File
@@ -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>
+79
View File
@@ -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>
+24 -2
View File
@@ -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
+27
View File
@@ -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 });
} }
+9
View File
@@ -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;