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 bridge_manager;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod git;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod snippets;
|
mod snippets;
|
||||||
@@ -17,6 +18,7 @@ mod wsl_notifications;
|
|||||||
use bridge_manager::create_shared_bridge_manager;
|
use bridge_manager::create_shared_bridge_manager;
|
||||||
use commands::load_saved_achievements;
|
use commands::load_saved_achievements;
|
||||||
use commands::*;
|
use commands::*;
|
||||||
|
use git::*;
|
||||||
use notifications::*;
|
use notifications::*;
|
||||||
use sessions::*;
|
use sessions::*;
|
||||||
use snippets::*;
|
use snippets::*;
|
||||||
@@ -118,6 +120,20 @@ pub fn run() {
|
|||||||
delete_snippet,
|
delete_snippet,
|
||||||
get_snippet_categories,
|
get_snippet_categories,
|
||||||
reset_default_snippets,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@
|
|||||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||||
import { achievementProgress } from "$lib/stores/achievements";
|
import { achievementProgress } from "$lib/stores/achievements";
|
||||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||||
|
import GitPanel from "./GitPanel.svelte";
|
||||||
|
|
||||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
let showHelp = $state(false);
|
let showHelp = $state(false);
|
||||||
let showKeyboardShortcuts = $state(false);
|
let showKeyboardShortcuts = $state(false);
|
||||||
let showSessionHistory = $state(false);
|
let showSessionHistory = $state(false);
|
||||||
|
let showGitPanel = $state(false);
|
||||||
const progress = $derived($achievementProgress);
|
const progress = $derived($achievementProgress);
|
||||||
let currentConfig: HikariConfig = $state({
|
let currentConfig: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
@@ -233,6 +235,20 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (showGitPanel = true)}
|
||||||
|
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||||
|
title="Git Panel"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (showStats = !showStats)}
|
onclick={() => (showStats = !showStats)}
|
||||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
|
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
|
||||||
@@ -391,3 +407,7 @@
|
|||||||
{#if showSessionHistory}
|
{#if showSessionHistory}
|
||||||
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showGitPanel}
|
||||||
|
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
||||||
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user