Merge branch 'new_featrues'
This commit is contained in:
+20
-3
@@ -15,6 +15,9 @@ interface GitStatus {
|
||||
behind: number;
|
||||
files: GitFileStatus[];
|
||||
clean: boolean;
|
||||
rebase_in_progress: boolean;
|
||||
cherry_pick_in_progress: boolean;
|
||||
merge_in_progress: boolean;
|
||||
}
|
||||
|
||||
interface GitFileStatus {
|
||||
@@ -56,19 +59,33 @@ interface GitRepositoryFile {
|
||||
|
||||
## 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>`
|
||||
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
|
||||
- `clone_repository(...): Promise<RepositoryBundle>`
|
||||
- `get_status(path: string): Promise<GitStatus>`
|
||||
- `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>`
|
||||
- `stage_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>`
|
||||
- `commit(path: string, message: string): Promise<GitStatus>`
|
||||
- `pull(path: string): Promise<GitStatus>`
|
||||
- `push(path: string): Promise<GitStatus>`
|
||||
- `fetch(path: string, prune?: boolean, remote?: 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[]>`
|
||||
- `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)
|
||||
- `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_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
|
||||
|
||||
Generated
+1
@@ -2537,6 +2537,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"commit_ai",
|
||||
"keyring",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@@ -21,6 +21,7 @@ tauri-plugin-aptabase = "1.0"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||
commit_ai = { path = "crates/commit_ai" }
|
||||
tokio = "1.52.3"
|
||||
log = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
+344
-15
@@ -51,6 +51,14 @@ pub struct GitStatus {
|
||||
pub clean: bool,
|
||||
pub rebase_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)]
|
||||
@@ -361,6 +369,34 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
||||
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]
|
||||
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -459,6 +495,125 @@ pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
||||
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]
|
||||
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -1409,18 +1564,43 @@ pub async fn pull(
|
||||
path: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
strategy: Option<String>,
|
||||
remote: Option<String>,
|
||||
branch: Option<String>,
|
||||
) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
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()) {
|
||||
(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()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(pull_args)
|
||||
.args(&pull_args)
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
||||
};
|
||||
@@ -1449,18 +1629,30 @@ pub async fn fetch(
|
||||
path: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
prune: Option<bool>,
|
||||
remote: Option<String>,
|
||||
) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
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()) {
|
||||
(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()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(fetch_args)
|
||||
.args(&fetch_args)
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
||||
};
|
||||
@@ -1484,10 +1676,15 @@ pub async fn push(
|
||||
path: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
force_with_lease: Option<bool>,
|
||||
remote: Option<String>,
|
||||
) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
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()) {
|
||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||
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) }
|
||||
}
|
||||
|
||||
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> {
|
||||
let branch = current_branch_name(repo).ok()?;
|
||||
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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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) {
|
||||
return Ok(vec![OsString::from("push")]);
|
||||
}
|
||||
@@ -1669,7 +1921,11 @@ pub fn cred_delete(key: String) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[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> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
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());
|
||||
}
|
||||
|
||||
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()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["merge", "--no-edit", branch])
|
||||
.args(args)
|
||||
.output()
|
||||
.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}"))?
|
||||
}
|
||||
|
||||
#[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]
|
||||
pub async fn rebase_branch(path: String, branch: String) -> 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,
|
||||
rebase_in_progress: rebase_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")
|
||||
}
|
||||
|
||||
fn merge_in_progress(repo: &Path) -> bool {
|
||||
git_path_exists(repo, "MERGE_HEAD")
|
||||
}
|
||||
|
||||
fn git_path_exists(repo: &Path, name: &str) -> bool {
|
||||
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
|
||||
return false;
|
||||
@@ -5633,9 +5949,16 @@ mod tests {
|
||||
],
|
||||
);
|
||||
|
||||
let status = pull(repo.path.to_string_lossy().to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = pull(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(status.clean, "{:?}", status.files);
|
||||
assert!(repo.path.join("remote.txt").exists());
|
||||
@@ -5706,9 +6029,15 @@ mod tests {
|
||||
["remote", "add", "origin", remote.path.to_str().unwrap()],
|
||||
);
|
||||
|
||||
let status = push(repo.path.to_string_lossy().to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = push(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
status.upstream.as_deref(),
|
||||
|
||||
+53
-10
@@ -5,24 +5,56 @@ mod git;
|
||||
|
||||
use badge::set_sync_badge;
|
||||
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,
|
||||
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
||||
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,
|
||||
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_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,
|
||||
cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
|
||||
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
|
||||
get_status, init_repository, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
|
||||
list_repository_files, list_stashes, list_tags, merge_abort, merge_branch, merge_continue,
|
||||
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
||||
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote,
|
||||
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,
|
||||
unstage_files,
|
||||
unstage_files, update_remote,
|
||||
};
|
||||
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]
|
||||
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("splashscreen") {
|
||||
@@ -45,6 +77,7 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init_console_logging();
|
||||
if let Some(result) = run_sequence_editor_if_requested() {
|
||||
if let Err(error) = result {
|
||||
eprintln!("{error}");
|
||||
@@ -82,11 +115,18 @@ async fn main() {
|
||||
builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_repository,
|
||||
init_repository,
|
||||
clone_repository,
|
||||
open_repo_in_explorer,
|
||||
open_repository_file,
|
||||
get_status,
|
||||
list_branches,
|
||||
list_remotes,
|
||||
add_remote,
|
||||
update_remote,
|
||||
remove_remote,
|
||||
set_branch_upstream,
|
||||
delete_remote_branch,
|
||||
list_stashes,
|
||||
checkout_branch,
|
||||
create_branch,
|
||||
@@ -124,6 +164,9 @@ async fn main() {
|
||||
restore_to_commit,
|
||||
restore_file_from_commit,
|
||||
merge_branch,
|
||||
merge_continue,
|
||||
merge_abort,
|
||||
revert_commit,
|
||||
rebase_branch,
|
||||
rebase_continue,
|
||||
rebase_abort,
|
||||
|
||||
+170
-7
@@ -36,10 +36,12 @@
|
||||
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
||||
import StashPanel from "./lib/components/StashPanel.svelte";
|
||||
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
||||
import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
|
||||
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
||||
|
||||
import {
|
||||
amendCommit,
|
||||
addRemote,
|
||||
checkoutBranch,
|
||||
cherryPickAbort,
|
||||
cherryPickCommit,
|
||||
@@ -59,6 +61,8 @@
|
||||
createTag,
|
||||
deleteBranch,
|
||||
deleteTag,
|
||||
deleteRemoteBranch,
|
||||
initRepository,
|
||||
diffFileAgainstWorkingTree,
|
||||
compareFileToParent,
|
||||
fetchRemote,
|
||||
@@ -66,6 +70,7 @@
|
||||
getStatus,
|
||||
lastCommitMessage,
|
||||
listBranches,
|
||||
listRemotes,
|
||||
listStashes,
|
||||
listTags,
|
||||
listCommits,
|
||||
@@ -74,12 +79,18 @@
|
||||
listReflog,
|
||||
listRepositoryFiles,
|
||||
mergeBranch,
|
||||
mergeAbort,
|
||||
mergeContinue,
|
||||
openRepoInExplorer,
|
||||
openRepositoryFile,
|
||||
openRepositoryBundle,
|
||||
pull,
|
||||
push,
|
||||
pushTag,
|
||||
removeRemote,
|
||||
revertCommit,
|
||||
setBranchUpstream,
|
||||
updateRemote,
|
||||
renameBranch,
|
||||
rebaseAbort,
|
||||
rebaseBranch,
|
||||
@@ -126,6 +137,8 @@
|
||||
GitDiffFile,
|
||||
GitFileStatus,
|
||||
GitRepositoryFile,
|
||||
GitRemote,
|
||||
PullStrategy,
|
||||
GitSearchHit,
|
||||
GitStash,
|
||||
GitStatus,
|
||||
@@ -231,6 +244,12 @@
|
||||
let repoStatusCache: Record<string, RepoTab> = {};
|
||||
let repoSearch = "";
|
||||
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 cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingClone: CloneRequest | null = null;
|
||||
@@ -393,6 +412,7 @@
|
||||
$: hasConflicts = conflictedFiles.length > 0;
|
||||
$: rebaseInProgress = status?.rebase_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
|
||||
// remote yet: no upstream at all, or the branch is still ahead of it.
|
||||
$: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress
|
||||
@@ -2260,7 +2280,27 @@
|
||||
|
||||
async function confirmDeleteBranch() {
|
||||
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}`;
|
||||
errorMessage = "";
|
||||
@@ -2318,8 +2358,11 @@
|
||||
|
||||
async function merge(branch: GitBranchInfo) {
|
||||
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 () => {
|
||||
applyStatus(await mergeBranch(activeRepoPath, branch.name));
|
||||
applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
@@ -2624,7 +2667,7 @@
|
||||
) {
|
||||
errorMessage = "";
|
||||
await runOperation("Pulling", async () => {
|
||||
applyStatus(await pull(activeRepoPath, username, password));
|
||||
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
@@ -2645,7 +2688,8 @@
|
||||
) {
|
||||
errorMessage = "";
|
||||
await runOperation("Fetching", async () => {
|
||||
applyStatus(await fetchRemote(activeRepoPath, username, password));
|
||||
applyStatus(await fetchRemote(activeRepoPath, username, password, remoteActionPrune, selectedRemote || undefined));
|
||||
remoteActionPrune = false;
|
||||
await refreshRefsAndCommitGraph(activeRepoPath);
|
||||
trackEvent("repository_fetched", {
|
||||
from_stored_credential: fromStore ? 1 : 0,
|
||||
@@ -2664,7 +2708,8 @@
|
||||
) {
|
||||
errorMessage = "";
|
||||
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 refreshCommitHistory(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
@@ -2788,6 +2833,88 @@
|
||||
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) {
|
||||
if (!activeRepoPath || changedFiles.length === 0) return;
|
||||
const stashedFiles = changedFiles.length;
|
||||
@@ -3576,6 +3703,7 @@
|
||||
}
|
||||
|
||||
function handleWindowContextMenu(event: MouseEvent) {
|
||||
if (import.meta.env.DEV) return;
|
||||
event.preventDefault();
|
||||
if (repoTabContextMenu) closeRepoTabContextMenu();
|
||||
}
|
||||
@@ -3632,6 +3760,9 @@
|
||||
onInteractiveRebase={openInteractiveRebase}
|
||||
onReflog={openReflog}
|
||||
onOpenInExplorer={openActiveRepoInExplorer}
|
||||
onFetchPrune={fetchPruneRepo}
|
||||
onForcePush={forcePushRepo}
|
||||
onSyncOptions={openSyncOptions}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -3677,7 +3808,15 @@
|
||||
</section>
|
||||
{/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">
|
||||
<GitMerge size={17} aria-hidden="true" />
|
||||
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
|
||||
@@ -3735,6 +3874,9 @@
|
||||
<Download size={15} aria-hidden="true" />
|
||||
Clone
|
||||
</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}>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
Browse
|
||||
@@ -3910,6 +4052,7 @@
|
||||
onCreateBranch={createNewBranch}
|
||||
onRenameBranch={renameLocalBranch}
|
||||
onDeleteBranch={deleteLocalBranch}
|
||||
onDeleteRemoteBranch={deleteTrackedRemoteBranch}
|
||||
onCreateTag={createNewTag}
|
||||
onDeleteTag={deleteLocalTag}
|
||||
onPushTag={pushLocalTag}
|
||||
@@ -4121,6 +4264,7 @@
|
||||
onPreviewCommitFile={previewCommitFileFromHistory}
|
||||
onCreateBranchFromCommit={openNewBranchDialog}
|
||||
onCherryPickCommit={cherryPickFromCommit}
|
||||
onRevertCommit={revertHistoryCommit}
|
||||
onToggleCommitFiles={(hash) => {
|
||||
const next = new Set(expandedCommitHashes);
|
||||
if (next.has(hash)) next.delete(hash); else next.add(hash);
|
||||
@@ -4297,7 +4441,7 @@
|
||||
/>
|
||||
{/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}
|
||||
<BranchDeleteConfirmDialog
|
||||
branch={deleteBranchTarget}
|
||||
@@ -4390,6 +4534,25 @@
|
||||
/>
|
||||
{/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 -->
|
||||
{#if cloneDialogOpen}
|
||||
<CloneRepositoryDialog
|
||||
|
||||
+105
-24
@@ -1703,20 +1703,23 @@
|
||||
.stash-toggle {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
min-width: 26px;
|
||||
min-height: 26px;
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
aspect-ratio: 1;
|
||||
flex: 0 0 28px;
|
||||
padding: 0;
|
||||
border-color: rgba(94,110,156,0.18);
|
||||
border-radius: 7px;
|
||||
border-radius: 6px;
|
||||
color: var(--color-ink-dim);
|
||||
background: rgba(255,255,255,0.035);
|
||||
background: rgba(255,255,255,0.018);
|
||||
}
|
||||
|
||||
.stash-toggle:hover:not(:disabled) {
|
||||
border-color: rgba(65,209,255,0.28);
|
||||
color: var(--color-ink);
|
||||
background: rgba(65,209,255,0.08);
|
||||
background: rgba(65,209,255,0.055);
|
||||
}
|
||||
|
||||
.stash-create {
|
||||
@@ -1847,19 +1850,24 @@
|
||||
}
|
||||
|
||||
.branch-create-toggle {
|
||||
width: 26px;
|
||||
min-width: 26px;
|
||||
min-height: 26px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
aspect-ratio: 1;
|
||||
flex: 0 0 28px;
|
||||
padding: 0;
|
||||
border-color: rgba(65,209,255,0.2);
|
||||
border-radius: 7px;
|
||||
border-color: rgba(94,110,156,0.18);
|
||||
border-radius: 6px;
|
||||
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) {
|
||||
border-color: rgba(65,209,255,0.45);
|
||||
color: #ffffff;
|
||||
background: rgba(65,209,255,0.13);
|
||||
border-color: rgba(65,209,255,0.3);
|
||||
color: var(--color-ink);
|
||||
background: rgba(65,209,255,0.06);
|
||||
}
|
||||
|
||||
.branch-list { gap: 8px; }
|
||||
@@ -2124,19 +2132,24 @@
|
||||
|
||||
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
|
||||
.explorer-bulk-button {
|
||||
width: 26px;
|
||||
min-width: 26px;
|
||||
min-height: 26px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
aspect-ratio: 1;
|
||||
flex: 0 0 28px;
|
||||
padding: 0;
|
||||
border-color: rgba(65,209,255,0.2);
|
||||
border-radius: 7px;
|
||||
border-color: rgba(94,110,156,0.18);
|
||||
border-radius: 6px;
|
||||
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) {
|
||||
border-color: rgba(65,209,255,0.45);
|
||||
color: #ffffff;
|
||||
background: rgba(65,209,255,0.13);
|
||||
border-color: rgba(65,209,255,0.3);
|
||||
color: var(--color-ink);
|
||||
background: rgba(65,209,255,0.06);
|
||||
}
|
||||
|
||||
.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-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); }
|
||||
|
||||
/* 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 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; }
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
RefreshCw,
|
||||
Search,
|
||||
Upload,
|
||||
Settings2,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
export let hasRepository: boolean = false;
|
||||
@@ -28,8 +29,12 @@
|
||||
export let onInteractiveRebase: () => void = () => {};
|
||||
export let onReflog: () => void = () => {};
|
||||
export let onOpenInExplorer: () => void = () => {};
|
||||
export let onFetchPrune: () => void = () => {};
|
||||
export let onForcePush: () => void = () => {};
|
||||
export let onSyncOptions: () => void = () => {};
|
||||
|
||||
let historyOpen = false;
|
||||
let syncOpen = false;
|
||||
let toolbarElement: HTMLDivElement;
|
||||
|
||||
$: isGerman = language === "de";
|
||||
@@ -40,9 +45,7 @@
|
||||
}
|
||||
|
||||
function handleWindowClick(event: MouseEvent) {
|
||||
if (historyOpen && toolbarElement && !toolbarElement.contains(event.target as Node)) {
|
||||
historyOpen = false;
|
||||
}
|
||||
if (toolbarElement && !toolbarElement.contains(event.target as Node)) { historyOpen = false; syncOpen = false; }
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
@@ -108,6 +111,18 @@
|
||||
<span class="repo-action-label">Push</span>
|
||||
{#if ahead > 0}<span class="repo-action-count ahead">↑{ahead}</span>{/if}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
onClose = () => {},
|
||||
}: 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>
|
||||
|
||||
@@ -26,7 +27,7 @@
|
||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<header class="dialog-header">
|
||||
<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>
|
||||
</div>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
@@ -41,7 +42,9 @@
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<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.
|
||||
{:else}
|
||||
Delete this local branch from the repository?
|
||||
@@ -52,7 +55,9 @@
|
||||
{branch.name}
|
||||
</code>
|
||||
<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.
|
||||
{:else}
|
||||
Git will refuse if the branch is not fully merged.
|
||||
@@ -69,7 +74,7 @@
|
||||
{:else}
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
{force ? "Force delete" : "Delete"}
|
||||
{branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||
onRenameBranch: (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>;
|
||||
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
||||
onPushTag: (tag: GitTag) => void | Promise<void>;
|
||||
@@ -74,6 +75,7 @@
|
||||
onCreateBranch = () => {},
|
||||
onRenameBranch = () => {},
|
||||
onDeleteBranch = () => {},
|
||||
onDeleteRemoteBranch = () => {},
|
||||
onCreateTag = () => {},
|
||||
onDeleteTag = () => {},
|
||||
onPushTag = () => {},
|
||||
@@ -264,9 +266,9 @@
|
||||
|
||||
async function deleteContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || branch.current || branch.remote || isBusy) return;
|
||||
if (!branch || branch.current || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
await onDeleteBranch(branch);
|
||||
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
|
||||
}
|
||||
|
||||
async function checkoutContextBranch() {
|
||||
@@ -669,11 +671,11 @@
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={deleteContextBranch}
|
||||
disabled={isBusy || contextBranch.current || contextBranch.remote}
|
||||
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"}
|
||||
disabled={isBusy || contextBranch.current}
|
||||
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
Delete
|
||||
{contextBranch.remote ? "Delete remote" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
onToggleCommitFiles: (hash: string) => void;
|
||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||
onCherryPickCommit: (commit: GitCommit) => void;
|
||||
onRevertCommit: (commit: GitCommit) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -58,6 +59,7 @@
|
||||
onToggleCommitFiles = () => {},
|
||||
onCreateBranchFromCommit = () => {},
|
||||
onCherryPickCommit = () => {},
|
||||
onRevertCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
||||
@@ -406,6 +408,13 @@
|
||||
await onCherryPickCommit(commit);
|
||||
}
|
||||
|
||||
async function revertContextCommit() {
|
||||
const commit = contextCommit;
|
||||
if (!commit || isBusy) return;
|
||||
closeCommitContextMenu();
|
||||
await onRevertCommit(commit);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
closeCommitContextMenu();
|
||||
@@ -718,6 +727,9 @@
|
||||
<Cherry size={14} aria-hidden="true" />
|
||||
Cherry-pick
|
||||
</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>
|
||||
{/if}
|
||||
</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
@@ -11,6 +11,9 @@ import type {
|
||||
GitCommit,
|
||||
GitCommitComparison,
|
||||
GitRepositoryFile,
|
||||
GitRemote,
|
||||
MergeStrategy,
|
||||
PullStrategy,
|
||||
RebaseCommit,
|
||||
RebasePlanItem,
|
||||
ReflogEntry,
|
||||
@@ -28,6 +31,10 @@ export function openRepository(path: string): Promise<GitStatus> {
|
||||
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> {
|
||||
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 });
|
||||
}
|
||||
|
||||
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[]> {
|
||||
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> {
|
||||
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
|
||||
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, strategy, remote: remote || null, branch: branch || null });
|
||||
}
|
||||
|
||||
export function fetchRemote(path: string, username?: string, password?: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null });
|
||||
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, prune, remote: remote || null });
|
||||
}
|
||||
|
||||
export function push(path: string, username?: string, password?: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null });
|
||||
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, forceWithLease, remote: remote || 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 });
|
||||
}
|
||||
|
||||
export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("merge_branch", { path, branch });
|
||||
export function mergeBranch(path: string, branch: string, strategy: MergeStrategy = "default"): Promise<GitStatus> {
|
||||
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> {
|
||||
return invoke<GitStatus>("rebase_branch", { path, branch });
|
||||
|
||||
@@ -68,8 +68,13 @@ export interface GitStatus {
|
||||
clean: boolean;
|
||||
rebase_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 {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
|
||||
Reference in New Issue
Block a user