Compare commits

..
9 Commits
Author SHA1 Message Date
Christoph Brandau c174fb3a80 Merge branch 'opt/light_mode'
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m59s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 27m53s
2026-07-10 22:59:20 +02:00
Christoph Brandau c747e02f29 feat(git): improve interactive rebase base handling and reflog display
The git module now correctly handles scenarios where the selected base branch is diverged from HEAD. This removes unnecessary ancestor checks during rebase planning, improving overall robustness of the feature. Additionally, the ReflogDialog component was updated to reliably retrieve the current HEAD hash for accurate display in the UI.

- Removed strict ancestor checking when starting interactive rebase operations.
- Updated App.svelte to accurately find and use the current HEAD entry from reflog data.
2026-07-10 22:56:48 +02:00
Christoph Brandau 791275f341 refactor(git): improve interactive rebase workflow robustness
This commit removes the dedicated feature QA page and significantly refactors the internal Git library logic for handling complex workflows, particularly interactive rebase. The changes introduce helper functions to manage temporary files and ensure proper cleanup of rebase artifacts regardless of success or failure. This improves the reliability of advanced git operations within the application.

- Added comprehensive cleanup routines for rebase helpers
- Centralized constants for rebase file names
- Removed obsolete QA feature code
2026-07-10 22:53:24 +02:00
Christoph Brandau f7b8beaad4 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
2026-07-10 22:49:08 +02:00
Christoph Brandau d0bca62362 refactor(ui): Improve title bar layout using flex and grid
The structure of the application title bar has been updated to utilize modern CSS layout techniques like Grid and Flexbox. This refactoring improves the responsiveness and organization of key elements such as repository name, branch indicator, and synchronization status. The changes ensure better alignment and handling of varying content lengths across different viewports.

- Restructures repo/branch info into a dedicated context container
- Uses flex properties for dynamic sizing of title bar sections
- Groups sync indicators into a cohesive unit
2026-07-10 22:21:53 +02:00
Christoph Brandau d3cde83518 style(css): standardize code surface and highlight colors
Introduces a comprehensive set of CSS variables defining surfaces, syntax elements, and diff coloring across both light and dark themes. This refactoring replaces numerous hardcoded color values throughout the stylesheet with these new variables, ensuring that all components consistently adhere to the defined theme palette.

- Added detailed variable definitions for code surfaces (e.g., `--code-surface`, `--code-input-bg`).
- Updated diff, hunk, and blame sections to use variable colors for better thematic consistency.
- Replaced fixed hex codes with variables across various component backgrounds and text colors.
2026-07-10 22:16:06 +02:00
Christoph 3fdd6d3467 Update version to 0.200.10 2026-07-10 20:27:29 +02:00
Christoph 900a6fa230 feat(core): improve platform compatibility and startup robustness
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m21s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Failing after 1h43m55s
This update enhances cross-platform reliability by addressing potential hangs during application startup on Linux environments using WebKitGTK. Additionally, native taskbar badge functionality is improved for macOS and Linux, ensuring accurate display of sync status. Configuration settings are also updated to include more specific internal crate reads and web search capabilities.

