diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 6d881ea..4179308 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1,7 +1,9 @@ use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet}, + env, ffi::{OsStr, OsString}, + fs, path::{Path, PathBuf}, process::{Command, Output, Stdio}, sync::{ @@ -170,6 +172,112 @@ pub struct GitSearchHit { pub matches_added: u32, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RebaseCommit { + pub hash: String, + pub short_hash: String, + pub summary: String, + pub author_name: String, + pub date: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RebaseAction { + Pick, + Reword, + Squash, + Fixup, + Drop, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RebasePlanItem { + pub hash: String, + pub action: RebaseAction, + pub message: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ReflogEntry { + pub hash: String, + pub short_hash: String, + pub selector: String, + pub action: String, + pub author_name: String, + pub date: String, +} + +const SEQUENCE_EDITOR_PLAN_ENV: &str = "GITTY_SEQUENCE_EDITOR_PLAN"; +const COMMIT_EDITOR_QUEUE_ENV: &str = "GITTY_COMMIT_EDITOR_QUEUE"; +const REBASE_TODO_FILE: &str = "gitty-interactive-rebase-todo"; +const REWORD_QUEUE_FILE: &str = "gitty-interactive-rebase-messages"; +const SEQUENCE_HELPER_STEM: &str = ".gitty-sequence-editor"; +const COMMIT_HELPER_STEM: &str = ".gitty-commit-editor"; + +pub fn run_sequence_editor_if_requested() -> Option> { + let executable = env::current_exe().ok()?; + let executable_name = executable.file_name()?.to_string_lossy(); + let target = env::args_os() + .nth(1) + .ok_or_else(|| "Git did not provide a sequence-editor target path.".to_string()); + if executable_name.contains("gitty-sequence-editor") { + let plan = env::var_os(SEQUENCE_EDITOR_PLAN_ENV)?; + return Some(target.and_then(|target| { + fs::copy(PathBuf::from(plan), PathBuf::from(target)) + .map(|_| ()) + .map_err(|err| format!("Could not write the interactive rebase plan: {err}")) + })); + } + if executable_name.contains("gitty-commit-editor") { + let queue = env::var_os(COMMIT_EDITOR_QUEUE_ENV)?; + return Some( + target.and_then(|target| apply_reword_message(Path::new(&queue), Path::new(&target))), + ); + } + None +} + +fn apply_reword_message(queue_path: &Path, message_path: &Path) -> Result<(), String> { + let queue = fs::read_to_string(queue_path) + .map_err(|err| format!("Could not read the reword queue: {err}"))?; + if queue.is_empty() { + return Ok(()); + } + let current = fs::read_to_string(message_path) + .map_err(|err| format!("Could not read the commit message: {err}"))?; + let current_subject = current + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('#')) + .unwrap_or(""); + + let mut remaining = Vec::new(); + let mut replacement = None; + for record in queue.split('\x1e').filter(|record| !record.is_empty()) { + let Some((old, new)) = record.split_once('\x1f') else { + return Err("The reword queue is malformed.".to_string()); + }; + if replacement.is_none() && old == current_subject { + replacement = Some(new.to_string()); + } else { + remaining.push(record); + } + } + + if let Some(message) = replacement { + fs::write(message_path, format!("{message}\n")) + .map_err(|err| format!("Could not update the commit message: {err}"))?; + let mut next_queue = remaining.join("\x1e"); + if !next_queue.is_empty() { + next_queue.push('\x1e'); + } + fs::write(queue_path, next_queue) + .map_err(|err| format!("Could not update the reword queue: {err}"))?; + } + Ok(()) +} + #[derive(Debug, Default, Clone, PartialEq, Eq)] struct BranchInfo { current_branch: Option, @@ -838,7 +946,11 @@ pub async fn unstage_files(path: String, files: Vec) -> Result, staged: bool) -> Result { +pub async fn restore_files( + path: String, + files: Vec, + staged: bool, +) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; validate_files(&files)?; @@ -1481,6 +1593,86 @@ pub async fn rebase_branch(path: String, branch: String) -> Result Result, String> { + let repo = resolve_repo(&path)?; + interactive_rebase_commits_for_repo(&repo, &base) +} + +#[tauri::command] +pub async fn start_interactive_rebase( + path: String, + base: String, + plan: Vec, +) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + let status = status_for_repo(&repo)?; + if !status.clean { + return Err( + "Commit or stash working tree changes before starting an interactive rebase." + .to_string(), + ); + } + if status.rebase_in_progress || status.cherry_pick_in_progress { + return Err( + "Finish the current Git operation before starting an interactive rebase." + .to_string(), + ); + } + + let base_hash = verify_commit(&repo, &base)?; + let available = interactive_rebase_commits_for_repo(&repo, &base_hash)?; + validate_rebase_plan(&available, &plan)?; + + let todo = build_rebase_todo(&available, &plan)?; + let reword_queue = build_reword_queue(&available, &plan)?; + let git_dir = git_dir_for_repo(&repo)?; + cleanup_interactive_rebase_helpers(&repo); + let todo_path = git_dir.join(REBASE_TODO_FILE); + let reword_queue_path = git_dir.join(REWORD_QUEUE_FILE); + fs::write(&todo_path, todo) + .map_err(|err| format!("Could not prepare interactive rebase plan: {err}"))?; + fs::write(&reword_queue_path, reword_queue) + .map_err(|err| format!("Could not prepare reword messages: {err}"))?; + + let sequence_helper_path = repo.join(sequence_helper_name()); + let commit_helper_path = repo.join(commit_helper_name()); + let current_exe = env::current_exe() + .map_err(|err| format!("Could not locate the Gitty executable: {err}"))?; + fs::copy(¤t_exe, &sequence_helper_path) + .and_then(|_| fs::copy(¤t_exe, &commit_helper_path)) + .map_err(|err| format!("Could not prepare interactive rebase helpers: {err}"))?; + + let sequence_editor_command = format!("./{}", sequence_helper_name()); + let commit_editor_command = format!("./{}", commit_helper_name()); + let output = git_command() + .arg("-C") + .arg(&repo) + .args(["rebase", "-i", base_hash.as_str()]) + .env("GIT_SEQUENCE_EDITOR", sequence_editor_command) + .env(SEQUENCE_EDITOR_PLAN_ENV, &todo_path) + .env("GIT_EDITOR", commit_editor_command) + .env(COMMIT_EDITOR_QUEUE_ENV, &reword_queue_path) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}")); + + let result = rebase_status_or_error(&repo, output?, "Interactive rebase failed", true); + let _ = fs::remove_file(&todo_path); + let _ = fs::remove_file(&sequence_helper_path); + if !matches!(&result, Ok(status) if status.rebase_in_progress) { + let _ = fs::remove_file(&reword_queue_path); + let _ = fs::remove_file(&commit_helper_path); + } + result + }) + .await + .map_err(|err| format!("Could not run interactive rebase: {err}"))? +} + #[tauri::command] pub fn rebase_continue(path: String) -> Result { let repo = resolve_repo(&path)?; @@ -1488,15 +1680,27 @@ pub fn rebase_continue(path: String) -> Result { return Err("No rebase is currently in progress.".to_string()); } - let output = git_command() - .arg("-C") - .arg(&repo) - .args(["rebase", "--continue"]) - .env("GIT_EDITOR", "true") + let git_dir = git_dir_for_repo(&repo)?; + let queue_path = git_dir.join(REWORD_QUEUE_FILE); + let helper_path = repo.join(commit_helper_name()); + let mut command = git_command(); + command.arg("-C").arg(&repo).args(["rebase", "--continue"]); + if queue_path.exists() && helper_path.exists() { + command + .env("GIT_EDITOR", format!("./{}", commit_helper_name())) + .env(COMMIT_EDITOR_QUEUE_ENV, &queue_path); + } else { + command.env("GIT_EDITOR", "true"); + } + let output = command .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; - rebase_status_or_error(&repo, output, "Rebase continue failed", false) + let result = rebase_status_or_error(&repo, output, "Rebase continue failed", false); + if !matches!(&result, Ok(status) if status.rebase_in_progress) { + cleanup_interactive_rebase_helpers(&repo); + } + result } #[tauri::command] @@ -1507,9 +1711,249 @@ pub fn rebase_abort(path: String) -> Result { } run_git(&repo, ["rebase", "--abort"])?; + cleanup_interactive_rebase_helpers(&repo); status_for_repo(&repo) } +#[tauri::command] +pub fn list_reflog(path: String, limit: Option) -> Result, String> { + let repo = resolve_repo(&path)?; + let limit = limit.unwrap_or(250).clamp(1, 1_000).to_string(); + let output = run_git( + &repo, + [ + "reflog", + "show", + "--date=iso-strict", + "--format=%H%x1f%h%x1f%gD%x1f%gs%x1f%an%x1f%aI%x1e", + "-n", + limit.as_str(), + ], + )?; + parse_reflog(&output) +} + +#[tauri::command] +pub fn restore_reflog_entry( + path: String, + commit: String, + branch: String, +) -> Result { + let repo = resolve_repo(&path)?; + let status = status_for_repo(&repo)?; + if !status.clean { + return Err( + "Commit or stash working tree changes before restoring from the reflog.".to_string(), + ); + } + let commit_hash = verify_commit(&repo, &commit)?; + let branch = validate_new_branch_name(&repo, &branch)?; + run_git( + &repo, + ["checkout", "-b", branch.as_str(), commit_hash.as_str()], + )?; + status_for_repo(&repo) +} + +fn interactive_rebase_commits_for_repo( + repo: &Path, + base: &str, +) -> Result, String> { + let base_hash = verify_commit(repo, base)?; + + let range = format!("{base_hash}..HEAD"); + let merges = run_git(repo, ["rev-list", "--merges", range.as_str()])?; + if !String::from_utf8_lossy(&merges).trim().is_empty() { + return Err("Interactive rebase currently supports linear commit ranges only. Choose a base after the last merge commit.".to_string()); + } + + let output = run_git( + repo, + [ + "log", + "--reverse", + "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1e", + range.as_str(), + ], + )?; + + let mut commits = Vec::new(); + for raw in output.split(|byte| *byte == 0x1e) { + let record = String::from_utf8_lossy(raw); + let record = record.trim_matches(['\r', '\n', ' ']); + if record.is_empty() { + continue; + } + let fields = record.split('\x1f').collect::>(); + if fields.len() != 5 { + return Err("Git returned an unexpected interactive rebase record.".to_string()); + } + commits.push(RebaseCommit { + hash: fields[0].to_string(), + short_hash: fields[1].to_string(), + summary: fields[2].to_string(), + author_name: fields[3].to_string(), + date: fields[4].to_string(), + }); + } + Ok(commits) +} + +fn validate_rebase_plan(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<(), String> { + if commits.is_empty() { + return Err("There are no commits to rebase onto the selected base.".to_string()); + } + if commits.len() != plan.len() { + return Err("The rebase plan must include every commit exactly once.".to_string()); + } + + let expected = commits + .iter() + .map(|item| item.hash.as_str()) + .collect::>(); + let actual = plan + .iter() + .map(|item| item.hash.as_str()) + .collect::>(); + if actual.len() != plan.len() || actual != expected { + return Err( + "The rebase plan contains missing, duplicate, or unexpected commits.".to_string(), + ); + } + + let mut has_kept_commit = false; + for item in plan { + match item.action { + RebaseAction::Drop => {} + RebaseAction::Squash | RebaseAction::Fixup if !has_kept_commit => { + return Err("Squash and fixup need an earlier picked commit.".to_string()); + } + RebaseAction::Reword => { + let message = item.message.as_deref().unwrap_or("").trim(); + if message.is_empty() || message.contains(['\r', '\n']) { + return Err("Reword messages must be a single non-empty line.".to_string()); + } + has_kept_commit = true; + } + _ => has_kept_commit = true, + } + } + if !has_kept_commit { + return Err("Keep at least one commit in the interactive rebase plan.".to_string()); + } + Ok(()) +} + +fn build_rebase_todo(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result { + let by_hash = commits + .iter() + .map(|commit| (commit.hash.as_str(), commit)) + .collect::>(); + let mut todo = String::new(); + for item in plan { + let commit = by_hash + .get(item.hash.as_str()) + .ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?; + let summary = commit.summary.replace(['\r', '\n'], " "); + match item.action { + RebaseAction::Pick => todo.push_str(&format!("pick {} {}\n", item.hash, summary)), + RebaseAction::Reword => todo.push_str(&format!("reword {} {}\n", item.hash, summary)), + RebaseAction::Squash => todo.push_str(&format!("squash {} {}\n", item.hash, summary)), + RebaseAction::Fixup => todo.push_str(&format!("fixup {} {}\n", item.hash, summary)), + RebaseAction::Drop => todo.push_str(&format!("drop {} {}\n", item.hash, summary)), + } + } + Ok(todo) +} + +fn build_reword_queue(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result { + let by_hash = commits + .iter() + .map(|commit| (commit.hash.as_str(), commit)) + .collect::>(); + let mut queue = String::new(); + for item in plan + .iter() + .filter(|item| item.action == RebaseAction::Reword) + { + let commit = by_hash + .get(item.hash.as_str()) + .ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?; + let message = item.message.as_deref().unwrap_or("").trim(); + queue.push_str(&commit.summary.replace(['\r', '\n'], " ")); + queue.push('\x1f'); + queue.push_str(message); + queue.push('\x1e'); + } + Ok(queue) +} + +fn git_dir_for_repo(repo: &Path) -> Result { + let output = run_git(repo, ["rev-parse", "--absolute-git-dir"])?; + let path = String::from_utf8_lossy(&output).trim().to_string(); + if path.is_empty() { + Err("Git could not determine its metadata directory.".to_string()) + } else { + Ok(PathBuf::from(path)) + } +} + +fn sequence_helper_name() -> &'static str { + if cfg!(windows) { + ".gitty-sequence-editor.exe" + } else { + SEQUENCE_HELPER_STEM + } +} + +fn commit_helper_name() -> &'static str { + if cfg!(windows) { + ".gitty-commit-editor.exe" + } else { + COMMIT_HELPER_STEM + } +} + +fn cleanup_interactive_rebase_helpers(repo: &Path) { + if let Ok(git_dir) = git_dir_for_repo(repo) { + let _ = fs::remove_file(git_dir.join(REBASE_TODO_FILE)); + let _ = fs::remove_file(git_dir.join(REWORD_QUEUE_FILE)); + } + let _ = fs::remove_file(repo.join(sequence_helper_name())); + let _ = fs::remove_file(repo.join(commit_helper_name())); +} + +fn is_interactive_rebase_helper_path(path: &str) -> bool { + matches!( + path.replace('\\', "/").rsplit('/').next(), + Some(name) if name == sequence_helper_name() || name == commit_helper_name() + ) +} + +fn parse_reflog(output: &[u8]) -> Result, String> { + let mut entries = Vec::new(); + for raw in output.split(|byte| *byte == 0x1e) { + let record = String::from_utf8_lossy(raw); + let record = record.trim_matches(['\r', '\n', ' ']); + if record.is_empty() { + continue; + } + let fields = record.split('\x1f').collect::>(); + if fields.len() != 6 { + return Err("Git returned an unexpected reflog record.".to_string()); + } + entries.push(ReflogEntry { + hash: fields[0].to_string(), + short_hash: fields[1].to_string(), + selector: fields[2].to_string(), + action: fields[3].to_string(), + author_name: fields[4].to_string(), + date: fields[5].to_string(), + }); + } + Ok(entries) +} + fn rebase_status_or_error( repo: &Path, output: Output, @@ -2521,6 +2965,7 @@ fn status_for_repo(repo: &Path) -> Result { )?; let (branch, mut files) = parse_status_output(&output)?; detect_worktree_renames(repo, &mut files); + files.retain(|file| !is_interactive_rebase_helper_path(&file.path)); Ok(GitStatus { repo_path: repo.to_string_lossy().to_string(), @@ -4582,6 +5027,134 @@ mod tests { assert_eq!(branch.behind, 0); } + #[test] + fn parses_reflog_records() { + let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fHEAD@{0}\x1fcommit: Add feature\x1fAda\x1f2026-07-10T12:00:00+02:00\x1e"; + let entries = parse_reflog(raw).expect("reflog should parse"); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].selector, "HEAD@{0}"); + assert_eq!(entries[0].action, "commit: Add feature"); + } + + #[test] + fn commit_editor_applies_only_the_matching_reword_message() { + let temp = temp_dir("commit_editor"); + let queue = temp.path.join("queue"); + let message = temp.path.join("COMMIT_EDITMSG"); + fs::write( + &queue, + "first commit\x1frenamed first\x1esecond commit\x1frenamed second\x1e", + ) + .expect("queue should be written"); + fs::write( + &message, + "first commit\n\n# Please enter the commit message\n", + ) + .expect("message should be written"); + + apply_reword_message(&queue, &message).expect("message should be applied"); + + assert_eq!(fs::read_to_string(&message).unwrap(), "renamed first\n"); + assert_eq!( + fs::read_to_string(&queue).unwrap(), + "second commit\x1frenamed second\x1e" + ); + } + + #[test] + fn interactive_rebase_builds_a_valid_reword_and_squash_plan() { + let repo = init_temp_repo("interactive_rebase"); + commit_initial_file(&repo.path); + let base = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + + fs::write(repo.path.join("first.txt"), "first\n").expect("first file should be written"); + run_git_test(&repo.path, ["add", "first.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "first commit"]); + fs::write(repo.path.join("second.txt"), "second\n").expect("second file should be written"); + run_git_test(&repo.path, ["add", "second.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "second commit"]); + + let commits = interactive_rebase_commits_for_repo(&repo.path, &base) + .expect("rebase commits should load"); + assert_eq!(commits.len(), 2); + let plan = vec![ + RebasePlanItem { + hash: commits[0].hash.clone(), + action: RebaseAction::Reword, + message: Some("combined feature".to_string()), + }, + RebasePlanItem { + hash: commits[1].hash.clone(), + action: RebaseAction::Squash, + message: None, + }, + ]; + + validate_rebase_plan(&commits, &plan).expect("plan should be valid"); + let todo = build_rebase_todo(&commits, &plan).expect("todo should build"); + + assert!(todo.contains(&format!("reword {} first commit", commits[0].hash))); + assert!(todo.contains(&format!("squash {} second commit", commits[1].hash))); + assert_eq!( + build_reword_queue(&commits, &plan).expect("queue should build"), + "first commit\x1fcombined feature\x1e" + ); + } + + #[test] + fn interactive_rebase_accepts_a_diverged_base_branch() { + let repo = init_temp_repo("interactive_rebase_diverged"); + commit_initial_file(&repo.path); + let main_branch = git_output_test(&repo.path, ["branch", "--show-current"]); + run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]); + fs::write(repo.path.join("feature.txt"), "feature\n") + .expect("feature file should be written"); + run_git_test(&repo.path, ["add", "feature.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "feature commit"]); + run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]); + fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written"); + run_git_test(&repo.path, ["add", "main.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "main advanced"]); + run_git_test(&repo.path, ["checkout", "-q", "feature"]); + + let commits = interactive_rebase_commits_for_repo(&repo.path, &main_branch) + .expect("diverged base should be accepted"); + + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].summary, "feature commit"); + } + + #[test] + fn reflog_restore_creates_a_recovery_branch_without_resetting_existing_branch() { + let repo = init_temp_repo("reflog_restore"); + commit_initial_file(&repo.path); + let initial = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + let original_branch = git_output_test(&repo.path, ["branch", "--show-current"]); + + fs::write(repo.path.join("later.txt"), "later\n").expect("later file should be written"); + run_git_test(&repo.path, ["add", "later.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "later"]); + + let entries = list_reflog(repo.path.to_string_lossy().to_string(), Some(20)) + .expect("reflog should load"); + assert!(entries.len() >= 2); + + let status = restore_reflog_entry( + repo.path.to_string_lossy().to_string(), + initial.clone(), + "recovery/initial".to_string(), + ) + .expect("recovery branch should be created"); + + assert_eq!(status.current_branch.as_deref(), Some("recovery/initial")); + assert_eq!(git_output_test(&repo.path, ["rev-parse", "HEAD"]), initial); + assert!( + ref_exists(&repo.path, &format!("refs/heads/{original_branch}")) + .expect("original branch should still exist") + ); + } + #[test] fn parses_commit_history_records() { let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\x1f2222222222222222222222222222222222222222 3333333333333333333333333333333333333333\x1fAdd history panel\x1e"; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9b3fbc6..cd62789 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -12,14 +12,16 @@ use git::{ compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save, delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits, - list_file_history, list_repository_files, list_stashes, list_tags, merge_branch, - open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull, - push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, - resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, - restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop, - stash_push, undo_last_commit, unstage_files, + list_file_history, list_interactive_rebase_commits, list_reflog, list_repository_files, + list_stashes, list_tags, merge_branch, open_repo_in_explorer, open_repository, + open_repository_bundle, open_repository_file, pull, push, push_tag, read_conflict, + rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict, + resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry, + restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files, + start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, + unstage_files, }; -use tauri::{Manager, AppHandle}; +use tauri::Manager; #[tauri::command] fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { @@ -42,10 +44,19 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { #[tokio::main] async fn main() { + if let Some(result) = run_sequence_editor_if_requested() { + if let Err(error) = result { + eprintln!("{error}"); + std::process::exit(1); + } + return; + } + tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _, _| { #[cfg(desktop)] - let _ = app.get_webview_window("main") + let _ = app + .get_webview_window("main") .expect("no main window") .set_focus(); })) @@ -107,6 +118,10 @@ async fn main() { rebase_branch, rebase_continue, rebase_abort, + list_interactive_rebase_commits, + start_interactive_rebase, + list_reflog, + restore_reflog_entry, list_repository_files, open_repository_bundle, list_file_history, diff --git a/src/App.svelte b/src/App.svelte index 3b54e63..a29bd62 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -22,9 +22,11 @@ import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte"; import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte"; import HistoryPanel from "./lib/components/HistoryPanel.svelte"; + import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte"; import LinePatchDialog from "./lib/components/LinePatchDialog.svelte"; import NewBranchDialog from "./lib/components/NewBranchDialog.svelte"; import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte"; + import ReflogDialog from "./lib/components/ReflogDialog.svelte"; import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import StashPanel from "./lib/components/StashPanel.svelte"; @@ -62,6 +64,8 @@ listTags, listCommits, listFileHistory, + listInteractiveRebaseCommits, + listReflog, listRepositoryFiles, mergeBranch, openRepoInExplorer, @@ -83,9 +87,11 @@ resolveConflict, resolveConflictSide, restoreFileFromCommit, + restoreReflogEntry, restoreFiles, restoreToCommit, searchCodeIntroductions, + startInteractiveRebase, setSyncBadge, stageFiles, stashApply, @@ -119,6 +125,9 @@ LocalModelOption, PatchApplyAction, PreparedResolution, + RebaseCommit, + RebasePlanItem, + ReflogEntry, StoredCredential, } from "./lib/types"; @@ -254,6 +263,15 @@ let deleteBranchForce = false; let compareSelectOpen = false; let compareDialogOpen = false; + let interactiveRebaseOpen = false; + let interactiveRebaseBase = ""; + let interactiveRebaseCommits: RebaseCommit[] = []; + let interactiveRebaseLoading = false; + let interactiveRebaseError = ""; + let reflogOpen = false; + let reflogEntries: ReflogEntry[] = []; + let reflogLoading = false; + let reflogError = ""; let selectedDiffPath = ""; let diffHighlightQuery = ""; let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null; @@ -654,7 +672,7 @@ } async function autoRefreshTick() { - if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return; + if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || newBranchCommit || globalSearchOpen) return; const path = activeRepoPath; autoRefreshInFlight = true; try { @@ -1580,6 +1598,13 @@ comparison = null; compareSelectOpen = false; compareDialogOpen = false; + interactiveRebaseOpen = false; + interactiveRebaseBase = ""; + interactiveRebaseCommits = []; + interactiveRebaseError = ""; + reflogOpen = false; + reflogEntries = []; + reflogError = ""; selectedDiffPath = ""; pendingRestoreFile = null; newBranchCommit = null; @@ -2250,6 +2275,99 @@ }); } + function preferredInteractiveRebaseBase(): string { + const candidates = [status?.upstream, "origin/main", "main", "origin/master", "master"] + .filter((value): value is string => Boolean(value) && value !== status?.current_branch); + for (const candidate of candidates) { + if (branches.some((branch) => branch.name === candidate)) return candidate; + } + return branches.find((branch) => !branch.current)?.name ?? ""; + } + + async function loadInteractiveRebaseRange(base: string) { + interactiveRebaseBase = base; + interactiveRebaseCommits = []; + interactiveRebaseError = ""; + if (!activeRepoPath || !base) return; + interactiveRebaseLoading = true; + try { + const result = await listInteractiveRebaseCommits(activeRepoPath, base); + if (interactiveRebaseBase === base) interactiveRebaseCommits = result; + } catch (error) { + if (interactiveRebaseBase === base) interactiveRebaseError = errorToMessage(error); + } finally { + if (interactiveRebaseBase === base) interactiveRebaseLoading = false; + } + } + + function openInteractiveRebase() { + if (!hasRepository || rebaseInProgress || cherryPickInProgress || isBusy) return; + interactiveRebaseOpen = true; + const base = preferredInteractiveRebaseBase(); + void loadInteractiveRebaseRange(base); + trackEvent("interactive_rebase_opened"); + } + + async function runInteractiveRebase(plan: RebasePlanItem[]) { + if (!activeRepoPath || !interactiveRebaseBase || isBusy) return; + interactiveRebaseError = ""; + await runOperation("Starting interactive rebase", async () => { + applyStatus(await startInteractiveRebase(activeRepoPath, interactiveRebaseBase, plan)); + interactiveRebaseOpen = false; + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + trackEvent("interactive_rebase_started", { commits: plan.length }); + }); + if (interactiveRebaseOpen && errorMessage) interactiveRebaseError = errorMessage; + } + + async function openReflog() { + if (!hasRepository || isBusy) return; + reflogOpen = true; + reflogEntries = []; + reflogError = ""; + reflogLoading = true; + try { + reflogEntries = await listReflog(activeRepoPath, 300); + trackEvent("reflog_opened", { entries: reflogEntries.length }); + } catch (error) { + reflogError = errorToMessage(error); + } finally { + reflogLoading = false; + } + } + + async function previewReflogEntry(entry: ReflogEntry) { + if (!activeRepoPath || isBusy) return; + await runOperation("Previewing reflog entry", async () => { + const result = await compareCommits(activeRepoPath, entry.hash, "HEAD"); + comparison = result; + selectedDiffPath = result.files[0]?.path ?? ""; + diffHighlightQuery = ""; + pendingRestoreFile = null; + reflogOpen = false; + compareDialogOpen = true; + trackEvent("reflog_previewed", { files: result.files.length }); + }); + } + + async function recoverReflogEntry(entry: ReflogEntry, branch: string) { + if (!activeRepoPath || !branch.trim() || isBusy) return; + reflogError = ""; + await runOperation("Restoring reflog entry", async () => { + applyStatus(await restoreReflogEntry(activeRepoPath, entry.hash, branch.trim())); + reflogOpen = false; + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + trackEvent("reflog_recovered"); + }); + if (reflogOpen && errorMessage) reflogError = errorMessage; + } + async function createNewTag(name: string, message: string) { const trimmed = name.trim(); if (!activeRepoPath || !trimmed) return; @@ -3337,6 +3455,8 @@ else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog(); + else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false; + else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); } @@ -3374,6 +3494,8 @@ onRefresh={refreshRepo} onSearch={openGlobalSearchDialog} onCompare={openCompareSelect} + onInteractiveRebase={openInteractiveRebase} + onReflog={openReflog} onOpenInExplorer={openActiveRepoInExplorer} onToggleAutoRefresh={toggleAutoRefresh} onOpenSettings={() => { appSettingsOpen = true; }} @@ -4093,6 +4215,37 @@ /> {/if} + +{#if interactiveRebaseOpen} + { if (!isBusy) interactiveRebaseOpen = false; }} + /> +{/if} + +{#if reflogOpen} + entry.selector === "HEAD@{0}")?.hash ?? ""} + isLoading={reflogLoading} + {isBusy} + {operation} + error={reflogError} + onPreview={previewReflogEntry} + onRestore={recoverReflogEntry} + onClose={() => { if (!isBusy) reflogOpen = false; }} + /> +{/if} + {#if compareSelectOpen} span { font-weight: 700; } + .rebase-base-bar label strong { color: var(--color-ink); font-family: var(--font-mono); } + .rebase-base-bar p { margin: 0 0 4px; color: var(--color-ink-faint); font-size: 12px; line-height: 1.45; } + .rebase-plan { + display: grid; + align-content: start; + min-height: 0; + padding: 8px; + overflow: auto; + background: var(--code-surface); + } + .rebase-plan-row { + display: grid; + grid-template-columns: auto 112px 64px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 48px; + padding: 6px 8px; + border: 1px solid transparent; + border-bottom-color: var(--color-border-subtle); + background: var(--code-surface); + } + .rebase-plan-row:hover { border-color: var(--color-border-subtle); background: var(--code-hover-bg); } + .rebase-plan-row.drop { opacity: 0.58; background: var(--code-delete-bg); } + .rebase-order-actions { display: inline-flex; gap: 3px; } + .rebase-order-actions button { + width: 25px; + min-height: 25px; + padding: 0; + border-radius: 5px; + background: var(--code-surface-subtle); + } + .rebase-action { height: 30px; font-family: var(--font-mono); font-weight: 800; } + .rebase-action.pick { color: var(--code-add-strong); } + .rebase-action.reword { color: var(--code-hunk-text); } + .rebase-action.squash, .rebase-action.fixup { color: #96620f; } + .rebase-action.drop { color: var(--code-delete-strong); } + .rebase-plan-row > code { color: var(--color-accent); font-family: var(--font-mono); font-size: 11px; font-weight: 800; } + .rebase-commit-copy { display: grid; gap: 3px; min-width: 0; } + .rebase-commit-copy strong { overflow: hidden; color: var(--color-ink); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; } + .rebase-commit-copy span { color: var(--color-ink-faint); font-size: 10.5px; } + .rebase-commit-copy input { height: 30px; font-family: var(--font-mono); font-size: 12px; } + .rebase-warning { + display: flex; + align-items: center; + gap: 7px; + margin: 8px 12px 0; + padding: 8px 10px; + border: 1px solid rgba(224,160,64,0.25); + border-radius: 7px; + color: #b87914; + background: rgba(224,160,64,0.08); + font-size: 12px; + } + .rebase-warning.error { border-color: rgba(232,96,96,0.28); color: var(--code-delete-text); background: var(--code-delete-bg); } + .rebase-footer-actions { display: flex; gap: 8px; } + + .reflog-body { display: grid; grid-template-columns: minmax(340px, 0.8fr) minmax(0, 1.2fr); min-height: 0; overflow: hidden; } + .reflog-list-pane { display: grid; grid-template-rows: auto minmax(0, 1fr); min-width: 0; min-height: 0; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); } + .reflog-search { position: relative; display: flex; align-items: center; padding: 10px; border-bottom: 1px solid var(--color-border-subtle); } + .reflog-search svg { position: absolute; left: 21px; color: var(--color-ink-faint); } + .reflog-search input { padding-left: 34px; } + .reflog-list { display: grid; align-content: start; min-height: 0; padding: 7px; overflow: auto; } + .reflog-list > button { + display: grid; + justify-content: stretch; + gap: 4px; + width: 100%; + min-height: 70px; + padding: 8px 10px; + border-color: transparent; + border-bottom-color: var(--color-border-subtle); + border-radius: 6px; + background: transparent; + text-align: left; + } + .reflog-list > button:hover:not(:disabled) { background: var(--color-surface-hover); } + .reflog-list > button.active { border-color: rgba(49,95,214,0.3); background: rgba(49,95,214,0.09); } + .reflog-list > button strong { overflow: hidden; color: var(--color-ink); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } + .reflog-row-top, .reflog-row-bottom { display: flex; align-items: center; justify-content: space-between; min-width: 0; gap: 8px; color: var(--color-ink-faint); font-size: 10.5px; } + .reflog-row-top code { color: var(--color-accent); font-weight: 800; } + .reflog-row-bottom code { color: var(--color-ink-dim); } + .reflog-detail { display: grid; align-content: start; gap: 14px; min-width: 0; padding: 18px; overflow: auto; } + .reflog-detail-head { display: flex; align-items: center; gap: 10px; } + .reflog-detail-head > svg { color: var(--color-accent); } + .reflog-detail-head h3 { margin: 2px 0 0; color: var(--color-ink); font-size: 17px; } + .reflog-detail dl { display: grid; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-border-subtle); } + .reflog-detail dl > div { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 10px; padding: 9px 11px; background: var(--color-surface-raised); } + .reflog-detail dt { color: var(--color-ink-faint); font-size: 11px; font-weight: 800; text-transform: uppercase; } + .reflog-detail dd { min-width: 0; margin: 0; overflow: hidden; color: var(--color-ink-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } + .reflog-detail dd code { font-family: var(--font-mono); } + .reflog-preview { justify-self: start; } + .reflog-recovery-card { display: grid; gap: 12px; padding: 14px; border: 1px solid rgba(78,202,118,0.24); border-radius: 10px; background: rgba(78,202,118,0.07); } + .reflog-recovery-title { display: flex; align-items: flex-start; gap: 9px; } + .reflog-recovery-title > svg { flex: 0 0 auto; color: var(--code-add-strong); } + .reflog-recovery-title div { display: grid; gap: 3px; } + .reflog-recovery-title strong { color: var(--color-ink); font-size: 13px; } + .reflog-recovery-title span { color: var(--color-ink-faint); font-size: 11px; line-height: 1.4; } + .reflog-recovery-card label { display: grid; gap: 5px; color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; } + .reflog-recovery-card label > div { position: relative; display: flex; align-items: center; } + .reflog-recovery-card label svg { position: absolute; left: 10px; color: var(--color-accent); } + .reflog-recovery-card label input { padding-left: 33px; font-family: var(--font-mono); text-transform: none; letter-spacing: 0; } + .reflog-recovery-card .btn-primary { justify-self: start; } .new-branch-dialog { display: block; width: min(520px, calc(100vw - 32px)); @@ -3124,7 +3302,7 @@ gap: 8px; padding: 7px 12px; border-bottom: 1px solid var(--color-border-subtle); - background: #171a2b; + background: var(--code-surface-raised); font-family: var(--font-mono); font-size: 12px; color: var(--color-ink-muted); @@ -3146,7 +3324,7 @@ font-size: 12px; line-height: 1.5; tab-size: 2; - background: #111321; + background: var(--code-surface); } .split-pane { @@ -3170,8 +3348,8 @@ white-space: pre-wrap; word-break: break-all; } - .split-span.split-meta { color: var(--color-ink-faint); background: #0c0e18; font-size: 11px; } - .split-span.split-hunk { color: #7aacff; background: rgba(122,172,255,0.08); padding: 3px 10px; } + .split-span.split-meta { color: var(--color-ink-faint); background: var(--code-surface-meta); font-size: 11px; } + .split-span.split-hunk { color: var(--code-hunk-text); background: var(--code-hunk-bg); padding: 3px 10px; } .split-num { padding: 0 6px 0 4px; @@ -3180,11 +3358,11 @@ font-size: 11px; user-select: none; border-right: 1px solid var(--color-border-subtle); - background: #0d101a; + background: var(--code-surface-subtle); } - .split-num.del { background: rgba(232,96,96,0.12); color: rgba(232,96,96,0.6); border-right-color: rgba(232,96,96,0.2); } - .split-num.add { background: rgba(78,202,118,0.1); color: rgba(78,202,118,0.6); border-right-color: rgba(78,202,118,0.2); } - .split-num.empty { background: #10131e; } + .split-num.del { background: var(--code-delete-gutter-bg); color: var(--code-delete-strong); border-right-color: rgba(232,96,96,0.2); } + .split-num.add { background: var(--code-add-gutter-bg); color: var(--code-add-strong); border-right-color: rgba(78,202,118,0.2); } + .split-num.empty { background: var(--code-surface-muted); } .split-cell { padding: 0 8px; @@ -3193,20 +3371,20 @@ min-width: 0; overflow: visible; } - .split-cell.del { background: rgba(232,96,96,0.1); color: #ef8080; } - .split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; } - .split-cell.empty { background: #10131e; } + .split-cell.del { background: var(--code-delete-bg); color: var(--code-delete-text); } + .split-cell.add { background: var(--code-add-bg); color: var(--code-add-text); } + .split-cell.empty { background: var(--code-surface-muted); } /* 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; + background: var(--code-match-bg); + color: var(--code-match-text); 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); + background: var(--code-match-gutter-bg); + color: var(--code-match-text); border-right-color: rgba(240,182,72,0.35); } @@ -3226,7 +3404,7 @@ text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-ink-faint); - background: #0d101a; + background: var(--code-surface-subtle); } .split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); } .split-col-hash { @@ -3338,7 +3516,7 @@ .line-patch-scroll { min-height: 0; overflow: auto; - background: #0b0b14; + background: var(--code-surface); } .line-patch-hunk { @@ -3358,7 +3536,7 @@ min-width: 100%; padding: 7px 10px; border-bottom: 1px solid var(--color-border-subtle); - background: rgba(20, 22, 36, 0.96); + background: color-mix(in srgb, var(--code-surface-raised) 96%, transparent); } .line-patch-hunk-head code { color: var(--color-accent); @@ -3378,23 +3556,23 @@ padding: 0 8px; border: 1px solid var(--color-border-subtle); border-radius: 3px; - background: rgba(255, 255, 255, 0.03); + background: var(--code-surface-subtle); 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); + background: var(--code-hover-bg); } .line-patch-hunk-button.discard { border-color: rgba(255, 90, 103, 0.7); - color: #ffccd1; + color: var(--code-delete-text); } .line-patch-hunk-button.stage, .line-patch-hunk-button.unstage { border-color: rgba(78, 202, 118, 0.72); - color: #bff1ce; + color: var(--code-add-text); } .line-patch-lines { @@ -3412,12 +3590,12 @@ color: var(--color-ink-muted); } .line-patch-row.add { - background: rgba(78, 202, 118, 0.09); - color: #bff1ce; + background: var(--code-add-bg); + color: var(--code-add-text); } .line-patch-row.delete { - background: rgba(255, 90, 103, 0.1); - color: #ffccd1; + background: var(--code-delete-bg); + color: var(--code-delete-text); } .line-patch-row.meta { color: var(--color-ink-faint); @@ -3427,8 +3605,8 @@ 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.add .line-patch-prefix { color: var(--code-add-strong); } + .line-patch-row.delete .line-patch-prefix { color: var(--code-delete-strong); } .line-patch-row code { white-space: pre; font-family: var(--font-mono); @@ -3437,7 +3615,7 @@ .blame-body { min-height: 0; overflow: hidden; - background: #111321; + background: var(--code-surface); } .blame-code-header strong { @@ -3455,7 +3633,7 @@ min-height: 38px; padding: 6px 10px; border-bottom: 1px solid var(--color-border-subtle); - background: #111321; + background: var(--code-surface); } .blame-search-bar svg { position: absolute; @@ -3469,7 +3647,7 @@ padding: 0 34px; border: 1px solid var(--color-border-subtle); border-radius: 6px; - background: #0b0e18; + background: var(--code-input-bg); color: var(--color-ink); font-size: 12px; } @@ -3522,7 +3700,7 @@ border-bottom: 1px solid var(--color-border-subtle); } .blame-group.uncommitted { - background: #171725; + background: var(--code-surface-raised); } .blame-meta { @@ -3535,8 +3713,8 @@ min-width: 0; padding: 8px 12px; border-right: 1px solid var(--color-border-subtle); - background: #0d101a; - box-shadow: 8px 0 18px rgba(0, 0, 0, 0.18); + background: var(--code-surface-subtle); + box-shadow: 8px 0 18px color-mix(in srgb, var(--color-ink) 8%, transparent); } .blame-hash { align-self: flex-start; @@ -3545,7 +3723,7 @@ padding: 2px 7px; border: 1px solid rgba(90,140,248,0.2); border-radius: 5px; - background: #141b2d; + background: var(--code-surface-raised); color: var(--color-accent); font-size: 10.5px; font-weight: 700; @@ -3562,7 +3740,7 @@ } .blame-summary { overflow: hidden; - color: #aeb6d8; + color: var(--color-ink-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; @@ -3572,7 +3750,7 @@ font-size: 10.5px; } .blame-group:hover .blame-meta { - background: #111728; + background: var(--code-hover-bg); } .blame-group.uncommitted .blame-hash, .blame-group.uncommitted .blame-author { @@ -3580,7 +3758,7 @@ } .blame-group.uncommitted .blame-hash { border-color: rgba(232, 180, 90, 0.26); - background: #271f14; + background: color-mix(in srgb, #e8b45a 13%, var(--code-surface)); } .blame-lines { @@ -3595,16 +3773,16 @@ min-height: 20px; } .blame-group:hover .blame-line-number { - background: #111728; + background: var(--code-hover-bg); } .blame-group:hover .blame-line-code { - background: #151a2b; + background: var(--code-hover-bg); } .blame-search-hit { padding: 0 1px; border-radius: 3px; - background: rgba(240,182,72,0.28); - color: #f3d487; + background: var(--code-match-bg); + color: var(--code-match-text); } .global-search-body { @@ -3800,8 +3978,9 @@ margin: 0; padding: 8px 10px; border-radius: 7px; - color: #5dd88a; - background: rgba(78,202,118,0.08); + border: 1px solid color-mix(in srgb, var(--code-add-strong) 18%, transparent); + color: var(--code-add-text); + background: var(--code-add-bg); font-family: var(--font-mono); font-size: 12px; line-height: 1.45; @@ -4321,14 +4500,14 @@ } .diff-line { display: block; white-space: pre-wrap; word-break: break-word; } .diff-line.meta { color: var(--color-ink-faint); } - .diff-line.hunk { color: #7aacff; background: rgba(122,172,255,0.07); } - .diff-line.add { color: #4eca76; background: rgba(78,202,118,0.09); } - .diff-line.del { color: #e86060; background: rgba(232,96,96,0.09); } + .diff-line.hunk { color: var(--code-hunk-text); background: var(--code-hunk-bg); } + .diff-line.add { color: var(--code-add-text); background: var(--code-add-bg); } + .diff-line.del { color: var(--code-delete-text); background: var(--code-delete-bg); } .diff-line.context { color: var(--color-ink-muted); } .diff-counts { display: flex; gap: 8px; font-family: var(--font-mono); font-size: 12px; font-weight: 700; } - .diff-counts .adds { color: #4eca76; } - .diff-counts .dels { color: #e86060; } + .diff-counts .adds { color: var(--code-add-strong); } + .diff-counts .dels { color: var(--code-delete-strong); } /* --- Conflict resolver --- */ @@ -4399,7 +4578,7 @@ overflow: hidden; border: 1px solid var(--color-border-subtle); border-radius: 6px; - background: rgba(0,0,0,0.12); + background: var(--code-surface); font-family: var(--font-mono); font-size: 12px; line-height: 1.5; @@ -4431,19 +4610,19 @@ font-weight: 800; text-transform: uppercase; } - .resolve-split-marker.unresolved { color: #ef8080; background: rgba(232,96,96,0.12); } + .resolve-split-marker.unresolved { color: var(--code-delete-text); background: var(--code-delete-gutter-bg); } .resolve-num { padding: 0 6px 0 4px; border-right: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); - background: rgba(0,0,0,0.14); + background: var(--code-surface-subtle); text-align: right; user-select: none; } - .resolve-num.ours { color: rgba(78,202,118,0.65); background: rgba(78,202,118,0.11); border-right-color: rgba(78,202,118,0.2); } - .resolve-num.theirs { color: rgba(122,172,255,0.65); background: rgba(122,172,255,0.11); border-right-color: rgba(122,172,255,0.2); } - .resolve-num.empty { background: rgba(0,0,0,0.07); } + .resolve-num.ours { color: var(--code-add-strong); background: var(--code-add-gutter-bg); border-right-color: rgba(78,202,118,0.2); } + .resolve-num.theirs { color: var(--code-hunk-text); background: var(--code-hunk-bg); border-right-color: rgba(122,172,255,0.2); } + .resolve-num.empty { background: var(--code-surface-muted); } .resolve-cell { min-width: 0; @@ -4452,9 +4631,9 @@ color: var(--color-ink-muted); white-space: pre; } - .resolve-cell.ours { color: #5dd88a; background: rgba(78,202,118,0.1); } - .resolve-cell.theirs { color: #8fb4ff; background: rgba(90,140,248,0.11); } - .resolve-cell.empty { background: rgba(0,0,0,0.06); } + .resolve-cell.ours { color: var(--code-add-text); background: var(--code-add-bg); } + .resolve-cell.theirs { color: var(--code-hunk-text); background: var(--code-hunk-bg); } + .resolve-cell.empty { background: var(--code-surface-muted); } .resolve-cell.dimmed { opacity: 0.42; filter: grayscale(0.5); } .resolve-context { margin: 0; padding: 2px 8px; overflow-x: auto; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; tab-size: 2; color: var(--color-ink-muted); } @@ -4474,13 +4653,13 @@ .resolve-side.dimmed { opacity: 0.4; filter: grayscale(0.5); } .resolve-side-label { font-size: 11px; font-weight: 800; text-transform: uppercase; } - .resolve-side.ours .resolve-side-label { color: #4eca76; } - .resolve-side.theirs .resolve-side-label { color: #6a9aff; } + .resolve-side.ours .resolve-side-label { color: var(--code-add-strong); } + .resolve-side.theirs .resolve-side-label { color: var(--code-hunk-text); } .resolve-lines { margin: 0; overflow-x: auto; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; tab-size: 2; } .resolve-line { display: block; white-space: pre-wrap; word-break: break-word; } - .resolve-line.ours { color: #4eca76; } - .resolve-line.theirs { color: #7aacff; } + .resolve-line.ours { color: var(--code-add-text); } + .resolve-line.theirs { color: var(--code-hunk-text); } .resolve-line.context { color: var(--color-ink-muted); } .resolve-binary { display: grid; align-content: start; gap: 12px; padding: 4px; } @@ -4834,22 +5013,75 @@ background: var(--color-surface-hover); } -:root[data-theme="light"] .split-span.split-meta { - background: #e9eef7; -} - -:root[data-theme="light"] .resolve-split, -:root[data-theme="light"] .resolve-num, -:root[data-theme="light"] .resolve-cell.empty, -:root[data-theme="light"] .resolve-num.empty { - background: rgba(234,239,248,0.72); -} - :root[data-theme="light"] .diff-line.meta, :root[data-theme="light"] .resolve-split-marker { color: #64728a; } +:root[data-theme="light"] .global-search-tabs, +:root[data-theme="light"] .file-search-history, +:root[data-theme="light"] .file-search-history-head { + background: var(--color-surface-dim); +} + +:root[data-theme="light"] .discard-target { + background: var(--code-surface-subtle); +} + +:root[data-theme="light"] .commit-amend-toggle input { + border-color: var(--color-border-input); + background: var(--app-input-bg); +} + +:root[data-theme="light"] .commit-amend-toggle input:checked { + border-color: var(--color-primary); + box-shadow: inset 0 0 0 2px #ffffff; +} + +:root[data-theme="light"] .cred-segment { + border-color: rgba(49,95,214,0.2); + background: #eef3f9; +} + +:root[data-theme="light"] .cred-seg-btn.active { + border-color: rgba(49,95,214,0.26); + background: linear-gradient(135deg, rgba(49,95,214,0.12), rgba(15,143,181,0.08)); + box-shadow: 0 8px 18px rgba(28,44,74,0.1), inset 0 1px 0 rgba(255,255,255,0.9); +} + +:root[data-theme="light"] .cred-close { + border-color: var(--color-border-subtle); + color: var(--color-ink-dim); + background: rgba(255,255,255,0.76); +} + +:root[data-theme="light"] .cred-close:hover:not(:disabled) { + border-color: var(--color-border-input); + color: var(--color-ink); + background: var(--color-surface-hover); +} + +:root[data-theme="light"] .status-badge.modified, +:root[data-theme="light"] .resolve-status, +:root[data-theme="light"] .resolve-conflict-label { + color: #8a580a; +} + +:root[data-theme="light"] .status-badge.added, +:root[data-theme="light"] .status-badge.untracked, +:root[data-theme="light"] .pill-active, +:root[data-theme="light"] .prepared-tag { + color: #19723d; +} + +:root[data-theme="light"] .status-badge.deleted { + color: #b4233b; +} + +:root[data-theme="light"] .status-badge.renamed { + color: #245cc7; +} + :root[data-theme="light"] .cred-input input, :root[data-theme="light"] .cred-expiry input[type="date"] { background: rgba(255,255,255,0.92); @@ -5022,6 +5254,10 @@ .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; } + .rebase-base-bar { grid-template-columns: 1fr; } + .rebase-plan-row { grid-template-columns: auto 96px 54px minmax(180px, 1fr); } + .reflog-body { grid-template-columns: 1fr; grid-template-rows: minmax(220px, 0.8fr) minmax(0, 1.2fr); } + .reflog-list-pane { 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; } .tb-action-label { display: none; } diff --git a/src/lib/TitleBar.svelte b/src/lib/TitleBar.svelte index 3d14bea..393e112 100644 --- a/src/lib/TitleBar.svelte +++ b/src/lib/TitleBar.svelte @@ -2,7 +2,7 @@ import { onDestroy, onMount } from "svelte"; import { getVersion } from "@tauri-apps/api/app"; import { getCurrentWindow } from "@tauri-apps/api/window"; - import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte"; + import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, History, ListRestart, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte"; import iconUrl from "../../src-tauri/icons/icon.png"; export let branch: string = ""; @@ -20,6 +20,8 @@ export let onRefresh: () => void = () => {}; export let onSearch: () => void = () => {}; export let onCompare: () => void = () => {}; + export let onInteractiveRebase: () => void = () => {}; + export let onReflog: () => void = () => {}; export let onOpenInExplorer: () => void = () => {}; export let onToggleAutoRefresh: () => void = () => {}; export let onOpenSettings: () => void = () => {}; @@ -79,17 +81,23 @@
{#if hasRepository} - {#if repoName} - {repoName} - - {/if} -
+ diff --git a/src/lib/components/ReflogDialog.svelte b/src/lib/components/ReflogDialog.svelte new file mode 100644 index 0000000..764fe4c --- /dev/null +++ b/src/lib/components/ReflogDialog.svelte @@ -0,0 +1,80 @@ + + + diff --git a/src/lib/git.ts b/src/lib/git.ts index 56e7ee4..6bf434c 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -10,6 +10,9 @@ import type { GitCommit, GitCommitComparison, GitRepositoryFile, + RebaseCommit, + RebasePlanItem, + ReflogEntry, GitSearchHit, GitStash, GitStatus, @@ -306,6 +309,26 @@ export function rebaseAbort(path: string): Promise { return invoke("rebase_abort", { path }); } +export function listInteractiveRebaseCommits(path: string, base: string): Promise { + return invoke("list_interactive_rebase_commits", { path, base }); +} + +export function startInteractiveRebase( + path: string, + base: string, + plan: RebasePlanItem[], +): Promise { + return invoke("start_interactive_rebase", { path, base, plan }); +} + +export function listReflog(path: string, limit = 250): Promise { + return invoke("list_reflog", { path, limit }); +} + +export function restoreReflogEntry(path: string, commit: string, branch: string): Promise { + return invoke("restore_reflog_entry", { path, commit, branch }); +} + export function listRepositoryFiles(path: string): Promise { return invoke("list_repository_files", { path }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index b761c75..0b9a25e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -198,6 +198,31 @@ export interface GitBlameResult { lines: GitBlameLine[]; } +export type RebaseAction = "pick" | "reword" | "squash" | "fixup" | "drop"; + +export interface RebaseCommit { + hash: string; + short_hash: string; + summary: string; + author_name: string; + date: string; +} + +export interface RebasePlanItem { + hash: string; + action: RebaseAction; + message: string | null; +} + +export interface ReflogEntry { + hash: string; + short_hash: string; + selector: string; + action: string; + author_name: string; + date: string; +} + export interface StoredCredential { username: string; password: string;