diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs new file mode 100644 index 0000000..96ea1a4 --- /dev/null +++ b/src-tauri/src/git.rs @@ -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, + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + pub staged: Vec, + pub unstaged: Vec, + pub untracked: Vec, +} + +#[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 { + 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 { + // 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, staged: bool) -> Result { + 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, String> { + let output = run_git_command(&working_dir, &["branch", "-a", "--format=%(refname:short)\t%(HEAD)"])?; + + let branches: Vec = 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 { + run_git_command(&working_dir, &["checkout", &branch]) +} + +#[tauri::command] +pub fn git_stage(working_dir: String, file_path: String) -> Result { + run_git_command(&working_dir, &["add", &file_path]) +} + +#[tauri::command] +pub fn git_unstage(working_dir: String, file_path: String) -> Result { + run_git_command(&working_dir, &["restore", "--staged", &file_path]) +} + +#[tauri::command] +pub fn git_stage_all(working_dir: String) -> Result { + run_git_command(&working_dir, &["add", "-A"]) +} + +#[tauri::command] +pub fn git_commit(working_dir: String, message: String) -> Result { + run_git_command(&working_dir, &["commit", "-m", &message]) +} + +#[tauri::command] +pub fn git_push(working_dir: String) -> Result { + run_git_command(&working_dir, &["push"]) +} + +#[tauri::command] +pub fn git_pull(working_dir: String) -> Result { + run_git_command(&working_dir, &["pull"]) +} + +#[tauri::command] +pub fn git_fetch(working_dir: String) -> Result { + run_git_command(&working_dir, &["fetch", "--all"]) +} + +#[tauri::command] +pub fn git_log(working_dir: String, limit: Option) -> Result, 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 = 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 { + run_git_command(&working_dir, &["checkout", "--", &file_path]) +} + +#[tauri::command] +pub fn git_create_branch(working_dir: String, branch_name: String) -> Result { + run_git_command(&working_dir, &["checkout", "-b", &branch_name]) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 96966e1..16ab2df 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src/lib/components/GitPanel.svelte b/src/lib/components/GitPanel.svelte new file mode 100644 index 0000000..adf4377 --- /dev/null +++ b/src/lib/components/GitPanel.svelte @@ -0,0 +1,1123 @@ + + + + +{#if isOpen} + + + {#if showDiff} +
(showDiff = false)} role="presentation"> + +
+ {/if} +{/if} + + diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 543f674..533d6d8 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -20,6 +20,7 @@ import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte"; import { achievementProgress } from "$lib/stores/achievements"; import SessionHistoryPanel from "./SessionHistoryPanel.svelte"; + import GitPanel from "./GitPanel.svelte"; const DISCORD_URL = "https://chat.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com"; @@ -35,6 +36,7 @@ let showHelp = $state(false); let showKeyboardShortcuts = $state(false); let showSessionHistory = $state(false); + let showGitPanel = $state(false); const progress = $derived($achievementProgress); let currentConfig: HikariConfig = $state({ model: null, @@ -233,6 +235,20 @@ /> +