- Added timeout fallback to waitForAnimationFrame in Svelte
- Improved set_sync_badge logic for macOS/Linux platforms
- Updated local configuration with detailed dependency reads
2026-07-10 20:24:48 +02:00
Christoph 2d7946f6fd Update version to 2026.7.18 2026-07-10 18:38:11 +02:00
14 changed files with 1428 additions and 113 deletions
+5 -1
View File
@@ -100,7 +100,11 @@
"Bash(pkg-config --exists openssl)", "Bash(pkg-config --exists openssl)",
"Bash(sudo apt-get install -y libssl-dev pkg-config)", "Bash(sudo apt-get install -y libssl-dev pkg-config)",
"Bash(dpkg -L libssl3t64)", "Bash(dpkg -L libssl3t64)",
"Bash(grep *)" "Bash(grep *)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/**)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-runtime-wry-2.11.4/src/**)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/window/**)",
"WebSearch"
] ]
} }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.7.17", "version": "0.200.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "2026.7.17", "version": "0.200.10",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.7.17", "version": "0.200.10",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+15 -1
View File
@@ -177,7 +177,21 @@ pub fn set_sync_badge(
.map_err(|err| format!("Could not set taskbar badge: {err}"))?; .map_err(|err| format!("Could not set taskbar badge: {err}"))?;
} }
#[cfg(not(target_os = "windows"))] // Native badge count: works on Linux (via the desktop's launcher API, e.g. Unity's
// LauncherEntry, also honored by GNOME/KDE) and macOS (dock badge). Matched against the
// app's `<product-name>.desktop` file on Linux, so it's a silent no-op on launchers that
// don't implement the protocol.
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let Some(window) = app.get_webview_window("main") else {
return Ok(());
};
window
.set_badge_count(if count > 0 { Some(count as i64) } else { None })
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{ {
let _ = (app, count); let _ = (app, count);
} }
+580 -7
View File
@@ -1,7 +1,9 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
collections::{BTreeMap, BTreeSet}, collections::{BTreeMap, BTreeSet},
env,
ffi::{OsStr, OsString}, ffi::{OsStr, OsString},
fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::{Command, Output, Stdio}, process::{Command, Output, Stdio},
sync::{ sync::{
@@ -170,6 +172,112 @@ pub struct GitSearchHit {
pub matches_added: u32, 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";
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<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)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
struct BranchInfo { struct BranchInfo {
current_branch: Option<String>, current_branch: Option<String>,
@@ -838,7 +946,11 @@ pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus
} }
#[tauri::command] #[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> { tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
validate_files(&files)?; validate_files(&files)?;
@@ -1481,6 +1593,86 @@ pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, St
.map_err(|err| format!("Could not rebase: {err}"))? .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)?;
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(&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 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] #[tauri::command]
pub fn rebase_continue(path: String) -> Result<GitStatus, String> { pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -1488,15 +1680,27 @@ pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
return Err("No rebase is currently in progress.".to_string()); return Err("No rebase is currently in progress.".to_string());
} }
let output = git_command() let git_dir = git_dir_for_repo(&repo)?;
.arg("-C") let queue_path = git_dir.join(REWORD_QUEUE_FILE);
.arg(&repo) let helper_path = repo.join(commit_helper_name());
.args(["rebase", "--continue"]) let mut command = git_command();
.env("GIT_EDITOR", "true") 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() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .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] #[tauri::command]
@@ -1507,9 +1711,249 @@ pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
} }
run_git(&repo, ["rebase", "--abort"])?; run_git(&repo, ["rebase", "--abort"])?;
cleanup_interactive_rebase_helpers(&repo);
status_for_repo(&repo) 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)?;
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 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 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<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( fn rebase_status_or_error(
repo: &Path, repo: &Path,
output: Output, output: Output,
@@ -2521,6 +2965,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
)?; )?;
let (branch, mut files) = parse_status_output(&output)?; let (branch, mut files) = parse_status_output(&output)?;
detect_worktree_renames(repo, &mut files); detect_worktree_renames(repo, &mut files);
files.retain(|file| !is_interactive_rebase_helper_path(&file.path));
Ok(GitStatus { Ok(GitStatus {
repo_path: repo.to_string_lossy().to_string(), repo_path: repo.to_string_lossy().to_string(),
@@ -4582,6 +5027,134 @@ mod tests {
assert_eq!(branch.behind, 0); 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] #[test]
fn parses_commit_history_records() { 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"; 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, 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, 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, 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, list_file_history, list_interactive_rebase_commits, list_reflog, list_repository_files,
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull, list_stashes, list_tags, merge_branch, open_repo_in_explorer, open_repository,
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, open_repository_bundle, open_repository_file, pull, push, push_tag, read_conflict,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict,
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop, resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry,
stash_push, undo_last_commit, unstage_files, 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] #[tauri::command]
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
@@ -42,10 +44,19 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
#[tokio::main] #[tokio::main]
async fn 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() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| { .plugin(tauri_plugin_single_instance::init(|app, _, _| {
#[cfg(desktop)] #[cfg(desktop)]
let _ = app.get_webview_window("main") let _ = app
.get_webview_window("main")
.expect("no main window") .expect("no main window")
.set_focus(); .set_focus();
})) }))
@@ -107,6 +118,10 @@ async fn main() {
rebase_branch, rebase_branch,
rebase_continue, rebase_continue,
rebase_abort, rebase_abort,
list_interactive_rebase_commits,
start_interactive_rebase,
list_reflog,
restore_reflog_entry,
list_repository_files, list_repository_files,
open_repository_bundle, open_repository_bundle,
list_file_history, list_file_history,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty", "productName": "Gitty",
"version": "2026.7.17", "version": "0.200.10",
"identifier": "com.gitty", "identifier": "com.gitty",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+169 -2
View File
@@ -22,9 +22,11 @@
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte"; import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte"; import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
import HistoryPanel from "./lib/components/HistoryPanel.svelte"; import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte";
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte"; import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte"; import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte"; import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte"; import StashPanel from "./lib/components/StashPanel.svelte";
@@ -62,6 +64,8 @@
listTags, listTags,
listCommits, listCommits,
listFileHistory, listFileHistory,
listInteractiveRebaseCommits,
listReflog,
listRepositoryFiles, listRepositoryFiles,
mergeBranch, mergeBranch,
openRepoInExplorer, openRepoInExplorer,
@@ -83,9 +87,11 @@
resolveConflict, resolveConflict,
resolveConflictSide, resolveConflictSide,
restoreFileFromCommit, restoreFileFromCommit,
restoreReflogEntry,
restoreFiles, restoreFiles,
restoreToCommit, restoreToCommit,
searchCodeIntroductions, searchCodeIntroductions,
startInteractiveRebase,
setSyncBadge, setSyncBadge,
stageFiles, stageFiles,
stashApply, stashApply,
@@ -119,6 +125,9 @@
LocalModelOption, LocalModelOption,
PatchApplyAction, PatchApplyAction,
PreparedResolution, PreparedResolution,
RebaseCommit,
RebasePlanItem,
ReflogEntry,
StoredCredential, StoredCredential,
} from "./lib/types"; } from "./lib/types";
@@ -254,6 +263,15 @@
let deleteBranchForce = false; let deleteBranchForce = false;
let compareSelectOpen = false; let compareSelectOpen = false;
let compareDialogOpen = 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 selectedDiffPath = "";
let diffHighlightQuery = ""; let diffHighlightQuery = "";
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null; let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
@@ -421,7 +439,21 @@
} }
function waitForAnimationFrame(): Promise<void> { function waitForAnimationFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve())); // requestAnimationFrame never fires for an unmapped/invisible window on
// WebKitGTK (Linux) — the compositor frame clock only runs for realized
// windows. The main window starts hidden until the splashscreen closes,
// so without a timeout fallback this promise (and the whole startup
// sequence, including closing the splashscreen) would hang forever on Linux.
return new Promise((resolve) => {
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
resolve();
};
requestAnimationFrame(() => settle());
window.setTimeout(settle, 50);
});
} }
async function waitForStartupPaint() { async function waitForStartupPaint() {
@@ -640,7 +672,7 @@
} }
async function autoRefreshTick() { 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; const path = activeRepoPath;
autoRefreshInFlight = true; autoRefreshInFlight = true;
try { try {
@@ -1566,6 +1598,13 @@
comparison = null; comparison = null;
compareSelectOpen = false; compareSelectOpen = false;
compareDialogOpen = false; compareDialogOpen = false;
interactiveRebaseOpen = false;
interactiveRebaseBase = "";
interactiveRebaseCommits = [];
interactiveRebaseError = "";
reflogOpen = false;
reflogEntries = [];
reflogError = "";
selectedDiffPath = ""; selectedDiffPath = "";
pendingRestoreFile = null; pendingRestoreFile = null;
newBranchCommit = null; newBranchCommit = null;
@@ -2236,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) { async function createNewTag(name: string, message: string) {
const trimmed = name.trim(); const trimmed = name.trim();
if (!activeRepoPath || !trimmed) return; if (!activeRepoPath || !trimmed) return;
@@ -3323,6 +3455,8 @@
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog(); 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" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
} }
@@ -3360,6 +3494,8 @@
onRefresh={refreshRepo} onRefresh={refreshRepo}
onSearch={openGlobalSearchDialog} onSearch={openGlobalSearchDialog}
onCompare={openCompareSelect} onCompare={openCompareSelect}
onInteractiveRebase={openInteractiveRebase}
onReflog={openReflog}
onOpenInExplorer={openActiveRepoInExplorer} onOpenInExplorer={openActiveRepoInExplorer}
onToggleAutoRefresh={toggleAutoRefresh} onToggleAutoRefresh={toggleAutoRefresh}
onOpenSettings={() => { appSettingsOpen = true; }} onOpenSettings={() => { appSettingsOpen = true; }}
@@ -4079,6 +4215,37 @@
/> />
{/if} {/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={reflogEntries.find((entry) => entry.selector === "HEAD@{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 --> <!-- Compare: pick the two commits to diff -->
{#if compareSelectOpen} {#if compareSelectOpen}
<CompareSelectDialog <CompareSelectDialog
+314 -78
View File
@@ -44,6 +44,27 @@
--app-settings-row-bg: #141b29; --app-settings-row-bg: #141b29;
--app-scrollbar-thumb: #303a4f; --app-scrollbar-thumb: #303a4f;
--app-scrollbar-thumb-hover: #44506a; --app-scrollbar-thumb-hover: #44506a;
--code-surface: #111321;
--code-surface-raised: #171a2b;
--code-surface-subtle: #0d101a;
--code-surface-muted: #10131e;
--code-surface-meta: #0c0e18;
--code-input-bg: #0b0e18;
--code-hover-bg: #151a2b;
--code-add-text: #5dd88a;
--code-add-strong: #4eca76;
--code-add-bg: rgba(78, 202, 118, 0.09);
--code-add-gutter-bg: rgba(78, 202, 118, 0.1);
--code-delete-text: #ef8080;
--code-delete-strong: #e86060;
--code-delete-bg: rgba(232, 96, 96, 0.1);
--code-delete-gutter-bg: rgba(232, 96, 96, 0.12);
--code-hunk-text: #7aacff;
--code-hunk-bg: rgba(122, 172, 255, 0.08);
--code-match-text: #f3c969;
--code-match-bg: rgba(240, 182, 72, 0.22);
--code-match-gutter-bg: rgba(240, 182, 72, 0.2);
} }
:root[data-theme="light"] { :root[data-theme="light"] {
@@ -89,6 +110,27 @@
--app-settings-row-bg: #f8fafd; --app-settings-row-bg: #f8fafd;
--app-scrollbar-thumb: #c2cada; --app-scrollbar-thumb: #c2cada;
--app-scrollbar-thumb-hover: #aeb8cb; --app-scrollbar-thumb-hover: #aeb8cb;
--code-surface: #fbfcfe;
--code-surface-raised: #f1f5fa;
--code-surface-subtle: #f3f6fb;
--code-surface-muted: #edf1f7;
--code-surface-meta: #e9eef7;
--code-input-bg: #ffffff;
--code-hover-bg: #e8eef7;
--code-add-text: #146c37;
--code-add-strong: #19723d;
--code-add-bg: rgba(25, 114, 61, 0.1);
--code-add-gutter-bg: rgba(25, 114, 61, 0.14);
--code-delete-text: #a91f36;
--code-delete-strong: #b4233b;
--code-delete-bg: rgba(180, 35, 59, 0.09);
--code-delete-gutter-bg: rgba(180, 35, 59, 0.13);
--code-hunk-text: #245cc7;
--code-hunk-bg: rgba(36, 92, 199, 0.09);
--code-match-text: #744500;
--code-match-bg: rgba(230, 158, 31, 0.2);
--code-match-gutter-bg: rgba(230, 158, 31, 0.24);
} }
@layer base { @layer base {
@@ -568,7 +610,8 @@
} }
.titlebar-info { .titlebar-info {
display: flex; display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 0 12px; padding: 0 12px;
@@ -576,12 +619,20 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
.titlebar-info svg { color: var(--color-accent); flex-shrink: 0; } .titlebar-context {
display: flex;
align-items: center;
min-width: 0;
gap: 6px;
overflow: hidden;
}
.titlebar-context svg { color: var(--color-accent); flex: 0 0 auto; }
.tb-repo { color: var(--color-bar-muted); font-size: 12px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; } .tb-repo { flex: 0 1 160px; min-width: 0; color: var(--color-bar-muted); font-size: 12px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tb-sep { color: rgba(255,255,255,0.22); font-size: 13px; } .tb-sep { flex: 0 0 auto; color: rgba(255,255,255,0.22); font-size: 13px; }
.tb-branch { color: #f5f7ff; font-size: 12px; font-weight: 700; font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; } .tb-branch { flex: 1 1 auto; min-width: 0; max-width: 220px; color: #f5f7ff; font-size: 12px; font-weight: 700; font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tb-sync-group { display: inline-flex; align-items: center; gap: 4px; min-width: max-content; }
.tb-sync { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 800; white-space: nowrap; flex-shrink: 0; } .tb-sync { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 800; white-space: nowrap; flex-shrink: 0; }
.tb-sync.ahead { color: #e0a040; background: rgba(224,160,64,0.13); } .tb-sync.ahead { color: #e0a040; background: rgba(224,160,64,0.13); }
.tb-sync.behind { color: #7aacff; background: rgba(122,172,255,0.13); } .tb-sync.behind { color: #7aacff; background: rgba(122,172,255,0.13); }
@@ -2735,6 +2786,133 @@
max-height: calc(100vh - 32px); max-height: calc(100vh - 32px);
overflow: auto; 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 { .new-branch-dialog {
display: block; display: block;
width: min(520px, calc(100vw - 32px)); width: min(520px, calc(100vw - 32px));
@@ -3124,7 +3302,7 @@
gap: 8px; gap: 8px;
padding: 7px 12px; padding: 7px 12px;
border-bottom: 1px solid var(--color-border-subtle); border-bottom: 1px solid var(--color-border-subtle);
background: #171a2b; background: var(--code-surface-raised);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
color: var(--color-ink-muted); color: var(--color-ink-muted);
@@ -3146,7 +3324,7 @@
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
tab-size: 2; tab-size: 2;
background: #111321; background: var(--code-surface);
} }
.split-pane { .split-pane {
@@ -3170,8 +3348,8 @@
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
} }
.split-span.split-meta { color: var(--color-ink-faint); background: #0c0e18; font-size: 11px; } .split-span.split-meta { color: var(--color-ink-faint); background: var(--code-surface-meta); font-size: 11px; }
.split-span.split-hunk { color: #7aacff; background: rgba(122,172,255,0.08); padding: 3px 10px; } .split-span.split-hunk { color: var(--code-hunk-text); background: var(--code-hunk-bg); padding: 3px 10px; }
.split-num { .split-num {
padding: 0 6px 0 4px; padding: 0 6px 0 4px;
@@ -3180,11 +3358,11 @@
font-size: 11px; font-size: 11px;
user-select: none; user-select: none;
border-right: 1px solid var(--color-border-subtle); 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.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: rgba(78,202,118,0.1); color: rgba(78,202,118,0.6); border-right-color: rgba(78,202,118,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: #10131e; } .split-num.empty { background: var(--code-surface-muted); }
.split-cell { .split-cell {
padding: 0 8px; padding: 0 8px;
@@ -3193,20 +3371,20 @@
min-width: 0; min-width: 0;
overflow: visible; overflow: visible;
} }
.split-cell.del { background: rgba(232,96,96,0.1); color: #ef8080; } .split-cell.del { background: var(--code-delete-bg); color: var(--code-delete-text); }
.split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; } .split-cell.add { background: var(--code-add-bg); color: var(--code-add-text); }
.split-cell.empty { background: #10131e; } .split-cell.empty { background: var(--code-surface-muted); }
/* Search-hit highlight: amber, distinct from add (green) / del (red). /* Search-hit highlight: amber, distinct from add (green) / del (red).
Higher specificity so it overrides the add/del backgrounds on a matched line. */ Higher specificity so it overrides the add/del backgrounds on a matched line. */
.split-diff .split-cell.match { .split-diff .split-cell.match {
background: rgba(240,182,72,0.22); background: var(--code-match-bg);
color: #f3c969; color: var(--code-match-text);
box-shadow: inset 2px 0 0 rgba(240,182,72,0.9); box-shadow: inset 2px 0 0 rgba(240,182,72,0.9);
} }
.split-diff .split-num.match { .split-diff .split-num.match {
background: rgba(240,182,72,0.2); background: var(--code-match-gutter-bg);
color: rgba(240,182,72,0.9); color: var(--code-match-text);
border-right-color: rgba(240,182,72,0.35); border-right-color: rgba(240,182,72,0.35);
} }
@@ -3226,7 +3404,7 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--color-ink-faint); 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-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); }
.split-col-hash { .split-col-hash {
@@ -3338,7 +3516,7 @@
.line-patch-scroll { .line-patch-scroll {
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
background: #0b0b14; background: var(--code-surface);
} }
.line-patch-hunk { .line-patch-hunk {
@@ -3358,7 +3536,7 @@
min-width: 100%; min-width: 100%;
padding: 7px 10px; padding: 7px 10px;
border-bottom: 1px solid var(--color-border-subtle); 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 { .line-patch-hunk-head code {
color: var(--color-accent); color: var(--color-accent);
@@ -3378,23 +3556,23 @@
padding: 0 8px; padding: 0 8px;
border: 1px solid var(--color-border-subtle); border: 1px solid var(--color-border-subtle);
border-radius: 3px; border-radius: 3px;
background: rgba(255, 255, 255, 0.03); background: var(--code-surface-subtle);
color: var(--color-ink); color: var(--color-ink);
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
} }
.line-patch-hunk-button:hover:not(:disabled) { .line-patch-hunk-button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08); background: var(--code-hover-bg);
} }
.line-patch-hunk-button.discard { .line-patch-hunk-button.discard {
border-color: rgba(255, 90, 103, 0.7); border-color: rgba(255, 90, 103, 0.7);
color: #ffccd1; color: var(--code-delete-text);
} }
.line-patch-hunk-button.stage, .line-patch-hunk-button.stage,
.line-patch-hunk-button.unstage { .line-patch-hunk-button.unstage {
border-color: rgba(78, 202, 118, 0.72); border-color: rgba(78, 202, 118, 0.72);
color: #bff1ce; color: var(--code-add-text);
} }
.line-patch-lines { .line-patch-lines {
@@ -3412,12 +3590,12 @@
color: var(--color-ink-muted); color: var(--color-ink-muted);
} }
.line-patch-row.add { .line-patch-row.add {
background: rgba(78, 202, 118, 0.09); background: var(--code-add-bg);
color: #bff1ce; color: var(--code-add-text);
} }
.line-patch-row.delete { .line-patch-row.delete {
background: rgba(255, 90, 103, 0.1); background: var(--code-delete-bg);
color: #ffccd1; color: var(--code-delete-text);
} }
.line-patch-row.meta { .line-patch-row.meta {
color: var(--color-ink-faint); color: var(--color-ink-faint);
@@ -3427,8 +3605,8 @@
text-align: center; text-align: center;
user-select: none; user-select: none;
} }
.line-patch-row.add .line-patch-prefix { color: #4eca76; } .line-patch-row.add .line-patch-prefix { color: var(--code-add-strong); }
.line-patch-row.delete .line-patch-prefix { color: #ff6b7a; } .line-patch-row.delete .line-patch-prefix { color: var(--code-delete-strong); }
.line-patch-row code { .line-patch-row code {
white-space: pre; white-space: pre;
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -3437,7 +3615,7 @@
.blame-body { .blame-body {
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
background: #111321; background: var(--code-surface);
} }
.blame-code-header strong { .blame-code-header strong {
@@ -3455,7 +3633,7 @@
min-height: 38px; min-height: 38px;
padding: 6px 10px; padding: 6px 10px;
border-bottom: 1px solid var(--color-border-subtle); border-bottom: 1px solid var(--color-border-subtle);
background: #111321; background: var(--code-surface);
} }
.blame-search-bar svg { .blame-search-bar svg {
position: absolute; position: absolute;
@@ -3469,7 +3647,7 @@
padding: 0 34px; padding: 0 34px;
border: 1px solid var(--color-border-subtle); border: 1px solid var(--color-border-subtle);
border-radius: 6px; border-radius: 6px;
background: #0b0e18; background: var(--code-input-bg);
color: var(--color-ink); color: var(--color-ink);
font-size: 12px; font-size: 12px;
} }
@@ -3522,7 +3700,7 @@
border-bottom: 1px solid var(--color-border-subtle); border-bottom: 1px solid var(--color-border-subtle);
} }
.blame-group.uncommitted { .blame-group.uncommitted {
background: #171725; background: var(--code-surface-raised);
} }
.blame-meta { .blame-meta {
@@ -3535,8 +3713,8 @@
min-width: 0; min-width: 0;
padding: 8px 12px; padding: 8px 12px;
border-right: 1px solid var(--color-border-subtle); border-right: 1px solid var(--color-border-subtle);
background: #0d101a; background: var(--code-surface-subtle);
box-shadow: 8px 0 18px rgba(0, 0, 0, 0.18); box-shadow: 8px 0 18px color-mix(in srgb, var(--color-ink) 8%, transparent);
} }
.blame-hash { .blame-hash {
align-self: flex-start; align-self: flex-start;
@@ -3545,7 +3723,7 @@
padding: 2px 7px; padding: 2px 7px;
border: 1px solid rgba(90,140,248,0.2); border: 1px solid rgba(90,140,248,0.2);
border-radius: 5px; border-radius: 5px;
background: #141b2d; background: var(--code-surface-raised);
color: var(--color-accent); color: var(--color-accent);
font-size: 10.5px; font-size: 10.5px;
font-weight: 700; font-weight: 700;
@@ -3562,7 +3740,7 @@
} }
.blame-summary { .blame-summary {
overflow: hidden; overflow: hidden;
color: #aeb6d8; color: var(--color-ink-muted);
font-size: 11px; font-size: 11px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -3572,7 +3750,7 @@
font-size: 10.5px; font-size: 10.5px;
} }
.blame-group:hover .blame-meta { .blame-group:hover .blame-meta {
background: #111728; background: var(--code-hover-bg);
} }
.blame-group.uncommitted .blame-hash, .blame-group.uncommitted .blame-hash,
.blame-group.uncommitted .blame-author { .blame-group.uncommitted .blame-author {
@@ -3580,7 +3758,7 @@
} }
.blame-group.uncommitted .blame-hash { .blame-group.uncommitted .blame-hash {
border-color: rgba(232, 180, 90, 0.26); border-color: rgba(232, 180, 90, 0.26);
background: #271f14; background: color-mix(in srgb, #e8b45a 13%, var(--code-surface));
} }
.blame-lines { .blame-lines {
@@ -3595,16 +3773,16 @@
min-height: 20px; min-height: 20px;
} }
.blame-group:hover .blame-line-number { .blame-group:hover .blame-line-number {
background: #111728; background: var(--code-hover-bg);
} }
.blame-group:hover .blame-line-code { .blame-group:hover .blame-line-code {
background: #151a2b; background: var(--code-hover-bg);
} }
.blame-search-hit { .blame-search-hit {
padding: 0 1px; padding: 0 1px;
border-radius: 3px; border-radius: 3px;
background: rgba(240,182,72,0.28); background: var(--code-match-bg);
color: #f3d487; color: var(--code-match-text);
} }
.global-search-body { .global-search-body {
@@ -3800,8 +3978,9 @@
margin: 0; margin: 0;
padding: 8px 10px; padding: 8px 10px;
border-radius: 7px; border-radius: 7px;
color: #5dd88a; border: 1px solid color-mix(in srgb, var(--code-add-strong) 18%, transparent);
background: rgba(78,202,118,0.08); color: var(--code-add-text);
background: var(--code-add-bg);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
line-height: 1.45; line-height: 1.45;
@@ -4321,14 +4500,14 @@
} }
.diff-line { display: block; white-space: pre-wrap; word-break: break-word; } .diff-line { display: block; white-space: pre-wrap; word-break: break-word; }
.diff-line.meta { color: var(--color-ink-faint); } .diff-line.meta { color: var(--color-ink-faint); }
.diff-line.hunk { color: #7aacff; background: rgba(122,172,255,0.07); } .diff-line.hunk { color: var(--code-hunk-text); background: var(--code-hunk-bg); }
.diff-line.add { color: #4eca76; background: rgba(78,202,118,0.09); } .diff-line.add { color: var(--code-add-text); background: var(--code-add-bg); }
.diff-line.del { color: #e86060; background: rgba(232,96,96,0.09); } .diff-line.del { color: var(--code-delete-text); background: var(--code-delete-bg); }
.diff-line.context { color: var(--color-ink-muted); } .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 { display: flex; gap: 8px; font-family: var(--font-mono); font-size: 12px; font-weight: 700; }
.diff-counts .adds { color: #4eca76; } .diff-counts .adds { color: var(--code-add-strong); }
.diff-counts .dels { color: #e86060; } .diff-counts .dels { color: var(--code-delete-strong); }
/* --- Conflict resolver --- */ /* --- Conflict resolver --- */
@@ -4399,7 +4578,7 @@
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-border-subtle); border: 1px solid var(--color-border-subtle);
border-radius: 6px; border-radius: 6px;
background: rgba(0,0,0,0.12); background: var(--code-surface);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
@@ -4431,19 +4610,19 @@
font-weight: 800; font-weight: 800;
text-transform: uppercase; 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 { .resolve-num {
padding: 0 6px 0 4px; padding: 0 6px 0 4px;
border-right: 1px solid var(--color-border-subtle); border-right: 1px solid var(--color-border-subtle);
color: var(--color-ink-faint); color: var(--color-ink-faint);
background: rgba(0,0,0,0.14); background: var(--code-surface-subtle);
text-align: right; text-align: right;
user-select: none; 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.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: rgba(122,172,255,0.65); background: rgba(122,172,255,0.11); border-right-color: rgba(122,172,255,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: rgba(0,0,0,0.07); } .resolve-num.empty { background: var(--code-surface-muted); }
.resolve-cell { .resolve-cell {
min-width: 0; min-width: 0;
@@ -4452,9 +4631,9 @@
color: var(--color-ink-muted); color: var(--color-ink-muted);
white-space: pre; white-space: pre;
} }
.resolve-cell.ours { color: #5dd88a; background: rgba(78,202,118,0.1); } .resolve-cell.ours { color: var(--code-add-text); background: var(--code-add-bg); }
.resolve-cell.theirs { color: #8fb4ff; background: rgba(90,140,248,0.11); } .resolve-cell.theirs { color: var(--code-hunk-text); background: var(--code-hunk-bg); }
.resolve-cell.empty { background: rgba(0,0,0,0.06); } .resolve-cell.empty { background: var(--code-surface-muted); }
.resolve-cell.dimmed { opacity: 0.42; filter: grayscale(0.5); } .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); } .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.dimmed { opacity: 0.4; filter: grayscale(0.5); }
.resolve-side-label { font-size: 11px; font-weight: 800; text-transform: uppercase; } .resolve-side-label { font-size: 11px; font-weight: 800; text-transform: uppercase; }
.resolve-side.ours .resolve-side-label { color: #4eca76; } .resolve-side.ours .resolve-side-label { color: var(--code-add-strong); }
.resolve-side.theirs .resolve-side-label { color: #6a9aff; } .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-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 { display: block; white-space: pre-wrap; word-break: break-word; }
.resolve-line.ours { color: #4eca76; } .resolve-line.ours { color: var(--code-add-text); }
.resolve-line.theirs { color: #7aacff; } .resolve-line.theirs { color: var(--code-hunk-text); }
.resolve-line.context { color: var(--color-ink-muted); } .resolve-line.context { color: var(--color-ink-muted); }
.resolve-binary { display: grid; align-content: start; gap: 12px; padding: 4px; } .resolve-binary { display: grid; align-content: start; gap: 12px; padding: 4px; }
@@ -4834,22 +5013,75 @@
background: var(--color-surface-hover); 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"] .diff-line.meta,
:root[data-theme="light"] .resolve-split-marker { :root[data-theme="light"] .resolve-split-marker {
color: #64728a; 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-input input,
:root[data-theme="light"] .cred-expiry input[type="date"] { :root[data-theme="light"] .cred-expiry input[type="date"] {
background: rgba(255,255,255,0.92); 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 { grid-template-columns: auto minmax(0, 1fr); align-items: start; }
.file-search-hit .status-badge, .file-search-hit .status-badge,
.file-search-action { grid-column: 2; justify-self: start; } .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); } .dialog-files { border-right: none; border-bottom: 1px solid var(--color-border-subtle); }
.branch-actions { flex-direction: row; justify-content: flex-start; } .branch-actions { flex-direction: row; justify-content: flex-start; }
.tb-action-label { display: none; } .tb-action-label { display: none; }
+42 -12
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window"; 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"; import iconUrl from "../../src-tauri/icons/icon.png";
export let branch: string = ""; export let branch: string = "";
@@ -20,6 +20,8 @@
export let onRefresh: () => void = () => {}; export let onRefresh: () => void = () => {};
export let onSearch: () => void = () => {}; export let onSearch: () => void = () => {};
export let onCompare: () => void = () => {}; export let onCompare: () => void = () => {};
export let onInteractiveRebase: () => void = () => {};
export let onReflog: () => void = () => {};
export let onOpenInExplorer: () => void = () => {}; export let onOpenInExplorer: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {}; export let onToggleAutoRefresh: () => void = () => {};
export let onOpenSettings: () => void = () => {}; export let onOpenSettings: () => void = () => {};
@@ -79,17 +81,23 @@
<!-- Center: repo + branch info --> <!-- Center: repo + branch info -->
<div class="titlebar-info" data-tauri-drag-region> <div class="titlebar-info" data-tauri-drag-region>
{#if hasRepository} {#if hasRepository}
{#if repoName} <div class="titlebar-context" data-tauri-drag-region>
<span class="tb-repo" data-tauri-drag-region>{repoName}</span> {#if repoName}
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span> <span class="tb-repo" data-tauri-drag-region>{repoName}</span>
{/if} <span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
<GitBranch size={12} aria-hidden="true" /> {/if}
<span class="tb-branch" data-tauri-drag-region>{branch}</span> <GitBranch size={12} aria-hidden="true" />
{#if ahead > 0} <span class="tb-branch" data-tauri-drag-region title={branch}>{branch}</span>
<span class="tb-sync ahead" title="{ahead} commits ahead">{ahead}</span> </div>
{/if} {#if ahead > 0 || behind > 0}
{#if behind > 0} <div class="tb-sync-group" aria-label="Branch synchronization status">
<span class="tb-sync behind" title="{behind} commits behind">{behind}</span> {#if ahead > 0}
<span class="tb-sync ahead" title="{ahead} commits ahead">{ahead}</span>
{/if}
{#if behind > 0}
<span class="tb-sync behind" title="{behind} commits behind">{behind}</span>
{/if}
</div>
{/if} {/if}
{:else} {:else}
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span> <span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
@@ -132,6 +140,28 @@
<span class="tb-action-label">Compare</span> <span class="tb-action-label">Compare</span>
</button> </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 <button
class="tb-action" class="tb-action"
onclick={onFetch} 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, GitCommit,
GitCommitComparison, GitCommitComparison,
GitRepositoryFile, GitRepositoryFile,
RebaseCommit,
RebasePlanItem,
ReflogEntry,
GitSearchHit, GitSearchHit,
GitStash, GitStash,
GitStatus, GitStatus,
@@ -306,6 +309,26 @@ export function rebaseAbort(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_abort", { path }); 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[]> { export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
return invoke<GitRepositoryFile[]>("list_repository_files", { path }); return invoke<GitRepositoryFile[]>("list_repository_files", { path });
} }
+25
View File
@@ -198,6 +198,31 @@ export interface GitBlameResult {
lines: GitBlameLine[]; 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 { export interface StoredCredential {
username: string; username: string;
password: string; password: string;