Merge branch 'new_featrues'

This commit is contained in:
Christoph Brandau
2026-07-20 23:05:00 +02:00
14 changed files with 882 additions and 80 deletions
+20 -3
View File
@@ -15,6 +15,9 @@ interface GitStatus {
behind: number; behind: number;
files: GitFileStatus[]; files: GitFileStatus[];
clean: boolean; clean: boolean;
rebase_in_progress: boolean;
cherry_pick_in_progress: boolean;
merge_in_progress: boolean;
} }
interface GitFileStatus { interface GitFileStatus {
@@ -56,19 +59,33 @@ interface GitRepositoryFile {
## Commands ## Commands
The command list below includes the repository-management and synchronization API. The TypeScript wrappers in `src/lib/git.ts` are the authoritative full list.
- `open_repository(path: string): Promise<GitStatus>` - `open_repository(path: string): Promise<GitStatus>`
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
- `clone_repository(...): Promise<RepositoryBundle>`
- `get_status(path: string): Promise<GitStatus>` - `get_status(path: string): Promise<GitStatus>`
- `list_branches(path: string): Promise<GitBranch[]>` - `list_branches(path: string): Promise<GitBranch[]>`
- `list_remotes(path: string): Promise<GitRemote[]>`
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
- `update_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
- `remove_remote(path: string, name: string): Promise<GitRemote[]>`
- `set_branch_upstream(path: string, branch: string, upstream?: string): Promise<GitStatus>`
- `delete_remote_branch(path: string, remote: string, branch: string): Promise<GitStatus>`
- `checkout_branch(path: string, branch: string): Promise<GitStatus>` - `checkout_branch(path: string, branch: string): Promise<GitStatus>`
- `stage_files(path: string, files: string[]): Promise<GitStatus>` - `stage_files(path: string, files: string[]): Promise<GitStatus>`
- `unstage_files(path: string, files: string[]): Promise<GitStatus>` - `unstage_files(path: string, files: string[]): Promise<GitStatus>`
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>` - `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
- `commit(path: string, message: string): Promise<GitStatus>` - `commit(path: string, message: string): Promise<GitStatus>`
- `pull(path: string): Promise<GitStatus>` - `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
- `push(path: string): Promise<GitStatus>` - `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>` - `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>` - `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path) - `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
- `merge_branch(path: string, branch: string): Promise<GitStatus>` - `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
- `merge_continue(path: string): Promise<GitStatus>`
- `merge_abort(path: string): Promise<GitStatus>`
- `revert_commit(path: string, commit: string): Promise<GitStatus>`
- `list_repository_files(path: string): Promise<GitRepositoryFile[]>` - `list_repository_files(path: string): Promise<GitRepositoryFile[]>`
- `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path) - `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
+1
View File
@@ -2537,6 +2537,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"commit_ai", "commit_ai",
"keyring", "keyring",
"log",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
+1
View File
@@ -21,6 +21,7 @@ tauri-plugin-aptabase = "1.0"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] } keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
commit_ai = { path = "crates/commit_ai" } commit_ai = { path = "crates/commit_ai" }
tokio = "1.52.3" tokio = "1.52.3"
log = "0.4"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
+340 -11
View File
@@ -51,6 +51,14 @@ pub struct GitStatus {
pub clean: bool, pub clean: bool,
pub rebase_in_progress: bool, pub rebase_in_progress: bool,
pub cherry_pick_in_progress: bool, pub cherry_pick_in_progress: bool,
pub merge_in_progress: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitRemote {
pub name: String,
pub fetch_url: String,
pub push_url: String,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -361,6 +369,34 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
status_for_repo(&repo) status_for_repo(&repo)
} }
#[tauri::command]
pub fn init_repository(path: String, initial_branch: Option<String>) -> Result<GitStatus, String> {
let path = PathBuf::from(path.trim());
if path.as_os_str().is_empty() {
return Err("Repository path must not be empty.".to_string());
}
fs::create_dir_all(&path)
.map_err(|err| format!("Could not create repository folder: {err}"))?;
let branch = initial_branch.unwrap_or_else(|| "main".to_string());
let branch = branch.trim();
if branch.is_empty() {
return Err("Initial branch must not be empty.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&path)
.args(["init", "-b", branch])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if !output.status.success() {
return Err(format!(
"Could not initialize repository: {}",
command_output_details(&output)
));
}
status_for_repo(&path)
}
#[tauri::command] #[tauri::command]
pub fn open_repo_in_explorer(path: String) -> Result<(), String> { pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -459,6 +495,125 @@ pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
branches_for_repo(&repo) branches_for_repo(&repo)
} }
#[tauri::command]
pub fn list_remotes(path: String) -> Result<Vec<GitRemote>, String> {
log::info!(target: "gitty::remote", "list_remotes invoked: path={path:?}");
let repo = resolve_repo(&path)?;
let names = run_git(&repo, ["remote"])?;
Ok(String::from_utf8_lossy(&names)
.lines()
.filter_map(|line| {
let name = line.trim();
if name.is_empty() {
return None;
}
Some(GitRemote {
name: name.to_string(),
fetch_url: remote_url_for(&repo, name).unwrap_or_default(),
push_url: remote_push_url_for(&repo, name).unwrap_or_default(),
})
})
.collect())
}
#[tauri::command]
pub fn add_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
let repo = resolve_repo(&path)?;
let name = validate_remote_name(&repo, &name, false)?;
let url = validate_remote_url(&url)?;
run_git(&repo, ["remote", "add", name.as_str(), url.as_str()])?;
list_remotes(path)
}
#[tauri::command]
pub fn update_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
let repo = resolve_repo(&path)?;
let name = validate_remote_name(&repo, &name, true)?;
let url = validate_remote_url(&url)?;
run_git(&repo, ["remote", "set-url", name.as_str(), url.as_str()])?;
list_remotes(path)
}
#[tauri::command]
pub fn remove_remote(path: String, name: String) -> Result<Vec<GitRemote>, String> {
log::info!(target: "gitty::remote", "remove_remote invoked: path={path:?}, name={name:?}");
let result = (|| {
let repo = resolve_repo(&path)?;
log::info!(target: "gitty::remote", "remove_remote resolved repository: {}", repo.display());
let name = validate_remote_name(&repo, &name, true)?;
log::info!(target: "gitty::remote", "remove_remote validated remote: {name}");
run_git(&repo, ["remote", "remove", name.as_str()])?;
log::info!(target: "gitty::remote", "remove_remote git command succeeded: {name}");
list_remotes(path)
})();
match &result {
Ok(remotes) => {
log::info!(target: "gitty::remote", "remove_remote completed; remaining={:?}", remotes.iter().map(|remote| remote.name.as_str()).collect::<Vec<_>>())
}
Err(error) => log::error!(target: "gitty::remote", "remove_remote failed: {error}"),
}
result
}
#[tauri::command]
pub fn set_branch_upstream(
path: String,
branch: String,
upstream: Option<String>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = validate_existing_local_branch_name(&repo, &branch)?;
match upstream
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
Some(upstream) => {
if !ref_exists(&repo, &format!("refs/remotes/{upstream}"))? {
return Err(format!("Remote branch '{upstream}' was not found."));
}
run_git(
&repo,
[
"branch",
"--set-upstream-to",
upstream.as_str(),
branch.as_str(),
],
)?;
}
None => {
run_git(&repo, ["branch", "--unset-upstream", branch.as_str()])?;
}
}
status_for_repo(&repo)
}
#[tauri::command]
pub fn delete_remote_branch(
path: String,
remote: String,
branch: String,
) -> Result<GitStatus, String> {
log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}");
let result = (|| {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
let branch = branch.trim();
if branch.is_empty() || branch.starts_with('-') {
return Err("Invalid remote branch name.".to_string());
}
run_git(&repo, ["check-ref-format", "--branch", branch])?;
log::info!(target: "gitty::remote", "deleting remote branch with git push: {remote}/{branch}");
run_git(&repo, ["push", remote.as_str(), "--delete", branch])?;
log::info!(target: "gitty::remote", "remote branch deleted successfully: {remote}/{branch}");
status_for_repo(&repo)
})();
if let Err(error) = &result {
log::error!(target: "gitty::remote", "delete_remote_branch failed: {error}");
}
result
}
#[tauri::command] #[tauri::command]
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> { pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -1409,18 +1564,43 @@ pub async fn pull(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
strategy: Option<String>,
remote: Option<String>,
branch: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> { tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"]; let strategy = strategy.as_deref().unwrap_or("merge");
let mut pull_args = vec![OsString::from("pull")];
match strategy {
"merge" => {
pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")])
}
"rebase" => pull_args.push(OsString::from("--rebase")),
"ff-only" => pull_args.push(OsString::from("--ff-only")),
_ => return Err("Unknown pull strategy.".to_string()),
}
if let Some(remote) = remote
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
validate_remote_name(&repo, &remote, true)?;
pull_args.push(remote.into());
if let Some(branch) = branch
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
pull_args.push(branch.into());
}
}
let output = match (username.as_deref(), password.as_deref()) { let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated_output(&repo, pull_args, u, p)? run_git_authenticated_output(&repo, pull_args.clone(), u, p)?
} }
_ => git_command() _ => git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(pull_args) .args(&pull_args)
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?, .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
}; };
@@ -1449,18 +1629,30 @@ pub async fn fetch(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
prune: Option<bool>,
remote: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> { tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let fetch_args = ["fetch"]; let mut fetch_args = vec![OsString::from("fetch")];
if prune.unwrap_or(false) {
fetch_args.push(OsString::from("--prune"));
}
if let Some(remote) = remote
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
validate_remote_name(&repo, &remote, true)?;
fetch_args.push(remote.into());
}
let output = match (username.as_deref(), password.as_deref()) { let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated_output(&repo, fetch_args, u, p)? run_git_authenticated_output(&repo, fetch_args.clone(), u, p)?
} }
_ => git_command() _ => git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(fetch_args) .args(&fetch_args)
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?, .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
}; };
@@ -1484,10 +1676,15 @@ pub async fn push(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
force_with_lease: Option<bool>,
remote: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> { tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let push_args = push_args_for_repo(&repo)?; let mut push_args = push_args_for_repo_to(&repo, remote.as_deref())?;
if force_with_lease.unwrap_or(false) {
push_args.insert(1, OsString::from("--force-with-lease"));
}
match (username.as_deref(), password.as_deref()) { match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated(&repo, push_args, u, p)?; run_git_authenticated(&repo, push_args, u, p)?;
@@ -1557,6 +1754,43 @@ fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
if url.is_empty() { None } else { Some(url) } if url.is_empty() { None } else { Some(url) }
} }
fn remote_push_url_for(repo: &Path, remote: &str) -> Option<String> {
let out = git_command()
.arg("-C")
.arg(repo)
.args(["remote", "get-url", "--push", remote])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() { None } else { Some(url) }
}
fn validate_remote_url(url: &str) -> Result<String, String> {
let url = url.trim();
if url.is_empty() || url.starts_with('-') {
return Err("Remote URL must not be empty.".to_string());
}
Ok(url.to_string())
}
fn validate_remote_name(repo: &Path, name: &str, must_exist: bool) -> Result<String, String> {
let name = name.trim();
if name.is_empty() || name.starts_with('-') || name.chars().any(char::is_whitespace) {
return Err("Invalid remote name.".to_string());
}
let exists = remote_url_for(repo, name).is_some();
if must_exist && !exists {
return Err(format!("Remote '{name}' was not found."));
}
if !must_exist && exists {
return Err(format!("Remote '{name}' already exists."));
}
Ok(name.to_string())
}
fn upstream_remote_name(repo: &Path) -> Option<String> { fn upstream_remote_name(repo: &Path) -> Option<String> {
let branch = current_branch_name(repo).ok()?; let branch = current_branch_name(repo).ok()?;
let out = git_command() let out = git_command()
@@ -1609,7 +1843,25 @@ fn initial_push_remote_name(repo: &Path) -> Result<String, String> {
.ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string()) .ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string())
} }
#[cfg(test)]
fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> { fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> {
push_args_for_repo_to(repo, None)
}
fn push_args_for_repo_to(
repo: &Path,
requested_remote: Option<&str>,
) -> Result<Vec<OsString>, String> {
if let Some(remote) = requested_remote.map(str::trim).filter(|v| !v.is_empty()) {
let remote = validate_remote_name(repo, remote, true)?;
let branch = current_branch_name(repo)?;
return Ok(vec![
OsString::from("push"),
OsString::from("--set-upstream"),
remote.into(),
branch.into(),
]);
}
if branch_has_upstream(repo) { if branch_has_upstream(repo) {
return Ok(vec![OsString::from("push")]); return Ok(vec![OsString::from("push")]);
} }
@@ -1669,7 +1921,11 @@ pub fn cred_delete(key: String) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> { pub async fn merge_branch(
path: String,
branch: String,
strategy: Option<String>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> { tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let branch = branch.trim(); let branch = branch.trim();
@@ -1677,10 +1933,19 @@ pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, Str
return Err("Branch name must not be empty.".to_string()); return Err("Branch name must not be empty.".to_string());
} }
let mut args = vec!["merge", "--no-edit"];
match strategy.as_deref().unwrap_or("default") {
"default" => {}
"squash" => args.push("--squash"),
"ff-only" => args.push("--ff-only"),
"no-ff" => args.push("--no-ff"),
_ => return Err("Unknown merge strategy.".to_string()),
}
args.push(branch);
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(["merge", "--no-edit", branch]) .args(args)
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
@@ -1712,6 +1977,52 @@ pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, Str
.map_err(|err| format!("Could not merge: {err}"))? .map_err(|err| format!("Could not merge: {err}"))?
} }
#[tauri::command]
pub fn merge_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !merge_in_progress(&repo) {
return Err("No merge is in progress.".to_string());
}
if has_unresolved_conflicts(&status_for_repo(&repo)?) {
return Err("Resolve all conflicts before continuing the merge.".to_string());
}
run_git(&repo, ["commit", "--no-edit"])?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn merge_abort(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !merge_in_progress(&repo) {
return Err("No merge is in progress.".to_string());
}
run_git(&repo, ["merge", "--abort"])?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn revert_commit(path: String, commit: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let commit = verify_commit(&repo, &commit)?;
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["revert", "--no-edit", commit.as_str()])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() {
return status_for_repo(&repo);
}
let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) {
return Ok(status);
}
Err(format!(
"Revert failed: {}",
command_output_details(&output)
))
}
#[tauri::command] #[tauri::command]
pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> { pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> { tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
@@ -3118,6 +3429,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
files, files,
rebase_in_progress: rebase_in_progress(repo), rebase_in_progress: rebase_in_progress(repo),
cherry_pick_in_progress: cherry_pick_in_progress(repo), cherry_pick_in_progress: cherry_pick_in_progress(repo),
merge_in_progress: merge_in_progress(repo),
}) })
} }
@@ -3129,6 +3441,10 @@ fn cherry_pick_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "CHERRY_PICK_HEAD") git_path_exists(repo, "CHERRY_PICK_HEAD")
} }
fn merge_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "MERGE_HEAD")
}
fn git_path_exists(repo: &Path, name: &str) -> bool { fn git_path_exists(repo: &Path, name: &str) -> bool {
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else { let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
return false; return false;
@@ -5633,7 +5949,14 @@ mod tests {
], ],
); );
let status = pull(repo.path.to_string_lossy().to_string(), None, None) let status = pull(
repo.path.to_string_lossy().to_string(),
None,
None,
None,
None,
None,
)
.await .await
.unwrap(); .unwrap();
@@ -5706,7 +6029,13 @@ mod tests {
["remote", "add", "origin", remote.path.to_str().unwrap()], ["remote", "add", "origin", remote.path.to_str().unwrap()],
); );
let status = push(repo.path.to_string_lossy().to_string(), None, None) let status = push(
repo.path.to_string_lossy().to_string(),
None,
None,
None,
None,
)
.await .await
.unwrap(); .unwrap();
+53 -10
View File
@@ -5,24 +5,56 @@ mod git;
use badge::set_sync_badge; use badge::set_sync_badge;
use git::{ use git::{
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search, SearchCancellationState, add_remote, amend_commit, apply_file_patch, cancel_code_search,
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit, cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits, commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete, compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete,
cred_load, cred_save, delete_branch, delete_tag, diff_file_against_working_tree, fetch, cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
get_file_blame, get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
list_commits, list_file_history, list_interactive_rebase_commits, list_reflog, get_status, init_repository, last_commit_message, list_branches, list_commits,
list_repository_files, list_stashes, list_tags, merge_branch, open_repo_in_explorer, list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
open_repository, open_repository_bundle, open_repository_file, pull, push, push_tag, list_repository_files, list_stashes, list_tags, merge_abort, merge_branch, merge_continue,
read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict, open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry, push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote,
restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files, rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_files, restore_reflog_entry, restore_to_commit, revert_commit,
run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, stage_files,
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
unstage_files, unstage_files, update_remote,
}; };
use tauri::Manager; use tauri::Manager;
struct ConsoleLogger;
impl log::Log for ConsoleLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= log::Level::Info
}
fn log(&self, record: &log::Record<'_>) {
if self.enabled(record.metadata()) {
eprintln!(
"[{}] [{}] {}",
record.level(),
record.target(),
record.args()
);
}
}
fn flush(&self) {}
}
static CONSOLE_LOGGER: ConsoleLogger = ConsoleLogger;
fn init_console_logging() {
if log::set_logger(&CONSOLE_LOGGER).is_ok() {
log::set_max_level(log::LevelFilter::Info);
log::info!(target: "gitty", "Rust console logging initialized");
}
}
#[tauri::command] #[tauri::command]
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("splashscreen") { if let Some(window) = app.get_webview_window("splashscreen") {
@@ -45,6 +77,7 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
init_console_logging();
if let Some(result) = run_sequence_editor_if_requested() { if let Some(result) = run_sequence_editor_if_requested() {
if let Err(error) = result { if let Err(error) = result {
eprintln!("{error}"); eprintln!("{error}");
@@ -82,11 +115,18 @@ async fn main() {
builder builder
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
open_repository, open_repository,
init_repository,
clone_repository, clone_repository,
open_repo_in_explorer, open_repo_in_explorer,
open_repository_file, open_repository_file,
get_status, get_status,
list_branches, list_branches,
list_remotes,
add_remote,
update_remote,
remove_remote,
set_branch_upstream,
delete_remote_branch,
list_stashes, list_stashes,
checkout_branch, checkout_branch,
create_branch, create_branch,
@@ -124,6 +164,9 @@ async fn main() {
restore_to_commit, restore_to_commit,
restore_file_from_commit, restore_file_from_commit,
merge_branch, merge_branch,
merge_continue,
merge_abort,
revert_commit,
rebase_branch, rebase_branch,
rebase_continue, rebase_continue,
rebase_abort, rebase_abort,
+170 -7
View File
@@ -36,10 +36,12 @@
import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte"; import StashPanel from "./lib/components/StashPanel.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte";
import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte";
import { import {
amendCommit, amendCommit,
addRemote,
checkoutBranch, checkoutBranch,
cherryPickAbort, cherryPickAbort,
cherryPickCommit, cherryPickCommit,
@@ -59,6 +61,8 @@
createTag, createTag,
deleteBranch, deleteBranch,
deleteTag, deleteTag,
deleteRemoteBranch,
initRepository,
diffFileAgainstWorkingTree, diffFileAgainstWorkingTree,
compareFileToParent, compareFileToParent,
fetchRemote, fetchRemote,
@@ -66,6 +70,7 @@
getStatus, getStatus,
lastCommitMessage, lastCommitMessage,
listBranches, listBranches,
listRemotes,
listStashes, listStashes,
listTags, listTags,
listCommits, listCommits,
@@ -74,12 +79,18 @@
listReflog, listReflog,
listRepositoryFiles, listRepositoryFiles,
mergeBranch, mergeBranch,
mergeAbort,
mergeContinue,
openRepoInExplorer, openRepoInExplorer,
openRepositoryFile, openRepositoryFile,
openRepositoryBundle, openRepositoryBundle,
pull, pull,
push, push,
pushTag, pushTag,
removeRemote,
revertCommit,
setBranchUpstream,
updateRemote,
renameBranch, renameBranch,
rebaseAbort, rebaseAbort,
rebaseBranch, rebaseBranch,
@@ -126,6 +137,8 @@
GitDiffFile, GitDiffFile,
GitFileStatus, GitFileStatus,
GitRepositoryFile, GitRepositoryFile,
GitRemote,
PullStrategy,
GitSearchHit, GitSearchHit,
GitStash, GitStash,
GitStatus, GitStatus,
@@ -231,6 +244,12 @@
let repoStatusCache: Record<string, RepoTab> = {}; let repoStatusCache: Record<string, RepoTab> = {};
let repoSearch = ""; let repoSearch = "";
let cloneDialogOpen = false; let cloneDialogOpen = false;
let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
let selectedRemote = localStorage.getItem("gitlite.selectedRemote") || "";
let remoteActionForceWithLease = false;
let remoteActionPrune = false;
let syncSettingsOpen = false;
let syncSettingsRemotes: GitRemote[] = [];
let cloneDialogError = ""; let cloneDialogError = "";
let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined; let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
let pendingClone: CloneRequest | null = null; let pendingClone: CloneRequest | null = null;
@@ -393,6 +412,7 @@
$: hasConflicts = conflictedFiles.length > 0; $: hasConflicts = conflictedFiles.length > 0;
$: rebaseInProgress = status?.rebase_in_progress ?? false; $: rebaseInProgress = status?.rebase_in_progress ?? false;
$: cherryPickInProgress = status?.cherry_pick_in_progress ?? false; $: cherryPickInProgress = status?.cherry_pick_in_progress ?? false;
$: mergeInProgress = status?.merge_in_progress ?? false;
// Amending/undoing is only offered while the last commit hasn't reached a // Amending/undoing is only offered while the last commit hasn't reached a
// remote yet: no upstream at all, or the branch is still ahead of it. // remote yet: no upstream at all, or the branch is still ahead of it.
$: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress $: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress
@@ -2260,7 +2280,27 @@
async function confirmDeleteBranch() { async function confirmDeleteBranch() {
const branch = deleteBranchTarget; const branch = deleteBranchTarget;
if (!activeRepoPath || !branch || branch.remote || branch.current || isBusy) return; if (!activeRepoPath || !branch || branch.current || isBusy) return;
if (branch.remote) {
const slash = branch.name.indexOf("/");
if (slash < 1) { errorMessage = "Could not determine remote name."; return; }
const remote = branch.name.slice(0, slash);
const remoteBranch = branch.name.slice(slash + 1);
operation = `Deleting ${branch.name} from remote`;
errorMessage = "";
try {
applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch));
deleteBranchTarget = null;
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("remote_branch_deleted");
} catch (error) {
errorMessage = errorToMessage(error);
} finally {
operation = "";
}
return;
}
operation = `${deleteBranchForce ? "Force deleting" : "Deleting"} ${branch.name}`; operation = `${deleteBranchForce ? "Force deleting" : "Deleting"} ${branch.name}`;
errorMessage = ""; errorMessage = "";
@@ -2318,8 +2358,11 @@
async function merge(branch: GitBranchInfo) { async function merge(branch: GitBranchInfo) {
if (!activeRepoPath || branch.current) return; if (!activeRepoPath || branch.current) return;
const strategy = (window.prompt("Merge strategy: default, squash, ff-only, or no-ff", "default") ?? "").trim();
if (!strategy) return;
if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; }
await runOperation(`Merging ${branch.name}`, async () => { await runOperation(`Merging ${branch.name}`, async () => {
applyStatus(await mergeBranch(activeRepoPath, branch.name)); applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy));
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
@@ -2624,7 +2667,7 @@
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Pulling", async () => { await runOperation("Pulling", async () => {
applyStatus(await pull(activeRepoPath, username, password)); applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
@@ -2645,7 +2688,8 @@
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Fetching", async () => { await runOperation("Fetching", async () => {
applyStatus(await fetchRemote(activeRepoPath, username, password)); applyStatus(await fetchRemote(activeRepoPath, username, password, remoteActionPrune, selectedRemote || undefined));
remoteActionPrune = false;
await refreshRefsAndCommitGraph(activeRepoPath); await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("repository_fetched", { trackEvent("repository_fetched", {
from_stored_credential: fromStore ? 1 : 0, from_stored_credential: fromStore ? 1 : 0,
@@ -2664,7 +2708,8 @@
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Pushing", async () => { await runOperation("Pushing", async () => {
applyStatus(await push(activeRepoPath, username, password)); applyStatus(await push(activeRepoPath, username, password, remoteActionForceWithLease, selectedRemote || undefined));
remoteActionForceWithLease = false;
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshFileHistory(activeRepoPath); await refreshFileHistory(activeRepoPath);
@@ -2788,6 +2833,88 @@
await startRemoteAction("push"); await startRemoteAction("push");
} }
async function deleteTrackedRemoteBranch(branch: GitBranchInfo) {
if (!activeRepoPath || !branch.remote) return;
if (import.meta.env.DEV) console.info("[Gitty remote] remote branch delete requested", branch);
deleteBranchTarget = branch;
deleteBranchForce = false;
trackEvent("remote_branch_delete_dialog_opened");
}
async function initializeRepository() {
const selected = await openDialog({ directory: true, multiple: false, title: "Choose an empty or existing folder" });
if (typeof selected !== "string") return;
const branch = window.prompt("Initial branch name", "main")?.trim(); if (!branch) return;
await runOperation("Initializing repository", async () => { await initRepository(selected, branch); await openRepo(selected); });
}
async function revertHistoryCommit(commit: GitCommit) {
if (!activeRepoPath || !window.confirm(`Revert commit ${commit.short_hash} (${commit.summary}) with a new commit?`)) return;
await runOperation(`Reverting ${commit.short_hash}`, async () => {
applyStatus(await revertCommit(activeRepoPath, commit.hash));
await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); await refreshFileHistory(activeRepoPath);
});
}
async function continueMerge() {
if (!activeRepoPath) return;
await runOperation("Continuing merge", async () => { applyStatus(await mergeContinue(activeRepoPath)); await refreshCommitHistory(activeRepoPath); });
}
async function abortMerge() {
if (!activeRepoPath || !window.confirm("Abort the current merge and restore the pre-merge state?")) return;
await runOperation("Aborting merge", async () => { applyStatus(await mergeAbort(activeRepoPath)); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); });
}
async function fetchPruneRepo() {
remoteActionPrune = true;
await startRemoteAction("fetch");
}
async function forcePushRepo() {
if (!window.confirm("Push the current branch with --force-with-lease? This is intended for a branch whose history you rebased.")) return;
remoteActionForceWithLease = true;
await startRemoteAction("push");
}
async function openSyncOptions() {
if (!activeRepoPath) return;
try {
syncSettingsRemotes = await listRemotes(activeRepoPath);
syncSettingsOpen = true;
} catch (error) { errorMessage = errorToMessage(error); }
}
async function saveSyncSettings(strategy: PullStrategy, remote: string, upstream: string) {
if (!activeRepoPath || !status?.current_branch) return;
await runOperation("Saving sync settings", async () => {
pullStrategy = strategy; selectedRemote = remote;
localStorage.setItem("gitlite.pullStrategy", strategy); localStorage.setItem("gitlite.selectedRemote", remote);
applyStatus(await setBranchUpstream(activeRepoPath, status!.current_branch!, upstream || undefined));
await refreshBranchList(activeRepoPath); syncSettingsOpen = false;
});
}
async function addSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await addRemote(activeRepoPath, name, url); await refreshBranchList(activeRepoPath); }
async function updateSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await updateRemote(activeRepoPath, name, url); }
async function removeSyncRemote(name: string) {
if (!activeRepoPath) return;
operation = `Removing remote ${name}`;
try {
syncSettingsRemotes = await removeRemote(activeRepoPath, name);
if (syncSettingsRemotes.some((remote) => remote.name === name)) throw new Error(`Remote '${name}' still exists after removal.`);
if (selectedRemote === name) { selectedRemote = ""; localStorage.setItem("gitlite.selectedRemote", ""); }
applyStatus(await getStatus(activeRepoPath));
await refreshBranchList(activeRepoPath);
} catch (error) {
const message = errorToMessage(error);
if (import.meta.env.DEV) console.error("[Gitty remote] remove_remote failed", { name, path: activeRepoPath, error });
throw new Error(message);
} finally {
operation = "";
}
}
async function saveStash(message: string, includeUntracked: boolean) { async function saveStash(message: string, includeUntracked: boolean) {
if (!activeRepoPath || changedFiles.length === 0) return; if (!activeRepoPath || changedFiles.length === 0) return;
const stashedFiles = changedFiles.length; const stashedFiles = changedFiles.length;
@@ -3576,6 +3703,7 @@
} }
function handleWindowContextMenu(event: MouseEvent) { function handleWindowContextMenu(event: MouseEvent) {
if (import.meta.env.DEV) return;
event.preventDefault(); event.preventDefault();
if (repoTabContextMenu) closeRepoTabContextMenu(); if (repoTabContextMenu) closeRepoTabContextMenu();
} }
@@ -3632,6 +3760,9 @@
onInteractiveRebase={openInteractiveRebase} onInteractiveRebase={openInteractiveRebase}
onReflog={openReflog} onReflog={openReflog}
onOpenInExplorer={openActiveRepoInExplorer} onOpenInExplorer={openActiveRepoInExplorer}
onFetchPrune={fetchPruneRepo}
onForcePush={forcePushRepo}
onSyncOptions={openSyncOptions}
/> />
{/if} {/if}
@@ -3677,7 +3808,15 @@
</section> </section>
{/if} {/if}
{#if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress} {#if workspaceActive && mergeInProgress}
<section class="notice conflict" role="status">
<GitMerge size={17} aria-hidden="true" />
<span>{hasConflicts ? "Merge in progress. Resolve all conflicts, then continue." : "Merge is ready to be completed."}</span>
{#if hasConflicts}<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>{/if}
<button type="button" onclick={continueMerge} disabled={isBusy || hasConflicts}>Continue</button>
<button type="button" onclick={abortMerge} disabled={isBusy}>Abort</button>
</section>
{:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
<section class="notice conflict" role="alert"> <section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" /> <GitMerge size={17} aria-hidden="true" />
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span> <span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
@@ -3735,6 +3874,9 @@
<Download size={15} aria-hidden="true" /> <Download size={15} aria-hidden="true" />
Clone Clone
</button> </button>
<button class="btn-secondary" type="button" onclick={initializeRepository} disabled={isBusy}>
<Plus size={15} aria-hidden="true" /> Init
</button>
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}> <button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" /> <FolderOpen size={15} aria-hidden="true" />
Browse Browse
@@ -3910,6 +4052,7 @@
onCreateBranch={createNewBranch} onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch} onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch} onDeleteBranch={deleteLocalBranch}
onDeleteRemoteBranch={deleteTrackedRemoteBranch}
onCreateTag={createNewTag} onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag} onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag} onPushTag={pushLocalTag}
@@ -4121,6 +4264,7 @@
onPreviewCommitFile={previewCommitFileFromHistory} onPreviewCommitFile={previewCommitFileFromHistory}
onCreateBranchFromCommit={openNewBranchDialog} onCreateBranchFromCommit={openNewBranchDialog}
onCherryPickCommit={cherryPickFromCommit} onCherryPickCommit={cherryPickFromCommit}
onRevertCommit={revertHistoryCommit}
onToggleCommitFiles={(hash) => { onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes); const next = new Set(expandedCommitHashes);
if (next.has(hash)) next.delete(hash); else next.add(hash); if (next.has(hash)) next.delete(hash); else next.add(hash);
@@ -4297,7 +4441,7 @@
/> />
{/if} {/if}
<!-- Delete a local branch from the branch context menu --> <!-- Confirm deletion of a local or remote branch from the shared branch context menu -->
{#if deleteBranchTarget} {#if deleteBranchTarget}
<BranchDeleteConfirmDialog <BranchDeleteConfirmDialog
branch={deleteBranchTarget} branch={deleteBranchTarget}
@@ -4390,6 +4534,25 @@
/> />
{/if} {/if}
<!-- Clone repository dialog -->
{#if syncSettingsOpen}
<SyncSettingsDialog
remotes={syncSettingsRemotes}
remoteBranches={remoteBranches.map((branch) => branch.name)}
currentBranch={status?.current_branch ?? ""}
currentUpstream={status?.upstream ?? ""}
strategy={pullStrategy}
{selectedRemote}
{isBusy}
language={appLanguage}
onSaveSync={saveSyncSettings}
onAddRemote={addSyncRemote}
onUpdateRemote={updateSyncRemote}
onRemoveRemote={removeSyncRemote}
onClose={() => { if (!isBusy) syncSettingsOpen = false; }}
/>
{/if}
<!-- Clone repository dialog --> <!-- Clone repository dialog -->
{#if cloneDialogOpen} {#if cloneDialogOpen}
<CloneRepositoryDialog <CloneRepositoryDialog
+105 -24
View File
@@ -1703,20 +1703,23 @@
.stash-toggle { .stash-toggle {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
width: 26px; width: 28px;
min-width: 26px; min-width: 28px;
min-height: 26px; height: 28px;
min-height: 28px;
aspect-ratio: 1;
flex: 0 0 28px;
padding: 0; padding: 0;
border-color: rgba(94,110,156,0.18); border-color: rgba(94,110,156,0.18);
border-radius: 7px; border-radius: 6px;
color: var(--color-ink-dim); color: var(--color-ink-dim);
background: rgba(255,255,255,0.035); background: rgba(255,255,255,0.018);
} }
.stash-toggle:hover:not(:disabled) { .stash-toggle:hover:not(:disabled) {
border-color: rgba(65,209,255,0.28); border-color: rgba(65,209,255,0.28);
color: var(--color-ink); color: var(--color-ink);
background: rgba(65,209,255,0.08); background: rgba(65,209,255,0.055);
} }
.stash-create { .stash-create {
@@ -1847,19 +1850,24 @@
} }
.branch-create-toggle { .branch-create-toggle {
width: 26px; display: inline-grid;
min-width: 26px; place-items: center;
min-height: 26px; width: 28px;
min-width: 28px;
height: 28px;
min-height: 28px;
aspect-ratio: 1;
flex: 0 0 28px;
padding: 0; padding: 0;
border-color: rgba(65,209,255,0.2); border-color: rgba(94,110,156,0.18);
border-radius: 7px; border-radius: 6px;
color: var(--color-ink-dim); color: var(--color-ink-dim);
background: rgba(65,209,255,0.06); background: rgba(255,255,255,0.018);
} }
.branch-create-toggle:hover:not(:disabled) { .branch-create-toggle:hover:not(:disabled) {
border-color: rgba(65,209,255,0.45); border-color: rgba(65,209,255,0.3);
color: #ffffff; color: var(--color-ink);
background: rgba(65,209,255,0.13); background: rgba(65,209,255,0.06);
} }
.branch-list { gap: 8px; } .branch-list { gap: 8px; }
@@ -2124,19 +2132,24 @@
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; } .explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
.explorer-bulk-button { .explorer-bulk-button {
width: 26px; display: inline-grid;
min-width: 26px; place-items: center;
min-height: 26px; width: 28px;
min-width: 28px;
height: 28px;
min-height: 28px;
aspect-ratio: 1;
flex: 0 0 28px;
padding: 0; padding: 0;
border-color: rgba(65,209,255,0.2); border-color: rgba(94,110,156,0.18);
border-radius: 7px; border-radius: 6px;
color: var(--color-ink-dim); color: var(--color-ink-dim);
background: rgba(65,209,255,0.06); background: rgba(255,255,255,0.018);
} }
.explorer-bulk-button:hover:not(:disabled) { .explorer-bulk-button:hover:not(:disabled) {
border-color: rgba(65,209,255,0.45); border-color: rgba(65,209,255,0.3);
color: #ffffff; color: var(--color-ink);
background: rgba(65,209,255,0.13); background: rgba(65,209,255,0.06);
} }
.explorer-row { .explorer-row {
@@ -6011,6 +6024,74 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.ai-review-finding-body > p { margin: 6px 0 8px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; } .ai-review-finding-body > p { margin: 6px 0 8px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
.ai-review-location { display: flex; align-items: center; gap: 5px; color: var(--color-primary); } .ai-review-location { display: flex; align-items: center; gap: 5px; color: var(--color-primary); }
.ai-review-location code { overflow-wrap: anywhere; font: 10.5px/1.4 var(--font-mono); } .ai-review-location code { overflow-wrap: anywhere; font: 10.5px/1.4 var(--font-mono); }
/* Sync settings: one place for pull behavior, upstream and remote connections. */
.sync-settings-dialog { width: min(820px, calc(100vw - 32px)); max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-border); border-radius: 14px; background: var(--app-dialog-bg); box-shadow: 0 28px 80px rgba(0,0,0,.48); }
.sync-settings-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.sync-settings-title { display: flex; align-items: center; gap: 12px; }
.sync-settings-title h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 19px; }
.sync-settings-icon { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid rgba(90,140,248,.28); border-radius: 10px; color: var(--color-accent); background: rgba(90,140,248,.1); }
.sync-settings-body { display: grid; gap: 12px; min-height: 0; padding: 14px; overflow: auto; }
.sync-settings-card { padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 11px; background: var(--color-surface-raised); }
.sync-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 13px; }
.sync-card-heading h3 { margin: 0; color: var(--color-ink); font-size: 13px; }
.sync-card-heading p { margin: 4px 0 0; color: var(--color-ink-faint); font-size: 11px; }
.count-pill { min-width: 24px; padding: 3px 7px; border-radius: 999px; color: var(--color-ink-muted); background: var(--color-surface-hover); font: 700 10px var(--font-mono); text-align: center; }
.strategy-options { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
.strategy-options label { position: relative; display: grid; grid-template-columns: 18px 1fr; gap: 8px; min-height: 94px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 9px; color: var(--color-ink-muted); background: var(--app-input-bg); cursor: pointer; }
.strategy-options label.active { border-color: rgba(90,140,248,.65); box-shadow: inset 0 0 0 1px rgba(90,140,248,.18); background: rgba(90,140,248,.08); }
.strategy-options input { margin-top: 2px; accent-color: var(--color-accent); }
.strategy-options span { display: grid; align-content: start; gap: 5px; }
.strategy-options strong { color: var(--color-ink); font-size: 12px; }
.strategy-options small, .sync-fields small { color: var(--color-ink-faint); font-size: 10px; line-height: 1.45; }
.sync-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-border-subtle); }
.sync-fields label { display: grid; gap: 6px; color: var(--color-ink-muted); font-size: 11px; font-weight: 700; }
.sync-fields select, .remote-add input, .remote-edit input { width: 100%; height: 35px; border: 1px solid var(--color-border-input); border-radius: 7px; color: var(--color-ink); background: var(--app-input-bg); font-size: 11px; }
.sync-fields select { padding: 0 9px; }
.remote-list { display: grid; gap: 5px; }
.sync-action-error { display: grid; gap: 4px; margin-bottom: 5px; padding: 10px 11px; border: 1px solid rgba(232,96,96,.28); border-radius: 8px; color: #ef8888; background: rgba(232,96,96,.08); }
.sync-action-error strong { font-size: 11px; }
.sync-action-error span { font: 9.5px/1.45 var(--font-mono); overflow-wrap: anywhere; }
.sync-action-status { margin-bottom: 5px; padding: 9px 11px; border: 1px solid rgba(90,140,248,.28); border-radius: 8px; color: var(--color-accent); background: rgba(90,140,248,.08); font-size: 10.5px; font-weight: 700; }
.remote-row { display: grid; grid-template-columns: 28px minmax(0,1fr) auto auto; align-items: center; gap: 7px; min-height: 48px; padding: 6px 7px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--app-input-bg); }
.remote-mark { display: grid; place-items: center; color: var(--color-accent); }
.remote-main { display: grid; gap: 3px; min-width: 0; padding: 0; border: 0; color: var(--color-ink); background: transparent; text-align: left; }
.remote-main strong, .remote-edit strong { font-size: 11.5px; }
.remote-main span { overflow: hidden; color: var(--color-ink-faint); font: 10px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
.remote-edit { display: grid; grid-template-columns: 80px minmax(0,1fr); align-items: center; gap: 8px; }
.remote-confirm { display: grid; gap: 3px; min-width: 0; }
.remote-confirm strong { color: var(--color-ink); font-size: 11px; }
.remote-confirm span { color: var(--color-ink-faint); font-size: 9.5px; }
.remote-edit input, .remote-add input { padding: 0 9px; }
.remote-delete { display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 8px; border: 0; border-radius: 6px; color: #e86060; background: transparent; font-size: 10px; font-weight: 750; }
.remote-delete:hover { background: rgba(235,87,87,.1); }
.btn-danger { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 34px; padding: 0 11px; border: 1px solid rgba(232,96,96,.38); border-radius: 7px; color: #fff; background: #c84f4f; font-size: 10.5px; font-weight: 750; }
.btn-danger:hover:not(:disabled) { background: #dd5b5b; }
.remote-empty { margin: 4px 0 10px; color: var(--color-ink-faint); font-size: 11px; }
.remote-add { display: grid; grid-template-columns: 120px minmax(180px,1fr) auto; gap: 7px; margin-top: 9px; padding-top: 10px; border-top: 1px solid var(--color-border-subtle); }
.sync-settings-footer { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 13px 20px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.sync-settings-footer p { max-width: 440px; margin: 0; color: var(--color-ink-faint); font-size: 9.5px; line-height: 1.4; }
.sync-settings-footer > div { display: flex; gap: 8px; }
@media (max-width: 720px) { .strategy-options, .sync-fields { grid-template-columns: 1fr; } .remote-add { grid-template-columns: 1fr; } .sync-settings-footer { align-items: stretch; flex-direction: column; } .sync-settings-footer > div { justify-content: flex-end; } }
/* Compact square controls shared by the Branches, Stash and Explorer headers. */
.branch-head-actions .branch-create-toggle,
.stash-head-actions .stash-toggle,
.explorer-head-actions .explorer-bulk-button {
box-sizing: border-box;
display: inline-grid;
place-items: center;
inline-size: 24px;
min-inline-size: 24px;
max-inline-size: 24px;
block-size: 24px;
min-block-size: 24px;
max-block-size: 24px;
flex: 0 0 24px;
aspect-ratio: 1 / 1;
padding: 0;
border-radius: 5px;
}
.ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); } .ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); }
.ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; } .ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; }
.ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; } .ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; }
+18 -3
View File
@@ -11,6 +11,7 @@
RefreshCw, RefreshCw,
Search, Search,
Upload, Upload,
Settings2,
} from "@lucide/svelte"; } from "@lucide/svelte";
export let hasRepository: boolean = false; export let hasRepository: boolean = false;
@@ -28,8 +29,12 @@
export let onInteractiveRebase: () => void = () => {}; export let onInteractiveRebase: () => void = () => {};
export let onReflog: () => void = () => {}; export let onReflog: () => void = () => {};
export let onOpenInExplorer: () => void = () => {}; export let onOpenInExplorer: () => void = () => {};
export let onFetchPrune: () => void = () => {};
export let onForcePush: () => void = () => {};
export let onSyncOptions: () => void = () => {};
let historyOpen = false; let historyOpen = false;
let syncOpen = false;
let toolbarElement: HTMLDivElement; let toolbarElement: HTMLDivElement;
$: isGerman = language === "de"; $: isGerman = language === "de";
@@ -40,9 +45,7 @@
} }
function handleWindowClick(event: MouseEvent) { function handleWindowClick(event: MouseEvent) {
if (historyOpen && toolbarElement && !toolbarElement.contains(event.target as Node)) { if (toolbarElement && !toolbarElement.contains(event.target as Node)) { historyOpen = false; syncOpen = false; }
historyOpen = false;
}
} }
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
@@ -108,6 +111,18 @@
<span class="repo-action-label">Push</span> <span class="repo-action-label">Push</span>
{#if ahead > 0}<span class="repo-action-count ahead">{ahead}</span>{/if} {#if ahead > 0}<span class="repo-action-count ahead">{ahead}</span>{/if}
</button> </button>
<div class="repo-history-wrap">
<button class="repo-action" type="button" onclick={() => { syncOpen = !syncOpen; historyOpen = false; }} disabled={!hasRepository || isBusy} aria-label={isGerman ? "Sync-Optionen" : "Sync options"} aria-haspopup="menu">
<ChevronDown size={14} aria-hidden="true" />
</button>
{#if syncOpen}
<div class="repo-history-menu" role="menu">
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onFetchPrune(); }}><CloudDownload size={15} /><span><strong>Fetch + Prune</strong><small>{isGerman ? "Veraltete Remote-Branches entfernen" : "Remove stale remote branches"}</small></span></button>
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onForcePush(); }}><Upload size={15} /><span><strong>Force with lease</strong><small>{isGerman ? "Sicheres Pushen nach Rebase" : "Safe push after rebase"}</small></span></button>
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onSyncOptions(); }}><Settings2 size={15} /><span><strong>{isGerman ? "Remotes & Strategien" : "Remotes & strategies"}</strong><small>{isGerman ? "Upstream, Pull und Remote verwalten" : "Manage upstream, pull and remotes"}</small></span></button>
</div>
{/if}
</div>
</div> </div>
</div> </div>
@@ -18,7 +18,8 @@
onClose = () => {}, onClose = () => {},
}: Props = $props(); }: Props = $props();
let title = $derived(force ? "Force delete branch?" : "Delete branch?"); let title = $derived(branch.remote ? "Delete remote branch?" : force ? "Force delete branch?" : "Delete branch?");
let remoteParts = $derived(branch.remote ? branch.name.split(/\/(.+)/) : []);
</script> </script>
@@ -26,7 +27,7 @@
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}> <div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header"> <header class="dialog-header">
<div> <div>
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span> <span class="eyebrow">{branch.remote ? "Remote branch" : force ? "Force delete" : "Delete branch"}</span>
<p class="dialog-title">{title}</p> <p class="dialog-title">{title}</p>
</div> </div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close"> <button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
@@ -41,7 +42,9 @@
<div class="discard-confirm-copy"> <div class="discard-confirm-copy">
<p> <p>
{#if force} {#if branch.remote}
Delete this branch from the remote server?
{:else if force}
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch. This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch.
{:else} {:else}
Delete this local branch from the repository? Delete this local branch from the repository?
@@ -52,7 +55,9 @@
{branch.name} {branch.name}
</code> </code>
<p class="discard-warning-text"> <p class="discard-warning-text">
{#if force} {#if branch.remote}
This affects everyone using <strong>{remoteParts[0] || "the remote"}</strong>. Your local commits and local branches are kept.
{:else if force}
Make sure you no longer need the unique commits on this branch. Make sure you no longer need the unique commits on this branch.
{:else} {:else}
Git will refuse if the branch is not fully merged. Git will refuse if the branch is not fully merged.
@@ -69,7 +74,7 @@
{:else} {:else}
<Trash2 size={15} aria-hidden="true" /> <Trash2 size={15} aria-hidden="true" />
{/if} {/if}
{force ? "Force delete" : "Delete"} {branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
</button> </button>
</footer> </footer>
</div> </div>
+7 -5
View File
@@ -54,6 +54,7 @@
onCreateBranch: (branchName: string) => void | Promise<void>; onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>; onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>; onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onCreateTag: (name: string, message: string) => void | Promise<void>; onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>; onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>; onPushTag: (tag: GitTag) => void | Promise<void>;
@@ -74,6 +75,7 @@
onCreateBranch = () => {}, onCreateBranch = () => {},
onRenameBranch = () => {}, onRenameBranch = () => {},
onDeleteBranch = () => {}, onDeleteBranch = () => {},
onDeleteRemoteBranch = () => {},
onCreateTag = () => {}, onCreateTag = () => {},
onDeleteTag = () => {}, onDeleteTag = () => {},
onPushTag = () => {}, onPushTag = () => {},
@@ -264,9 +266,9 @@
async function deleteContextBranch() { async function deleteContextBranch() {
const branch = contextBranch; const branch = contextBranch;
if (!branch || branch.current || branch.remote || isBusy) return; if (!branch || branch.current || isBusy) return;
closeBranchContextMenu(); closeBranchContextMenu();
await onDeleteBranch(branch); if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
} }
async function checkoutContextBranch() { async function checkoutContextBranch() {
@@ -669,11 +671,11 @@
type="button" type="button"
role="menuitem" role="menuitem"
onclick={deleteContextBranch} onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current || contextBranch.remote} disabled={isBusy || contextBranch.current}
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"} title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
> >
<Trash2 size={14} aria-hidden="true" /> <Trash2 size={14} aria-hidden="true" />
Delete {contextBranch.remote ? "Delete remote" : "Delete"}
</button> </button>
</div> </div>
{/if} {/if}
+12
View File
@@ -42,6 +42,7 @@
onToggleCommitFiles: (hash: string) => void; onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void; onCreateBranchFromCommit: (commit: GitCommit) => void;
onCherryPickCommit: (commit: GitCommit) => void; onCherryPickCommit: (commit: GitCommit) => void;
onRevertCommit: (commit: GitCommit) => void;
} }
let { let {
@@ -58,6 +59,7 @@
onToggleCommitFiles = () => {}, onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {}, onCreateBranchFromCommit = () => {},
onCherryPickCommit = () => {}, onCherryPickCommit = () => {},
onRevertCommit = () => {},
}: Props = $props(); }: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set()); let hiddenGraphBranches = $state<Set<string>>(new Set());
@@ -406,6 +408,13 @@
await onCherryPickCommit(commit); await onCherryPickCommit(commit);
} }
async function revertContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onRevertCommit(commit);
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
closeCommitContextMenu(); closeCommitContextMenu();
@@ -718,6 +727,9 @@
<Cherry size={14} aria-hidden="true" /> <Cherry size={14} aria-hidden="true" />
Cherry-pick Cherry-pick
</button> </button>
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
<RotateCcw size={14} aria-hidden="true" /> Revert
</button>
</div> </div>
{/if} {/if}
</section> </section>
@@ -0,0 +1,109 @@
<script lang="ts">
import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte";
import type { GitRemote, PullStrategy } from "../types";
export let remotes: GitRemote[] = [];
export let remoteBranches: string[] = [];
export let currentBranch = "";
export let currentUpstream = "";
export let strategy: PullStrategy = "merge";
export let selectedRemote = "";
export let isBusy = false;
export let language: "en" | "de" = "en";
export let onSaveSync: (strategy: PullStrategy, remote: string, upstream: string) => void | Promise<void> = () => {};
export let onAddRemote: (name: string, url: string) => void | Promise<void> = () => {};
export let onUpdateRemote: (name: string, url: string) => void | Promise<void> = () => {};
export let onRemoveRemote: (name: string) => void | Promise<void> = () => {};
export let onClose: () => void = () => {};
let draftStrategy = strategy;
let draftRemote = selectedRemote;
let draftUpstream = currentUpstream;
let newName = "origin";
let newUrl = "";
let editingName = "";
let editingUrl = "";
let actionError = "";
let actionStatus = "";
$: de = language === "de";
function beginEdit(remote: GitRemote) { actionError = ""; editingName = remote.name; editingUrl = remote.fetch_url; }
async function requestDelete(event: MouseEvent, name: string) {
console.log(name)
event.preventDefault();
event.stopPropagation();
editingName = "";
actionError = "";
actionStatus = de ? `Remote „${name}“ wird entfernt …` : `Removing remote “${name}” …`;
console.info("[Gitty remote] remove button activated", { name });
await remove(name);
}
function cancelEdit() { editingName = ""; editingUrl = ""; }
async function add() { if (!newName.trim() || !newUrl.trim()) return; await onAddRemote(newName.trim(), newUrl.trim()); newName = "origin"; newUrl = ""; }
async function update() { if (!editingName || !editingUrl.trim()) return; await onUpdateRemote(editingName, editingUrl.trim()); cancelEdit(); }
async function remove(name: string) {
actionError = "";
console.log("remove")
try {
await onRemoveRemote(name);
if (draftRemote === name) draftRemote = "";
if (draftUpstream.startsWith(`${name}/`)) draftUpstream = "";
} catch (error) {
actionError = error instanceof Error ? error.message : String(error);
} finally {
actionStatus = "";
}
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="sync-settings-dialog" role="dialog" aria-modal="true" aria-labelledby="sync-settings-title">
<header class="sync-settings-head">
<div class="sync-settings-title">
<span class="sync-settings-icon"><Cloud size={18} aria-hidden="true" /></span>
<div><span class="eyebrow">Git sync</span><h2 id="sync-settings-title">{de ? "Synchronisierung & Remotes" : "Sync & remotes"}</h2></div>
</div>
<button class="dialog-icon-button" type="button" onclick={onClose} disabled={isBusy} aria-label={de ? "Schließen" : "Close"}><X size={17} /></button>
</header>
<div class="sync-settings-body">
<section class="sync-settings-card">
<div class="sync-card-heading"><div><h3>{de ? "Pull-Verhalten" : "Pull behavior"}</h3><p>{de ? "Legt fest, wie entfernte Änderungen in deinen aktuellen Branch übernommen werden." : "Controls how remote changes are integrated into your current branch."}</p></div></div>
<div class="strategy-options">
<label class:active={draftStrategy === "merge"}><input type="radio" bind:group={draftStrategy} value="merge" /><span><strong>Merge</strong><small>{de ? "Erstellt bei getrennten Verläufen einen Merge-Commit. Sicher und leicht nachvollziehbar." : "Creates a merge commit for diverged history. Safe and easy to follow."}</small></span></label>
<label class:active={draftStrategy === "rebase"}><input type="radio" bind:group={draftStrategy} value="rebase" /><span><strong>Rebase</strong><small>{de ? "Setzt deine lokalen Commits auf die Remote-Änderungen. Ergibt eine lineare Historie." : "Replays your local commits on remote changes for a linear history."}</small></span></label>
<label class:active={draftStrategy === "ff-only"}><input type="radio" bind:group={draftStrategy} value="ff-only" /><span><strong>Fast-forward only</strong><small>{de ? "Pull stoppt, sobald ein Merge nötig wäre. Verändert die Historie nie automatisch." : "Stops when a merge would be required. Never combines diverged history automatically."}</small></span></label>
</div>
<div class="sync-fields">
<label><span>{de ? "Remote für Sync" : "Remote used for sync"}</span><select bind:value={draftRemote}><option value="">{de ? "Automatisch wählen" : "Choose automatically"}</option>{#each remotes as remote}<option value={remote.name}>{remote.name}</option>{/each}</select><small>{de ? "Ein Remote ist die gespeicherte Verbindung zu einem Server-Repository." : "A remote is a saved connection to a repository on a server."}</small></label>
<label><span>{de ? `Upstream für ${currentBranch || "aktuellen Branch"}` : `Upstream for ${currentBranch || "current branch"}`}</span><select bind:value={draftUpstream}><option value="">{de ? "Kein Upstream" : "No upstream"}</option>{#each remoteBranches as branch}<option value={branch}>{branch}</option>{/each}</select><small>{de ? "Der Upstream ist der Remote-Branch, mit dem Pull, Push und Ahead/Behind verglichen werden." : "The upstream is the remote branch used by Pull, Push, and Ahead/Behind."}</small></label>
</div>
</section>
<section class="sync-settings-card">
<div class="sync-card-heading"><div><h3>Remotes</h3><p>{de ? "Server-Verbindungen dieses Repositorys verwalten." : "Manage this repository's server connections."}</p></div><span class="count-pill">{remotes.length}</span></div>
<div class="remote-list">
{#if actionError}<div class="sync-action-error" role="alert"><strong>{de ? "Remote konnte nicht entfernt werden" : "Remote could not be removed"}</strong><span>{actionError}</span></div>{/if}
{#if actionStatus}<div class="sync-action-status" role="status">{actionStatus}</div>{/if}
{#each remotes as remote (remote.name)}
<div class="remote-row">
<span class="remote-mark"><GitBranch size={15} /></span>
{#if editingName === remote.name}
<div class="remote-edit"><strong>{remote.name}</strong><input bind:value={editingUrl} aria-label={`URL for ${remote.name}`} /></div>
<button class="btn-sm" type="button" onclick={update} disabled={isBusy || !editingUrl.trim()}><Save size={13} /> {de ? "Speichern" : "Save"}</button>
<button class="btn-sm" type="button" onclick={cancelEdit} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button>
{:else}
<button class="remote-main" type="button" onclick={() => beginEdit(remote)} disabled={isBusy}><strong>{remote.name}</strong><span>{remote.fetch_url}</span></button>
<button class="remote-delete" type="button" onclick={(event) => requestDelete(event, remote.name)} data-remote-name={remote.name} title={de ? "Remote-Verbindung sofort entfernen; lokale Daten bleiben erhalten" : "Remove remote connection now; local data is kept"}><Trash2 size={13} /><span>{de ? "Entfernen" : "Remove"}</span></button>
{/if}
</div>
{:else}<p class="remote-empty">{de ? "Noch kein Remote eingerichtet." : "No remote configured yet."}</p>{/each}
</div>
<div class="remote-add"><input bind:value={newName} placeholder={de ? "Name, z. B. origin" : "Name, e.g. origin"} aria-label="Remote name" /><input bind:value={newUrl} placeholder="https://… or git@…" aria-label="Remote URL" /><button class="btn-secondary" type="button" onclick={add} disabled={isBusy || !newName.trim() || !newUrl.trim()}><Plus size={14} /> {de ? "Hinzufügen" : "Add remote"}</button></div>
</section>
</div>
<footer class="sync-settings-footer"><p>{de ? "Fetch + Prune entfernt veraltete Remote-Verweise. Force with lease ist ein geschütztes Force-Push nach einem Rebase." : "Fetch + Prune removes stale remote references. Force with lease is a protected force-push after a rebase."}</p><div><button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="button" onclick={() => onSaveSync(draftStrategy, draftRemote, draftUpstream)} disabled={isBusy}><Save size={14} /> {de ? "Einstellungen speichern" : "Save settings"}</button></div></footer>
</div>
</div>
+27 -8
View File
@@ -11,6 +11,9 @@ import type {
GitCommit, GitCommit,
GitCommitComparison, GitCommitComparison,
GitRepositoryFile, GitRepositoryFile,
GitRemote,
MergeStrategy,
PullStrategy,
RebaseCommit, RebaseCommit,
RebasePlanItem, RebasePlanItem,
ReflogEntry, ReflogEntry,
@@ -28,6 +31,10 @@ export function openRepository(path: string): Promise<GitStatus> {
return invoke<GitStatus>("open_repository", { path }); return invoke<GitStatus>("open_repository", { path });
} }
export function initRepository(path: string, initialBranch = "main"): Promise<GitStatus> {
return invoke<GitStatus>("init_repository", { path, initialBranch });
}
export function openRepoInExplorer(path: string): Promise<void> { export function openRepoInExplorer(path: string): Promise<void> {
return invoke<void>("open_repo_in_explorer", { path }); return invoke<void>("open_repo_in_explorer", { path });
} }
@@ -72,6 +79,15 @@ export function listBranches(path: string): Promise<GitBranch[]> {
return invoke<GitBranch[]>("list_branches", { path }); return invoke<GitBranch[]>("list_branches", { path });
} }
export function listRemotes(path: string): Promise<GitRemote[]> { return invoke("list_remotes", { path }); }
export function addRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("add_remote", { path, name, url }); }
export function updateRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("update_remote", { path, name, url }); }
export function removeRemote(path: string, name: string): Promise<GitRemote[]> {
console.log("remove_remote")
return invoke("remove_remote", { path, name }); }
export function setBranchUpstream(path: string, branch: string, upstream?: string): Promise<GitStatus> { return invoke("set_branch_upstream", { path, branch, upstream: upstream || null }); }
export function deleteRemoteBranch(path: string, remote: string, branch: string): Promise<GitStatus> { return invoke("delete_remote_branch", { path, remote, branch }); }
export function listStashes(path: string): Promise<GitStash[]> { export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path }); return invoke<GitStash[]>("list_stashes", { path });
} }
@@ -255,16 +271,16 @@ export function commitAiReview(path: string, options: CommitAiGenerateOptions):
}); });
} }
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> { export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null }); return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
} }
export function fetchRemote(path: string, username?: string, password?: string): Promise<GitStatus> { export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise<GitStatus> {
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null }); return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null, prune, remote: remote || null });
} }
export function push(path: string, username?: string, password?: string): Promise<GitStatus> { export function push(path: string, username?: string, password?: string, forceWithLease = false, remote?: string): Promise<GitStatus> {
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null }); return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
} }
export function getRemoteUrl(path: string): Promise<string | null> { export function getRemoteUrl(path: string): Promise<string | null> {
@@ -304,9 +320,12 @@ export function restoreFileFromCommit(
return invoke<GitStatus>("restore_file_from_commit", { path, commit, file }); return invoke<GitStatus>("restore_file_from_commit", { path, commit, file });
} }
export function mergeBranch(path: string, branch: string): Promise<GitStatus> { export function mergeBranch(path: string, branch: string, strategy: MergeStrategy = "default"): Promise<GitStatus> {
return invoke<GitStatus>("merge_branch", { path, branch }); return invoke<GitStatus>("merge_branch", { path, branch, strategy });
} }
export function mergeContinue(path: string): Promise<GitStatus> { return invoke("merge_continue", { path }); }
export function mergeAbort(path: string): Promise<GitStatus> { return invoke("merge_abort", { path }); }
export function revertCommit(path: string, commit: string): Promise<GitStatus> { return invoke("revert_commit", { path, commit }); }
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> { export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_branch", { path, branch }); return invoke<GitStatus>("rebase_branch", { path, branch });
+5
View File
@@ -68,8 +68,13 @@ export interface GitStatus {
clean: boolean; clean: boolean;
rebase_in_progress: boolean; rebase_in_progress: boolean;
cherry_pick_in_progress: boolean; cherry_pick_in_progress: boolean;
merge_in_progress: boolean;
} }
export interface GitRemote { name: string; fetch_url: string; push_url: string; }
export type PullStrategy = "merge" | "rebase" | "ff-only";
export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff";
export interface GitFileStatus { export interface GitFileStatus {
path: string; path: string;
old_path: string | null; old_path: string | null;