feat(external-tools): add cross-platform external tool discovery
Adds a new external tools subsystem to detect and launch diff and editor tools across Windows, macOS, and Linux. It exposes data models for tools, commands, and results to the UI and serializes them for consumption by the app. - Implement cross-platform discovery of editors and diff tools - Expose serialized results to the UI for user selection - Centralize per-OS known tool lists and overrides
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -3129,6 +3129,230 @@ pub async fn list_commits(
|
||||
.await
|
||||
}
|
||||
|
||||
const COMMIT_NOTES_REF: &str = "refs/notes/commits";
|
||||
const COMMIT_NOTES_SYNC_REF: &str = "refs/gitlite/notes-sync";
|
||||
const MAX_COMMIT_NOTE_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_commit_note(path: String, commit: String) -> Result<Option<String>, String> {
|
||||
run_git_task("Could not load commit note", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
commit_note_for_repo(&repo, &commit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_commit_note(path: String, commit: String, note: String) -> Result<(), String> {
|
||||
run_git_task("Could not save commit note", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
set_commit_note_for_repo(&repo, &commit, ¬e)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_commit_note(path: String, commit: String) -> Result<(), String> {
|
||||
run_git_task("Could not delete commit note", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
delete_commit_note_for_repo(&repo, &commit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_commit_notes(
|
||||
path: String,
|
||||
remote: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
run_git_task("Could not fetch commit notes", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
fetch_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn push_commit_notes(
|
||||
path: String,
|
||||
remote: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
run_git_task("Could not push commit notes", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
push_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn commit_note_for_repo(repo: &Path, commit: &str) -> Result<Option<String>, String> {
|
||||
let commit = verify_commit(repo, commit)?;
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["notes", "--ref", COMMIT_NOTES_REF, "list", commit.as_str()])
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"Could not inspect commit note: {}",
|
||||
command_output_details(&output)
|
||||
));
|
||||
}
|
||||
|
||||
let note_object = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if note_object.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let note = run_git(repo, ["cat-file", "blob", note_object.as_str()])?;
|
||||
let mut note =
|
||||
String::from_utf8(note).map_err(|_| "Commit note is not valid UTF-8 text.".to_string())?;
|
||||
if note.ends_with('\n') {
|
||||
note.pop();
|
||||
if note.ends_with('\r') {
|
||||
note.pop();
|
||||
}
|
||||
}
|
||||
Ok(Some(note))
|
||||
}
|
||||
|
||||
fn set_commit_note_for_repo(repo: &Path, commit: &str, note: &str) -> Result<(), String> {
|
||||
let commit = verify_commit(repo, commit)?;
|
||||
if note.trim().is_empty() {
|
||||
return Err("Commit note must not be empty. Use Delete to remove it.".to_string());
|
||||
}
|
||||
if note.len() > MAX_COMMIT_NOTE_BYTES {
|
||||
return Err(format!(
|
||||
"Commit note is too large (maximum {} KiB).",
|
||||
MAX_COMMIT_NOTE_BYTES / 1024
|
||||
));
|
||||
}
|
||||
|
||||
run_git_with_stdin(
|
||||
repo,
|
||||
[
|
||||
"notes",
|
||||
"--ref",
|
||||
COMMIT_NOTES_REF,
|
||||
"add",
|
||||
"-f",
|
||||
"-F",
|
||||
"-",
|
||||
"--",
|
||||
commit.as_str(),
|
||||
],
|
||||
note.as_bytes(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_commit_note_for_repo(repo: &Path, commit: &str) -> Result<(), String> {
|
||||
let commit = verify_commit(repo, commit)?;
|
||||
run_git(
|
||||
repo,
|
||||
[
|
||||
"notes",
|
||||
"--ref",
|
||||
COMMIT_NOTES_REF,
|
||||
"remove",
|
||||
"--ignore-missing",
|
||||
"--",
|
||||
commit.as_str(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_commit_notes_for_repo(
|
||||
repo: &Path,
|
||||
remote: &str,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let remote = validate_remote_name(repo, remote, true)?;
|
||||
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
|
||||
let refspec = format!("+{COMMIT_NOTES_REF}:{COMMIT_NOTES_SYNC_REF}");
|
||||
let fetch_args = ["fetch", remote.as_str(), refspec.as_str()];
|
||||
let fetched = match (username, password) {
|
||||
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
|
||||
run_git_authenticated(repo, fetch_args, user, pass)
|
||||
}
|
||||
_ => run_git(repo, fetch_args),
|
||||
};
|
||||
|
||||
if let Err(error) = fetched {
|
||||
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
|
||||
return Err(
|
||||
if error.to_lowercase().contains("couldn't find remote ref") {
|
||||
format!("Remote '{remote}' does not contain commit notes yet.")
|
||||
} else {
|
||||
error
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let merge_result = (|| -> Result<(), String> {
|
||||
if ref_exists(repo, COMMIT_NOTES_REF)? {
|
||||
run_git(
|
||||
repo,
|
||||
[
|
||||
"notes",
|
||||
"--ref",
|
||||
COMMIT_NOTES_REF,
|
||||
"merge",
|
||||
"-s",
|
||||
"cat_sort_uniq",
|
||||
COMMIT_NOTES_SYNC_REF,
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
let remote_notes_hash = run_git(repo, ["rev-parse", COMMIT_NOTES_SYNC_REF])?;
|
||||
let remote_notes_hash = String::from_utf8_lossy(&remote_notes_hash)
|
||||
.trim()
|
||||
.to_string();
|
||||
run_git(
|
||||
repo,
|
||||
["update-ref", COMMIT_NOTES_REF, remote_notes_hash.as_str()],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
|
||||
merge_result
|
||||
}
|
||||
|
||||
fn push_commit_notes_for_repo(
|
||||
repo: &Path,
|
||||
remote: &str,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let remote = validate_remote_name(repo, remote, true)?;
|
||||
if !ref_exists(repo, COMMIT_NOTES_REF)? {
|
||||
return Err("There are no local commit notes to push.".to_string());
|
||||
}
|
||||
let refspec = format!("{COMMIT_NOTES_REF}:{COMMIT_NOTES_REF}");
|
||||
let push_args = ["push", remote.as_str(), refspec.as_str()];
|
||||
match (username, password) {
|
||||
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
|
||||
run_git_authenticated(repo, push_args, user, pass)?;
|
||||
}
|
||||
_ => {
|
||||
run_git(repo, push_args)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
||||
let bounded_limit = limit.unwrap_or(100).clamp(1, 500);
|
||||
commit_page_for_repo(repo, Some(bounded_limit), None)
|
||||
@@ -5923,6 +6147,91 @@ mod tests {
|
||||
run_git_test(repo, ["commit", "-q", "-m", "init"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_notes_can_be_created_updated_and_deleted_without_changing_commit() {
|
||||
let repo = init_temp_repo("commit_notes_crud");
|
||||
commit_initial_file(&repo.path);
|
||||
let commit_before = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
|
||||
assert_eq!(
|
||||
commit_note_for_repo(&repo.path, &commit_before).expect("note lookup should work"),
|
||||
None
|
||||
);
|
||||
|
||||
set_commit_note_for_repo(
|
||||
&repo.path,
|
||||
&commit_before,
|
||||
"Review: sieht gut aus\nBuild: 42",
|
||||
)
|
||||
.expect("note should be created");
|
||||
assert_eq!(
|
||||
commit_note_for_repo(&repo.path, &commit_before).expect("note should load"),
|
||||
Some("Review: sieht gut aus\nBuild: 42".to_string())
|
||||
);
|
||||
|
||||
set_commit_note_for_repo(&repo.path, &commit_before, "Freigabe erteilt")
|
||||
.expect("note should be replaced");
|
||||
assert_eq!(
|
||||
commit_note_for_repo(&repo.path, &commit_before).expect("updated note should load"),
|
||||
Some("Freigabe erteilt".to_string())
|
||||
);
|
||||
|
||||
delete_commit_note_for_repo(&repo.path, &commit_before).expect("note should be deleted");
|
||||
assert_eq!(
|
||||
commit_note_for_repo(&repo.path, &commit_before)
|
||||
.expect("deleted note lookup should work"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
git_output_test(&repo.path, ["rev-parse", "HEAD"]),
|
||||
commit_before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
|
||||
)]
|
||||
fn commit_notes_can_be_pushed_and_fetched_through_the_notes_ref() {
|
||||
let source = init_temp_repo("commit_notes_source");
|
||||
let target = init_temp_repo("commit_notes_target");
|
||||
let remote = init_bare_temp_repo("commit_notes_remote");
|
||||
commit_initial_file(&source.path);
|
||||
let commit = git_output_test(&source.path, ["rev-parse", "HEAD"]);
|
||||
let remote_url = format!(
|
||||
"file:///{}",
|
||||
remote.path.to_string_lossy().replace('\\', "/")
|
||||
);
|
||||
|
||||
run_git_test(
|
||||
&source.path,
|
||||
["remote", "add", "origin", remote_url.as_str()],
|
||||
);
|
||||
run_git_test(
|
||||
&source.path,
|
||||
["push", "-q", "origin", "HEAD:refs/heads/main"],
|
||||
);
|
||||
set_commit_note_for_repo(&source.path, &commit, "Shared review note")
|
||||
.expect("source note should be created");
|
||||
push_commit_notes_for_repo(&source.path, "origin", None, None)
|
||||
.expect("notes should be pushed");
|
||||
|
||||
run_git_test(
|
||||
&target.path,
|
||||
["remote", "add", "origin", remote_url.as_str()],
|
||||
);
|
||||
run_git_test(&target.path, ["fetch", "-q", "origin", "main"]);
|
||||
run_git_test(&target.path, ["checkout", "-q", "FETCH_HEAD"]);
|
||||
fetch_commit_notes_for_repo(&target.path, "origin", None, None)
|
||||
.expect("notes should be fetched");
|
||||
|
||||
assert_eq!(
|
||||
commit_note_for_repo(&target.path, &commit).expect("fetched note should load"),
|
||||
Some("Shared review note".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_directory_name_is_inferred_from_common_remote_urls() {
|
||||
assert_eq!(
|
||||
|
||||
+26
-12
@@ -1,28 +1,33 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod badge;
|
||||
mod external_tools;
|
||||
mod git;
|
||||
mod telemetry;
|
||||
|
||||
use badge::set_sync_badge;
|
||||
use external_tools::{
|
||||
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||
};
|
||||
use git::{
|
||||
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
||||
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
||||
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
|
||||
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
|
||||
cred_delete, cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
|
||||
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
|
||||
get_status, init_repository, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
|
||||
list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort,
|
||||
merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository,
|
||||
open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag,
|
||||
read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree,
|
||||
rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
|
||||
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
||||
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
||||
set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
|
||||
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
|
||||
delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note,
|
||||
get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
|
||||
last_commit_message, list_branches, list_commits, list_file_history,
|
||||
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
|
||||
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
|
||||
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
|
||||
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
|
||||
repair_worktree, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||
restore_files, restore_reflog_entry, restore_to_commit, revert_commit,
|
||||
run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream,
|
||||
set_commit_note, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
|
||||
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
|
||||
};
|
||||
use tauri::Manager;
|
||||
@@ -123,6 +128,10 @@ async fn main() {
|
||||
clone_repository,
|
||||
open_repo_in_explorer,
|
||||
open_repository_file,
|
||||
detect_external_tools,
|
||||
launch_external_tool,
|
||||
launch_external_diff,
|
||||
launch_external_merge,
|
||||
get_status,
|
||||
list_branches,
|
||||
list_remotes,
|
||||
@@ -174,6 +183,11 @@ async fn main() {
|
||||
push,
|
||||
fetch,
|
||||
list_commits,
|
||||
get_commit_note,
|
||||
set_commit_note,
|
||||
delete_commit_note,
|
||||
fetch_commit_notes,
|
||||
push_commit_notes,
|
||||
restore_to_commit,
|
||||
restore_file_from_commit,
|
||||
merge_branch,
|
||||
|
||||
Reference in New Issue
Block a user