generated from nhcarrigan/template
feat: add git integration panel
- Add Rust backend for git operations (status, diff, branches, commit, push, pull, fetch, stage, unstage, discard, create branch) - Add GitPanel.svelte component with three tabs: Changes, Branches, History - Integrate git button into StatusBar with link icon - Use consistent accent color theming for buttons
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitStatus {
|
||||
pub is_repo: bool,
|
||||
pub branch: Option<String>,
|
||||
pub upstream: Option<String>,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
pub staged: Vec<GitFileChange>,
|
||||
pub unstaged: Vec<GitFileChange>,
|
||||
pub untracked: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitFileChange {
|
||||
pub path: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitBranch {
|
||||
pub name: String,
|
||||
pub is_current: bool,
|
||||
pub is_remote: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitLogEntry {
|
||||
pub hash: String,
|
||||
pub short_hash: String,
|
||||
pub author: String,
|
||||
pub date: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(working_dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute git: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_status(working_dir: String) -> Result<GitStatus, String> {
|
||||
// Check if it's a git repo
|
||||
let is_repo = run_git_command(&working_dir, &["rev-parse", "--git-dir"]).is_ok();
|
||||
|
||||
if !is_repo {
|
||||
return Ok(GitStatus {
|
||||
is_repo: false,
|
||||
branch: None,
|
||||
upstream: None,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
staged: vec![],
|
||||
unstaged: vec![],
|
||||
untracked: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
let branch = run_git_command(&working_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
// Get upstream branch
|
||||
let upstream = run_git_command(
|
||||
&working_dir,
|
||||
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||
)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
// Get ahead/behind counts
|
||||
let (ahead, behind) = if upstream.is_some() {
|
||||
let rev_list =
|
||||
run_git_command(&working_dir, &["rev-list", "--left-right", "--count", "@{u}...HEAD"])
|
||||
.unwrap_or_default();
|
||||
let parts: Vec<&str> = rev_list.trim().split('\t').collect();
|
||||
if parts.len() == 2 {
|
||||
(
|
||||
parts[1].parse().unwrap_or(0),
|
||||
parts[0].parse().unwrap_or(0),
|
||||
)
|
||||
} else {
|
||||
(0, 0)
|
||||
}
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
// Get status with porcelain format
|
||||
let status_output =
|
||||
run_git_command(&working_dir, &["status", "--porcelain=v1"]).unwrap_or_default();
|
||||
|
||||
let mut staged = vec![];
|
||||
let mut unstaged = vec![];
|
||||
let mut untracked = vec![];
|
||||
|
||||
for line in status_output.lines() {
|
||||
if line.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let index_status = line.chars().next().unwrap_or(' ');
|
||||
let worktree_status = line.chars().nth(1).unwrap_or(' ');
|
||||
let path = line[3..].to_string();
|
||||
|
||||
// Untracked files
|
||||
if index_status == '?' && worktree_status == '?' {
|
||||
untracked.push(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Staged changes (index status)
|
||||
if index_status != ' ' && index_status != '?' {
|
||||
staged.push(GitFileChange {
|
||||
path: path.clone(),
|
||||
status: match index_status {
|
||||
'M' => "modified".to_string(),
|
||||
'A' => "added".to_string(),
|
||||
'D' => "deleted".to_string(),
|
||||
'R' => "renamed".to_string(),
|
||||
'C' => "copied".to_string(),
|
||||
_ => "unknown".to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Unstaged changes (worktree status)
|
||||
if worktree_status != ' ' && worktree_status != '?' {
|
||||
unstaged.push(GitFileChange {
|
||||
path,
|
||||
status: match worktree_status {
|
||||
'M' => "modified".to_string(),
|
||||
'D' => "deleted".to_string(),
|
||||
_ => "unknown".to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(GitStatus {
|
||||
is_repo: true,
|
||||
branch,
|
||||
upstream,
|
||||
ahead,
|
||||
behind,
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_diff(working_dir: String, file_path: Option<String>, staged: bool) -> Result<String, String> {
|
||||
let mut args = vec!["diff"];
|
||||
|
||||
if staged {
|
||||
args.push("--cached");
|
||||
}
|
||||
|
||||
if let Some(ref path) = file_path {
|
||||
args.push("--");
|
||||
args.push(path);
|
||||
}
|
||||
|
||||
run_git_command(&working_dir, &args)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_branches(working_dir: String) -> Result<Vec<GitBranch>, String> {
|
||||
let output = run_git_command(&working_dir, &["branch", "-a", "--format=%(refname:short)\t%(HEAD)"])?;
|
||||
|
||||
let branches: Vec<GitBranch> = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = parts[0].to_string();
|
||||
let is_current = parts.get(1).map(|s| *s == "*").unwrap_or(false);
|
||||
let is_remote = name.starts_with("remotes/") || name.starts_with("origin/");
|
||||
|
||||
Some(GitBranch {
|
||||
name,
|
||||
is_current,
|
||||
is_remote,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_checkout(working_dir: String, branch: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["checkout", &branch])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_stage(working_dir: String, file_path: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["add", &file_path])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_unstage(working_dir: String, file_path: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["restore", "--staged", &file_path])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_stage_all(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["add", "-A"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_commit(working_dir: String, message: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["commit", "-m", &message])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["push"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_pull(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["pull"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_fetch(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["fetch", "--all"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_log(working_dir: String, limit: Option<u32>) -> Result<Vec<GitLogEntry>, String> {
|
||||
let limit_str = limit.unwrap_or(10).to_string();
|
||||
let output = run_git_command(
|
||||
&working_dir,
|
||||
&[
|
||||
"log",
|
||||
&format!("-{}", limit_str),
|
||||
"--pretty=format:%H\t%h\t%an\t%ar\t%s",
|
||||
],
|
||||
)?;
|
||||
|
||||
let entries: Vec<GitLogEntry> = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(GitLogEntry {
|
||||
hash: parts[0].to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
author: parts[2].to_string(),
|
||||
date: parts[3].to_string(),
|
||||
message: parts[4..].join("\t"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_discard(working_dir: String, file_path: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["checkout", "--", &file_path])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_create_branch(working_dir: String, branch_name: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["checkout", "-b", &branch_name])
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod achievements;
|
||||
mod bridge_manager;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod git;
|
||||
mod notifications;
|
||||
mod sessions;
|
||||
mod snippets;
|
||||
@@ -17,6 +18,7 @@ mod wsl_notifications;
|
||||
use bridge_manager::create_shared_bridge_manager;
|
||||
use commands::load_saved_achievements;
|
||||
use commands::*;
|
||||
use git::*;
|
||||
use notifications::*;
|
||||
use sessions::*;
|
||||
use snippets::*;
|
||||
@@ -118,6 +120,20 @@ pub fn run() {
|
||||
delete_snippet,
|
||||
get_snippet_categories,
|
||||
reset_default_snippets,
|
||||
git_status,
|
||||
git_diff,
|
||||
git_branches,
|
||||
git_checkout,
|
||||
git_stage,
|
||||
git_unstage,
|
||||
git_stage_all,
|
||||
git_commit,
|
||||
git_push,
|
||||
git_pull,
|
||||
git_fetch,
|
||||
git_log,
|
||||
git_discard,
|
||||
git_create_branch,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
Reference in New Issue
Block a user