gites: colorful git tree viewer
Tauri 2 + Vue app that draws the commit graph of the repository given on the command line. Branches can be hidden one by one or by patterns, runs of plain commits fold into a dotted line, and everything is reachable from the keyboard.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
//! Filters are stored per repository in `~/.local/share/gites/filters.json`,
|
||||
//! as one JSON object keyed by the repository's root path.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn file() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir().ok_or("no data folder found")?.join("gites");
|
||||
Ok(dir.join("filters.json"))
|
||||
}
|
||||
|
||||
fn read_all() -> Result<Map<String, Value>, String> {
|
||||
let path = file()?;
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => match serde_json::from_str(&text) {
|
||||
Ok(Value::Object(map)) => Ok(map),
|
||||
_ => Ok(Map::new()),
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
|
||||
Err(e) => Err(format!("could not read {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(repo: &str) -> Result<Value, String> {
|
||||
Ok(read_all()?.remove(repo).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn save(repo: &str, value: Value) -> Result<(), String> {
|
||||
let mut all = read_all()?;
|
||||
all.insert(repo.to_string(), value);
|
||||
let path = file()?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&Value::Object(all)).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, text).map_err(|e| format!("could not write {}: {e}", path.display()))
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Commit {
|
||||
hash: String,
|
||||
short: String,
|
||||
parents: Vec<String>,
|
||||
author: String,
|
||||
time: i64,
|
||||
subject: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Ref {
|
||||
/// Full name, e.g. `refs/heads/main`.
|
||||
name: String,
|
||||
/// The commit the ref points to (annotated tags are followed to their commit).
|
||||
target: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RepoData {
|
||||
root: String,
|
||||
commits: Vec<Commit>,
|
||||
refs: Vec<Ref>,
|
||||
/// Full name of the checked-out branch, or `None` when HEAD is detached.
|
||||
head_ref: Option<String>,
|
||||
/// The checked-out commit, or `None` in a repository with no commits.
|
||||
head: Option<String>,
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("could not run git: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Result<RepoData, String> {
|
||||
let root = git(dir, &["rev-parse", "--show-toplevel"])?.trim().to_string();
|
||||
let root_path = Path::new(&root);
|
||||
|
||||
let head = git(root_path, &["rev-parse", "--verify", "-q", "HEAD"])
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let head_ref = git(root_path, &["symbolic-ref", "-q", "HEAD"])
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let mut refs = Vec::new();
|
||||
let ref_out = git(
|
||||
root_path,
|
||||
&[
|
||||
"for-each-ref",
|
||||
"--format=%(refname)%1f%(objectname)%1f%(*objectname)%1f%(symref)",
|
||||
"refs/heads",
|
||||
"refs/remotes",
|
||||
"refs/tags",
|
||||
],
|
||||
)?;
|
||||
for line in ref_out.lines() {
|
||||
let f: Vec<&str> = line.split('\x1f').collect();
|
||||
if f.len() < 4 || !f[3].is_empty() {
|
||||
// Skip symbolic refs such as origin/HEAD: they only repeat another branch.
|
||||
continue;
|
||||
}
|
||||
let target = if f[2].is_empty() { f[1] } else { f[2] };
|
||||
refs.push(Ref { name: f[0].to_string(), target: target.to_string() });
|
||||
}
|
||||
|
||||
let mut commits = Vec::new();
|
||||
if head.is_some() || !refs.is_empty() {
|
||||
let log = git(
|
||||
root_path,
|
||||
&[
|
||||
"log",
|
||||
"--branches",
|
||||
"--remotes",
|
||||
"--tags",
|
||||
"HEAD",
|
||||
"--topo-order",
|
||||
"--format=%H%x1f%h%x1f%P%x1f%an%x1f%at%x1f%s%x1e",
|
||||
"--",
|
||||
],
|
||||
)
|
||||
.or_else(|_| {
|
||||
// No HEAD commit (fresh repository with other refs): log without it.
|
||||
git(
|
||||
root_path,
|
||||
&[
|
||||
"log", "--branches", "--remotes", "--tags", "--topo-order",
|
||||
"--format=%H%x1f%h%x1f%P%x1f%an%x1f%at%x1f%s%x1e", "--",
|
||||
],
|
||||
)
|
||||
})?;
|
||||
for rec in log.split('\x1e') {
|
||||
let rec = rec.trim_start_matches('\n');
|
||||
if rec.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let f: Vec<&str> = rec.splitn(6, '\x1f').collect();
|
||||
if f.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
commits.push(Commit {
|
||||
hash: f[0].to_string(),
|
||||
short: f[1].to_string(),
|
||||
parents: f[2].split_whitespace().map(String::from).collect(),
|
||||
author: f[3].to_string(),
|
||||
time: f[4].parse().unwrap_or(0),
|
||||
subject: f[5].to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RepoData { root, commits, refs, head_ref, head })
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
mod filters;
|
||||
mod git;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The repository folder: the first command line argument, or the current folder.
|
||||
struct RepoArg(PathBuf);
|
||||
|
||||
#[tauri::command]
|
||||
fn load_repo(arg: tauri::State<RepoArg>) -> Result<git::RepoData, String> {
|
||||
git::load(&arg.0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_filters(repo: String) -> Result<serde_json::Value, String> {
|
||||
filters::load(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_filters(repo: String, value: serde_json::Value) -> Result<(), String> {
|
||||
filters::save(&repo, value)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let path = std::env::args_os()
|
||||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.manage(RepoArg(path))
|
||||
.invoke_handler(tauri::generate_handler![load_repo, load_filters, save_filters])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
gites_lib::run()
|
||||
}
|
||||
Reference in New Issue
Block a user