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.
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "gites"
|
||||
version = "0.1.0"
|
||||
description = "A colorful git tree viewer"
|
||||
authors = ["Mirek Wróbel"]
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "gites_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
dirs = "6"
|
||||
|
||||
|
||||
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = 3
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-set-title",
|
||||
"clipboard-manager:allow-write-text"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "gites",
|
||||
"version": "0.1.0",
|
||||
"identifier": "it.wrobel.gites",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "gites",
|
||||
"width": 1280,
|
||||
"height": 820
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||