add new style and global search

This commit is contained in:
Christoph Brandau
2026-06-29 17:24:08 +02:00
parent 9861fa2446
commit b0491d1479
17 changed files with 1805 additions and 70 deletions
+680 -2
View File
@@ -1,9 +1,15 @@
use serde::Serialize;
use std::{
collections::BTreeMap,
collections::{BTreeMap, BTreeSet},
ffi::{OsStr, OsString},
path::{Path, PathBuf},
process::Command,
process::{Command, Stdio},
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
thread,
time::Duration,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
@@ -102,6 +108,21 @@ pub struct GitRepositoryFile {
pub status: Option<FileStatusKind>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitSearchHit {
pub commit_hash: String,
pub short_hash: String,
pub summary: String,
pub author_name: String,
pub author_email: String,
pub date: String,
pub file: String,
pub old_file: Option<String>,
pub line_number: Option<u32>,
pub line: String,
pub matches_added: u32,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct BranchInfo {
current_branch: Option<String>,
@@ -118,6 +139,54 @@ enum CheckoutPlan {
}
const FULL_FILE_DIFF_CONTEXT: &str = "--unified=1000000";
const SEARCH_CANCELLED_MESSAGE: &str = "Suche wurde abgebrochen.";
static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Default, Clone)]
pub struct SearchCancellationState {
cancelled: Arc<Mutex<BTreeSet<String>>>,
}
impl SearchCancellationState {
fn cancel(&self, search_id: &str) -> Result<(), String> {
self.cancelled
.lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())?
.insert(search_id.to_string());
Ok(())
}
fn clear(&self, search_id: &str) -> Result<(), String> {
self.cancelled
.lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())?
.remove(search_id);
Ok(())
}
fn is_cancelled(&self, search_id: &str) -> Result<bool, String> {
Ok(self
.cancelled
.lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())?
.contains(search_id))
}
}
#[derive(Clone)]
struct SearchCancellation {
state: SearchCancellationState,
search_id: String,
}
fn check_search_cancelled(cancellation: Option<&SearchCancellation>) -> Result<(), String> {
if let Some(cancellation) = cancellation {
if cancellation.state.is_cancelled(&cancellation.search_id)? {
return Err(SEARCH_CANCELLED_MESSAGE.to_string());
}
}
Ok(())
}
#[tauri::command]
pub fn open_repository(path: String) -> Result<GitStatus, String> {
@@ -394,6 +463,174 @@ pub fn list_file_history(
parse_commit_log(&repo, &output)
}
#[tauri::command]
pub async fn search_code_introductions(
path: String,
query: String,
case_sensitive: Option<bool>,
limit: Option<u32>,
search_id: Option<String>,
state: tauri::State<'_, SearchCancellationState>,
) -> Result<Vec<GitSearchHit>, String> {
let state = state.inner().clone();
tauri::async_runtime::spawn_blocking(move || {
let repo = resolve_repo(&path)?;
let query = normalize_newlines(&query);
let query = query.trim_matches('\n').to_string();
if query.trim().is_empty() {
return Err("Suchtext darf nicht leer sein.".to_string());
}
if verify_commit(&repo, "HEAD").is_err() {
return Ok(Vec::new());
}
let case_sensitive = case_sensitive.unwrap_or(false);
let limit = limit.unwrap_or(250).clamp(1, 1000) as usize;
let search_id = search_id
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty());
let cancellation = search_id.as_ref().map(|search_id| SearchCancellation {
state: state.clone(),
search_id: search_id.clone(),
});
let result = search_code_introductions_core(
&repo,
query,
case_sensitive,
limit,
cancellation.as_ref(),
);
if let Some(search_id) = search_id.as_deref() {
let _ = state.clear(search_id);
}
result
})
.await
.map_err(|err| format!("Such-Task konnte nicht abgeschlossen werden: {err}"))?
}
#[tauri::command]
pub fn cancel_code_search(
search_id: String,
state: tauri::State<'_, SearchCancellationState>,
) -> Result<(), String> {
let search_id = search_id.trim();
if search_id.is_empty() {
return Ok(());
}
state.cancel(search_id)
}
fn search_code_introductions_core(
repo: &Path,
query: String,
case_sensitive: bool,
limit: usize,
cancellation: Option<&SearchCancellation>,
) -> Result<Vec<GitSearchHit>, String> {
check_search_cancelled(cancellation)?;
let candidates = search_candidate_commits(&repo, &query, case_sensitive, cancellation)?;
let mut hits = Vec::new();
for commit in candidates {
check_search_cancelled(cancellation)?;
if hits.len() >= limit {
break;
}
let files = commit_files(&repo, &commit)?;
if files.is_empty() {
continue;
}
let parents = commit_parents(&repo, &commit)?;
let mut metadata: Option<GitSearchCommitMetadata> = None;
for file in files {
check_search_cancelled(cancellation)?;
if hits.len() >= limit {
break;
}
if matches!(file.status, FileStatusKind::Deleted) {
continue;
}
let Some(after_content) = read_text_blob(&repo, &commit, &file.path)? else {
continue;
};
let after_count = count_matches(&after_content, &query, case_sensitive);
if after_count == 0 {
continue;
}
let before_count = max_parent_match_count(
&repo,
&parents,
file.old_path.as_deref().unwrap_or(&file.path),
&query,
case_sensitive,
)?;
if after_count <= before_count {
continue;
}
let match_line = first_added_match_line(
&repo,
parents.first().map(String::as_str),
&commit,
&file.path,
&query,
case_sensitive,
cancellation,
)?
.or_else(|| first_match_line(&after_content, &query, case_sensitive));
let Some((line_number, line)) = match_line else {
continue;
};
let info = metadata
.get_or_insert_with(|| {
commit_search_metadata(&repo, &commit).unwrap_or_else(|_| {
GitSearchCommitMetadata {
commit_hash: commit.clone(),
short_hash: short_hash(&commit),
author_name: String::new(),
author_email: String::new(),
date: String::new(),
summary: String::new(),
}
})
})
.clone();
hits.push(GitSearchHit {
commit_hash: info.commit_hash,
short_hash: info.short_hash,
summary: info.summary,
author_name: info.author_name,
author_email: info.author_email,
date: info.date,
file: file.path,
old_file: file.old_path,
line_number: Some(line_number),
line,
matches_added: after_count.saturating_sub(before_count) as u32,
});
}
}
Ok(hits)
}
#[tauri::command]
pub fn restore_to_commit(path: String, commit: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
@@ -770,6 +1007,265 @@ fn has_unresolved_conflicts(status: &GitStatus) -> bool {
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GitSearchCommitMetadata {
commit_hash: String,
short_hash: String,
author_name: String,
author_email: String,
date: String,
summary: String,
}
fn search_candidate_commits(
repo: &Path,
query: &str,
case_sensitive: bool,
cancellation: Option<&SearchCancellation>,
) -> Result<Vec<String>, String> {
let output = if query.contains('\n') {
run_git_cancellable(
repo,
["rev-list", "--all", "--reverse"],
cancellation,
"Git-Suche fehlgeschlagen",
)?
} else {
let mut args = vec![
OsString::from("log"),
OsString::from("--all"),
OsString::from("--reverse"),
OsString::from("--format=%H"),
];
if !case_sensitive {
args.push(OsString::from("-i"));
}
args.push(OsString::from(format!("-S{query}")));
run_git_cancellable(repo, args, cancellation, "Git-Suche fehlgeschlagen")?
};
Ok(String::from_utf8_lossy(&output)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect())
}
fn commit_parents(repo: &Path, commit: &str) -> Result<Vec<String>, String> {
let output = run_git(repo, ["rev-list", "--parents", "-n", "1", commit])?;
let text = String::from_utf8_lossy(&output);
Ok(text
.split_whitespace()
.skip(1)
.map(ToString::to_string)
.collect())
}
fn max_parent_match_count(
repo: &Path,
parents: &[String],
file: &str,
query: &str,
case_sensitive: bool,
) -> Result<usize, String> {
let mut max_count = 0;
for parent in parents {
if let Some(content) = read_text_blob(repo, parent, file)? {
max_count = max_count.max(count_matches(&content, query, case_sensitive));
}
}
Ok(max_count)
}
fn read_text_blob(repo: &Path, commit: &str, file: &str) -> Result<Option<String>, String> {
let spec = format!("{commit}:{file}");
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["show", spec.as_str()])
.output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
if !output.status.success() {
return Ok(None);
}
if is_binary_bytes(&output.stdout) {
return Ok(None);
}
Ok(Some(normalize_newlines(&String::from_utf8_lossy(
&output.stdout,
))))
}
fn normalize_newlines(value: &str) -> String {
value.replace("\r\n", "\n").replace('\r', "\n")
}
fn count_matches(content: &str, query: &str, case_sensitive: bool) -> usize {
let content = if case_sensitive {
content.to_string()
} else {
content.to_ascii_lowercase()
};
let query = if case_sensitive {
query.to_string()
} else {
query.to_ascii_lowercase()
};
if query.is_empty() {
return 0;
}
let mut count = 0;
let mut start = 0;
while let Some(index) = content[start..].find(&query) {
count += 1;
start += index + query.len();
}
count
}
fn first_added_match_line(
repo: &Path,
parent: Option<&str>,
commit: &str,
file: &str,
query: &str,
case_sensitive: bool,
cancellation: Option<&SearchCancellation>,
) -> Result<Option<(u32, String)>, String> {
let Some(parent) = parent else {
return Ok(None);
};
check_search_cancelled(cancellation)?;
let output = run_git_with_paths_cancellable(
repo,
&["diff", "--unified=0", parent, commit],
&[file.to_string()],
cancellation,
"Git-Diff fuer Suchtreffer fehlgeschlagen",
)?;
let patch = String::from_utf8_lossy(&output);
let line_query = first_query_line(query);
let mut new_line = 0u32;
for line in patch.lines() {
check_search_cancelled(cancellation)?;
if line.starts_with("@@") {
if let Some(start) = parse_new_hunk_start(line) {
new_line = start;
}
continue;
}
if line.starts_with("+++") || line.starts_with("---") || line.starts_with("diff ") {
continue;
}
if let Some(added) = line.strip_prefix('+') {
if count_matches(added, line_query, case_sensitive) > 0 {
return Ok(Some((new_line.max(1), compact_search_line(added))));
}
new_line = new_line.saturating_add(1);
} else if line.starts_with('-') {
continue;
} else if line.starts_with(' ') {
new_line = new_line.saturating_add(1);
}
}
Ok(None)
}
fn first_query_line(query: &str) -> &str {
query
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or(query)
}
fn parse_new_hunk_start(line: &str) -> Option<u32> {
let plus = line.split_whitespace().find(|part| part.starts_with('+'))?;
let number = plus
.trim_start_matches('+')
.split_once(',')
.map(|(start, _)| start)
.unwrap_or_else(|| plus.trim_start_matches('+'));
number.parse().ok()
}
fn first_match_line(content: &str, query: &str, case_sensitive: bool) -> Option<(u32, String)> {
let haystack = if case_sensitive {
content.to_string()
} else {
content.to_ascii_lowercase()
};
let needle = if case_sensitive {
query.to_string()
} else {
query.to_ascii_lowercase()
};
let index = haystack.find(&needle)?;
let line_number = content[..index]
.bytes()
.filter(|byte| *byte == b'\n')
.count() as u32
+ 1;
let line_start = content[..index].rfind('\n').map(|pos| pos + 1).unwrap_or(0);
let line_end = content[index..]
.find('\n')
.map(|pos| index + pos)
.unwrap_or(content.len());
Some((
line_number,
compact_search_line(&content[line_start..line_end]),
))
}
fn compact_search_line(line: &str) -> String {
const MAX_LEN: usize = 240;
let compact = line.trim().replace('\t', " ");
if compact.chars().count() <= MAX_LEN {
return compact;
}
let mut shortened: String = compact.chars().take(MAX_LEN).collect();
shortened.push_str("...");
shortened
}
fn commit_search_metadata(repo: &Path, commit: &str) -> Result<GitSearchCommitMetadata, String> {
let output = run_git(
repo,
[
"show",
"-s",
"--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s",
commit,
],
)?;
let text = String::from_utf8_lossy(&output);
let fields: Vec<&str> = text.trim_end().splitn(6, '\x1f').collect();
if fields.len() != 6 {
return Err(format!("Unerwarteter Git-Commit-Eintrag: {text}"));
}
Ok(GitSearchCommitMetadata {
commit_hash: fields[0].to_string(),
short_hash: fields[1].to_string(),
author_name: fields[2].to_string(),
author_email: fields[3].to_string(),
date: fields[4].to_string(),
summary: fields[5].to_string(),
})
}
fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String> {
const FIELD_SEPARATOR: char = '\x1f';
const RECORD_SEPARATOR: char = '\x1e';
@@ -1283,6 +1779,20 @@ fn run_git_with_paths(
run_git(repo, args)
}
fn run_git_with_paths_cancellable(
repo: &Path,
base_args: &[&str],
files: &[String],
cancellation: Option<&SearchCancellation>,
context: &str,
) -> Result<Vec<u8>, String> {
let mut args = Vec::with_capacity(base_args.len() + files.len() + 1);
args.extend(base_args.iter().map(OsString::from));
args.push(OsString::from("--"));
args.extend(files.iter().map(OsString::from));
run_git_cancellable(repo, args, cancellation, context)
}
fn run_git<I, S>(repo: &Path, args: I) -> Result<Vec<u8>, String>
where
I: IntoIterator<Item = S>,
@@ -1291,6 +1801,91 @@ where
run_git_at(repo, args, "Git-Befehl fehlgeschlagen")
}
fn run_git_cancellable<I, S>(
repo: &Path,
args: I,
cancellation: Option<&SearchCancellation>,
context: &str,
) -> Result<Vec<u8>, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
check_search_cancelled(cancellation)?;
let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
let temp_dir = std::env::temp_dir();
let stdout_path = temp_dir.join(format!(
"gitlite_search_{}_{}.out",
std::process::id(),
counter
));
let stderr_path = temp_dir.join(format!(
"gitlite_search_{}_{}.err",
std::process::id(),
counter
));
let stdout_file = std::fs::File::create(&stdout_path)
.map_err(|err| format!("Git-Ausgabedatei konnte nicht erstellt werden: {err}"))?;
let stderr_file = std::fs::File::create(&stderr_path)
.map_err(|err| format!("Git-Fehlerdatei konnte nicht erstellt werden: {err}"))?;
let mut child = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.stdout(Stdio::from(stdout_file))
.stderr(Stdio::from(stderr_file))
.spawn()
.map_err(|err| {
let _ = std::fs::remove_file(&stdout_path);
let _ = std::fs::remove_file(&stderr_path);
format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}")
})?;
let status = loop {
if let Err(err) = check_search_cancelled(cancellation) {
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_file(&stdout_path);
let _ = std::fs::remove_file(&stderr_path);
return Err(err);
}
if let Some(status) = child
.try_wait()
.map_err(|err| format!("Git-Prozess konnte nicht geprueft werden: {err}"))?
{
break status;
}
thread::sleep(Duration::from_millis(60));
};
let stdout = std::fs::read(&stdout_path)
.map_err(|err| format!("Git-Ausgabe konnte nicht gelesen werden: {err}"))?;
let stderr = std::fs::read(&stderr_path)
.map_err(|err| format!("Git-Fehlerausgabe konnte nicht gelesen werden: {err}"))?;
let _ = std::fs::remove_file(&stdout_path);
let _ = std::fs::remove_file(&stderr_path);
if status.success() {
return Ok(stdout);
}
let stderr_text = String::from_utf8_lossy(&stderr);
let stdout_text = String::from_utf8_lossy(&stdout);
let details = if !stderr_text.trim().is_empty() {
stderr_text.trim()
} else if !stdout_text.trim().is_empty() {
stdout_text.trim()
} else {
"unbekannter Fehler"
};
Err(format!("{context}: {details}"))
}
fn run_git_at<I, S>(path: &Path, args: I, context: &str) -> Result<Vec<u8>, String>
where
I: IntoIterator<Item = S>,
@@ -1565,6 +2160,89 @@ mod tests {
run_git_test(repo, ["commit", "-q", "-m", "init"]);
}
#[test]
fn search_code_introductions_finds_added_string() {
let repo = init_temp_repo("search_added_string");
fs::create_dir_all(repo.path.join("src")).expect("src directory should be created");
fs::write(repo.path.join("src/app.ts"), "export const existing = 1;\n")
.expect("initial source file should be written");
run_git_test(&repo.path, ["add", "src/app.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "initial source"]);
fs::write(
repo.path.join("src/app.ts"),
"export const existing = 1;\n\nexport function renderWidget() {\n return \"needle-token\";\n}\n",
)
.expect("updated source file should be written");
run_git_test(&repo.path, ["add", "src/app.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add render widget"]);
let hits =
search_code_introductions_core(&repo.path, "renderWidget".to_string(), false, 20, None)
.expect("search should succeed");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].file, "src/app.ts");
assert_eq!(hits[0].summary, "add render widget");
assert_eq!(hits[0].line_number, Some(3));
}
#[test]
fn search_code_introductions_finds_multiline_function_block() {
let repo = init_temp_repo("search_multiline_function");
fs::write(repo.path.join("module.ts"), "export const ready = true;\n")
.expect("initial module should be written");
run_git_test(&repo.path, ["add", "module.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "initial module"]);
let function_body = "export function parseThing() {\n return \"needle-token\";\n}";
fs::write(
repo.path.join("module.ts"),
format!("export const ready = true;\n\n{function_body}\n"),
)
.expect("updated module should be written");
run_git_test(&repo.path, ["add", "module.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add parser"]);
let hits =
search_code_introductions_core(&repo.path, function_body.to_string(), false, 20, None)
.expect("search should succeed");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].file, "module.ts");
assert_eq!(hits[0].summary, "add parser");
assert_eq!(hits[0].line_number, Some(3));
}
#[test]
fn search_code_introductions_can_be_cancelled() {
let repo = init_temp_repo("search_cancelled");
fs::write(
repo.path.join("module.ts"),
"export const value = \"needle\";\n",
)
.expect("module should be written");
run_git_test(&repo.path, ["add", "module.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add module"]);
let state = SearchCancellationState::default();
state
.cancel("test-search")
.expect("cancel flag should be set");
let result = search_code_introductions_core(
&repo.path,
"needle".to_string(),
false,
20,
Some(&SearchCancellation {
state: state.clone(),
search_id: "test-search".to_string(),
}),
);
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
}
#[test]
fn parses_branch_tracking_and_file_states() {
let raw = b"## main...origin/main [ahead 2, behind 1]\0 M changed.txt\0D deleted.txt\0?? new.txt\0";