Made more like Github Desktop
This commit is contained in:
+50
-23
@@ -1,18 +1,33 @@
|
||||
:root {
|
||||
--bg-app: #0d1117;
|
||||
--bg-panel: #161b22;
|
||||
--bg-panel-alt: #21262d;
|
||||
--bg-hover: #30363d;
|
||||
--border: #30363d;
|
||||
--text-main: #e6edf3;
|
||||
--text-muted: #848d97;
|
||||
--accent: #2f81f7;
|
||||
--bg-app: #1c2128;
|
||||
--bg-panel: #22272e;
|
||||
--bg-panel-alt: #2a3038;
|
||||
--bg-hover: #2d3540;
|
||||
--border: #1e242b;
|
||||
--text-main: #cdd9e5;
|
||||
--text-muted: #768390;
|
||||
--accent: #58a6ff;
|
||||
--accent-strong: #1f6feb;
|
||||
--success: #3fb950;
|
||||
--danger: #f85149;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--shadow: 0 8px 24px rgba(1, 4, 9, 0.75);
|
||||
--shadow: 0 8px 24px rgba(1, 4, 9, 0.7);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg-app: #f6f8fa;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-panel-alt: #f0f3f6;
|
||||
--bg-hover: #eaeef2;
|
||||
--border: #d0d7de;
|
||||
--text-main: #24292f;
|
||||
--text-muted: #57606a;
|
||||
--accent: #0969da;
|
||||
--accent-strong: #0969da;
|
||||
--success: #1a7f37;
|
||||
--danger: #cf222e;
|
||||
--shadow: 0 8px 24px rgba(31, 35, 40, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -29,6 +44,7 @@ body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -45,40 +61,51 @@ button {
|
||||
color: var(--text-main);
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease, border-color 0.1s ease;
|
||||
font-size: 14px;
|
||||
transition: background 0.1s ease, border-color 0.1s ease, opacity 0.1s ease;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: #8b949e;
|
||||
border-color: #6e7681;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button:disabled:hover {
|
||||
background: var(--bg-panel-alt);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #238636;
|
||||
border-color: rgba(240, 246, 252, 0.1);
|
||||
border-color: transparent;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: #2ea043;
|
||||
border-color: rgba(240, 246, 252, 0.1);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
button.primary-blue {
|
||||
background: var(--accent-strong);
|
||||
border-color: rgba(240, 246, 252, 0.1);
|
||||
border-color: transparent;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
button.primary-blue:hover {
|
||||
background: var(--accent);
|
||||
border-color: rgba(240, 246, 252, 0.1);
|
||||
background: #388bfd;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
border-color: rgba(248, 81, 73, 0.4);
|
||||
border-color: rgba(248, 81, 73, 0.35);
|
||||
color: #f85149;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
@@ -90,12 +117,12 @@ input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 5px 12px;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-app);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text-main);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
|
||||
@@ -104,10 +131,10 @@ select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(47, 129, 247, 0.15);
|
||||
box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.12);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 96px;
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
+1176
-557
File diff suppressed because it is too large
Load Diff
+1361
-375
File diff suppressed because it is too large
Load Diff
+59
-4
@@ -1,10 +1,16 @@
|
||||
import { loadSettings, saveSettings } from "./storage.js";
|
||||
|
||||
const initialSettings = loadSettings();
|
||||
|
||||
const state = {
|
||||
settings: loadSettings(),
|
||||
selectedRepoPath: "",
|
||||
selectedRepoName: "",
|
||||
settings: initialSettings,
|
||||
selectedRepoPath: initialSettings.lastSelectedRepoPath || "",
|
||||
selectedRepoName: initialSettings.lastSelectedRepoPath ? initialSettings.lastSelectedRepoPath.split(/[/\\]/).filter(Boolean).pop() || "" : "",
|
||||
repoSearch: "",
|
||||
changesFilter: "",
|
||||
historyFilter: "",
|
||||
commitSummary: "",
|
||||
commitDescription: "",
|
||||
localRepoPathInput: "",
|
||||
localRepoScanRootInput: "",
|
||||
localRepoScanResults: [],
|
||||
@@ -13,6 +19,55 @@ const state = {
|
||||
cloneUrlInput: "",
|
||||
cloneDestinationInput: "",
|
||||
commitMessage: "",
|
||||
workingTree: {
|
||||
loading: false,
|
||||
error: "",
|
||||
branch: "",
|
||||
upstream: "",
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
files: [],
|
||||
selectedPaths: new Set(),
|
||||
selectedPath: "",
|
||||
selectedDiff: null,
|
||||
diffLoading: false,
|
||||
diffError: "",
|
||||
},
|
||||
sync: {
|
||||
loading: false,
|
||||
error: "",
|
||||
operation: "",
|
||||
branch: "",
|
||||
upstream: "",
|
||||
upstreamRemote: "",
|
||||
defaultRemote: "",
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: false,
|
||||
isDetached: false,
|
||||
isUnpublished: false,
|
||||
lastUpdated: 0,
|
||||
},
|
||||
branches: {
|
||||
loading: false,
|
||||
error: "",
|
||||
items: [],
|
||||
createName: "",
|
||||
menuOpen: false,
|
||||
dialog: {
|
||||
mode: "",
|
||||
target: "",
|
||||
value: "",
|
||||
error: "",
|
||||
},
|
||||
},
|
||||
history: {
|
||||
loading: false,
|
||||
error: "",
|
||||
commits: [],
|
||||
selectedHash: "",
|
||||
selectedCommit: null,
|
||||
},
|
||||
viewer: {
|
||||
source: "",
|
||||
repoName: "",
|
||||
@@ -47,7 +102,7 @@ export function addRecentRepo(path) {
|
||||
if (!path) return;
|
||||
const current = state.settings.recentRepositories.filter((item) => item !== path);
|
||||
const next = [path, ...current].slice(0, 15);
|
||||
updateSettings({ recentRepositories: next });
|
||||
updateSettings({ recentRepositories: next, lastSelectedRepoPath: path });
|
||||
}
|
||||
|
||||
export function getActiveServer() {
|
||||
|
||||
@@ -5,6 +5,9 @@ export function getDefaultSettings() {
|
||||
theme: "dark",
|
||||
gitExecutablePath: "",
|
||||
defaultCloneDirectory: "",
|
||||
externalEditorPath: "",
|
||||
autoFetchOnRepoOpen: false,
|
||||
lastSelectedRepoPath: "",
|
||||
activeServerId: null,
|
||||
servers: [],
|
||||
recentRepositories: [],
|
||||
|
||||
@@ -25,6 +25,21 @@ export async function runGitPush(repoPath, gitPath) {
|
||||
return invoke("git_push", { repoPath, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function runGitPublishBranch(repoPath, remote, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("git_publish_branch", { repoPath, remote: remote || null, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function runGitFetch(repoPath, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("git_fetch", { repoPath, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function runGitSync(repoPath, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("git_sync", { repoPath, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function runGitStatus(repoPath, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("git_status", { repoPath, gitPath: gitPath || null });
|
||||
@@ -35,6 +50,87 @@ export async function runGitBranch(repoPath, gitPath) {
|
||||
return invoke("git_branch", { repoPath, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function getWorkingTreeStatus(repoPath, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("working_tree_status", { repoPath, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function getRepositorySyncStatus(repoPath, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("repository_sync_status", { repoPath, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function getFileDiff(repoPath, path, status, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("get_file_diff", { repoPath, path, status, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function stageFiles(repoPath, paths, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("stage_files", { repoPath, paths, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function unstageFiles(repoPath, paths, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("unstage_files", { repoPath, paths, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function commitChanges(repoPath, paths, summary, description, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("commit_changes", {
|
||||
repoPath,
|
||||
paths,
|
||||
summary,
|
||||
description: description || null,
|
||||
gitPath: gitPath || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkoutBranch(repoPath, branch, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("checkout_branch", { repoPath, branch, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function createBranch(repoPath, branch, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("create_branch", { repoPath, branch, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function deleteBranch(repoPath, branch, force, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("delete_branch", { repoPath, branch, force: !!force, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function renameBranch(repoPath, oldBranch, newBranch, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("rename_branch", {
|
||||
repoPath,
|
||||
oldBranch,
|
||||
newBranch,
|
||||
gitPath: gitPath || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCommitHistory(repoPath, limit, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("commit_history", { repoPath, limit, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function getCommitDetail(repoPath, hash, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("commit_detail", { repoPath, hash, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function openInFileExplorer(repoPath) {
|
||||
ensureInvoke();
|
||||
return invoke("open_in_file_explorer", { repoPath });
|
||||
}
|
||||
|
||||
export async function openInExternalEditor(repoPath, editorPath) {
|
||||
ensureInvoke();
|
||||
return invoke("open_in_external_editor", { repoPath, editorPath });
|
||||
}
|
||||
|
||||
export async function scanLocalRepos(roots = [], allowedRemoteUrls = [], gitPath = "", maxDepth = 4, maxResults = 200) {
|
||||
ensureInvoke();
|
||||
return invoke("scan_local_repos", {
|
||||
|
||||
+846
-2
@@ -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(¤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>>,
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user