Merge branch 'new_featrues'
This commit is contained in:
+20
-3
@@ -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)
|
||||||
|
|||||||
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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 = [] }
|
||||||
|
|||||||
+344
-15
@@ -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,9 +5949,16 @@ mod tests {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
let status = pull(repo.path.to_string_lossy().to_string(), None, None)
|
let status = pull(
|
||||||
.await
|
repo.path.to_string_lossy().to_string(),
|
||||||
.unwrap();
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(status.clean, "{:?}", status.files);
|
assert!(status.clean, "{:?}", status.files);
|
||||||
assert!(repo.path.join("remote.txt").exists());
|
assert!(repo.path.join("remote.txt").exists());
|
||||||
@@ -5706,9 +6029,15 @@ 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(
|
||||||
.await
|
repo.path.to_string_lossy().to_string(),
|
||||||
.unwrap();
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
status.upstream.as_deref(),
|
status.upstream.as_deref(),
|
||||||
|
|||||||
+53
-10
@@ -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
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user