Add pull request draft generation to the commit_ai crate and expose it
via a new Tauri command. The backend builds a branch-range context from
published remote-tracking refs only, calls the chosen AI provider, and
parses a JSON {"title","description"} draft (with validation). Also
register the command in the app and add unit tests for parsing and the
branch-context behavior.
Consolidate AI settings in the frontend by renaming the dialog to an
AiSettingsPage and integrating AI options into the main AppSettings
dialog. Persisted AI preferences are merged with existing localStorage
rather than replacing it, and the settings UI now supports opening the
app settings to a specific initial page ("integrations" or "ai").
Other changes:
- Replace the commit-message system prompt used by build_messages with
the updated, more detailed guidance text.
474 lines
16 KiB
Rust
474 lines
16 KiB
Rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
|
|
mod badge;
|
|
mod external_tools;
|
|
mod git;
|
|
mod integrations;
|
|
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_to_gitignore, add_worktree, amend_commit,
|
|
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
|
pull_request_ai_generate, cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
|
commit_ai_review, commit_ai_split, compare_commits, compare_file_to_head,
|
|
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
|
delete_branch, delete_commit_note, delete_remote_branch, delete_remote_branches, delete_tag,
|
|
diff_file_against_working_tree, fetch, fetch_commit_notes, get_bisect_state, get_commit_note,
|
|
get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune,
|
|
git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, 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, mark_bisect, 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,
|
|
rename_remote_branch, repair_worktree, reset_bisect, 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_bisect, start_interactive_rebase,
|
|
stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree,
|
|
unstage_files, untrack_paths, update_remote,
|
|
};
|
|
use integrations::{
|
|
create_integration_review_request, list_integration_repository_branches,
|
|
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
|
|
create_integration_issue, list_azure_issue_projects, list_azure_issue_types,
|
|
run_integration_review_action, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue, list_azure_issue_states, set_azure_issue_state,
|
|
};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Mutex;
|
|
use tauri::{Emitter, Manager};
|
|
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
|
|
|
struct StartupRepository(Mutex<Option<String>>);
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct StartupCloneRequest {
|
|
remote_url: String,
|
|
parent_path: String,
|
|
directory_name: String,
|
|
}
|
|
|
|
struct StartupClone(Mutex<Option<StartupCloneRequest>>);
|
|
|
|
fn resolve_startup_path(path: &str, cwd: &Path) -> Option<PathBuf> {
|
|
let path = path.trim();
|
|
if path.is_empty() {
|
|
return None;
|
|
}
|
|
let path = PathBuf::from(path);
|
|
Some(if path.is_absolute() {
|
|
path
|
|
} else {
|
|
cwd.join(path)
|
|
})
|
|
}
|
|
|
|
fn clone_request_from_args(
|
|
args: impl IntoIterator<Item = String>,
|
|
cwd: &Path,
|
|
) -> Option<StartupCloneRequest> {
|
|
let mut args = args.into_iter();
|
|
|
|
while let Some(arg) = args.next() {
|
|
let remote_url = if arg == "clone" || arg == "--clone" {
|
|
args.next()
|
|
} else {
|
|
arg.strip_prefix("--clone=").map(ToString::to_string)
|
|
};
|
|
let Some(remote_url) = remote_url.filter(|value| !value.trim().is_empty()) else {
|
|
continue;
|
|
};
|
|
let target = resolve_startup_path(&args.next()?, cwd)?;
|
|
let directory_name = target.file_name()?.to_string_lossy().trim().to_string();
|
|
let parent_path = target.parent()?.to_string_lossy().into_owned();
|
|
if directory_name.is_empty() || parent_path.trim().is_empty() {
|
|
return None;
|
|
}
|
|
return Some(StartupCloneRequest {
|
|
remote_url,
|
|
parent_path,
|
|
directory_name,
|
|
});
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path) -> Option<String> {
|
|
let mut args = args.into_iter();
|
|
let mut repository = None;
|
|
|
|
while let Some(arg) = args.next() {
|
|
if arg == "clone" || arg == "--clone" || arg.starts_with("--clone=") {
|
|
return None;
|
|
}
|
|
if arg == "--repo" {
|
|
repository = args.next();
|
|
break;
|
|
}
|
|
if let Some(path) = arg.strip_prefix("--repo=") {
|
|
repository = Some(path.to_string());
|
|
break;
|
|
}
|
|
if !arg.starts_with('-') {
|
|
repository = Some(arg);
|
|
break;
|
|
}
|
|
}
|
|
|
|
repository
|
|
.and_then(|path| resolve_startup_path(&path, cwd))
|
|
.map(|path| path.to_string_lossy().into_owned())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod startup_repository_tests {
|
|
use super::{clone_request_from_args, repository_path_from_args};
|
|
use std::path::Path;
|
|
|
|
#[test]
|
|
fn accepts_direct_relative_repository_path() {
|
|
let path =
|
|
repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user"));
|
|
assert_eq!(
|
|
path.map(|value| value.replace('\\', "/")),
|
|
Some("/home/user/projects/repo".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_repo_option() {
|
|
let path = repository_path_from_args(
|
|
["--repo".to_string(), "/projects/repo".to_string()],
|
|
Path::new("/home/user"),
|
|
);
|
|
assert_eq!(path.as_deref(), Some("/projects/repo"));
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_repo_equals_option() {
|
|
let path = repository_path_from_args(
|
|
["--repo=projects/repo".to_string()],
|
|
Path::new("/home/user"),
|
|
);
|
|
assert_eq!(
|
|
path.map(|value| value.replace('\\', "/")),
|
|
Some("/home/user/projects/repo".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_clone_command_with_exact_relative_target() {
|
|
let request = clone_request_from_args(
|
|
[
|
|
"clone".to_string(),
|
|
"https://example.com/team/project.git".to_string(),
|
|
"clones/local-copy".to_string(),
|
|
],
|
|
Path::new("/home/user"),
|
|
)
|
|
.expect("clone request should be parsed");
|
|
|
|
assert_eq!(request.remote_url, "https://example.com/team/project.git");
|
|
assert_eq!(request.directory_name, "local-copy");
|
|
assert_eq!(request.parent_path.replace('\\', "/"), "/home/user/clones");
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_clone_option_and_clone_equals_option() {
|
|
for args in [
|
|
vec![
|
|
"--clone".to_string(),
|
|
"git@example.com:team/project.git".to_string(),
|
|
"/projects/project".to_string(),
|
|
],
|
|
vec![
|
|
"--clone=git@example.com:team/project.git".to_string(),
|
|
"/projects/project".to_string(),
|
|
],
|
|
] {
|
|
let request = clone_request_from_args(args, Path::new("/home/user"))
|
|
.expect("clone option should be parsed");
|
|
assert_eq!(request.directory_name, "project");
|
|
assert_eq!(request.parent_path.replace('\\', "/"), "/projects");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn repository_parser_does_not_treat_clone_values_as_repository_paths() {
|
|
let path = repository_path_from_args(
|
|
[
|
|
"--clone".to_string(),
|
|
"https://example.com/team/project.git".to_string(),
|
|
"clones/project".to_string(),
|
|
],
|
|
Path::new("/home/user"),
|
|
);
|
|
assert!(path.is_none());
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn take_startup_repository(state: tauri::State<'_, StartupRepository>) -> Option<String> {
|
|
state.0.lock().ok()?.take()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn take_startup_clone(state: tauri::State<'_, StartupClone>) -> Option<StartupCloneRequest> {
|
|
state.0.lock().ok()?.take()
|
|
}
|
|
|
|
struct ConsoleLogger;
|
|
|
|
impl log::Log for ConsoleLogger {
|
|
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
|
metadata.level() <= log::Level::Info
|
|
}
|
|
|
|
fn log(&self, record: &log::Record<'_>) {
|
|
if self.enabled(record.metadata()) {
|
|
eprintln!(
|
|
"[{}] [{}] {}",
|
|
record.level(),
|
|
record.target(),
|
|
record.args()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn flush(&self) {}
|
|
}
|
|
|
|
static CONSOLE_LOGGER: ConsoleLogger = ConsoleLogger;
|
|
|
|
fn init_console_logging() {
|
|
if log::set_logger(&CONSOLE_LOGGER).is_ok() {
|
|
log::set_max_level(log::LevelFilter::Info);
|
|
log::info!(target: "gitty", "Rust console logging initialized");
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
|
if let Some(window) = app.get_webview_window("splashscreen") {
|
|
let _ = window.hide();
|
|
window
|
|
.destroy()
|
|
.map_err(|error| format!("failed to destroy splashscreen: {error}"))?;
|
|
}
|
|
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.maximize();
|
|
window
|
|
.show()
|
|
.map_err(|error| format!("failed to show main window: {error}"))?;
|
|
let _ = window.set_focus();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
init_console_logging();
|
|
telemetry::init();
|
|
if let Some(result) = run_sequence_editor_if_requested() {
|
|
if let Err(error) = result {
|
|
eprintln!("{error}");
|
|
std::process::exit(1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let startup_args: Vec<String> = std::env::args().skip(1).collect();
|
|
let startup_cwd = std::env::current_dir().unwrap_or_default();
|
|
let startup_clone = clone_request_from_args(startup_args.clone(), &startup_cwd);
|
|
let startup_repository = repository_path_from_args(startup_args, &startup_cwd);
|
|
|
|
let builder = tauri::Builder::default()
|
|
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
|
let args: Vec<String> = args.into_iter().skip(1).collect();
|
|
if let Some(request) = clone_request_from_args(args.clone(), Path::new(&cwd)) {
|
|
if let Ok(mut pending) = app.state::<StartupClone>().0.lock() {
|
|
*pending = Some(request);
|
|
}
|
|
let _ = app.emit("open-startup-repository", ());
|
|
} else if let Some(path) = repository_path_from_args(args, Path::new(&cwd)) {
|
|
if let Ok(mut pending) = app.state::<StartupRepository>().0.lock() {
|
|
*pending = Some(path);
|
|
}
|
|
let _ = app.emit("open-startup-repository", ());
|
|
}
|
|
#[cfg(desktop)]
|
|
let _ = app
|
|
.get_webview_window("main")
|
|
.expect("no main window")
|
|
.set_focus();
|
|
}))
|
|
.plugin(
|
|
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
|
.with_options(tauri_plugin_aptabase::InitOptions {
|
|
host: Some("https://aptabase.cbsk-tech.de".to_string()),
|
|
flush_interval: None,
|
|
})
|
|
.build(),
|
|
)
|
|
.manage(StartupRepository(Mutex::new(startup_repository)))
|
|
.manage(StartupClone(Mutex::new(startup_clone)))
|
|
.manage(SearchCancellationState::default())
|
|
.plugin(tauri_plugin_dialog::init());
|
|
|
|
// Linux installs are expected to come from the system package manager (see the
|
|
// PKGBUILD), which owns updates itself — the self-updater is only wired up for
|
|
// the platforms whose install method this app ships (NSIS/Windows, .app/macOS).
|
|
#[cfg(not(target_os = "linux"))]
|
|
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
|
|
|
builder
|
|
.invoke_handler(tauri::generate_handler![
|
|
open_repository,
|
|
init_repository,
|
|
clone_repository,
|
|
open_repo_in_explorer,
|
|
open_repository_file,
|
|
detect_external_tools,
|
|
launch_external_tool,
|
|
launch_external_diff,
|
|
launch_external_merge,
|
|
get_status,
|
|
git_lfs_status,
|
|
git_lfs_install,
|
|
git_lfs_track,
|
|
git_lfs_untrack,
|
|
git_lfs_pull,
|
|
git_lfs_prune,
|
|
list_branches,
|
|
list_remotes,
|
|
add_remote,
|
|
update_remote,
|
|
remove_remote,
|
|
set_branch_upstream,
|
|
delete_remote_branch,
|
|
delete_remote_branches,
|
|
list_stashes,
|
|
checkout_branch,
|
|
create_branch,
|
|
rename_branch,
|
|
rename_remote_branch,
|
|
delete_branch,
|
|
list_worktrees,
|
|
add_worktree,
|
|
remove_worktree,
|
|
move_worktree,
|
|
lock_worktree,
|
|
unlock_worktree,
|
|
prune_worktrees,
|
|
repair_worktree,
|
|
list_tags,
|
|
create_tag,
|
|
delete_tag,
|
|
push_tag,
|
|
cherry_pick_commit,
|
|
cherry_pick_continue,
|
|
cherry_pick_abort,
|
|
stage_files,
|
|
unstage_files,
|
|
add_to_gitignore,
|
|
untrack_paths,
|
|
stash_push,
|
|
stash_apply,
|
|
stash_pop,
|
|
stash_drop,
|
|
restore_files,
|
|
get_file_patch,
|
|
apply_file_patch,
|
|
commit,
|
|
amend_commit,
|
|
undo_last_commit,
|
|
last_commit_message,
|
|
commit_ai_generate,
|
|
pull_request_ai_generate,
|
|
commit_ai_review,
|
|
commit_ai_split,
|
|
pull,
|
|
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,
|
|
merge_continue,
|
|
merge_abort,
|
|
revert_commit,
|
|
rebase_branch,
|
|
rebase_continue,
|
|
rebase_abort,
|
|
list_interactive_rebase_commits,
|
|
start_interactive_rebase,
|
|
list_reflog,
|
|
restore_reflog_entry,
|
|
get_bisect_state,
|
|
start_bisect,
|
|
mark_bisect,
|
|
reset_bisect,
|
|
list_repository_files,
|
|
open_repository_bundle,
|
|
list_file_history,
|
|
cancel_file_history,
|
|
get_file_blame,
|
|
compare_commits,
|
|
compare_file_to_head,
|
|
compare_file_to_parent,
|
|
diff_file_against_working_tree,
|
|
search_code_introductions,
|
|
cancel_code_search,
|
|
read_conflict,
|
|
resolve_conflict,
|
|
resolve_conflict_side,
|
|
get_remote_url,
|
|
cred_load,
|
|
cred_save,
|
|
cred_delete,
|
|
list_integration_repository_branches,
|
|
create_integration_review_request,
|
|
list_integration_repositories,
|
|
list_integration_review_requests,
|
|
list_integration_issues,
|
|
get_integration_board,
|
|
list_integration_boards,
|
|
move_integration_board_card,
|
|
list_integration_issue_comments,
|
|
add_integration_issue_comment,
|
|
create_integration_issue,
|
|
list_azure_issue_projects,
|
|
list_azure_issue_types,
|
|
close_integration_issue,
|
|
list_azure_issue_states,
|
|
set_azure_issue_state,
|
|
get_integration_review_details,
|
|
add_integration_review_comment,
|
|
run_integration_review_action,
|
|
open_in_browser,
|
|
set_sync_badge,
|
|
close_splashscreen,
|
|
set_telemetry_enabled,
|
|
emit_frontend_log,
|
|
emit_frontend_span,
|
|
take_startup_repository,
|
|
take_startup_clone
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|