1594 lines
43 KiB
Rust
1594 lines
43 KiB
Rust
use reqwest::blocking::Client;
|
|
use reqwest::header::{ACCEPT, AUTHORIZATION};
|
|
use serde::Serialize;
|
|
use std::collections::HashSet;
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
|
|
#[derive(Serialize)]
|
|
struct GitCommandResult {
|
|
command: String,
|
|
stdout: String,
|
|
stderr: String,
|
|
success: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ServerConnectionResult {
|
|
ok: bool,
|
|
message: String,
|
|
api_base_url: String,
|
|
version: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct LocalRepoBranch {
|
|
name: String,
|
|
current: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct LocalRepoEntry {
|
|
name: String,
|
|
path: String,
|
|
entry_type: String,
|
|
size: Option<u64>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct LocalRepoFile {
|
|
path: String,
|
|
size: u64,
|
|
content: Option<String>,
|
|
is_binary: bool,
|
|
too_large: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct LocalRepoCandidate {
|
|
name: String,
|
|
path: String,
|
|
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('/');
|
|
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
|
|
return Err("Server URL must start with http:// or https://".to_string());
|
|
}
|
|
|
|
if trimmed.ends_with("/api/v1") {
|
|
Ok(trimmed.to_string())
|
|
} else {
|
|
Ok(format!("{trimmed}/api/v1"))
|
|
}
|
|
}
|
|
|
|
fn resolve_git_binary(git_path: Option<String>) -> String {
|
|
git_path
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or("git")
|
|
.to_string()
|
|
}
|
|
|
|
fn run_git_command(
|
|
repo_path: Option<&str>,
|
|
git_path: Option<String>,
|
|
args: Vec<String>,
|
|
) -> Result<GitCommandResult, String> {
|
|
// Central command executor keeps Git command behavior consistent.
|
|
let git_binary = resolve_git_binary(git_path);
|
|
let mut command = Command::new(&git_binary);
|
|
|
|
if let Some(path) = repo_path {
|
|
if !Path::new(path).exists() {
|
|
return Err(format!("Repository path does not exist: {path}"));
|
|
}
|
|
command.current_dir(path);
|
|
}
|
|
|
|
command.args(&args);
|
|
let output = command
|
|
.output()
|
|
.map_err(|err| format!("Failed to run git command: {err}"))?;
|
|
|
|
Ok(GitCommandResult {
|
|
command: format!("{git_binary} {}", args.join(" ")),
|
|
stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
|
|
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
|
success: output.status.success(),
|
|
})
|
|
}
|
|
|
|
fn run_git_output(
|
|
repo_path: &str,
|
|
git_path: Option<String>,
|
|
args: Vec<String>,
|
|
) -> Result<Vec<u8>, String> {
|
|
let git_binary = resolve_git_binary(git_path);
|
|
let path = repo_path.trim();
|
|
if !Path::new(path).exists() {
|
|
return Err(format!("Repository path does not exist: {path}"));
|
|
}
|
|
|
|
let output = Command::new(&git_binary)
|
|
.current_dir(path)
|
|
.args(&args)
|
|
.output()
|
|
.map_err(|err| format!("Failed to run git command: {err}"))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
return Err(if stderr.is_empty() {
|
|
format!("{git_binary} {} failed", args.join(" "))
|
|
} else {
|
|
stderr
|
|
});
|
|
}
|
|
|
|
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') {
|
|
return Err("Repository paths must use forward slashes.".to_string());
|
|
}
|
|
|
|
let mut parts = Vec::new();
|
|
for part in trimmed.split('/') {
|
|
if part.is_empty() || part == "." {
|
|
continue;
|
|
}
|
|
if part == ".." {
|
|
return Err("Repository paths cannot contain parent directory segments.".to_string());
|
|
}
|
|
parts.push(part);
|
|
}
|
|
|
|
Ok(parts.join("/"))
|
|
}
|
|
|
|
fn normalize_reference(reference: &str) -> Result<String, String> {
|
|
let trimmed = reference.trim();
|
|
if trimmed.is_empty() || trimmed.contains('\0') {
|
|
return Err("Branch or reference is required.".to_string());
|
|
}
|
|
Ok(trimmed.to_string())
|
|
}
|
|
|
|
fn treeish(reference: &str, path: &str) -> String {
|
|
if path.is_empty() {
|
|
reference.to_string()
|
|
} else {
|
|
format!("{reference}:{path}")
|
|
}
|
|
}
|
|
|
|
fn dedupe_key(path: &Path) -> String {
|
|
let path = path
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| path.to_path_buf())
|
|
.to_string_lossy()
|
|
.replace('\\', "/")
|
|
.trim_end_matches('/')
|
|
.to_string();
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
path.to_lowercase()
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
{
|
|
path
|
|
}
|
|
}
|
|
|
|
fn add_if_exists(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
|
if !path.exists() {
|
|
return;
|
|
}
|
|
|
|
let key = dedupe_key(&path);
|
|
if !paths.iter().any(|existing| dedupe_key(existing) == key) {
|
|
paths.push(path);
|
|
}
|
|
}
|
|
|
|
fn default_repo_scan_roots() -> Vec<PathBuf> {
|
|
let mut roots = Vec::new();
|
|
|
|
if let Some(home) = env::var_os("USERPROFILE").or_else(|| env::var_os("HOME")) {
|
|
let home = PathBuf::from(home);
|
|
add_if_exists(&mut roots, home.join("Repos"));
|
|
add_if_exists(&mut roots, home.join("repos"));
|
|
add_if_exists(&mut roots, home.join("Code"));
|
|
add_if_exists(&mut roots, home.join("code"));
|
|
add_if_exists(&mut roots, home.join("Source"));
|
|
add_if_exists(&mut roots, home.join("source"));
|
|
add_if_exists(&mut roots, home.join("Projects"));
|
|
add_if_exists(&mut roots, home.join("GitHub"));
|
|
add_if_exists(&mut roots, home.join("Documents").join("GitHub"));
|
|
add_if_exists(&mut roots, home.join("Documents").join("Repos"));
|
|
add_if_exists(&mut roots, home.join("Desktop"));
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
for drive in b'A'..=b'Z' {
|
|
let root = format!("{}:\\", drive as char);
|
|
let root_path = PathBuf::from(&root);
|
|
if !root_path.exists() {
|
|
continue;
|
|
}
|
|
|
|
for folder in [
|
|
"Repos", "repos", "Code", "code", "Source", "source", "Projects", "GitHub", "Dev",
|
|
] {
|
|
add_if_exists(&mut roots, root_path.join(folder));
|
|
}
|
|
}
|
|
}
|
|
|
|
roots
|
|
}
|
|
|
|
fn is_skippable_dir(path: &Path) -> bool {
|
|
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
|
return false;
|
|
};
|
|
|
|
matches!(
|
|
name,
|
|
".cache"
|
|
| ".cargo"
|
|
| ".gradle"
|
|
| ".npm"
|
|
| ".rustup"
|
|
| ".svn"
|
|
| ".tauri"
|
|
| "AppData"
|
|
| "Library"
|
|
| "node_modules"
|
|
| "target"
|
|
| "vendor"
|
|
)
|
|
}
|
|
|
|
fn is_git_repo(path: &Path) -> bool {
|
|
path.join(".git").exists()
|
|
}
|
|
|
|
fn normalize_git_remote_url(value: &str) -> String {
|
|
let mut remote = value.trim().trim_end_matches('/').to_lowercase();
|
|
if let Some(stripped) = remote.strip_suffix(".git") {
|
|
remote = stripped.to_string();
|
|
}
|
|
remote
|
|
}
|
|
|
|
fn local_remote_urls(repo_path: &Path, git_path: Option<String>) -> Vec<String> {
|
|
let git_binary = resolve_git_binary(git_path);
|
|
let Ok(output) = Command::new(&git_binary)
|
|
.current_dir(repo_path)
|
|
.args(["config", "--get-regexp", r"^remote\..*\.url$"])
|
|
.output()
|
|
else {
|
|
return Vec::new();
|
|
};
|
|
|
|
if !output.status.success() {
|
|
return Vec::new();
|
|
}
|
|
|
|
String::from_utf8_lossy(&output.stdout)
|
|
.lines()
|
|
.filter_map(|line| line.split_once(' ').map(|(_, url)| url.trim().to_string()))
|
|
.filter(|url| !url.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
fn matched_server_remote(
|
|
repo_path: &Path,
|
|
git_path: Option<String>,
|
|
allowed_remote_urls: &HashSet<String>,
|
|
) -> Option<String> {
|
|
local_remote_urls(repo_path, git_path)
|
|
.into_iter()
|
|
.find(|remote| allowed_remote_urls.contains(&normalize_git_remote_url(remote)))
|
|
}
|
|
|
|
fn collect_repo_candidates(
|
|
root: &Path,
|
|
depth: u8,
|
|
max_depth: u8,
|
|
results: &mut Vec<LocalRepoCandidate>,
|
|
seen: &mut HashSet<String>,
|
|
allowed_remote_urls: &HashSet<String>,
|
|
git_path: Option<String>,
|
|
max_results: usize,
|
|
) {
|
|
if results.len() >= max_results || is_skippable_dir(root) {
|
|
return;
|
|
}
|
|
|
|
if is_git_repo(root) {
|
|
if seen.insert(dedupe_key(root)) {
|
|
let display_path = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
|
|
let path = display_path.to_string_lossy().to_string();
|
|
let Some(matched_remote_url) =
|
|
matched_server_remote(root, git_path.clone(), allowed_remote_urls)
|
|
else {
|
|
return;
|
|
};
|
|
let name = root
|
|
.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or(&path)
|
|
.to_string();
|
|
results.push(LocalRepoCandidate {
|
|
name,
|
|
path,
|
|
matched_remote_url,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if depth >= max_depth {
|
|
return;
|
|
}
|
|
|
|
let Ok(entries) = fs::read_dir(root) else {
|
|
return;
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
if results.len() >= max_results {
|
|
return;
|
|
}
|
|
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
collect_repo_candidates(
|
|
&path,
|
|
depth + 1,
|
|
max_depth,
|
|
results,
|
|
seen,
|
|
allowed_remote_urls,
|
|
git_path.clone(),
|
|
max_results,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn git_clone(
|
|
repo_url: String,
|
|
destination_path: String,
|
|
git_path: Option<String>,
|
|
) -> Result<GitCommandResult, String> {
|
|
if repo_url.trim().is_empty() {
|
|
return Err("Repository URL is required".to_string());
|
|
}
|
|
if destination_path.trim().is_empty() {
|
|
return Err("Destination path is required".to_string());
|
|
}
|
|
|
|
run_git_command(
|
|
None,
|
|
git_path,
|
|
vec![
|
|
"clone".to_string(),
|
|
repo_url.trim().to_string(),
|
|
destination_path.trim().to_string(),
|
|
],
|
|
)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn git_pull(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!["pull".to_string()],
|
|
)?)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn git_push(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!["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]
|
|
fn git_status(repo_path: String, git_path: Option<String>) -> Result<GitCommandResult, String> {
|
|
run_git_command(
|
|
Some(repo_path.trim()),
|
|
git_path,
|
|
vec![
|
|
"status".to_string(),
|
|
"--short".to_string(),
|
|
"--branch".to_string(),
|
|
],
|
|
)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn git_branch(repo_path: String, git_path: Option<String>) -> Result<GitCommandResult, String> {
|
|
run_git_command(
|
|
Some(repo_path.trim()),
|
|
git_path,
|
|
vec!["branch".to_string(), "--all".to_string()],
|
|
)
|
|
}
|
|
|
|
#[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(¤t).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>>,
|
|
allowed_remote_urls: Vec<String>,
|
|
git_path: Option<String>,
|
|
max_depth: Option<u8>,
|
|
max_results: Option<usize>,
|
|
) -> Result<Vec<LocalRepoCandidate>, String> {
|
|
let max_depth = max_depth.unwrap_or(4).clamp(1, 8);
|
|
let max_results = max_results.unwrap_or(200).clamp(1, 500);
|
|
let allowed_remote_urls = allowed_remote_urls
|
|
.into_iter()
|
|
.map(|url| normalize_git_remote_url(&url))
|
|
.filter(|url| !url.is_empty())
|
|
.collect::<HashSet<_>>();
|
|
|
|
if allowed_remote_urls.is_empty() {
|
|
return Err(
|
|
"No selected server repository URLs are available to match against.".to_string(),
|
|
);
|
|
}
|
|
|
|
let root_paths = roots
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|path| path.trim().to_string())
|
|
.filter(|path| !path.is_empty())
|
|
.map(PathBuf::from)
|
|
.collect::<Vec<_>>();
|
|
let root_paths = if root_paths.is_empty() {
|
|
default_repo_scan_roots()
|
|
} else {
|
|
root_paths
|
|
};
|
|
|
|
if root_paths.is_empty() {
|
|
return Err(
|
|
"No scan roots found. Set a default clone directory or enter a scan root.".to_string(),
|
|
);
|
|
}
|
|
|
|
let mut results = Vec::new();
|
|
let mut seen = HashSet::new();
|
|
for root in root_paths {
|
|
if root.exists() && root.is_dir() {
|
|
collect_repo_candidates(
|
|
&root,
|
|
0,
|
|
max_depth,
|
|
&mut results,
|
|
&mut seen,
|
|
&allowed_remote_urls,
|
|
git_path.clone(),
|
|
max_results,
|
|
);
|
|
}
|
|
if results.len() >= max_results {
|
|
break;
|
|
}
|
|
}
|
|
|
|
results.sort_by(|a, b| {
|
|
a.name
|
|
.to_lowercase()
|
|
.cmp(&b.name.to_lowercase())
|
|
.then_with(|| a.path.to_lowercase().cmp(&b.path.to_lowercase()))
|
|
});
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn local_repo_branches(
|
|
repo_path: String,
|
|
git_path: Option<String>,
|
|
) -> Result<Vec<LocalRepoBranch>, String> {
|
|
let current = run_git_output(
|
|
repo_path.trim(),
|
|
git_path.clone(),
|
|
vec![
|
|
"rev-parse".to_string(),
|
|
"--abbrev-ref".to_string(),
|
|
"HEAD".to_string(),
|
|
],
|
|
)
|
|
.ok()
|
|
.map(|output| String::from_utf8_lossy(&output).trim().to_string())
|
|
.filter(|value| !value.is_empty() && value != "HEAD");
|
|
|
|
let output = run_git_output(
|
|
repo_path.trim(),
|
|
git_path,
|
|
vec![
|
|
"for-each-ref".to_string(),
|
|
"--format=%(refname)".to_string(),
|
|
"refs/heads".to_string(),
|
|
"refs/remotes".to_string(),
|
|
],
|
|
)?;
|
|
|
|
let branches = String::from_utf8_lossy(&output)
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty() && !line.ends_with("/HEAD"))
|
|
.filter_map(|ref_name| {
|
|
ref_name
|
|
.strip_prefix("refs/heads/")
|
|
.or_else(|| ref_name.strip_prefix("refs/remotes/"))
|
|
})
|
|
.map(|name| LocalRepoBranch {
|
|
name: name.to_string(),
|
|
current: current.as_deref() == Some(name),
|
|
})
|
|
.collect();
|
|
|
|
Ok(branches)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn local_repo_tree(
|
|
repo_path: String,
|
|
reference: String,
|
|
path: String,
|
|
git_path: Option<String>,
|
|
) -> Result<Vec<LocalRepoEntry>, String> {
|
|
let reference = normalize_reference(&reference)?;
|
|
let path = normalize_repo_path(&path)?;
|
|
let output = run_git_output(
|
|
repo_path.trim(),
|
|
git_path,
|
|
vec![
|
|
"ls-tree".to_string(),
|
|
"-l".to_string(),
|
|
treeish(&reference, &path),
|
|
],
|
|
)?;
|
|
|
|
let mut entries = Vec::new();
|
|
for line in String::from_utf8_lossy(&output).lines() {
|
|
let Some((meta, name)) = line.split_once('\t') else {
|
|
continue;
|
|
};
|
|
let meta_parts: Vec<&str> = meta.split_whitespace().collect();
|
|
if meta_parts.len() < 4 {
|
|
continue;
|
|
}
|
|
|
|
let entry_type = meta_parts[1].to_string();
|
|
let size = meta_parts[3].parse::<u64>().ok();
|
|
let entry_path = if path.is_empty() {
|
|
name.to_string()
|
|
} else {
|
|
format!("{path}/{name}")
|
|
};
|
|
|
|
entries.push(LocalRepoEntry {
|
|
name: name.to_string(),
|
|
path: entry_path,
|
|
entry_type,
|
|
size,
|
|
});
|
|
}
|
|
|
|
entries.sort_by(|a, b| {
|
|
let a_is_tree = a.entry_type == "tree";
|
|
let b_is_tree = b.entry_type == "tree";
|
|
b_is_tree
|
|
.cmp(&a_is_tree)
|
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
|
});
|
|
|
|
Ok(entries)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn local_repo_file(
|
|
repo_path: String,
|
|
reference: String,
|
|
path: String,
|
|
git_path: Option<String>,
|
|
) -> Result<LocalRepoFile, String> {
|
|
const MAX_TEXT_PREVIEW_BYTES: u64 = 256 * 1024;
|
|
|
|
let reference = normalize_reference(&reference)?;
|
|
let path = normalize_repo_path(&path)?;
|
|
if path.is_empty() {
|
|
return Err("File path is required.".to_string());
|
|
}
|
|
|
|
let spec = treeish(&reference, &path);
|
|
let object_type = run_git_output(
|
|
repo_path.trim(),
|
|
git_path.clone(),
|
|
vec!["cat-file".to_string(), "-t".to_string(), spec.clone()],
|
|
)?;
|
|
if String::from_utf8_lossy(&object_type).trim() != "blob" {
|
|
return Err("Selected path is not a file.".to_string());
|
|
}
|
|
|
|
let size_output = run_git_output(
|
|
repo_path.trim(),
|
|
git_path.clone(),
|
|
vec!["cat-file".to_string(), "-s".to_string(), spec.clone()],
|
|
)?;
|
|
let size = String::from_utf8_lossy(&size_output)
|
|
.trim()
|
|
.parse::<u64>()
|
|
.map_err(|_| "Unable to determine file size.".to_string())?;
|
|
|
|
if size > MAX_TEXT_PREVIEW_BYTES {
|
|
return Ok(LocalRepoFile {
|
|
path,
|
|
size,
|
|
content: None,
|
|
is_binary: false,
|
|
too_large: true,
|
|
});
|
|
}
|
|
|
|
let content = run_git_output(
|
|
repo_path.trim(),
|
|
git_path,
|
|
vec!["show".to_string(), "--no-ext-diff".to_string(), spec],
|
|
)?;
|
|
let is_binary = content.contains(&0);
|
|
let content = if is_binary {
|
|
None
|
|
} else {
|
|
String::from_utf8(content).ok()
|
|
};
|
|
|
|
Ok(LocalRepoFile {
|
|
path,
|
|
size,
|
|
is_binary: content.is_none(),
|
|
too_large: false,
|
|
content,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn test_gitea_connection(
|
|
server_url: String,
|
|
auth_method: String,
|
|
token: Option<String>,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
) -> Result<ServerConnectionResult, String> {
|
|
// A lightweight compatibility check against the canonical Gitea version endpoint.
|
|
let api_base_url = normalize_api_base_url(&server_url)?;
|
|
let version_url = format!("{api_base_url}/version");
|
|
|
|
let mut request = Client::new()
|
|
.get(&version_url)
|
|
.header(ACCEPT, "application/json");
|
|
|
|
if auth_method == "token" {
|
|
if let Some(value) = token.as_deref() {
|
|
if !value.trim().is_empty() {
|
|
request = request.header(AUTHORIZATION, format!("token {}", value.trim()));
|
|
}
|
|
}
|
|
} else if auth_method == "password" {
|
|
request = request.basic_auth(username.unwrap_or_default(), password);
|
|
}
|
|
|
|
let response = request
|
|
.send()
|
|
.map_err(|err| format!("Failed to connect to Gitea server: {err}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Ok(ServerConnectionResult {
|
|
ok: false,
|
|
message: format!("Server responded with status {}", response.status()),
|
|
api_base_url,
|
|
version: None,
|
|
});
|
|
}
|
|
|
|
let response_json: serde_json::Value = response
|
|
.json()
|
|
.map_err(|err| format!("Failed to parse Gitea response: {err}"))?;
|
|
|
|
let version = response_json
|
|
.get("version")
|
|
.and_then(|value| value.as_str())
|
|
.map(ToString::to_string);
|
|
|
|
Ok(ServerConnectionResult {
|
|
ok: true,
|
|
message: "Gitea API is reachable and compatible.".to_string(),
|
|
api_base_url,
|
|
version,
|
|
})
|
|
}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
tauri::Builder::default()
|
|
.plugin(tauri_plugin_opener::init())
|
|
.invoke_handler(tauri::generate_handler![
|
|
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,
|
|
local_repo_file,
|
|
test_gitea_connection
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|