Made more like Github Desktop

This commit is contained in:
2026-05-10 20:33:03 +12:00
parent 346f8536f0
commit ac7fc231a0
7 changed files with 3630 additions and 1000 deletions
+846 -2
View File
@@ -59,6 +59,73 @@ struct LocalRepoCandidate {
matched_remote_url: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GitChangedFile {
path: String,
original_path: Option<String>,
x: String,
y: String,
status: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GitStatusSummary {
branch: Option<String>,
upstream: Option<String>,
ahead: u32,
behind: u32,
files: Vec<GitChangedFile>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GitRepositorySyncStatus {
branch: Option<String>,
upstream: Option<String>,
upstream_remote: Option<String>,
default_remote: Option<String>,
ahead: u32,
behind: u32,
has_remote: bool,
is_detached: bool,
is_unpublished: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GitFileDiff {
path: String,
diff: String,
is_binary: bool,
is_deleted: bool,
is_untracked: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GitCommitSummary {
hash: String,
short_hash: String,
title: String,
author: String,
date: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GitCommitDetail {
hash: String,
short_hash: String,
title: String,
message: String,
author: String,
date: String,
files: Vec<GitChangedFile>,
diff: String,
}
fn normalize_api_base_url(server_url: &str) -> Result<String, String> {
// Normalize user input so every backend consistently resolves to /api/v1.
let trimmed = server_url.trim().trim_end_matches('/');
@@ -140,6 +207,223 @@ fn run_git_output(
Ok(output.stdout)
}
fn ensure_git_success(result: GitCommandResult) -> Result<GitCommandResult, String> {
if result.success {
Ok(result)
} else if !result.stderr.is_empty() {
Err(result.stderr)
} else {
Err(format!("{} failed", result.command))
}
}
fn validate_repo_dir(repo_path: &str) -> Result<String, String> {
let path = repo_path.trim();
if path.is_empty() {
return Err("Repository path is required.".to_string());
}
let path_buf = PathBuf::from(path);
if !path_buf.exists() || !path_buf.is_dir() {
return Err(format!("Repository path does not exist: {path}"));
}
if !path_buf.join(".git").exists() {
return Err(format!("Selected path is not a Git repository: {path}"));
}
Ok(path.to_string())
}
fn validate_git_path(path: &str) -> Result<String, String> {
let normalized = normalize_repo_path(path)?;
if normalized.is_empty() {
return Err("File path is required.".to_string());
}
Ok(normalized)
}
fn status_kind(x: &str, y: &str) -> String {
match (x, y) {
("?", "?") => "untracked",
("U", _) | (_, "U") | ("A", "A") | ("D", "D") => "conflicted",
("R", _) | (_, "R") => "renamed",
("A", _) | (_, "A") => "added",
("D", _) | (_, "D") => "deleted",
_ => "modified",
}
.to_string()
}
fn parse_porcelain_file(line: &str) -> Option<GitChangedFile> {
if line.len() < 4 {
return None;
}
let x = line.get(0..1)?.to_string();
let y = line.get(1..2)?.to_string();
let path_part = line.get(3..)?.trim();
if path_part.is_empty() {
return None;
}
let (path, original_path) =
if matches!(x.as_str(), "R" | "C") || matches!(y.as_str(), "R" | "C") {
if let Some((old_path, new_path)) = path_part.split_once(" -> ") {
(
new_path.trim().to_string(),
Some(old_path.trim().to_string()),
)
} else {
(path_part.to_string(), None)
}
} else {
(path_part.to_string(), None)
};
Some(GitChangedFile {
status: status_kind(&x, &y),
path,
original_path,
x,
y,
})
}
fn parse_branch_status(line: &str) -> (Option<String>, Option<String>, u32, u32) {
let value = line.trim_start_matches("## ").trim();
if value == "No commits yet on" {
return (None, None, 0, 0);
}
let (head, meta) = value
.split_once(" [")
.map(|(left, right)| (left, Some(right.trim_end_matches(']'))))
.unwrap_or((value, None));
let (branch, upstream) = head
.split_once("...")
.map(|(left, right)| (left.to_string(), Some(right.to_string())))
.unwrap_or((head.to_string(), None));
let mut ahead = 0;
let mut behind = 0;
if let Some(meta) = meta {
for part in meta.split(',').map(str::trim) {
if let Some(value) = part.strip_prefix("ahead ") {
ahead = value.parse().unwrap_or(0);
} else if let Some(value) = part.strip_prefix("behind ") {
behind = value.parse().unwrap_or(0);
}
}
}
(Some(branch), upstream, ahead, behind)
}
fn parse_porcelain_status(stdout: &str) -> GitStatusSummary {
let mut branch = None;
let mut upstream = None;
let mut ahead = 0;
let mut behind = 0;
let mut files = Vec::new();
for line in stdout.lines() {
if line.starts_with("## ") {
(branch, upstream, ahead, behind) = parse_branch_status(line);
} else if let Some(file) = parse_porcelain_file(line) {
files.push(file);
}
}
GitStatusSummary {
branch,
upstream,
ahead,
behind,
files,
}
}
fn git_output_text(
repo_path: &str,
git_path: Option<String>,
args: Vec<&str>,
) -> Result<String, String> {
let result = ensure_git_success(run_git_command(
Some(repo_path),
git_path,
args.into_iter().map(str::to_string).collect(),
)?)?;
Ok(result.stdout.trim().to_string())
}
fn git_output_text_optional(
repo_path: &str,
git_path: Option<String>,
args: Vec<&str>,
) -> Option<String> {
git_output_text(repo_path, git_path, args)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn parse_remote_names(stdout: &str) -> Vec<String> {
stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect()
}
fn split_upstream_remote(upstream: &str) -> Option<String> {
upstream
.split_once('/')
.map(|(remote, _)| remote.to_string())
.filter(|remote| !remote.is_empty())
}
fn ahead_behind_counts(repo_path: &str, git_path: Option<String>) -> (u32, u32) {
let Some(stdout) = git_output_text_optional(
repo_path,
git_path,
vec!["rev-list", "--left-right", "--count", "HEAD...@{u}"],
) else {
return (0, 0);
};
let mut parts = stdout.split_whitespace();
let ahead = parts
.next()
.and_then(|value| value.parse().ok())
.unwrap_or(0);
let behind = parts
.next()
.and_then(|value| value.parse().ok())
.unwrap_or(0);
(ahead, behind)
}
fn command_with_paths(base_args: &[&str], paths: Vec<String>) -> Result<Vec<String>, String> {
if paths.is_empty() {
return Err("At least one file must be selected.".to_string());
}
let mut args = base_args
.iter()
.map(|value| value.to_string())
.collect::<Vec<_>>();
args.push("--".to_string());
for path in paths {
args.push(validate_git_path(&path)?);
}
Ok(args)
}
fn is_binary_file(path: &Path) -> bool {
fs::read(path)
.map(|content| content.contains(&0))
.unwrap_or(false)
}
fn normalize_repo_path(path: &str) -> Result<String, String> {
let trimmed = path.trim().trim_matches('/');
if trimmed.contains('\\') || trimmed.contains('\0') {
@@ -402,12 +686,52 @@ fn git_clone(
#[tauri::command]
fn git_pull(repo_path: String, git_path: Option<String>) -> Result<GitCommandResult, String> {
run_git_command(Some(repo_path.trim()), git_path, vec!["pull".to_string()])
let repo_path = validate_repo_dir(&repo_path)?;
ensure_git_success(run_git_command(
Some(&repo_path),
git_path,
vec!["pull".to_string()],
)?)
}
#[tauri::command]
fn git_push(repo_path: String, git_path: Option<String>) -> Result<GitCommandResult, String> {
run_git_command(Some(repo_path.trim()), git_path, vec!["push".to_string()])
let repo_path = validate_repo_dir(&repo_path)?;
ensure_git_success(run_git_command(
Some(&repo_path),
git_path,
vec!["push".to_string()],
)?)
}
#[tauri::command]
fn git_publish_branch(
repo_path: String,
remote: Option<String>,
git_path: Option<String>,
) -> Result<GitCommandResult, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let status = repository_sync_status(repo_path.clone(), git_path.clone())?;
if status.is_detached {
return Err("Cannot publish a detached HEAD.".to_string());
}
let branch = status
.branch
.ok_or_else(|| "Cannot determine the current branch.".to_string())?;
let remote = remote
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.or(status.default_remote.as_deref())
.ok_or_else(|| "No Git remote is configured for this repository.".to_string())?
.to_string();
ensure_git_success(run_git_command(
Some(&repo_path),
git_path,
vec!["push".to_string(), "-u".to_string(), remote, branch],
)?)
}
#[tauri::command]
@@ -432,6 +756,509 @@ fn git_branch(repo_path: String, git_path: Option<String>) -> Result<GitCommandR
)
}
#[tauri::command]
fn git_fetch(repo_path: String, git_path: Option<String>) -> Result<GitCommandResult, String> {
let repo_path = validate_repo_dir(&repo_path)?;
ensure_git_success(run_git_command(
Some(&repo_path),
git_path,
vec!["fetch".to_string(), "--prune".to_string()],
)?)
}
#[tauri::command]
fn git_sync(repo_path: String, git_path: Option<String>) -> Result<GitCommandResult, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let fetch = ensure_git_success(run_git_command(
Some(&repo_path),
git_path.clone(),
vec!["fetch".to_string(), "--prune".to_string()],
)?)?;
let pull = ensure_git_success(run_git_command(
Some(&repo_path),
git_path.clone(),
vec!["pull".to_string()],
)?)?;
let push = ensure_git_success(run_git_command(
Some(&repo_path),
git_path,
vec!["push".to_string()],
)?)?;
Ok(GitCommandResult {
command: "git fetch --prune && git pull && git push".to_string(),
stdout: [fetch.stdout, pull.stdout, push.stdout]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n\n"),
stderr: [fetch.stderr, pull.stderr, push.stderr]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n\n"),
success: true,
})
}
#[tauri::command]
fn repository_sync_status(
repo_path: String,
git_path: Option<String>,
) -> Result<GitRepositorySyncStatus, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let branch_output = git_output_text(
&repo_path,
git_path.clone(),
vec!["rev-parse", "--abbrev-ref", "HEAD"],
)?;
let is_detached = branch_output == "HEAD";
let branch = if is_detached {
None
} else {
Some(branch_output)
};
let upstream = git_output_text_optional(
&repo_path,
git_path.clone(),
vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
);
let upstream_remote = upstream.as_deref().and_then(split_upstream_remote);
let (ahead, behind) = if upstream.is_some() {
ahead_behind_counts(&repo_path, git_path.clone())
} else {
(0, 0)
};
let remotes = git_output_text_optional(&repo_path, git_path, vec!["remote"])
.map(|stdout| parse_remote_names(&stdout))
.unwrap_or_default();
let default_remote = remotes
.iter()
.find(|remote| remote.as_str() == "origin")
.or_else(|| remotes.first())
.cloned();
let has_remote = default_remote.is_some();
let is_unpublished = branch.is_some() && upstream.is_none() && has_remote && !is_detached;
Ok(GitRepositorySyncStatus {
branch,
upstream,
upstream_remote,
default_remote,
ahead,
behind,
has_remote,
is_detached,
is_unpublished,
})
}
#[tauri::command]
fn working_tree_status(
repo_path: String,
git_path: Option<String>,
) -> Result<GitStatusSummary, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let result = ensure_git_success(run_git_command(
Some(&repo_path),
git_path,
vec![
"status".to_string(),
"--porcelain=v1".to_string(),
"--branch".to_string(),
"--renames".to_string(),
],
)?)?;
Ok(parse_porcelain_status(&result.stdout))
}
#[tauri::command]
fn get_file_diff(
repo_path: String,
path: String,
status: Option<String>,
git_path: Option<String>,
) -> Result<GitFileDiff, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let file_path = validate_git_path(&path)?;
let status = status.unwrap_or_default();
let is_untracked = status == "untracked";
let is_deleted = status == "deleted";
if is_untracked {
let absolute_path = PathBuf::from(&repo_path).join(&file_path);
if is_binary_file(&absolute_path) {
return Ok(GitFileDiff {
path: file_path,
diff: "Binary file. Diff preview is not available for untracked binary files."
.to_string(),
is_binary: true,
is_deleted: false,
is_untracked: true,
});
}
let content = fs::read_to_string(&absolute_path)
.map_err(|err| format!("Unable to read untracked file: {err}"))?;
let diff = format!(
"diff --git a/{0} b/{0}\nnew file mode 100644\n--- /dev/null\n+++ b/{0}\n{1}",
file_path,
content
.lines()
.map(|line| format!("+{line}"))
.collect::<Vec<_>>()
.join("\n")
);
return Ok(GitFileDiff {
path: file_path,
diff,
is_binary: false,
is_deleted: false,
is_untracked: true,
});
}
let output = run_git_command(
Some(&repo_path),
git_path,
vec![
"diff".to_string(),
"--no-ext-diff".to_string(),
"--".to_string(),
file_path.clone(),
],
)?;
let mut diff = output.stdout;
if diff.is_empty() {
diff = output.stderr;
}
let is_binary = diff.contains("Binary files") || diff.contains("GIT binary patch");
Ok(GitFileDiff {
path: file_path,
diff: if diff.is_empty() {
"No unstaged diff is available for this file.".to_string()
} else {
diff
},
is_binary,
is_deleted,
is_untracked: false,
})
}
#[tauri::command]
fn stage_files(
repo_path: String,
paths: Vec<String>,
git_path: Option<String>,
) -> Result<GitCommandResult, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let args = command_with_paths(&["add"], paths)?;
ensure_git_success(run_git_command(Some(&repo_path), git_path, args)?)
}
#[tauri::command]
fn unstage_files(
repo_path: String,
paths: Vec<String>,
git_path: Option<String>,
) -> Result<GitCommandResult, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let args = command_with_paths(&["restore", "--staged"], paths)?;
ensure_git_success(run_git_command(Some(&repo_path), git_path, args)?)
}
#[tauri::command]
fn commit_changes(
repo_path: String,
paths: Vec<String>,
summary: String,
description: Option<String>,
git_path: Option<String>,
) -> Result<GitCommandResult, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let summary = summary.trim();
if summary.is_empty() {
return Err("Commit summary is required.".to_string());
}
let paths = paths
.into_iter()
.map(|path| validate_git_path(&path))
.collect::<Result<Vec<_>, _>>()?;
if paths.is_empty() {
return Err("Select at least one file to commit.".to_string());
}
ensure_git_success(run_git_command(
Some(&repo_path),
git_path.clone(),
command_with_paths(&["add"], paths.clone())?,
)?)?;
let mut args = vec!["commit".to_string(), "--only".to_string()];
args.push("-m".to_string());
args.push(summary.to_string());
if let Some(description) = description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
args.push("-m".to_string());
args.push(description.to_string());
}
args.push("--".to_string());
for path in paths {
args.push(path);
}
ensure_git_success(run_git_command(Some(&repo_path), git_path, args)?)
}
#[tauri::command]
fn checkout_branch(
repo_path: String,
branch: String,
git_path: Option<String>,
) -> Result<GitCommandResult, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let branch = normalize_reference(&branch)?;
ensure_git_success(run_git_command(
Some(&repo_path),
git_path,
vec!["checkout".to_string(), branch],
)?)
}
#[tauri::command]
fn create_branch(
repo_path: String,
branch: String,
git_path: Option<String>,
) -> Result<GitCommandResult, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let branch = normalize_reference(&branch)?;
ensure_git_success(run_git_command(
Some(&repo_path),
git_path,
vec!["checkout".to_string(), "-b".to_string(), branch],
)?)
}
#[tauri::command]
fn delete_branch(
repo_path: String,
branch: String,
force: Option<bool>,
git_path: Option<String>,
) -> Result<GitCommandResult, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let branch = normalize_reference(&branch)?;
let current = run_git_output(
&repo_path,
git_path.clone(),
vec![
"rev-parse".to_string(),
"--abbrev-ref".to_string(),
"HEAD".to_string(),
],
)?;
if String::from_utf8_lossy(&current).trim() == branch {
return Err("Cannot delete the current branch.".to_string());
}
ensure_git_success(run_git_command(
Some(&repo_path),
git_path,
vec![
"branch".to_string(),
if force.unwrap_or(false) { "-D" } else { "-d" }.to_string(),
branch,
],
)?)
}
#[tauri::command]
fn rename_branch(
repo_path: String,
old_branch: String,
new_branch: String,
git_path: Option<String>,
) -> Result<GitCommandResult, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let old_branch = normalize_reference(&old_branch)?;
let new_branch = normalize_reference(&new_branch)?;
if old_branch == new_branch {
return Err("Choose a different branch name.".to_string());
}
ensure_git_success(run_git_command(
Some(&repo_path),
git_path,
vec![
"branch".to_string(),
"-m".to_string(),
old_branch,
new_branch,
],
)?)
}
#[tauri::command]
fn commit_history(
repo_path: String,
limit: Option<u32>,
git_path: Option<String>,
) -> Result<Vec<GitCommitSummary>, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let max_count = limit.unwrap_or(100).clamp(1, 500).to_string();
let output = run_git_output(
&repo_path,
git_path,
vec![
"log".to_string(),
format!("--max-count={max_count}"),
"--date=short".to_string(),
"--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1e".to_string(),
],
)?;
let commits = String::from_utf8_lossy(&output)
.split('\x1e')
.filter_map(|record| {
let fields = record.trim_matches('\n').split('\x1f').collect::<Vec<_>>();
if fields.len() < 5 {
return None;
}
Some(GitCommitSummary {
hash: fields[0].to_string(),
short_hash: fields[1].to_string(),
author: fields[2].to_string(),
date: fields[3].to_string(),
title: fields[4].to_string(),
})
})
.collect();
Ok(commits)
}
#[tauri::command]
fn commit_detail(
repo_path: String,
hash: String,
git_path: Option<String>,
) -> Result<GitCommitDetail, String> {
let repo_path = validate_repo_dir(&repo_path)?;
let hash = normalize_reference(&hash)?;
let meta = run_git_output(
&repo_path,
git_path.clone(),
vec![
"show".to_string(),
"--quiet".to_string(),
"--date=short".to_string(),
"--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%B".to_string(),
hash.clone(),
],
)?;
let meta_text = String::from_utf8_lossy(&meta);
let fields = meta_text.splitn(6, '\x1f').collect::<Vec<_>>();
if fields.len() < 6 {
return Err("Unable to parse commit metadata.".to_string());
}
let files_output = run_git_output(
&repo_path,
git_path.clone(),
vec![
"show".to_string(),
"--name-status".to_string(),
"--pretty=format:".to_string(),
hash.clone(),
],
)?;
let files = String::from_utf8_lossy(&files_output)
.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let status_code = parts.next()?.chars().next()?.to_string();
let first_path = parts.next()?.to_string();
let second_path = parts.next().map(ToString::to_string);
let path = second_path.clone().unwrap_or_else(|| first_path.clone());
Some(GitChangedFile {
status: status_kind(&status_code, " "),
path,
original_path: second_path.map(|_| first_path),
x: status_code,
y: " ".to_string(),
})
})
.collect();
let diff = run_git_output(
&repo_path,
git_path,
vec![
"show".to_string(),
"--format=".to_string(),
"--no-ext-diff".to_string(),
hash,
],
)
.map(|output| String::from_utf8_lossy(&output).trim().to_string())
.unwrap_or_default();
Ok(GitCommitDetail {
hash: fields[0].to_string(),
short_hash: fields[1].to_string(),
author: fields[2].to_string(),
date: fields[3].to_string(),
title: fields[4].to_string(),
message: fields[5].trim().to_string(),
files,
diff,
})
}
#[tauri::command]
fn open_in_file_explorer(repo_path: String) -> Result<(), String> {
let repo_path = validate_repo_dir(&repo_path)?;
#[cfg(target_os = "windows")]
let mut command = {
let mut cmd = Command::new("explorer");
cmd.arg(&repo_path);
cmd
};
#[cfg(target_os = "macos")]
let mut command = {
let mut cmd = Command::new("open");
cmd.arg(&repo_path);
cmd
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut command = {
let mut cmd = Command::new("xdg-open");
cmd.arg(&repo_path);
cmd
};
command
.spawn()
.map_err(|err| format!("Failed to open file explorer: {err}"))?;
Ok(())
}
#[tauri::command]
fn open_in_external_editor(repo_path: String, editor_path: String) -> Result<(), String> {
let repo_path = validate_repo_dir(&repo_path)?;
let editor_path = editor_path.trim();
if editor_path.is_empty() {
return Err("Configure an external editor command in Settings first.".to_string());
}
Command::new(editor_path)
.arg(repo_path)
.spawn()
.map_err(|err| format!("Failed to open external editor: {err}"))?;
Ok(())
}
#[tauri::command]
fn scan_local_repos(
roots: Option<Vec<String>>,
@@ -736,8 +1563,25 @@ pub fn run() {
git_clone,
git_pull,
git_push,
git_publish_branch,
git_status,
git_branch,
git_fetch,
git_sync,
repository_sync_status,
working_tree_status,
get_file_diff,
stage_files,
unstage_files,
commit_changes,
checkout_branch,
create_branch,
delete_branch,
rename_branch,
commit_history,
commit_detail,
open_in_file_explorer,
open_in_external_editor,
scan_local_repos,
local_repo_branches,
local_repo_tree,