Merge remote-tracking branch 'origin/main'
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.7.18",
|
||||
"version": "2026.7.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitty",
|
||||
"version": "2026.7.18",
|
||||
"version": "2026.7.19",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.7.18",
|
||||
"version": "2026.7.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+580
-7
@@ -1,7 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
env,
|
||||
ffi::{OsStr, OsString},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Output, Stdio},
|
||||
sync::{
|
||||
@@ -170,6 +172,112 @@ pub struct GitSearchHit {
|
||||
pub matches_added: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct RebaseCommit {
|
||||
pub hash: String,
|
||||
pub short_hash: String,
|
||||
pub summary: String,
|
||||
pub author_name: String,
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RebaseAction {
|
||||
Pick,
|
||||
Reword,
|
||||
Squash,
|
||||
Fixup,
|
||||
Drop,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct RebasePlanItem {
|
||||
pub hash: String,
|
||||
pub action: RebaseAction,
|
||||
pub message: Option<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)]
|
||||
struct BranchInfo {
|
||||
current_branch: Option<String>,
|
||||
@@ -838,7 +946,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 +1593,86 @@ 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)?;
|
||||
let available = interactive_rebase_commits_for_repo(&repo, &base_hash)?;
|
||||
validate_rebase_plan(&available, &plan)?;
|
||||
|
||||
let todo = build_rebase_todo(&available, &plan)?;
|
||||
let reword_queue = build_reword_queue(&available, &plan)?;
|
||||
let git_dir = git_dir_for_repo(&repo)?;
|
||||
cleanup_interactive_rebase_helpers(&repo);
|
||||
let todo_path = git_dir.join(REBASE_TODO_FILE);
|
||||
let reword_queue_path = git_dir.join(REWORD_QUEUE_FILE);
|
||||
fs::write(&todo_path, todo)
|
||||
.map_err(|err| format!("Could not prepare interactive rebase plan: {err}"))?;
|
||||
fs::write(&reword_queue_path, reword_queue)
|
||||
.map_err(|err| format!("Could not prepare reword messages: {err}"))?;
|
||||
|
||||
let sequence_helper_path = repo.join(sequence_helper_name());
|
||||
let commit_helper_path = repo.join(commit_helper_name());
|
||||
let current_exe = env::current_exe()
|
||||
.map_err(|err| format!("Could not locate the Gitty executable: {err}"))?;
|
||||
fs::copy(¤t_exe, &sequence_helper_path)
|
||||
.and_then(|_| fs::copy(¤t_exe, &commit_helper_path))
|
||||
.map_err(|err| format!("Could not prepare interactive rebase helpers: {err}"))?;
|
||||
|
||||
let sequence_editor_command = format!("./{}", sequence_helper_name());
|
||||
let commit_editor_command = format!("./{}", commit_helper_name());
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["rebase", "-i", base_hash.as_str()])
|
||||
.env("GIT_SEQUENCE_EDITOR", sequence_editor_command)
|
||||
.env(SEQUENCE_EDITOR_PLAN_ENV, &todo_path)
|
||||
.env("GIT_EDITOR", commit_editor_command)
|
||||
.env(COMMIT_EDITOR_QUEUE_ENV, &reword_queue_path)
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"));
|
||||
|
||||
let result = rebase_status_or_error(&repo, output?, "Interactive rebase failed", true);
|
||||
let _ = fs::remove_file(&todo_path);
|
||||
let _ = fs::remove_file(&sequence_helper_path);
|
||||
if !matches!(&result, Ok(status) if status.rebase_in_progress) {
|
||||
let _ = fs::remove_file(&reword_queue_path);
|
||||
let _ = fs::remove_file(&commit_helper_path);
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Could not run interactive rebase: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
|
||||
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());
|
||||
}
|
||||
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["rebase", "--continue"])
|
||||
.env("GIT_EDITOR", "true")
|
||||
let git_dir = git_dir_for_repo(&repo)?;
|
||||
let queue_path = git_dir.join(REWORD_QUEUE_FILE);
|
||||
let helper_path = repo.join(commit_helper_name());
|
||||
let mut command = git_command();
|
||||
command.arg("-C").arg(&repo).args(["rebase", "--continue"]);
|
||||
if queue_path.exists() && helper_path.exists() {
|
||||
command
|
||||
.env("GIT_EDITOR", format!("./{}", commit_helper_name()))
|
||||
.env(COMMIT_EDITOR_QUEUE_ENV, &queue_path);
|
||||
} else {
|
||||
command.env("GIT_EDITOR", "true");
|
||||
}
|
||||
let output = command
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
rebase_status_or_error(&repo, output, "Rebase continue failed", false)
|
||||
let result = rebase_status_or_error(&repo, output, "Rebase continue failed", false);
|
||||
if !matches!(&result, Ok(status) if status.rebase_in_progress) {
|
||||
cleanup_interactive_rebase_helpers(&repo);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1507,9 +1711,249 @@ pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
|
||||
}
|
||||
|
||||
run_git(&repo, ["rebase", "--abort"])?;
|
||||
cleanup_interactive_rebase_helpers(&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(
|
||||
repo: &Path,
|
||||
output: Output,
|
||||
@@ -2521,6 +2965,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
||||
)?;
|
||||
let (branch, mut files) = parse_status_output(&output)?;
|
||||
detect_worktree_renames(repo, &mut files);
|
||||
files.retain(|file| !is_interactive_rebase_helper_path(&file.path));
|
||||
|
||||
Ok(GitStatus {
|
||||
repo_path: repo.to_string_lossy().to_string(),
|
||||
@@ -4582,6 +5027,134 @@ mod tests {
|
||||
assert_eq!(branch.behind, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_reflog_records() {
|
||||
let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fHEAD@{0}\x1fcommit: Add feature\x1fAda\x1f2026-07-10T12:00:00+02:00\x1e";
|
||||
let entries = parse_reflog(raw).expect("reflog should parse");
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].selector, "HEAD@{0}");
|
||||
assert_eq!(entries[0].action, "commit: Add feature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_editor_applies_only_the_matching_reword_message() {
|
||||
let temp = temp_dir("commit_editor");
|
||||
let queue = temp.path.join("queue");
|
||||
let message = temp.path.join("COMMIT_EDITMSG");
|
||||
fs::write(
|
||||
&queue,
|
||||
"first commit\x1frenamed first\x1esecond commit\x1frenamed second\x1e",
|
||||
)
|
||||
.expect("queue should be written");
|
||||
fs::write(
|
||||
&message,
|
||||
"first commit\n\n# Please enter the commit message\n",
|
||||
)
|
||||
.expect("message should be written");
|
||||
|
||||
apply_reword_message(&queue, &message).expect("message should be applied");
|
||||
|
||||
assert_eq!(fs::read_to_string(&message).unwrap(), "renamed first\n");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&queue).unwrap(),
|
||||
"second commit\x1frenamed second\x1e"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_rebase_builds_a_valid_reword_and_squash_plan() {
|
||||
let repo = init_temp_repo("interactive_rebase");
|
||||
commit_initial_file(&repo.path);
|
||||
let base = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
|
||||
fs::write(repo.path.join("first.txt"), "first\n").expect("first file should be written");
|
||||
run_git_test(&repo.path, ["add", "first.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "first commit"]);
|
||||
fs::write(repo.path.join("second.txt"), "second\n").expect("second file should be written");
|
||||
run_git_test(&repo.path, ["add", "second.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "second commit"]);
|
||||
|
||||
let commits = interactive_rebase_commits_for_repo(&repo.path, &base)
|
||||
.expect("rebase commits should load");
|
||||
assert_eq!(commits.len(), 2);
|
||||
let plan = vec![
|
||||
RebasePlanItem {
|
||||
hash: commits[0].hash.clone(),
|
||||
action: RebaseAction::Reword,
|
||||
message: Some("combined feature".to_string()),
|
||||
},
|
||||
RebasePlanItem {
|
||||
hash: commits[1].hash.clone(),
|
||||
action: RebaseAction::Squash,
|
||||
message: None,
|
||||
},
|
||||
];
|
||||
|
||||
validate_rebase_plan(&commits, &plan).expect("plan should be valid");
|
||||
let todo = build_rebase_todo(&commits, &plan).expect("todo should build");
|
||||
|
||||
assert!(todo.contains(&format!("reword {} first commit", commits[0].hash)));
|
||||
assert!(todo.contains(&format!("squash {} second commit", commits[1].hash)));
|
||||
assert_eq!(
|
||||
build_reword_queue(&commits, &plan).expect("queue should build"),
|
||||
"first commit\x1fcombined feature\x1e"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_rebase_accepts_a_diverged_base_branch() {
|
||||
let repo = init_temp_repo("interactive_rebase_diverged");
|
||||
commit_initial_file(&repo.path);
|
||||
let main_branch = git_output_test(&repo.path, ["branch", "--show-current"]);
|
||||
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]);
|
||||
fs::write(repo.path.join("feature.txt"), "feature\n")
|
||||
.expect("feature file should be written");
|
||||
run_git_test(&repo.path, ["add", "feature.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "feature commit"]);
|
||||
run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]);
|
||||
fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written");
|
||||
run_git_test(&repo.path, ["add", "main.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "main advanced"]);
|
||||
run_git_test(&repo.path, ["checkout", "-q", "feature"]);
|
||||
|
||||
let commits = interactive_rebase_commits_for_repo(&repo.path, &main_branch)
|
||||
.expect("diverged base should be accepted");
|
||||
|
||||
assert_eq!(commits.len(), 1);
|
||||
assert_eq!(commits[0].summary, "feature commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflog_restore_creates_a_recovery_branch_without_resetting_existing_branch() {
|
||||
let repo = init_temp_repo("reflog_restore");
|
||||
commit_initial_file(&repo.path);
|
||||
let initial = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
let original_branch = git_output_test(&repo.path, ["branch", "--show-current"]);
|
||||
|
||||
fs::write(repo.path.join("later.txt"), "later\n").expect("later file should be written");
|
||||
run_git_test(&repo.path, ["add", "later.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "later"]);
|
||||
|
||||
let entries = list_reflog(repo.path.to_string_lossy().to_string(), Some(20))
|
||||
.expect("reflog should load");
|
||||
assert!(entries.len() >= 2);
|
||||
|
||||
let status = restore_reflog_entry(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
initial.clone(),
|
||||
"recovery/initial".to_string(),
|
||||
)
|
||||
.expect("recovery branch should be created");
|
||||
|
||||
assert_eq!(status.current_branch.as_deref(), Some("recovery/initial"));
|
||||
assert_eq!(git_output_test(&repo.path, ["rev-parse", "HEAD"]), initial);
|
||||
assert!(
|
||||
ref_exists(&repo.path, &format!("refs/heads/{original_branch}"))
|
||||
.expect("original branch should still exist")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_commit_history_records() {
|
||||
let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\x1f2222222222222222222222222222222222222222 3333333333333333333333333333333333333333\x1fAdd history panel\x1e";
|
||||
|
||||
+24
-9
@@ -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() {
|
||||
let builder = tauri::Builder::default()
|
||||
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();
|
||||
}))
|
||||
@@ -114,6 +125,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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gitty",
|
||||
"version": "2026.7.18",
|
||||
"version": "2026.7.19",
|
||||
"identifier": "com.gitty",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+208
-3
@@ -21,10 +21,13 @@
|
||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
||||
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
||||
import HelpOverlay from "./lib/components/HelpOverlay.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 +65,8 @@
|
||||
listTags,
|
||||
listCommits,
|
||||
listFileHistory,
|
||||
listInteractiveRebaseCommits,
|
||||
listReflog,
|
||||
listRepositoryFiles,
|
||||
mergeBranch,
|
||||
openRepoInExplorer,
|
||||
@@ -83,9 +88,11 @@
|
||||
resolveConflict,
|
||||
resolveConflictSide,
|
||||
restoreFileFromCommit,
|
||||
restoreReflogEntry,
|
||||
restoreFiles,
|
||||
restoreToCommit,
|
||||
searchCodeIntroductions,
|
||||
startInteractiveRebase,
|
||||
setSyncBadge,
|
||||
stageFiles,
|
||||
stashApply,
|
||||
@@ -98,6 +105,7 @@
|
||||
|
||||
import type {
|
||||
AiSettings,
|
||||
AppLanguage,
|
||||
AppTheme,
|
||||
AnalyticsSettings,
|
||||
CommitAiPhase,
|
||||
@@ -119,6 +127,9 @@
|
||||
LocalModelOption,
|
||||
PatchApplyAction,
|
||||
PreparedResolution,
|
||||
RebaseCommit,
|
||||
RebasePlanItem,
|
||||
ReflogEntry,
|
||||
StoredCredential,
|
||||
} from "./lib/types";
|
||||
|
||||
@@ -167,6 +178,7 @@
|
||||
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
||||
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
|
||||
const APP_THEME_KEY = "gitlite.theme.v1";
|
||||
const APP_LANGUAGE_KEY = "gitlite.language.v1";
|
||||
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||||
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
|
||||
const LEFT_BRANCH_PANEL_HEIGHT_KEY = "gitlite.leftBranchPanelHeight.v1";
|
||||
@@ -239,9 +251,11 @@
|
||||
let aiSettings: AiSettings = defaultAiSettings();
|
||||
let aiSettingsOpen = false;
|
||||
let appSettingsOpen = false;
|
||||
let helpOpen = false;
|
||||
let analyticsNoticeOpen = false;
|
||||
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
|
||||
let appTheme: AppTheme = loadThemePreference();
|
||||
let appLanguage: AppLanguage = loadLanguagePreference();
|
||||
let localModelOptions: LocalModelOption[] = [];
|
||||
let errorMessage = "";
|
||||
let operation = "";
|
||||
@@ -254,6 +268,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;
|
||||
@@ -394,6 +417,7 @@
|
||||
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
|
||||
|
||||
$: applyThemePreference(appTheme);
|
||||
$: applyLanguagePreference(appLanguage);
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -654,7 +678,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 || helpOpen) return;
|
||||
const path = activeRepoPath;
|
||||
autoRefreshInFlight = true;
|
||||
try {
|
||||
@@ -795,6 +819,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
function loadLanguagePreference(): AppLanguage {
|
||||
try {
|
||||
const stored = localStorage.getItem(APP_LANGUAGE_KEY);
|
||||
if (stored === "en" || stored === "de") return stored;
|
||||
} catch {
|
||||
// Local storage is optional; English is the first-start default.
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
function persistLanguagePreference(next: AppLanguage) {
|
||||
try {
|
||||
localStorage.setItem(APP_LANGUAGE_KEY, next);
|
||||
} catch {
|
||||
// Ignore storage quota/private-mode errors.
|
||||
}
|
||||
}
|
||||
|
||||
function applyLanguagePreference(next: AppLanguage) {
|
||||
document.documentElement.lang = next;
|
||||
document.documentElement.dataset.language = next;
|
||||
}
|
||||
|
||||
function applyThemePreference(next: AppTheme) {
|
||||
const prefersLight = themeMediaQuery?.matches ?? window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||
const resolved = next === "system"
|
||||
@@ -810,13 +857,15 @@
|
||||
if (appTheme === "system") applyThemePreference(appTheme);
|
||||
}
|
||||
|
||||
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme) {
|
||||
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage) {
|
||||
analyticsSettings = next;
|
||||
appTheme = nextTheme;
|
||||
appLanguage = nextLanguage;
|
||||
persistAnalyticsSettings(next);
|
||||
persistThemePreference(nextTheme);
|
||||
persistLanguagePreference(nextLanguage);
|
||||
appSettingsOpen = false;
|
||||
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme });
|
||||
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, language: nextLanguage });
|
||||
}
|
||||
|
||||
function updateCommitMessage(message: string) {
|
||||
@@ -1580,6 +1629,13 @@
|
||||
comparison = null;
|
||||
compareSelectOpen = false;
|
||||
compareDialogOpen = false;
|
||||
interactiveRebaseOpen = false;
|
||||
interactiveRebaseBase = "";
|
||||
interactiveRebaseCommits = [];
|
||||
interactiveRebaseError = "";
|
||||
reflogOpen = false;
|
||||
reflogEntries = [];
|
||||
reflogError = "";
|
||||
selectedDiffPath = "";
|
||||
pendingRestoreFile = null;
|
||||
newBranchCommit = null;
|
||||
@@ -2250,6 +2306,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;
|
||||
@@ -3141,6 +3290,11 @@
|
||||
trackEvent("global_search_opened");
|
||||
}
|
||||
|
||||
function openHelp() {
|
||||
helpOpen = true;
|
||||
trackEvent("help_opened");
|
||||
}
|
||||
|
||||
async function compareSelectedCommits() {
|
||||
if (!canCompare) return;
|
||||
await runOperation("Comparing commits", async () => {
|
||||
@@ -3331,12 +3485,23 @@
|
||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "/") {
|
||||
event.preventDefault();
|
||||
openHelp();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && helpOpen) {
|
||||
helpOpen = false;
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu();
|
||||
else if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
|
||||
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
|
||||
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
|
||||
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
|
||||
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
|
||||
else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
|
||||
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
|
||||
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
|
||||
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
|
||||
}
|
||||
@@ -3374,9 +3539,13 @@
|
||||
onRefresh={refreshRepo}
|
||||
onSearch={openGlobalSearchDialog}
|
||||
onCompare={openCompareSelect}
|
||||
onInteractiveRebase={openInteractiveRebase}
|
||||
onReflog={openReflog}
|
||||
onOpenInExplorer={openActiveRepoInExplorer}
|
||||
onToggleAutoRefresh={toggleAutoRefresh}
|
||||
onOpenSettings={() => { appSettingsOpen = true; }}
|
||||
onOpenHelp={openHelp}
|
||||
language={appLanguage}
|
||||
/>
|
||||
|
||||
<div class="shell-body">
|
||||
@@ -3991,11 +4160,16 @@
|
||||
<AppSettingsDialog
|
||||
analytics={analyticsSettings}
|
||||
theme={appTheme}
|
||||
language={appLanguage}
|
||||
onSave={saveAppSettings}
|
||||
onClose={() => { appSettingsOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if helpOpen}
|
||||
<HelpOverlay language={appLanguage} onClose={() => { helpOpen = false; }} />
|
||||
{/if}
|
||||
|
||||
{#if linePatchOpen && linePatchFile}
|
||||
<LinePatchDialog
|
||||
file={linePatchFile}
|
||||
@@ -4093,6 +4267,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={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 -->
|
||||
{#if compareSelectOpen}
|
||||
<CompareSelectDialog
|
||||
|
||||
+318
-78
@@ -44,6 +44,27 @@
|
||||
--app-settings-row-bg: #141b29;
|
||||
--app-scrollbar-thumb: #303a4f;
|
||||
--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"] {
|
||||
@@ -89,6 +110,27 @@
|
||||
--app-settings-row-bg: #f8fafd;
|
||||
--app-scrollbar-thumb: #c2cada;
|
||||
--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 {
|
||||
@@ -568,7 +610,8 @@
|
||||
}
|
||||
|
||||
.titlebar-info {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
@@ -576,12 +619,20 @@
|
||||
height: 100%;
|
||||
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-sep { 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-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 { flex: 0 0 auto; color: rgba(255,255,255,0.22); font-size: 13px; }
|
||||
.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.ahead { color: #e0a040; background: rgba(224,160,64,0.13); }
|
||||
.tb-sync.behind { color: #7aacff; background: rgba(122,172,255,0.13); }
|
||||
@@ -2735,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));
|
||||
@@ -2905,6 +3083,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-segmented.settings-language {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.settings-toggle-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
@@ -3124,7 +3306,7 @@
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
background: #171a2b;
|
||||
background: var(--code-surface-raised);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-muted);
|
||||
@@ -3146,7 +3328,7 @@
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
tab-size: 2;
|
||||
background: #111321;
|
||||
background: var(--code-surface);
|
||||
}
|
||||
|
||||
.split-pane {
|
||||
@@ -3170,8 +3352,8 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.split-span.split-meta { color: var(--color-ink-faint); background: #0c0e18; font-size: 11px; }
|
||||
.split-span.split-hunk { color: #7aacff; background: rgba(122,172,255,0.08); padding: 3px 10px; }
|
||||
.split-span.split-meta { color: var(--color-ink-faint); background: var(--code-surface-meta); font-size: 11px; }
|
||||
.split-span.split-hunk { color: var(--code-hunk-text); background: var(--code-hunk-bg); padding: 3px 10px; }
|
||||
|
||||
.split-num {
|
||||
padding: 0 6px 0 4px;
|
||||
@@ -3180,11 +3362,11 @@
|
||||
font-size: 11px;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
background: #0d101a;
|
||||
background: var(--code-surface-subtle);
|
||||
}
|
||||
.split-num.del { background: rgba(232,96,96,0.12); color: rgba(232,96,96,0.6); border-right-color: rgba(232,96,96,0.2); }
|
||||
.split-num.add { background: rgba(78,202,118,0.1); color: rgba(78,202,118,0.6); border-right-color: rgba(78,202,118,0.2); }
|
||||
.split-num.empty { background: #10131e; }
|
||||
.split-num.del { background: var(--code-delete-gutter-bg); color: var(--code-delete-strong); border-right-color: rgba(232,96,96,0.2); }
|
||||
.split-num.add { background: var(--code-add-gutter-bg); color: var(--code-add-strong); border-right-color: rgba(78,202,118,0.2); }
|
||||
.split-num.empty { background: var(--code-surface-muted); }
|
||||
|
||||
.split-cell {
|
||||
padding: 0 8px;
|
||||
@@ -3193,20 +3375,20 @@
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.split-cell.del { background: rgba(232,96,96,0.1); color: #ef8080; }
|
||||
.split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; }
|
||||
.split-cell.empty { background: #10131e; }
|
||||
.split-cell.del { background: var(--code-delete-bg); color: var(--code-delete-text); }
|
||||
.split-cell.add { background: var(--code-add-bg); color: var(--code-add-text); }
|
||||
.split-cell.empty { background: var(--code-surface-muted); }
|
||||
|
||||
/* Search-hit highlight: amber, distinct from add (green) / del (red).
|
||||
Higher specificity so it overrides the add/del backgrounds on a matched line. */
|
||||
.split-diff .split-cell.match {
|
||||
background: rgba(240,182,72,0.22);
|
||||
color: #f3c969;
|
||||
background: var(--code-match-bg);
|
||||
color: var(--code-match-text);
|
||||
box-shadow: inset 2px 0 0 rgba(240,182,72,0.9);
|
||||
}
|
||||
.split-diff .split-num.match {
|
||||
background: rgba(240,182,72,0.2);
|
||||
color: rgba(240,182,72,0.9);
|
||||
background: var(--code-match-gutter-bg);
|
||||
color: var(--code-match-text);
|
||||
border-right-color: rgba(240,182,72,0.35);
|
||||
}
|
||||
|
||||
@@ -3226,7 +3408,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-ink-faint);
|
||||
background: #0d101a;
|
||||
background: var(--code-surface-subtle);
|
||||
}
|
||||
.split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); }
|
||||
.split-col-hash {
|
||||
@@ -3338,7 +3520,7 @@
|
||||
.line-patch-scroll {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #0b0b14;
|
||||
background: var(--code-surface);
|
||||
}
|
||||
|
||||
.line-patch-hunk {
|
||||
@@ -3358,7 +3540,7 @@
|
||||
min-width: 100%;
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
background: rgba(20, 22, 36, 0.96);
|
||||
background: color-mix(in srgb, var(--code-surface-raised) 96%, transparent);
|
||||
}
|
||||
.line-patch-hunk-head code {
|
||||
color: var(--color-accent);
|
||||
@@ -3378,23 +3560,23 @@
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--code-surface-subtle);
|
||||
color: var(--color-ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.line-patch-hunk-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--code-hover-bg);
|
||||
}
|
||||
.line-patch-hunk-button.discard {
|
||||
border-color: rgba(255, 90, 103, 0.7);
|
||||
color: #ffccd1;
|
||||
color: var(--code-delete-text);
|
||||
}
|
||||
.line-patch-hunk-button.stage,
|
||||
.line-patch-hunk-button.unstage {
|
||||
border-color: rgba(78, 202, 118, 0.72);
|
||||
color: #bff1ce;
|
||||
color: var(--code-add-text);
|
||||
}
|
||||
|
||||
.line-patch-lines {
|
||||
@@ -3412,12 +3594,12 @@
|
||||
color: var(--color-ink-muted);
|
||||
}
|
||||
.line-patch-row.add {
|
||||
background: rgba(78, 202, 118, 0.09);
|
||||
color: #bff1ce;
|
||||
background: var(--code-add-bg);
|
||||
color: var(--code-add-text);
|
||||
}
|
||||
.line-patch-row.delete {
|
||||
background: rgba(255, 90, 103, 0.1);
|
||||
color: #ffccd1;
|
||||
background: var(--code-delete-bg);
|
||||
color: var(--code-delete-text);
|
||||
}
|
||||
.line-patch-row.meta {
|
||||
color: var(--color-ink-faint);
|
||||
@@ -3427,8 +3609,8 @@
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
.line-patch-row.add .line-patch-prefix { color: #4eca76; }
|
||||
.line-patch-row.delete .line-patch-prefix { color: #ff6b7a; }
|
||||
.line-patch-row.add .line-patch-prefix { color: var(--code-add-strong); }
|
||||
.line-patch-row.delete .line-patch-prefix { color: var(--code-delete-strong); }
|
||||
.line-patch-row code {
|
||||
white-space: pre;
|
||||
font-family: var(--font-mono);
|
||||
@@ -3437,7 +3619,7 @@
|
||||
.blame-body {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #111321;
|
||||
background: var(--code-surface);
|
||||
}
|
||||
|
||||
.blame-code-header strong {
|
||||
@@ -3455,7 +3637,7 @@
|
||||
min-height: 38px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
background: #111321;
|
||||
background: var(--code-surface);
|
||||
}
|
||||
.blame-search-bar svg {
|
||||
position: absolute;
|
||||
@@ -3469,7 +3651,7 @@
|
||||
padding: 0 34px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
background: #0b0e18;
|
||||
background: var(--code-input-bg);
|
||||
color: var(--color-ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -3522,7 +3704,7 @@
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.blame-group.uncommitted {
|
||||
background: #171725;
|
||||
background: var(--code-surface-raised);
|
||||
}
|
||||
|
||||
.blame-meta {
|
||||
@@ -3535,8 +3717,8 @@
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
background: #0d101a;
|
||||
box-shadow: 8px 0 18px rgba(0, 0, 0, 0.18);
|
||||
background: var(--code-surface-subtle);
|
||||
box-shadow: 8px 0 18px color-mix(in srgb, var(--color-ink) 8%, transparent);
|
||||
}
|
||||
.blame-hash {
|
||||
align-self: flex-start;
|
||||
@@ -3545,7 +3727,7 @@
|
||||
padding: 2px 7px;
|
||||
border: 1px solid rgba(90,140,248,0.2);
|
||||
border-radius: 5px;
|
||||
background: #141b2d;
|
||||
background: var(--code-surface-raised);
|
||||
color: var(--color-accent);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
@@ -3562,7 +3744,7 @@
|
||||
}
|
||||
.blame-summary {
|
||||
overflow: hidden;
|
||||
color: #aeb6d8;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -3572,7 +3754,7 @@
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.blame-group:hover .blame-meta {
|
||||
background: #111728;
|
||||
background: var(--code-hover-bg);
|
||||
}
|
||||
.blame-group.uncommitted .blame-hash,
|
||||
.blame-group.uncommitted .blame-author {
|
||||
@@ -3580,7 +3762,7 @@
|
||||
}
|
||||
.blame-group.uncommitted .blame-hash {
|
||||
border-color: rgba(232, 180, 90, 0.26);
|
||||
background: #271f14;
|
||||
background: color-mix(in srgb, #e8b45a 13%, var(--code-surface));
|
||||
}
|
||||
|
||||
.blame-lines {
|
||||
@@ -3595,16 +3777,16 @@
|
||||
min-height: 20px;
|
||||
}
|
||||
.blame-group:hover .blame-line-number {
|
||||
background: #111728;
|
||||
background: var(--code-hover-bg);
|
||||
}
|
||||
.blame-group:hover .blame-line-code {
|
||||
background: #151a2b;
|
||||
background: var(--code-hover-bg);
|
||||
}
|
||||
.blame-search-hit {
|
||||
padding: 0 1px;
|
||||
border-radius: 3px;
|
||||
background: rgba(240,182,72,0.28);
|
||||
color: #f3d487;
|
||||
background: var(--code-match-bg);
|
||||
color: var(--code-match-text);
|
||||
}
|
||||
|
||||
.global-search-body {
|
||||
@@ -3800,8 +3982,9 @@
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 7px;
|
||||
color: #5dd88a;
|
||||
background: rgba(78,202,118,0.08);
|
||||
border: 1px solid color-mix(in srgb, var(--code-add-strong) 18%, transparent);
|
||||
color: var(--code-add-text);
|
||||
background: var(--code-add-bg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
@@ -4321,14 +4504,14 @@
|
||||
}
|
||||
.diff-line { display: block; white-space: pre-wrap; word-break: break-word; }
|
||||
.diff-line.meta { color: var(--color-ink-faint); }
|
||||
.diff-line.hunk { color: #7aacff; background: rgba(122,172,255,0.07); }
|
||||
.diff-line.add { color: #4eca76; background: rgba(78,202,118,0.09); }
|
||||
.diff-line.del { color: #e86060; background: rgba(232,96,96,0.09); }
|
||||
.diff-line.hunk { color: var(--code-hunk-text); background: var(--code-hunk-bg); }
|
||||
.diff-line.add { color: var(--code-add-text); background: var(--code-add-bg); }
|
||||
.diff-line.del { color: var(--code-delete-text); background: var(--code-delete-bg); }
|
||||
.diff-line.context { color: var(--color-ink-muted); }
|
||||
|
||||
.diff-counts { display: flex; gap: 8px; font-family: var(--font-mono); font-size: 12px; font-weight: 700; }
|
||||
.diff-counts .adds { color: #4eca76; }
|
||||
.diff-counts .dels { color: #e86060; }
|
||||
.diff-counts .adds { color: var(--code-add-strong); }
|
||||
.diff-counts .dels { color: var(--code-delete-strong); }
|
||||
|
||||
/* --- Conflict resolver --- */
|
||||
|
||||
@@ -4399,7 +4582,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
background: rgba(0,0,0,0.12);
|
||||
background: var(--code-surface);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
@@ -4431,19 +4614,19 @@
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.resolve-split-marker.unresolved { color: #ef8080; background: rgba(232,96,96,0.12); }
|
||||
.resolve-split-marker.unresolved { color: var(--code-delete-text); background: var(--code-delete-gutter-bg); }
|
||||
|
||||
.resolve-num {
|
||||
padding: 0 6px 0 4px;
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-ink-faint);
|
||||
background: rgba(0,0,0,0.14);
|
||||
background: var(--code-surface-subtle);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
.resolve-num.ours { color: rgba(78,202,118,0.65); background: rgba(78,202,118,0.11); border-right-color: rgba(78,202,118,0.2); }
|
||||
.resolve-num.theirs { color: rgba(122,172,255,0.65); background: rgba(122,172,255,0.11); border-right-color: rgba(122,172,255,0.2); }
|
||||
.resolve-num.empty { background: rgba(0,0,0,0.07); }
|
||||
.resolve-num.ours { color: var(--code-add-strong); background: var(--code-add-gutter-bg); border-right-color: rgba(78,202,118,0.2); }
|
||||
.resolve-num.theirs { color: var(--code-hunk-text); background: var(--code-hunk-bg); border-right-color: rgba(122,172,255,0.2); }
|
||||
.resolve-num.empty { background: var(--code-surface-muted); }
|
||||
|
||||
.resolve-cell {
|
||||
min-width: 0;
|
||||
@@ -4452,9 +4635,9 @@
|
||||
color: var(--color-ink-muted);
|
||||
white-space: pre;
|
||||
}
|
||||
.resolve-cell.ours { color: #5dd88a; background: rgba(78,202,118,0.1); }
|
||||
.resolve-cell.theirs { color: #8fb4ff; background: rgba(90,140,248,0.11); }
|
||||
.resolve-cell.empty { background: rgba(0,0,0,0.06); }
|
||||
.resolve-cell.ours { color: var(--code-add-text); background: var(--code-add-bg); }
|
||||
.resolve-cell.theirs { color: var(--code-hunk-text); background: var(--code-hunk-bg); }
|
||||
.resolve-cell.empty { background: var(--code-surface-muted); }
|
||||
.resolve-cell.dimmed { opacity: 0.42; filter: grayscale(0.5); }
|
||||
|
||||
.resolve-context { margin: 0; padding: 2px 8px; overflow-x: auto; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; tab-size: 2; color: var(--color-ink-muted); }
|
||||
@@ -4474,13 +4657,13 @@
|
||||
.resolve-side.dimmed { opacity: 0.4; filter: grayscale(0.5); }
|
||||
|
||||
.resolve-side-label { font-size: 11px; font-weight: 800; text-transform: uppercase; }
|
||||
.resolve-side.ours .resolve-side-label { color: #4eca76; }
|
||||
.resolve-side.theirs .resolve-side-label { color: #6a9aff; }
|
||||
.resolve-side.ours .resolve-side-label { color: var(--code-add-strong); }
|
||||
.resolve-side.theirs .resolve-side-label { color: var(--code-hunk-text); }
|
||||
|
||||
.resolve-lines { margin: 0; overflow-x: auto; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; tab-size: 2; }
|
||||
.resolve-line { display: block; white-space: pre-wrap; word-break: break-word; }
|
||||
.resolve-line.ours { color: #4eca76; }
|
||||
.resolve-line.theirs { color: #7aacff; }
|
||||
.resolve-line.ours { color: var(--code-add-text); }
|
||||
.resolve-line.theirs { color: var(--code-hunk-text); }
|
||||
.resolve-line.context { color: var(--color-ink-muted); }
|
||||
|
||||
.resolve-binary { display: grid; align-content: start; gap: 12px; padding: 4px; }
|
||||
@@ -4834,22 +5017,75 @@
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .split-span.split-meta {
|
||||
background: #e9eef7;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .resolve-split,
|
||||
:root[data-theme="light"] .resolve-num,
|
||||
:root[data-theme="light"] .resolve-cell.empty,
|
||||
:root[data-theme="light"] .resolve-num.empty {
|
||||
background: rgba(234,239,248,0.72);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .diff-line.meta,
|
||||
:root[data-theme="light"] .resolve-split-marker {
|
||||
color: #64728a;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .global-search-tabs,
|
||||
:root[data-theme="light"] .file-search-history,
|
||||
:root[data-theme="light"] .file-search-history-head {
|
||||
background: var(--color-surface-dim);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .discard-target {
|
||||
background: var(--code-surface-subtle);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .commit-amend-toggle input {
|
||||
border-color: var(--color-border-input);
|
||||
background: var(--app-input-bg);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .commit-amend-toggle input:checked {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: inset 0 0 0 2px #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cred-segment {
|
||||
border-color: rgba(49,95,214,0.2);
|
||||
background: #eef3f9;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cred-seg-btn.active {
|
||||
border-color: rgba(49,95,214,0.26);
|
||||
background: linear-gradient(135deg, rgba(49,95,214,0.12), rgba(15,143,181,0.08));
|
||||
box-shadow: 0 8px 18px rgba(28,44,74,0.1), inset 0 1px 0 rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cred-close {
|
||||
border-color: var(--color-border-subtle);
|
||||
color: var(--color-ink-dim);
|
||||
background: rgba(255,255,255,0.76);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cred-close:hover:not(:disabled) {
|
||||
border-color: var(--color-border-input);
|
||||
color: var(--color-ink);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-badge.modified,
|
||||
:root[data-theme="light"] .resolve-status,
|
||||
:root[data-theme="light"] .resolve-conflict-label {
|
||||
color: #8a580a;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-badge.added,
|
||||
:root[data-theme="light"] .status-badge.untracked,
|
||||
:root[data-theme="light"] .pill-active,
|
||||
:root[data-theme="light"] .prepared-tag {
|
||||
color: #19723d;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-badge.deleted {
|
||||
color: #b4233b;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-badge.renamed {
|
||||
color: #245cc7;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cred-input input,
|
||||
:root[data-theme="light"] .cred-expiry input[type="date"] {
|
||||
background: rgba(255,255,255,0.92);
|
||||
@@ -5022,6 +5258,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; }
|
||||
|
||||
+57
-15
@@ -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 { CircleHelp, 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,9 +20,13 @@
|
||||
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 onOpenHelp: () => void = () => {};
|
||||
export let onOpenSettings: () => void = () => {};
|
||||
export let language: "en" | "de" = "en";
|
||||
|
||||
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
||||
let isMaximized = false;
|
||||
@@ -79,17 +83,23 @@
|
||||
<!-- Center: repo + branch info -->
|
||||
<div class="titlebar-info" data-tauri-drag-region>
|
||||
{#if hasRepository}
|
||||
{#if repoName}
|
||||
<span class="tb-repo" data-tauri-drag-region>{repoName}</span>
|
||||
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
|
||||
{/if}
|
||||
<GitBranch size={12} aria-hidden="true" />
|
||||
<span class="tb-branch" data-tauri-drag-region>{branch}</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>
|
||||
<div class="titlebar-context" data-tauri-drag-region>
|
||||
{#if repoName}
|
||||
<span class="tb-repo" data-tauri-drag-region>{repoName}</span>
|
||||
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
|
||||
{/if}
|
||||
<GitBranch size={12} aria-hidden="true" />
|
||||
<span class="tb-branch" data-tauri-drag-region title={branch}>{branch}</span>
|
||||
</div>
|
||||
{#if ahead > 0 || behind > 0}
|
||||
<div class="tb-sync-group" aria-label="Branch synchronization status">
|
||||
{#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}
|
||||
{:else}
|
||||
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
|
||||
@@ -132,6 +142,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}
|
||||
@@ -208,14 +240,24 @@
|
||||
<span class="tb-action-label">Auto</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenHelp}
|
||||
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
|
||||
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
|
||||
>
|
||||
<CircleHelp size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">{language === "de" ? "Hilfe" : "Help"}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenSettings}
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
title={language === "de" ? "Einstellungen" : "Settings"}
|
||||
aria-label={language === "de" ? "Einstellungen" : "Settings"}
|
||||
>
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Settings</span>
|
||||
<span class="tb-action-label">{language === "de" ? "Einstellungen" : "Settings"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Check, Settings, X } from "@lucide/svelte";
|
||||
import type { AnalyticsSettings, AppTheme } from "../types";
|
||||
import { Check, Languages, Settings, X } from "@lucide/svelte";
|
||||
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
|
||||
|
||||
interface Props {
|
||||
analytics: AnalyticsSettings;
|
||||
theme: AppTheme;
|
||||
onSave: (settings: AnalyticsSettings, theme: AppTheme) => void;
|
||||
language: AppLanguage;
|
||||
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { analytics, theme = "system", onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||
let { analytics, theme = "system", language = "en", onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||
|
||||
let analyticsEnabled = $state(true);
|
||||
let selectedTheme = $state<AppTheme>("system");
|
||||
let selectedLanguage = $state<AppLanguage>("en");
|
||||
const isGerman = $derived(selectedLanguage === "de");
|
||||
|
||||
$effect(() => {
|
||||
analyticsEnabled = analytics.enabled;
|
||||
selectedTheme = theme;
|
||||
selectedLanguage = language;
|
||||
});
|
||||
|
||||
function save() {
|
||||
@@ -24,18 +28,18 @@
|
||||
...analytics,
|
||||
enabled: analyticsEnabled,
|
||||
noticeSeen: true,
|
||||
}, selectedTheme);
|
||||
}, selectedTheme, selectedLanguage);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
|
||||
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Gitty</span>
|
||||
<h2 class="dialog-title">Settings</h2>
|
||||
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
@@ -45,23 +49,44 @@
|
||||
<header>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">Appearance</span>
|
||||
<h3>Theme</h3>
|
||||
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span>
|
||||
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="settings-segmented" role="radiogroup" aria-label="Theme">
|
||||
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
|
||||
<label class:active={selectedTheme === "system"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="system" />
|
||||
<span>System</span>
|
||||
</label>
|
||||
<label class:active={selectedTheme === "light"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="light" />
|
||||
<span>Light</span>
|
||||
<span>{isGerman ? "Hell" : "Light"}</span>
|
||||
</label>
|
||||
<label class:active={selectedTheme === "dark"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="dark" />
|
||||
<span>Dark</span>
|
||||
<span>{isGerman ? "Dunkel" : "Dark"}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Languages size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">{isGerman ? "Sprache" : "Language"}</span>
|
||||
<h3>{isGerman ? "App-Sprache" : "App language"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
||||
<label class:active={selectedLanguage === "en"}>
|
||||
<input type="radio" bind:group={selectedLanguage} value="en" />
|
||||
<span>EN · English</span>
|
||||
</label>
|
||||
<label class:active={selectedLanguage === "de"}>
|
||||
<input type="radio" bind:group={selectedLanguage} value="de" />
|
||||
<span>DE · Deutsch</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
@@ -71,24 +96,24 @@
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">Analytics</span>
|
||||
<h3>Anonymous usage analytics</h3>
|
||||
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label class="settings-toggle-row">
|
||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||
<span>
|
||||
<strong>Allow anonymous Aptabase events</strong>
|
||||
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
|
||||
<strong>{isGerman ? "Anonyme Aptabase-Ereignisse erlauben" : "Allow anonymous Aptabase events"}</strong>
|
||||
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
|
||||
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||
<button class="btn-primary" type="submit">
|
||||
<Check size={16} aria-hidden="true" />
|
||||
Save
|
||||
{isGerman ? "Speichern" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,798 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from "svelte";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Clipboard,
|
||||
Cloud,
|
||||
Command,
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
Home,
|
||||
Keyboard,
|
||||
Lightbulb,
|
||||
Search,
|
||||
Wrench,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
interface CommandExample {
|
||||
command: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface HelpSection {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
steps?: string[];
|
||||
commands?: CommandExample[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface HelpCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
sections: HelpSection[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
language: "en" | "de";
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const deCategories: HelpCategory[] = [
|
||||
{
|
||||
id: "start",
|
||||
label: "Erste Schritte",
|
||||
description: "Repository öffnen, Oberfläche verstehen und den ersten Commit erstellen.",
|
||||
sections: [
|
||||
{
|
||||
id: "start-workflow",
|
||||
title: "Dein erster Gitty-Workflow",
|
||||
summary: "Vom Repository bis zum veröffentlichten Commit in fünf klaren Schritten.",
|
||||
steps: [
|
||||
"Öffne ein vorhandenes Repository oder klone ein Projekt über die Repository-Verwaltung.",
|
||||
"Bearbeite deine Dateien. Gitty zeigt Änderungen im Arbeitsverzeichnis automatisch an.",
|
||||
"Prüfe den Diff und stage einzelne Dateien, Zeilen oder alle passenden Änderungen.",
|
||||
"Schreibe eine aussagekräftige Commit-Nachricht und erstelle den Commit.",
|
||||
"Nutze Fetch, Pull und Push, um deinen Stand mit dem Remote-Repository abzugleichen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "start-layout",
|
||||
title: "Die Oberfläche im Überblick",
|
||||
summary: "Links findest du Branches, Stashes und Dateien. In der Mitte prüfst und commitest du Änderungen; rechts siehst du Verlauf und Dateihistorie.",
|
||||
note: "Fast alle Bereiche lassen sich über die Trennlinien in der Größe anpassen oder über ihren Kopf einklappen.",
|
||||
},
|
||||
{
|
||||
id: "start-safety",
|
||||
title: "Sicher arbeiten",
|
||||
summary: "Prüfe vor Commit, Pull, Rebase oder Verwerfen immer den aktuellen Branch und die betroffenen Dateien. Gitty fragt bei destruktiven Aktionen nochmals nach.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app",
|
||||
label: "Arbeiten mit Gitty",
|
||||
description: "Die wichtigsten Funktionen der App Schritt für Schritt.",
|
||||
sections: [
|
||||
{
|
||||
id: "app-changes",
|
||||
title: "Änderungen prüfen und stagen",
|
||||
summary: "Wähle eine geänderte Datei, lies den Diff und verschiebe gezielt Änderungen in den Staging-Bereich.",
|
||||
steps: [
|
||||
"Unstaged enthält noch nicht vorbereitete Änderungen; Staged enthält den nächsten Commit-Inhalt.",
|
||||
"Öffne eine Datei, um hinzugefügte und entfernte Zeilen zu prüfen.",
|
||||
"Stage ganze Dateien oder nutze die Zeilen-/Hunk-Aktionen für kleinere, saubere Commits.",
|
||||
"Verwerfen entfernt lokale Änderungen. Diese Aktion lässt sich nicht immer rückgängig machen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-history",
|
||||
title: "Verlauf, Suche und Vergleich",
|
||||
summary: "Der Commit-Verlauf zeigt Branches und Merges. Mit der globalen Suche findest du die Einführung von Code oder den Verlauf einer Datei.",
|
||||
steps: [
|
||||
"Klappe einen Commit auf, um seine Dateien zu sehen.",
|
||||
"Vergleiche zwei Commits über Compare in der Titelleiste.",
|
||||
"Nutze Reflog, um auch verschobene oder nicht mehr sichtbare Referenzen wiederzufinden.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-stash",
|
||||
title: "Zwischenstände mit Stash sichern",
|
||||
summary: "Ein Stash parkt unvollständige Änderungen, ohne einen Commit zu erzeugen. Apply behält den Stash, Pop wendet ihn an und entfernt ihn.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "basics",
|
||||
label: "Git-Grundlagen",
|
||||
description: "Alltägliche Befehle für Status, Änderungen, Commits und Wiederherstellung.",
|
||||
sections: [
|
||||
{
|
||||
id: "basics-inspect",
|
||||
title: "Repository prüfen",
|
||||
summary: "Diese Befehle verändern nichts und eignen sich immer als erster Blick auf den aktuellen Zustand.",
|
||||
commands: [
|
||||
{ command: "git status", description: "Arbeitsverzeichnis und Staging-Bereich anzeigen" },
|
||||
{ command: "git diff", description: "Noch nicht gestagte Änderungen anzeigen" },
|
||||
{ command: "git diff --staged", description: "Inhalt des nächsten Commits anzeigen" },
|
||||
{ command: "git log --oneline --graph --decorate --all", description: "Kompakten Branch- und Commit-Verlauf anzeigen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "basics-commit",
|
||||
title: "Änderungen speichern",
|
||||
summary: "Stage nur zusammengehörige Änderungen und beschreibe im Commit, warum die Änderung nötig ist.",
|
||||
commands: [
|
||||
{ command: "git add <datei>", description: "Eine Datei für den Commit vormerken" },
|
||||
{ command: "git add -p", description: "Änderungen interaktiv und abschnittsweise stagen" },
|
||||
{ command: "git commit -m \"Kurze Beschreibung\"", description: "Einen Commit erstellen" },
|
||||
{ command: "git commit --amend", description: "Den letzten lokalen Commit ergänzen oder umbenennen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "basics-undo",
|
||||
title: "Änderungen rückgängig machen",
|
||||
summary: "Restore arbeitet am Arbeitsverzeichnis oder Staging-Bereich; Revert erzeugt einen neuen Gegen-Commit und ist für veröffentlichte Historie sicherer.",
|
||||
commands: [
|
||||
{ command: "git restore <datei>", description: "Lokale, noch nicht gestagte Änderung verwerfen" },
|
||||
{ command: "git restore --staged <datei>", description: "Datei aus dem Staging-Bereich entfernen" },
|
||||
{ command: "git revert <commit>", description: "Wirkung eines Commits durch neuen Commit umkehren" },
|
||||
],
|
||||
note: "Vorsicht: git reset --hard verwirft lokale Änderungen. Verwende den Befehl nur, wenn du den Verlust bewusst akzeptierst.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "branches",
|
||||
label: "Branches & Merges",
|
||||
description: "Parallele Arbeit organisieren, zusammenführen und aufräumen.",
|
||||
sections: [
|
||||
{
|
||||
id: "branches-manage",
|
||||
title: "Branches verwalten",
|
||||
summary: "Ein Branch ist ein beweglicher Zeiger auf eine Commit-Linie. Erstelle für jede getrennte Aufgabe einen eigenen Branch.",
|
||||
commands: [
|
||||
{ command: "git switch -c feature/meine-aenderung", description: "Neuen Branch erstellen und wechseln" },
|
||||
{ command: "git switch main", description: "Zu einem vorhandenen Branch wechseln" },
|
||||
{ command: "git branch -vv", description: "Lokale Branches mit Upstream-Status anzeigen" },
|
||||
{ command: "git branch -d <branch>", description: "Bereits zusammengeführten Branch löschen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "branches-integrate",
|
||||
title: "Merge oder Rebase?",
|
||||
summary: "Merge bewahrt die tatsächliche Verzweigung. Rebase setzt lokale Commits auf eine neue Basis und erzeugt eine lineare Historie.",
|
||||
commands: [
|
||||
{ command: "git merge <branch>", description: "Einen Branch in den aktuellen Branch integrieren" },
|
||||
{ command: "git rebase main", description: "Lokale Commits auf den aktuellen Stand von main setzen" },
|
||||
{ command: "git rebase --continue", description: "Rebase nach gelöstem Konflikt fortsetzen" },
|
||||
{ command: "git rebase --abort", description: "Rebase abbrechen und Ausgangszustand wiederherstellen" },
|
||||
],
|
||||
note: "Rebase keine Commits, an denen andere bereits weiterarbeiten. Das Umschreiben veröffentlichter Historie verursacht unnötige Konflikte.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
label: "Remote & Sync",
|
||||
description: "Änderungen sicher mit GitHub, GitLab oder anderen Remotes austauschen.",
|
||||
sections: [
|
||||
{
|
||||
id: "remote-sync",
|
||||
title: "Fetch, Pull und Push",
|
||||
summary: "Fetch lädt Referenzen ohne deine Dateien zu ändern. Pull integriert Remote-Änderungen. Push veröffentlicht deine Commits.",
|
||||
commands: [
|
||||
{ command: "git fetch --all --prune", description: "Remote-Stände laden und entfernte Referenzen aufräumen" },
|
||||
{ command: "git pull --rebase", description: "Remote-Änderungen laden und lokale Commits darauf neu abspielen" },
|
||||
{ command: "git push -u origin <branch>", description: "Branch erstmals veröffentlichen und Upstream setzen" },
|
||||
{ command: "git remote -v", description: "Konfigurierte Remote-Adressen anzeigen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "remote-ahead",
|
||||
title: "Ahead und Behind verstehen",
|
||||
summary: "Ahead bedeutet: lokale Commits wurden noch nicht gepusht. Behind bedeutet: im Remote liegen neue Commits, die lokal fehlen. Beides gleichzeitig weist auf auseinanderlaufende Historien hin.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "troubleshooting",
|
||||
label: "Probleme lösen",
|
||||
description: "Konflikte, verlorene Commits und typische Fehlermeldungen verstehen.",
|
||||
sections: [
|
||||
{
|
||||
id: "trouble-conflicts",
|
||||
title: "Merge-Konflikte lösen",
|
||||
summary: "Ein Konflikt entsteht, wenn Git Änderungen nicht eindeutig kombinieren kann. Gitty bietet dafür einen eigenen Resolve-Dialog.",
|
||||
steps: [
|
||||
"Öffne jede Konfliktdatei und vergleiche Current, Incoming und das kombinierte Ergebnis.",
|
||||
"Übernimm eine Seite oder bearbeite den Zielinhalt manuell.",
|
||||
"Markiere die Datei als gelöst und prüfe anschließend den vollständigen Diff.",
|
||||
"Führe Merge, Rebase oder Cherry-pick fort – oder brich die Operation vollständig ab.",
|
||||
],
|
||||
commands: [
|
||||
{ command: "git status", description: "Konfliktdateien und laufende Operation anzeigen" },
|
||||
{ command: "git merge --abort", description: "Laufenden Merge abbrechen" },
|
||||
{ command: "git cherry-pick --abort", description: "Laufenden Cherry-pick abbrechen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trouble-reflog",
|
||||
title: "Verlorene Commits wiederfinden",
|
||||
summary: "Reflog protokolliert lokale Bewegungen von HEAD und Branches. Kopiere den Hash des gesuchten Eintrags und erstelle daraus einen Sicherungs-Branch.",
|
||||
commands: [
|
||||
{ command: "git reflog", description: "Lokale Referenzbewegungen anzeigen" },
|
||||
{ command: "git branch recovery/<name> <commit>", description: "Gefundenen Commit dauerhaft über Branch sichern" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "shortcuts",
|
||||
label: "Tastenkürzel",
|
||||
description: "Gitty schneller und ohne Maus bedienen.",
|
||||
sections: [
|
||||
{
|
||||
id: "shortcuts-main",
|
||||
title: "Globale Bedienung",
|
||||
summary: "Die Hilfe ist überall erreichbar. Dialoge lassen sich konsistent schließen und Suchfelder direkt fokussieren.",
|
||||
commands: [
|
||||
{ command: "Ctrl + /", description: "Diese Hilfe öffnen" },
|
||||
{ command: "Escape", description: "Aktuelles Overlay oder Dialogfenster schließen" },
|
||||
{ command: "Tab / Shift + Tab", description: "Zwischen Bedienelementen wechseln" },
|
||||
{ command: "Enter / Leertaste", description: "Fokussierte Aktion ausführen" },
|
||||
],
|
||||
note: "Auf macOS kannst du für Ctrl in der Regel die Command-Taste verwenden.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const enCategories: HelpCategory[] = [
|
||||
{
|
||||
id: "start",
|
||||
label: "Getting started",
|
||||
description: "Open a repository, understand the interface, and create your first commit.",
|
||||
sections: [
|
||||
{
|
||||
id: "start-workflow",
|
||||
title: "Your first Gitty workflow",
|
||||
summary: "From repository to published commit in five clear steps.",
|
||||
steps: [
|
||||
"Open an existing repository or clone a project from Repository Management.",
|
||||
"Edit your files. Gitty automatically displays working-tree changes.",
|
||||
"Review the diff and stage individual files, lines, or all related changes.",
|
||||
"Write a meaningful commit message and create the commit.",
|
||||
"Use Fetch, Pull, and Push to synchronize with the remote repository.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "start-layout",
|
||||
title: "Interface overview",
|
||||
summary: "Branches, stashes, and files are on the left. Review and commit changes in the center; history and file history are on the right.",
|
||||
note: "Most areas can be resized using their dividers or collapsed from their header.",
|
||||
},
|
||||
{
|
||||
id: "start-safety",
|
||||
title: "Work safely",
|
||||
summary: "Before committing, pulling, rebasing, or discarding, always check the current branch and affected files. Gitty confirms destructive actions.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app",
|
||||
label: "Working with Gitty",
|
||||
description: "The app's most important features, explained step by step.",
|
||||
sections: [
|
||||
{
|
||||
id: "app-changes",
|
||||
title: "Review and stage changes",
|
||||
summary: "Select a changed file, review its diff, and move changes into the staging area.",
|
||||
steps: [
|
||||
"Unstaged contains changes not yet prepared; Staged contains the next commit.",
|
||||
"Open a file to review added and removed lines.",
|
||||
"Stage whole files or use line and hunk actions for focused commits.",
|
||||
"Discard removes local changes and cannot always be undone.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-history",
|
||||
title: "History, search, and comparison",
|
||||
summary: "Commit History shows branches and merges. Global Search finds where code was introduced or displays a file's history.",
|
||||
steps: [
|
||||
"Expand a commit to inspect its files.",
|
||||
"Compare two commits with Compare in the title bar.",
|
||||
"Use Reflog to recover moved or otherwise hidden references.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-stash",
|
||||
title: "Save temporary work with Stash",
|
||||
summary: "A stash parks unfinished changes without creating a commit. Apply keeps the stash; Pop applies and removes it.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "basics",
|
||||
label: "Git basics",
|
||||
description: "Everyday commands for status, changes, commits, and recovery.",
|
||||
sections: [
|
||||
{
|
||||
id: "basics-inspect",
|
||||
title: "Inspect a repository",
|
||||
summary: "These commands do not change anything and are always a good first look at the current state.",
|
||||
commands: [
|
||||
{ command: "git status", description: "Show the working tree and staging area" },
|
||||
{ command: "git diff", description: "Show changes that have not been staged" },
|
||||
{ command: "git diff --staged", description: "Show the contents of the next commit" },
|
||||
{ command: "git log --oneline --graph --decorate --all", description: "Show a compact branch and commit graph" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "basics-commit",
|
||||
title: "Save changes",
|
||||
summary: "Stage only related changes and explain why the change is needed in the commit message.",
|
||||
commands: [
|
||||
{ command: "git add <file>", description: "Stage one file" },
|
||||
{ command: "git add -p", description: "Stage changes interactively by hunk" },
|
||||
{ command: "git commit -m \"Short description\"", description: "Create a commit" },
|
||||
{ command: "git commit --amend", description: "Update or rename the latest local commit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "basics-undo",
|
||||
title: "Undo changes",
|
||||
summary: "Restore changes the working tree or staging area. Revert creates a new inverse commit and is safer for published history.",
|
||||
commands: [
|
||||
{ command: "git restore <file>", description: "Discard an unstaged local change" },
|
||||
{ command: "git restore --staged <file>", description: "Remove a file from the staging area" },
|
||||
{ command: "git revert <commit>", description: "Undo a commit through a new commit" },
|
||||
],
|
||||
note: "Caution: git reset --hard discards local changes. Use it only when that data loss is intentional.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "branches",
|
||||
label: "Branches & merges",
|
||||
description: "Organize parallel work, integrate it, and clean up afterward.",
|
||||
sections: [
|
||||
{
|
||||
id: "branches-manage",
|
||||
title: "Manage branches",
|
||||
summary: "A branch is a movable pointer to a commit line. Create a separate branch for each independent task.",
|
||||
commands: [
|
||||
{ command: "git switch -c feature/my-change", description: "Create and switch to a new branch" },
|
||||
{ command: "git switch main", description: "Switch to an existing branch" },
|
||||
{ command: "git branch -vv", description: "Show local branches and upstream status" },
|
||||
{ command: "git branch -d <branch>", description: "Delete a branch that has already been merged" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "branches-integrate",
|
||||
title: "Merge or rebase?",
|
||||
summary: "Merge preserves the actual branch structure. Rebase moves local commits onto a new base for a linear history.",
|
||||
commands: [
|
||||
{ command: "git merge <branch>", description: "Integrate a branch into the current branch" },
|
||||
{ command: "git rebase main", description: "Move local commits onto the latest main" },
|
||||
{ command: "git rebase --continue", description: "Continue after resolving a conflict" },
|
||||
{ command: "git rebase --abort", description: "Abort and restore the original state" },
|
||||
],
|
||||
note: "Do not rebase commits other people are already using. Rewriting published history creates avoidable conflicts.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
label: "Remote & sync",
|
||||
description: "Exchange changes safely with GitHub, GitLab, or other remotes.",
|
||||
sections: [
|
||||
{
|
||||
id: "remote-sync",
|
||||
title: "Fetch, pull, and push",
|
||||
summary: "Fetch downloads references without changing files. Pull integrates remote changes. Push publishes your commits.",
|
||||
commands: [
|
||||
{ command: "git fetch --all --prune", description: "Download remote state and remove stale references" },
|
||||
{ command: "git pull --rebase", description: "Download changes and replay local commits on top" },
|
||||
{ command: "git push -u origin <branch>", description: "Publish a branch and set its upstream" },
|
||||
{ command: "git remote -v", description: "Show configured remote URLs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "remote-ahead",
|
||||
title: "Understanding Ahead and Behind",
|
||||
summary: "Ahead means local commits have not been pushed. Behind means remote commits are missing locally. Both at once means the histories have diverged.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "troubleshooting",
|
||||
label: "Troubleshooting",
|
||||
description: "Resolve conflicts, recover lost commits, and understand common errors.",
|
||||
sections: [
|
||||
{
|
||||
id: "trouble-conflicts",
|
||||
title: "Resolve merge conflicts",
|
||||
summary: "A conflict occurs when Git cannot combine changes unambiguously. Gitty provides a dedicated Resolve dialog.",
|
||||
steps: [
|
||||
"Open each conflicted file and compare Current, Incoming, and the combined result.",
|
||||
"Accept one side or edit the final content manually.",
|
||||
"Mark the file resolved and review the complete diff.",
|
||||
"Continue the merge, rebase, or cherry-pick—or abort the operation.",
|
||||
],
|
||||
commands: [
|
||||
{ command: "git status", description: "Show conflicts and the active operation" },
|
||||
{ command: "git merge --abort", description: "Abort the current merge" },
|
||||
{ command: "git cherry-pick --abort", description: "Abort the current cherry-pick" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trouble-reflog",
|
||||
title: "Recover lost commits",
|
||||
summary: "Reflog records local movements of HEAD and branches. Copy the desired hash and create a recovery branch from it.",
|
||||
commands: [
|
||||
{ command: "git reflog", description: "Show local reference movements" },
|
||||
{ command: "git branch recovery/<name> <commit>", description: "Preserve the recovered commit with a branch" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "shortcuts",
|
||||
label: "Keyboard shortcuts",
|
||||
description: "Use Gitty quickly without reaching for the mouse.",
|
||||
sections: [
|
||||
{
|
||||
id: "shortcuts-main",
|
||||
title: "Global controls",
|
||||
summary: "Help is available everywhere. Dialogs close consistently and search fields receive focus automatically.",
|
||||
commands: [
|
||||
{ command: "Ctrl + /", description: "Open this help center" },
|
||||
{ command: "Escape", description: "Close the current overlay or dialog" },
|
||||
{ command: "Tab / Shift + Tab", description: "Move between controls" },
|
||||
{ command: "Enter / Space", description: "Activate the focused control" },
|
||||
],
|
||||
note: "On macOS, you can generally use Command instead of Ctrl.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
let { language = "en", onClose = () => {} }: Props = $props();
|
||||
const isGerman = $derived(language === "de");
|
||||
const categories = $derived(isGerman ? deCategories : enCategories);
|
||||
let selectedCategoryId = $state("start");
|
||||
let searchQuery = $state("");
|
||||
let copiedCommand = $state("");
|
||||
let searchInput: HTMLInputElement;
|
||||
let contentElement: HTMLElement;
|
||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const normalizedQuery = $derived(searchQuery.trim().toLocaleLowerCase(language));
|
||||
const selectedCategory = $derived(categories.find((category) => category.id === selectedCategoryId) ?? categories[0]);
|
||||
const visibleGroups = $derived.by(() => {
|
||||
if (!normalizedQuery) return [{ category: selectedCategory, sections: selectedCategory.sections }];
|
||||
return categories.flatMap((category) => {
|
||||
const sections = category.sections.filter((section) => sectionSearchText(category, section).includes(normalizedQuery));
|
||||
return sections.length > 0 ? [{ category, sections }] : [];
|
||||
});
|
||||
});
|
||||
const resultCount = $derived(visibleGroups.reduce((sum, group) => sum + group.sections.length, 0));
|
||||
|
||||
$effect(() => {
|
||||
normalizedQuery;
|
||||
selectedCategoryId;
|
||||
if (contentElement) contentElement.scrollTop = 0;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
void tick().then(() => searchInput?.focus());
|
||||
return () => {
|
||||
if (copyTimer) clearTimeout(copyTimer);
|
||||
};
|
||||
});
|
||||
|
||||
function sectionSearchText(category: HelpCategory, section: HelpSection): string {
|
||||
return [
|
||||
category.label,
|
||||
category.description,
|
||||
section.title,
|
||||
section.summary,
|
||||
...(section.steps ?? []),
|
||||
...(section.commands?.flatMap((entry) => [entry.command, entry.description]) ?? []),
|
||||
section.note ?? "",
|
||||
].join(" ").toLocaleLowerCase(language);
|
||||
}
|
||||
|
||||
function selectCategory(id: string) {
|
||||
selectedCategoryId = id;
|
||||
searchQuery = "";
|
||||
}
|
||||
|
||||
function isWarningNote(note: string): boolean {
|
||||
return /^(Vorsicht|Rebase|Caution|Do not)/.test(note);
|
||||
}
|
||||
|
||||
async function copyCommand(command: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
copiedCommand = command;
|
||||
if (copyTimer) clearTimeout(copyTimer);
|
||||
copyTimer = setTimeout(() => { copiedCommand = ""; }, 1800);
|
||||
} catch {
|
||||
copiedCommand = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="help-backdrop" role="presentation" onclick={handleBackdropClick}>
|
||||
<div class="help-overlay" role="dialog" aria-modal="true" aria-labelledby="help-title">
|
||||
<header class="help-header">
|
||||
<div class="help-title-wrap">
|
||||
<span class="help-mark"><CircleHelp size={19} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h2 id="help-title">{isGerman ? "Gitty Hilfe" : "Gitty Help"}</h2>
|
||||
<p>{isGerman ? "App-Anleitung und Git-Wissen an einem Ort" : "App guidance and Git knowledge in one place"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="help-search">
|
||||
<span class="help-search-icon"><Search size={17} aria-hidden="true" /></span>
|
||||
<input bind:this={searchInput} bind:value={searchQuery} type="search" placeholder={isGerman ? "Hilfe und Git-Befehle durchsuchen …" : "Search help and Git commands …"} aria-label={isGerman ? "Hilfe durchsuchen" : "Search help"} />
|
||||
<kbd>Ctrl /</kbd>
|
||||
</label>
|
||||
|
||||
<button class="help-close" type="button" onclick={onClose} title={isGerman ? "Hilfe schließen" : "Close help"} aria-label={isGerman ? "Hilfe schließen" : "Close help"}>
|
||||
<X size={19} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="help-layout">
|
||||
<nav class="help-nav" aria-label={isGerman ? "Hilfethemen" : "Help topics"}>
|
||||
<p class="help-nav-label">{isGerman ? "Themen" : "Topics"}</p>
|
||||
{#each categories as category, index}
|
||||
<button
|
||||
type="button"
|
||||
class:active={!normalizedQuery && category.id === selectedCategoryId}
|
||||
onclick={() => selectCategory(category.id)}
|
||||
>
|
||||
<span class="help-nav-icon">
|
||||
{#if index === 0}<Home size={17} aria-hidden="true" />
|
||||
{:else if index === 1}<BookOpen size={17} aria-hidden="true" />
|
||||
{:else if index === 2}<GitCommitHorizontal size={17} aria-hidden="true" />
|
||||
{:else if index === 3}<GitBranch size={17} aria-hidden="true" />
|
||||
{:else if index === 4}<Cloud size={17} aria-hidden="true" />
|
||||
{:else if index === 5}<Wrench size={17} aria-hidden="true" />
|
||||
{:else}<Keyboard size={17} aria-hidden="true" />{/if}
|
||||
</span>
|
||||
<span>{category.label}</span>
|
||||
<span class="help-nav-chevron"><ChevronRight size={14} aria-hidden="true" /></span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<div class="help-nav-tip">
|
||||
<span class="help-tip-icon"><Lightbulb size={16} aria-hidden="true" /></span>
|
||||
<span>{isGerman ? "Suche auch nach Befehlen wie" : "Try commands such as"} <code>rebase</code>, <code>stash</code> {isGerman ? "oder" : "or"} <code>reflog</code>.</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="help-content" bind:this={contentElement}>
|
||||
{#if normalizedQuery}
|
||||
<header class="help-result-header">
|
||||
<div>
|
||||
<span>{isGerman ? "Suchergebnisse" : "Search results"}</span>
|
||||
<h3>{resultCount} {isGerman ? "Treffer für" : resultCount === 1 ? "result for" : "results for"} „{searchQuery.trim()}“</h3>
|
||||
</div>
|
||||
<button type="button" onclick={() => { searchQuery = ""; }}>{isGerman ? "Suche löschen" : "Clear search"}</button>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
{#if visibleGroups.length === 0}
|
||||
<div class="help-empty">
|
||||
<span class="help-empty-icon"><Search size={28} aria-hidden="true" /></span>
|
||||
<h3>{isGerman ? "Kein Hilfethema gefunden" : "No help topic found"}</h3>
|
||||
<p>{isGerman ? "Versuche einen allgemeineren Begriff wie „Commit“, „Branch“, „Remote“ oder „Konflikt“." : "Try a broader term such as “commit”, “branch”, “remote”, or “conflict”."}</p>
|
||||
<button class="btn-secondary" type="button" onclick={() => { searchQuery = ""; }}>{isGerman ? "Alle Themen anzeigen" : "Show all topics"}</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#each visibleGroups as group}
|
||||
<div class="help-group">
|
||||
<header class="help-group-header">
|
||||
<span>{normalizedQuery ? group.category.label : isGerman ? "Gitty Handbuch" : "Gitty handbook"}</span>
|
||||
<h3>{normalizedQuery ? group.category.label : group.category.label}</h3>
|
||||
<p>{group.category.description}</p>
|
||||
</header>
|
||||
|
||||
{#each group.sections as section, sectionIndex}
|
||||
<article class="help-section" id={section.id}>
|
||||
<div class="help-section-number">{String(sectionIndex + 1).padStart(2, "0")}</div>
|
||||
<div class="help-section-body">
|
||||
<h4>{section.title}</h4>
|
||||
<p>{section.summary}</p>
|
||||
|
||||
{#if section.steps}
|
||||
<ol class="help-steps">
|
||||
{#each section.steps as step}
|
||||
<li><span>{step}</span></li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
{#if section.commands}
|
||||
<div class="help-commands">
|
||||
{#each section.commands as entry}
|
||||
<div class="help-command-row">
|
||||
<code>{entry.command}</code>
|
||||
<span>{entry.description}</span>
|
||||
<button type="button" onclick={() => copyCommand(entry.command)} aria-label={(isGerman ? "Befehl kopieren: " : "Copy command: ") + entry.command} title={isGerman ? "Befehl kopieren" : "Copy command"}>
|
||||
{#if copiedCommand === entry.command}
|
||||
<Check size={15} aria-hidden="true" /><span>{isGerman ? "Kopiert" : "Copied"}</span>
|
||||
{:else}
|
||||
<Clipboard size={15} aria-hidden="true" /><span>{isGerman ? "Kopieren" : "Copy"}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if section.note}
|
||||
<div class="help-note" class:warning={isWarningNote(section.note)}>
|
||||
{#if isWarningNote(section.note)}
|
||||
<span class="help-note-icon"><AlertTriangle size={16} aria-hidden="true" /></span>
|
||||
{:else}
|
||||
<span class="help-note-icon"><Lightbulb size={16} aria-hidden="true" /></span>
|
||||
{/if}
|
||||
<span>{section.note}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="help-footer">
|
||||
<span><Command size={14} aria-hidden="true" /> <kbd>Ctrl</kbd><kbd>/</kbd> {isGerman ? "Hilfe öffnen" : "open help"}</span>
|
||||
<span><kbd>Esc</kbd> {isGerman ? "schließen" : "close"}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.help-backdrop {
|
||||
position: fixed;
|
||||
inset: var(--app-titlebar-height, 42px) 0 0;
|
||||
z-index: 70;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(3, 7, 13, 0.82);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.help-overlay {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
width: min(1240px, 100%);
|
||||
height: min(820px, 100%);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
color: var(--color-ink);
|
||||
background: var(--app-dialog-bg);
|
||||
box-shadow: var(--app-dialog-shadow);
|
||||
}
|
||||
|
||||
.help-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.72fr) minmax(320px, 1.2fr) minmax(44px, 0.72fr);
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
min-height: 74px;
|
||||
padding: 12px 16px 12px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--app-dialog-chrome);
|
||||
}
|
||||
|
||||
.help-title-wrap { display: flex; align-items: center; gap: 11px; min-width: 0; }
|
||||
.help-mark { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid rgba(90, 140, 248, 0.28); border-radius: 8px; color: var(--color-accent); background: rgba(90, 140, 248, 0.09); }
|
||||
.help-title-wrap h2 { margin: 0; font-size: 17px; line-height: 1.2; }
|
||||
.help-title-wrap p { margin: 3px 0 0; overflow: hidden; color: var(--color-ink-faint); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.help-search { position: relative; display: flex; align-items: center; min-width: 0; }
|
||||
.help-search-icon { position: absolute; left: 12px; display: grid; color: var(--color-ink-faint); pointer-events: none; }
|
||||
.help-search input { width: 100%; height: 40px; padding: 0 68px 0 39px; border-color: var(--color-border-input); border-radius: 8px; background: var(--app-input-bg); color: var(--color-ink); font-size: 12.5px; }
|
||||
.help-search kbd { position: absolute; right: 8px; }
|
||||
.help-close { justify-self: end; width: 36px; min-height: 36px; padding: 0; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); }
|
||||
|
||||
.help-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 0; }
|
||||
.help-nav { display: flex; flex-direction: column; min-height: 0; padding: 14px 10px 12px; border-right: 1px solid var(--color-border); background: var(--color-surface-dim); }
|
||||
.help-nav-label { margin: 0 10px 8px; color: var(--color-ink-faint); font-size: 9.5px; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; }
|
||||
.help-nav > button { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 42px; padding: 5px 9px; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); font-size: 11.5px; font-weight: 650; text-align: left; }
|
||||
.help-nav > button:hover { background: var(--color-surface-hover); color: var(--color-ink); }
|
||||
.help-nav > button.active { border-color: rgba(90, 140, 248, 0.22); background: rgba(90, 140, 248, 0.12); color: var(--color-accent); }
|
||||
.help-nav-icon { display: grid; place-items: center; color: currentColor; }
|
||||
.help-nav-chevron { display: grid; color: var(--color-ink-faint); }
|
||||
.help-nav-tip { display: flex; align-items: flex-start; gap: 8px; margin: auto 6px 0; padding: 11px; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.5; }
|
||||
.help-tip-icon { flex: 0 0 auto; display: grid; margin-top: 2px; color: #d8a13d; }
|
||||
.help-nav-tip code { color: var(--color-ink-muted); font-family: var(--font-mono); }
|
||||
|
||||
.help-content { min-width: 0; min-height: 0; padding: 0 34px 48px; overflow: auto; outline: none; scroll-behavior: smooth; }
|
||||
.help-group { max-width: 850px; margin: 0 auto; }
|
||||
.help-group + .help-group { margin-top: 22px; border-top: 1px solid var(--color-border); }
|
||||
.help-group-header { padding: 34px 0 26px; border-bottom: 1px solid var(--color-border); }
|
||||
.help-group-header > span, .help-result-header span { color: var(--color-accent); font-size: 9.5px; font-weight: 800; letter-spacing: 0.11em; text-transform: uppercase; }
|
||||
.help-group-header h3 { margin: 7px 0 8px; font-size: clamp(24px, 3vw, 32px); letter-spacing: -0.025em; }
|
||||
.help-group-header p { max-width: 650px; margin: 0; color: var(--color-ink-muted); font-size: 13px; line-height: 1.55; }
|
||||
|
||||
.help-result-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; max-width: 850px; margin: 0 auto; padding: 22px 0 0; }
|
||||
.help-result-header h3 { margin: 4px 0 0; font-size: 17px; }
|
||||
.help-result-header button { min-height: 30px; padding: 0 10px; border-color: var(--color-border-subtle); background: transparent; color: var(--color-ink-muted); font-size: 10.5px; }
|
||||
|
||||
.help-section { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 2px; padding: 26px 0; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.help-section-number { padding-top: 3px; color: var(--color-ink-faint); font-family: var(--font-mono); font-size: 10px; }
|
||||
.help-section-body h4 { margin: 0 0 7px; font-size: 16px; }
|
||||
.help-section-body > p { margin: 0; color: var(--color-ink-muted); font-size: 12.5px; line-height: 1.6; }
|
||||
.help-steps { display: grid; gap: 10px; margin: 18px 0 0; padding: 0; list-style: none; counter-reset: help-step; }
|
||||
.help-steps li { display: grid; grid-template-columns: 24px minmax(0, 1fr); align-items: start; gap: 9px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; counter-increment: help-step; }
|
||||
.help-steps li::before { content: counter(help-step); display: grid; place-items: center; width: 22px; height: 22px; border: 1px solid rgba(90, 140, 248, 0.35); border-radius: 50%; color: var(--color-accent); font-family: var(--font-mono); font-size: 9px; font-weight: 800; }
|
||||
|
||||
.help-commands { display: grid; gap: 1px; margin-top: 17px; overflow: hidden; border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-border-subtle); }
|
||||
.help-command-row { display: grid; grid-template-columns: minmax(210px, 0.9fr) minmax(190px, 1.2fr) auto; align-items: center; gap: 14px; min-height: 47px; padding: 6px 7px 6px 13px; background: var(--code-surface); }
|
||||
.help-command-row:hover { background: var(--code-hover-bg); }
|
||||
.help-command-row > code { overflow: hidden; color: var(--color-accent); font-family: var(--font-mono); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.help-command-row > span { color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.35; }
|
||||
.help-command-row button { display: inline-flex; align-items: center; gap: 6px; min-width: 83px; min-height: 31px; padding: 0 9px; border-color: var(--color-border-subtle); border-radius: 6px; background: var(--color-surface-raised); color: var(--color-ink-muted); font-size: 10px; }
|
||||
.help-command-row button:hover { border-color: rgba(90, 140, 248, 0.32); color: var(--color-accent); }
|
||||
|
||||
.help-note { display: flex; align-items: flex-start; gap: 9px; margin-top: 15px; padding: 11px 12px; border: 1px solid rgba(90, 140, 248, 0.2); border-radius: 7px; background: rgba(90, 140, 248, 0.07); color: var(--color-ink-muted); font-size: 11px; line-height: 1.5; }
|
||||
.help-note-icon { flex: 0 0 auto; display: grid; margin-top: 1px; color: var(--color-accent); }
|
||||
.help-note.warning { border-color: rgba(224, 160, 64, 0.24); background: rgba(224, 160, 64, 0.07); }
|
||||
.help-note.warning .help-note-icon { color: #d8a13d; }
|
||||
|
||||
.help-empty { display: grid; justify-items: center; max-width: 520px; margin: 90px auto 0; text-align: center; }
|
||||
.help-empty-icon { display: grid; color: var(--color-ink-faint); }
|
||||
.help-empty h3 { margin: 14px 0 5px; font-size: 18px; }
|
||||
.help-empty p { margin: 0 0 18px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.55; }
|
||||
|
||||
.help-footer { display: flex; align-items: center; justify-content: space-between; min-height: 38px; padding: 0 16px 0 20px; border-top: 1px solid var(--color-border); background: var(--app-dialog-chrome); color: var(--color-ink-faint); font-size: 9.5px; }
|
||||
.help-footer span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
kbd { display: inline-grid; place-items: center; min-width: 20px; height: 21px; padding: 0 5px; border: 1px solid var(--color-border-input); border-radius: 5px; background: var(--color-surface-raised); color: var(--color-ink-muted); box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.04); font-family: var(--font-mono); font-size: 9px; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.help-backdrop { padding: 10px; }
|
||||
.help-header { grid-template-columns: minmax(0, 1fr) auto; gap: 10px; }
|
||||
.help-title-wrap { display: none; }
|
||||
.help-layout { grid-template-columns: 190px minmax(0, 1fr); }
|
||||
.help-content { padding-inline: 20px; }
|
||||
.help-command-row { grid-template-columns: minmax(0, 1fr) auto; gap: 5px 10px; }
|
||||
.help-command-row > span { grid-column: 1; }
|
||||
.help-command-row button { grid-column: 2; grid-row: 1 / 3; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.help-layout { grid-template-columns: 1fr; }
|
||||
.help-nav { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--color-border); }
|
||||
.help-nav-label, .help-nav-tip, .help-nav-chevron { display: none; }
|
||||
.help-nav > button { flex: 0 0 auto; width: auto; grid-template-columns: 24px auto; }
|
||||
.help-content { padding-inline: 15px; }
|
||||
.help-section { grid-template-columns: 1fr; }
|
||||
.help-section-number { display: none; }
|
||||
.help-footer { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
||||
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
||||
export type AppTheme = "system" | "light" | "dark";
|
||||
export type AppLanguage = "en" | "de";
|
||||
|
||||
export interface CommitAiStatus {
|
||||
phase: CommitAiPhase;
|
||||
@@ -198,6 +199,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;
|
||||
|
||||
Reference in New Issue
Block a user