feat(git): add interactive rebase and reflog recovery features

This update significantly expands Git functionality by implementing support for advanced workflows, including interactive rebasing and recovering lost commits via the reflog. New logic handles preparing the necessary environment files (todo lists and reword queues) required by Git's internal editors. The frontend components are also updated to expose these new capabilities to the user interface.

- Implements full planning and execution flow for interactive rebase
- Adds functionality to list and restore commits using the reflog history
- Updates Rust backend commands to support advanced git operations
This commit is contained in:
Christoph Brandau
2026-07-10 22:49:08 +02:00
parent d0bca62362
commit f7b8beaad4
11 changed files with 1168 additions and 11 deletions
+1
View File
@@ -0,0 +1 @@
<!doctype html><html data-theme="light"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>Git features QA</title><script type="module" src="/src/feature-qa.ts"></script></head><body><div id="qa"></div></body></html>
+531 -1
View File
@@ -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,108 @@ 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<String>,
}
#[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";
pub fn run_sequence_editor_if_requested() -> Option<Result<(), String>> {
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<String>,
@@ -838,7 +942,11 @@ pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus
}
#[tauri::command]
pub async fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
pub async fn restore_files(
path: String,
files: Vec<String>,
staged: bool,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(&files)?;
@@ -1481,6 +1589,97 @@ pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, St
.map_err(|err| format!("Could not rebase: {err}"))?
}
#[tauri::command]
pub fn list_interactive_rebase_commits(
path: String,
base: String,
) -> Result<Vec<RebaseCommit>, 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<RebasePlanItem>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
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)?;
ensure_ancestor(
&repo,
&base_hash,
"The selected base must be an ancestor of HEAD.",
)?;
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)?;
let todo_path = git_dir.join("gitty-interactive-rebase-todo");
let reword_queue_path = git_dir.join("gitty-interactive-rebase-messages");
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_name = if cfg!(windows) {
format!(".gitty-sequence-editor-{}.exe", std::process::id())
} else {
format!(".gitty-sequence-editor-{}", std::process::id())
};
let commit_helper_name = if cfg!(windows) {
format!(".gitty-commit-editor-{}.exe", std::process::id())
} else {
format!(".gitty-commit-editor-{}", std::process::id())
};
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(&current_exe, &sequence_helper_path)
.and_then(|_| fs::copy(&current_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 _ = fs::remove_file(&todo_path);
let _ = fs::remove_file(&reword_queue_path);
let _ = fs::remove_file(&sequence_helper_path);
let _ = fs::remove_file(&commit_helper_path);
rebase_status_or_error(&repo, output?, "Interactive rebase failed", true)
})
.await
.map_err(|err| format!("Could not run interactive rebase: {err}"))?
}
#[tauri::command]
pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
@@ -1510,6 +1709,232 @@ pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
status_for_repo(&repo)
}
#[tauri::command]
pub fn list_reflog(path: String, limit: Option<u32>) -> Result<Vec<ReflogEntry>, 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<GitStatus, String> {
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<Vec<RebaseCommit>, String> {
let base_hash = verify_commit(repo, base)?;
ensure_ancestor(
repo,
&base_hash,
"The selected base must be an ancestor of HEAD.",
)?;
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::<Vec<_>>();
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 ensure_ancestor(repo: &Path, commit: &str, message: &str) -> Result<(), String> {
let output = git_command()
.arg("-C")
.arg(repo)
.args(["merge-base", "--is-ancestor", commit, "HEAD"])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() {
Ok(())
} else {
Err(message.to_string())
}
}
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::<BTreeSet<_>>();
let actual = plan
.iter()
.map(|item| item.hash.as_str())
.collect::<BTreeSet<_>>();
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<String, String> {
let by_hash = commits
.iter()
.map(|commit| (commit.hash.as_str(), commit))
.collect::<BTreeMap<_, _>>();
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<String, String> {
let by_hash = commits
.iter()
.map(|commit| (commit.hash.as_str(), commit))
.collect::<BTreeMap<_, _>>();
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<PathBuf, String> {
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 parse_reflog(output: &[u8]) -> Result<Vec<ReflogEntry>, 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::<Vec<_>>();
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,
@@ -4582,6 +5007,111 @@ 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 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";
+23 -8
View File
@@ -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,
+154 -1
View File
@@ -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;
@@ -640,7 +658,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 {
@@ -1566,6 +1584,13 @@
comparison = null;
compareSelectOpen = false;
compareDialogOpen = false;
interactiveRebaseOpen = false;
interactiveRebaseBase = "";
interactiveRebaseCommits = [];
interactiveRebaseError = "";
reflogOpen = false;
reflogEntries = [];
reflogError = "";
selectedDiffPath = "";
pendingRestoreFile = null;
newBranchCommit = null;
@@ -2236,6 +2261,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;
@@ -3323,6 +3441,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();
}
@@ -3360,6 +3480,8 @@
onRefresh={refreshRepo}
onSearch={openGlobalSearchDialog}
onCompare={openCompareSelect}
onInteractiveRebase={openInteractiveRebase}
onReflog={openReflog}
onOpenInExplorer={openActiveRepoInExplorer}
onToggleAutoRefresh={toggleAutoRefresh}
onOpenSettings={() => { appSettingsOpen = true; }}
@@ -4079,6 +4201,37 @@
/>
{/if}
<!-- Compare: pick the two commits to diff -->
{#if interactiveRebaseOpen}
<InteractiveRebaseDialog
{branches}
currentBranch={status?.current_branch ?? ""}
base={interactiveRebaseBase}
commits={interactiveRebaseCommits}
isLoading={interactiveRebaseLoading}
{isBusy}
{operation}
error={interactiveRebaseError}
onBaseChange={loadInteractiveRebaseRange}
onStart={runInteractiveRebase}
onClose={() => { if (!isBusy) interactiveRebaseOpen = false; }}
/>
{/if}
{#if reflogOpen}
<ReflogDialog
entries={reflogEntries}
currentHash={commits[0]?.hash ?? ""}
isLoading={reflogLoading}
{isBusy}
{operation}
error={reflogError}
onPreview={previewReflogEntry}
onRestore={recoverReflogEntry}
onClose={() => { if (!isBusy) reflogOpen = false; }}
/>
{/if}
<!-- Compare: pick the two commits to diff -->
{#if compareSelectOpen}
<CompareSelectDialog
+131
View File
@@ -2786,6 +2786,133 @@
max-height: calc(100vh - 32px);
overflow: auto;
}
.interactive-rebase-dialog,
.reflog-dialog {
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(1120px, calc(100vw - 32px));
height: min(820px, 100%);
}
.interactive-rebase-body {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
align-content: start;
min-height: 0;
overflow: hidden;
}
.rebase-base-bar {
display: grid;
grid-template-columns: minmax(280px, 0.7fr) minmax(260px, 1fr);
align-items: end;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-dim);
}
.rebase-base-bar label { display: grid; gap: 5px; color: var(--color-ink-muted); font-size: 12px; }
.rebase-base-bar label > 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));
@@ -5127,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; }
+27
View File
@@ -0,0 +1,27 @@
import { mount } from "svelte";
import "./app.css";
import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte";
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
const target = document.getElementById("qa")!;
const branches = [
{ name: "features/rewrite-history", current: true, remote: false },
{ name: "main", current: false, remote: false },
{ name: "origin/main", current: false, remote: true },
];
const commits = [
{ hash: "1111111111111111111111111111111111111111", short_hash: "1111111", summary: "Add reflog backend", author_name: "Ada", date: "2026-07-10T09:10:00+02:00" },
{ hash: "2222222222222222222222222222222222222222", short_hash: "2222222", summary: "Build interactive rebase dialog", author_name: "Linus", date: "2026-07-10T10:20:00+02:00" },
{ hash: "3333333333333333333333333333333333333333", short_hash: "3333333", summary: "Polish recovery workflow", author_name: "Grace", date: "2026-07-10T11:30:00+02:00" },
];
const entries = [
{ hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", short_hash: "aaaaaaa", selector: "HEAD@{0}", action: "commit: Add recovery workflow", author_name: "Ada", date: "2026-07-10T12:00:00+02:00" },
{ hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", short_hash: "bbbbbbb", selector: "HEAD@{1}", action: "rebase (finish): returning to refs/heads/feature", author_name: "Ada", date: "2026-07-10T11:00:00+02:00" },
{ hash: "cccccccccccccccccccccccccccccccccccccccc", short_hash: "ccccccc", selector: "HEAD@{2}", action: "checkout: moving from main to feature", author_name: "Ada", date: "2026-07-10T10:00:00+02:00" },
];
if (location.hash === "#reflog") {
mount(ReflogDialog, { target, props: { entries, currentHash: entries[0].hash, isLoading: false, isBusy: false, operation: "", error: "", onPreview: () => {}, onRestore: (_entry, branch) => { document.title = `Recovered ${branch}`; }, onClose: () => {} } });
} else {
mount(InteractiveRebaseDialog, { target, props: { branches, currentBranch: branches[0].name, base: "main", commits, isLoading: false, isBusy: false, operation: "", error: "", onBaseChange: () => {}, onStart: (plan) => { document.title = `Rebase ${plan.length} commits`; }, onClose: () => {} } });
}
+25 -1
View File
@@ -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 = () => {};
@@ -138,6 +140,28 @@
<span class="tb-action-label">Compare</span>
</button>
<button
class="tb-action"
onclick={onInteractiveRebase}
disabled={!hasRepository || isBusy}
title="Interactive rebase"
aria-label="Interactive rebase"
>
<ListRestart size={14} aria-hidden="true" />
<span class="tb-action-label">Rebase</span>
</button>
<button
class="tb-action"
onclick={onReflog}
disabled={!hasRepository || isBusy}
title="Reflog"
aria-label="Reflog"
>
<History size={14} aria-hidden="true" />
<span class="tb-action-label">Reflog</span>
</button>
<button
class="tb-action"
onclick={onFetch}
@@ -0,0 +1,148 @@
<script lang="ts">
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
interface PlanRow extends RebaseCommit {
action: RebaseAction;
message: string;
}
interface Props {
branches: GitBranchInfo[];
currentBranch: string;
base: string;
commits: RebaseCommit[];
isLoading: boolean;
isBusy: boolean;
operation: string;
error: string;
onBaseChange: (base: string) => void;
onStart: (plan: RebasePlanItem[]) => void;
onClose: () => void;
}
let {
branches = [], currentBranch = "", base = "", commits = [], isLoading = false,
isBusy = false, operation = "", error = "", onBaseChange = () => {},
onStart = () => {}, onClose = () => {},
}: Props = $props();
let rows = $state<PlanRow[]>([]);
$effect(() => {
rows = commits.map((commit) => ({ ...commit, action: "pick", message: commit.summary }));
});
let availableBases = $derived(branches.filter((branch) => !branch.current));
let keptCount = $derived(rows.filter((row) => row.action !== "drop").length);
let invalidSquash = $derived(rows.some((row, index) =>
(row.action === "squash" || row.action === "fixup")
&& rows.slice(0, index).every((previous) => previous.action === "drop")
));
let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim()));
let canStart = $derived(Boolean(base) && rows.length > 0 && keptCount > 0 && !invalidSquash && !invalidReword && !isLoading && !isBusy);
function updateAction(index: number, action: RebaseAction) {
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
}
function updateMessage(index: number, message: string) {
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, message } : row);
}
function move(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= rows.length) return;
const next = [...rows];
[next[index], next[target]] = [next[target], next[index]];
rows = next;
}
function start() {
if (!canStart) return;
onStart(rows.map((row) => ({
hash: row.hash,
action: row.action,
message: row.action === "reword" ? row.message.trim() : null,
})));
}
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label="Interactive rebase" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Rewrite local history</span>
<h2 class="dialog-title">Interactive rebase</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
</header>
<div class="interactive-rebase-body">
<section class="rebase-base-bar">
<label>
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
<select value={base} onchange={(event) => onBaseChange((event.target as HTMLSelectElement).value)} disabled={isBusy || isLoading}>
<option value="" disabled>Select a base branch</option>
{#each availableBases as branch (branch.name)}
<option value={branch.name}>{branch.remote ? "Remote · " : "Local · "}{branch.name}</option>
{/each}
</select>
</label>
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
</section>
{#if error}
<div class="rebase-warning error"><AlertTriangle size={16} aria-hidden="true" /><span>{error}</span></div>
{/if}
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading rebase range…</div>
{:else if !base}
<div class="blank-state">Select the branch or commit that should become the new base.</div>
{:else if rows.length === 0}
<div class="blank-state">No linear commits are available above this base.</div>
{:else}
<div class="rebase-plan" role="list" aria-label="Interactive rebase plan">
{#each rows as row, index (row.hash)}
<article class:drop={row.action === "drop"} class="rebase-plan-row" role="listitem">
<div class="rebase-order-actions">
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
</div>
<select class={`rebase-action ${row.action}`} value={row.action} onchange={(event) => updateAction(index, (event.target as HTMLSelectElement).value as RebaseAction)} disabled={isBusy} aria-label={`Action for ${row.short_hash}`}>
<option value="pick">pick</option><option value="reword">reword</option><option value="squash">squash</option><option value="fixup">fixup</option><option value="drop">drop</option>
</select>
<code>{row.short_hash}</code>
<div class="rebase-commit-copy">
{#if row.action === "reword"}
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={`New message for ${row.short_hash}`} maxlength="240" />
{:else}
<strong>{row.summary}</strong>
{/if}
<span>{row.author_name} · {new Date(row.date).toLocaleString()}</span>
</div>
</article>
{/each}
</div>
{/if}
{#if invalidSquash}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Squash and fixup need an earlier commit that is not dropped.</div>
{:else if invalidReword}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Reword messages cannot be empty.</div>
{/if}
</div>
<footer class="dialog-footer">
<span class="dialog-footer-info">{keptCount} of {rows.length} commits kept</span>
<div class="rebase-footer-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-primary" type="button" onclick={start} disabled={!canStart}>
{#if operation === "Starting interactive rebase"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Play size={16} aria-hidden="true" />{/if}
Start rebase
</button>
</div>
</footer>
</div>
</div>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import { GitBranch, History, LoaderCircle, Search, ShieldCheck, X } from "@lucide/svelte";
import type { ReflogEntry } from "../types";
interface Props {
entries: ReflogEntry[];
currentHash: string;
isLoading: boolean;
isBusy: boolean;
operation: string;
error: string;
onPreview: (entry: ReflogEntry) => void;
onRestore: (entry: ReflogEntry, branch: string) => void;
onClose: () => void;
}
let { entries = [], currentHash = "", isLoading = false, isBusy = false, operation = "", error = "", onPreview = () => {}, onRestore = () => {}, onClose = () => {} }: Props = $props();
let query = $state("");
let selectedHash = $state("");
let recoveryBranch = $state("");
let filteredEntries = $derived(entries.filter((entry) => `${entry.selector} ${entry.action} ${entry.short_hash} ${entry.author_name}`.toLowerCase().includes(query.trim().toLowerCase())));
let selected = $derived(entries.find((entry) => entry.hash === selectedHash) ?? filteredEntries[0] ?? null);
$effect(() => {
if (!selectedHash && entries.length > 0) select(entries[0]);
});
function select(entry: ReflogEntry) {
selectedHash = entry.hash;
recoveryBranch = `recovery/${entry.short_hash}`;
}
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label="Reflog" tabindex="-1">
<header class="dialog-header">
<div><span class="eyebrow">Recovery history</span><h2 class="dialog-title">Reflog</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
</header>
<div class="reflog-body">
<aside class="reflog-list-pane">
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder="Search actions, hashes or authors" aria-label="Search reflog" /></label>
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading reflog…</div>
{:else if filteredEntries.length === 0}
<div class="blank-state">No reflog entries match this search.</div>
{:else}
<div class="reflog-list" role="listbox" aria-label="Reflog entries">
{#each filteredEntries as entry (`${entry.selector}:${entry.hash}`)}
<button class:active={selected?.selector === entry.selector} type="button" role="option" aria-selected={selected?.selector === entry.selector} onclick={() => select(entry)}>
<span class="reflog-row-top"><code>{entry.selector}</code><span>{new Date(entry.date).toLocaleString()}</span></span>
<strong>{entry.action}</strong>
<span class="reflog-row-bottom"><code>{entry.short_hash}</code><span>{entry.author_name}</span></span>
</button>
{/each}
</div>
{/if}
</aside>
<section class="reflog-detail">
{#if error}<div class="rebase-warning error">{error}</div>{/if}
{#if selected}
<div class="reflog-detail-head"><History size={20} aria-hidden="true" /><div><span class="eyebrow">{selected.selector}</span><h3>{selected.action}</h3></div></div>
<dl><div><dt>Commit</dt><dd><code>{selected.hash}</code></dd></div><div><dt>Author</dt><dd>{selected.author_name}</dd></div><div><dt>Date</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> Preview changes to current HEAD</button>
<div class="reflog-recovery-card">
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>Safe recovery</strong><span>Create a new branch here. The current branch is not reset or deleted.</span></div></div>
<label><span>Recovery branch</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
<button class="btn-primary" type="button" onclick={() => onRestore(selected, recoveryBranch.trim())} disabled={isBusy || !recoveryBranch.trim()}>
{#if operation === "Restoring reflog entry"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<ShieldCheck size={16} aria-hidden="true" />{/if}
Create and checkout recovery branch
</button>
</div>
{:else}
<div class="blank-state">Select a reflog entry to inspect or recover it.</div>
{/if}
</section>
</div>
</div>
</div>
+23
View File
@@ -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<GitStatus> {
return invoke<GitStatus>("rebase_abort", { path });
}
export function listInteractiveRebaseCommits(path: string, base: string): Promise<RebaseCommit[]> {
return invoke<RebaseCommit[]>("list_interactive_rebase_commits", { path, base });
}
export function startInteractiveRebase(
path: string,
base: string,
plan: RebasePlanItem[],
): Promise<GitStatus> {
return invoke<GitStatus>("start_interactive_rebase", { path, base, plan });
}
export function listReflog(path: string, limit = 250): Promise<ReflogEntry[]> {
return invoke<ReflogEntry[]>("list_reflog", { path, limit });
}
export function restoreReflogEntry(path: string, commit: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("restore_reflog_entry", { path, commit, branch });
}
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
}
+25
View File
@@ -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;