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:
2026-10-14 21:11:55 +02:00
commit c05edf7b74
58 changed files with 7716 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
src-tauri/gen/schemas
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gites</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1e1b3a"/>
<stop offset="1" stop-color="#0d1020"/>
</linearGradient>
<linearGradient id="main" gradientUnits="userSpaceOnUse" x1="0" y1="420" x2="0" y2="92">
<stop offset="0" stop-color="#4dabf7"/>
<stop offset="1" stop-color="#b197fc"/>
</linearGradient>
<linearGradient id="left" gradientUnits="userSpaceOnUse" x1="0" y1="360" x2="0" y2="170">
<stop offset="0" stop-color="#ff6b6b"/>
<stop offset="1" stop-color="#ffd43b"/>
</linearGradient>
<linearGradient id="right" gradientUnits="userSpaceOnUse" x1="0" y1="300" x2="0" y2="150">
<stop offset="0" stop-color="#38d9a9"/>
<stop offset="1" stop-color="#69db7c"/>
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="10" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
<g fill="none" stroke-width="30" stroke-linecap="round" filter="url(#glow)">
<path d="M256 420 V92" stroke="url(#main)"/>
<path d="M256 360 C256 300 150 300 150 240 V170" stroke="url(#left)"/>
<path d="M256 300 C256 250 362 250 362 200 V150" stroke="url(#right)" stroke-dasharray="2 46"/>
</g>
<g stroke="#0d1020" stroke-width="12">
<circle cx="256" cy="420" r="30" fill="#4dabf7"/>
<circle cx="256" cy="92" r="34" fill="#b197fc"/>
<circle cx="150" cy="170" r="30" fill="#ffd43b"/>
<circle cx="362" cy="150" r="30" fill="#69db7c"/>
</g>
<circle cx="256" cy="92" r="52" fill="none" stroke="#b197fc" stroke-width="10" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+22
View File
@@ -0,0 +1,22 @@
[tools]
node = "24"
pnpm = "11"
[tasks.dev]
description = "Run the app in development mode. Pass a repo path: mise run dev /path/to/repo"
run = "pnpm tauri dev -- --"
[tasks.test]
description = "Run unit tests"
run = "pnpm vitest run"
[tasks.check]
description = "Type-check the frontend and the Rust code"
run = ["pnpm vue-tsc --noEmit", "cargo check --manifest-path src-tauri/Cargo.toml"]
[tasks.install]
description = "Build a release binary and install it as ~/.local/bin/gites"
run = [
"pnpm tauri build --no-bundle",
"install -Dm755 src-tauri/target/release/gites ~/.local/bin/gites",
]
+25
View File
@@ -0,0 +1,25 @@
{
"name": "gites",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.4.2",
"vue": "^3.5.13"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vitest": "^5.0.4",
"vue-tsc": "^3.3.5"
}
}
+1027
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1e1b3a"/>
<stop offset="1" stop-color="#0d1020"/>
</linearGradient>
<linearGradient id="main" gradientUnits="userSpaceOnUse" x1="0" y1="420" x2="0" y2="92">
<stop offset="0" stop-color="#4dabf7"/>
<stop offset="1" stop-color="#b197fc"/>
</linearGradient>
<linearGradient id="left" gradientUnits="userSpaceOnUse" x1="0" y1="360" x2="0" y2="170">
<stop offset="0" stop-color="#ff6b6b"/>
<stop offset="1" stop-color="#ffd43b"/>
</linearGradient>
<linearGradient id="right" gradientUnits="userSpaceOnUse" x1="0" y1="300" x2="0" y2="150">
<stop offset="0" stop-color="#38d9a9"/>
<stop offset="1" stop-color="#69db7c"/>
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="10" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
<g fill="none" stroke-width="30" stroke-linecap="round" filter="url(#glow)">
<path d="M256 420 V92" stroke="url(#main)"/>
<path d="M256 360 C256 300 150 300 150 240 V170" stroke="url(#left)"/>
<path d="M256 300 C256 250 362 250 362 200 V150" stroke="url(#right)" stroke-dasharray="2 46"/>
</g>
<g stroke="#0d1020" stroke-width="12">
<circle cx="256" cy="420" r="30" fill="#4dabf7"/>
<circle cx="256" cy="92" r="34" fill="#b197fc"/>
<circle cx="150" cy="170" r="30" fill="#ffd43b"/>
<circle cx="362" cy="150" r="30" fill="#69db7c"/>
</g>
<circle cx="256" cy="92" r="52" fill="none" stroke="#b197fc" stroke-width="10" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+7
View File
@@ -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
+4810
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+13
View File
@@ -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"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+37
View File
@@ -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()))
}
+127
View File
@@ -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 })
}
+37
View File
@@ -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");
}
+6
View File
@@ -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()
}
+35
View File
@@ -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"
]
}
}
+435
View File
@@ -0,0 +1,435 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { buildRows } from "./lib/collapse";
import { isRefVisible, lineOwners, reachable, refKind, shortName } from "./lib/filter";
import { layout } from "./lib/layout";
import { colorOf } from "./lib/palette";
import type { FilterState, Ref, RefKind, RepoData } from "./lib/types";
import GraphCell from "./components/GraphCell.vue";
import RefIcon from "./components/RefIcon.vue";
import Sidebar from "./components/Sidebar.vue";
const ROW_H = 28;
const repo = shallowRef<RepoData | null>(null);
const error = ref<string | null>(null);
const filters = reactive<FilterState>({ hidden: [], rules: [] });
const collapseAll = ref(false);
const toggled = ref(new Set<string>());
const selected = ref(0);
const toast = ref<string | null>(null);
const sidebar = ref<InstanceType<typeof Sidebar>>();
// ---- loading and saving -------------------------------------------------
let filtersLoadedFor: string | null = null;
async function load() {
try {
const data = await invoke<RepoData>("load_repo");
error.value = null;
if (filtersLoadedFor !== data.root) {
const saved = await invoke<Partial<FilterState & { collapseAll: boolean }> | null>("load_filters", {
repo: data.root,
});
filters.hidden = saved?.hidden ?? [];
filters.rules = saved?.rules ?? [];
collapseAll.value = saved?.collapseAll ?? false;
filtersLoadedFor = data.root;
await getCurrentWindow().setTitle(`gites — ${data.root}`);
}
repo.value = data;
} catch (e) {
error.value = String(e);
}
}
let saveTimer: number | undefined;
watch(
[filters, collapseAll],
() => {
if (!filtersLoadedFor) return;
const repoRoot = filtersLoadedFor;
clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
invoke("save_filters", {
repo: repoRoot,
value: { hidden: filters.hidden, rules: filters.rules, collapseAll: collapseAll.value },
}).catch((e) => showToast(`could not save filters: ${e}`));
}, 300);
},
{ deep: true },
);
// ---- what is shown ---------------------------------------------------------
const visibleRefs = computed(() => (repo.value?.refs ?? []).filter((r) => isRefVisible(r.name, filters)));
const visibleCommits = computed(() => {
const r = repo.value;
if (!r) return [];
const starts = visibleRefs.value.map((x) => x.target);
// A detached HEAD has no branch to hide it with, so it is always shown.
if (r.head && !r.head_ref) starts.push(r.head);
return reachable(r.commits, starts);
});
interface Label { name: string; short: string; kind: RefKind; isHead: boolean }
const labels = computed(() => {
const m = new Map<string, Label[]>();
const order: Record<RefKind, number> = { local: 0, remote: 1, tag: 2 };
for (const r of visibleRefs.value) {
const list = m.get(r.target) ?? [];
list.push({ name: r.name, short: shortName(r.name), kind: refKind(r.name), isHead: r.name === repo.value?.head_ref });
m.set(r.target, list);
}
for (const list of m.values()) list.sort((a, b) => Number(b.isHead) - Number(a.isHead) || order[a.kind] - order[b.kind]);
return m;
});
const built = computed(() => {
const labeled = new Set(labels.value.keys());
if (repo.value?.head) labeled.add(repo.value.head);
return buildRows(visibleCommits.value, labeled, collapseAll.value, toggled.value);
});
const rows = computed(() => built.value.rows);
const graph = computed(() => layout(rows.value));
const owners = computed(() => lineOwners(visibleCommits.value, visibleRefs.value, repo.value?.head_ref ?? null));
const commitTimes = computed(() => new Map((repo.value?.commits ?? []).map((c) => [c.hash, c.time])));
const timeOf = (hash: string) => commitTimes.value.get(hash) ?? 0;
const graphWidth = computed(() => 20 + Math.max(0, graph.value.width - 1) * 16);
// Keep the same commit selected when rows change, if it is still shown.
watch(rows, (now, before) => {
const hash = before?.[selected.value]?.commit.hash;
const i = hash ? now.findIndex((r) => r.commit.hash === hash) : -1;
selected.value = i >= 0 ? i : Math.min(selected.value, Math.max(0, now.length - 1));
});
// ---- only draw the rows on screen ---------------------------------------------
const scroller = ref<HTMLElement>();
const scrollTop = ref(0);
const viewHeight = ref(600);
const firstRow = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - 10));
const lastRow = computed(() => Math.min(rows.value.length, Math.ceil((scrollTop.value + viewHeight.value) / ROW_H) + 10));
const pageRows = computed(() => Math.max(1, Math.floor(viewHeight.value / ROW_H) - 1));
let resizeObserver: ResizeObserver | undefined;
function scrollToSelected() {
const el = scroller.value;
if (!el) return;
const top = selected.value * ROW_H;
if (top < el.scrollTop) el.scrollTop = top;
else if (top + ROW_H > el.scrollTop + el.clientHeight) el.scrollTop = top + ROW_H - el.clientHeight;
}
function select(i: number) {
selected.value = Math.max(0, Math.min(rows.value.length - 1, i));
nextTick(scrollToSelected);
}
// ---- actions -------------------------------------------------------------
let toastTimer: number | undefined;
function showToast(text: string) {
toast.value = text;
clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => (toast.value = null), 1800);
}
async function copyHash() {
const row = rows.value[selected.value];
if (!row) return;
await writeText(row.commit.short);
showToast(`copied ${row.commit.short}`);
}
function toggleRun(hash: string) {
const id = built.value.runOf.get(hash);
if (!id) return;
const next = new Set(toggled.value);
if (!next.delete(id)) next.add(id);
toggled.value = next;
}
function toggleCollapseAll() {
collapseAll.value = !collapseAll.value;
toggled.value = new Set();
}
function hideSelectedLine() {
const row = rows.value[selected.value];
if (!row) return;
const owner = owners.value.get(row.commit.hash);
if (!owner) return showToast("this commit is not on a branch that can be hidden");
filters.hidden.push(owner);
showToast(`hid ${shortName(owner)}`);
}
function jumpTo(r: Ref) {
const i = rows.value.findIndex((x) => x.commit.hash === r.target);
if (i >= 0) select(i);
else showToast(`${shortName(r.name)} is hidden`);
}
function onKey(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.ctrlKey || e.metaKey || e.altKey) return;
const actions: Record<string, () => void> = {
ArrowDown: () => select(selected.value + 1),
j: () => select(selected.value + 1),
ArrowUp: () => select(selected.value - 1),
k: () => select(selected.value - 1),
PageDown: () => select(selected.value + pageRows.value),
PageUp: () => select(selected.value - pageRows.value),
Home: () => select(0),
g: () => select(0),
End: () => select(rows.value.length - 1),
G: () => select(rows.value.length - 1),
" ": () => {
const row = rows.value[selected.value];
if (row) toggleRun(row.commit.hash);
},
c: toggleCollapseAll,
y: copyHash,
h: hideSelectedLine,
r: load,
"/": () => sidebar.value?.focusPattern(),
};
const action = actions[e.key];
if (action) {
e.preventDefault();
action();
}
}
const dateFmt = new Intl.DateTimeFormat("sv-SE", {
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
});
const formatTime = (t: number) => dateFmt.format(new Date(t * 1000));
onMounted(() => {
window.addEventListener("keydown", onKey);
// Pick up new commits when coming back to the window.
window.addEventListener("focus", load);
resizeObserver = new ResizeObserver(() => (viewHeight.value = scroller.value?.clientHeight ?? 600));
if (scroller.value) resizeObserver.observe(scroller.value);
load();
});
onUnmounted(() => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("focus", load);
resizeObserver?.disconnect();
});
</script>
<template>
<div class="app">
<header class="bar">
<img src="/logo.svg" class="logo" alt="" />
<span class="title">gites</span>
<span v-if="repo" class="path">{{ repo.root }}</span>
<span class="spacer" />
<span v-if="repo" class="stats">{{ visibleCommits.length }} commits · {{ visibleRefs.length }}/{{ repo.refs.length }} refs</span>
<button class="collapse" :class="{ on: collapseAll }" title="c" @click="toggleCollapseAll">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<circle cx="8" cy="2.5" r="1.6" />
<circle cx="8" cy="13.5" r="1.6" />
<path d="M8 4.5v1.5M8 10v1.5" />
<path :d="collapseAll ? 'M8 7.4v0.1M8 8.5v0.1' : 'M8 6.5v3'" stroke-dasharray="none" />
</svg>
{{ collapseAll ? "Expand" : "Collapse" }} linear
</button>
</header>
<div class="main">
<Sidebar
v-if="repo"
ref="sidebar"
:refs="repo.refs"
:filters="filters"
:head-ref="repo.head_ref"
:time-of="timeOf"
@jump="jumpTo"
/>
<div v-if="error" class="error">
<h2>Could not open the repository</h2>
<pre>{{ error }}</pre>
<p>Start gites inside a git repository, or pass its path: <code>gites /path/to/repo</code></p>
</div>
<div v-else ref="scroller" class="list" @scroll="scrollTop = ($event.target as HTMLElement).scrollTop">
<div v-if="repo && rows.length === 0" class="empty">No commits to show. Check the filters on the left.</div>
<div class="spacer-rows" :style="{ height: rows.length * ROW_H + 'px' }">
<div
v-for="i in lastRow - firstRow"
:key="rows[firstRow + i - 1].commit.hash"
class="row"
:class="{ sel: firstRow + i - 1 === selected, alt: (firstRow + i - 1) % 2 === 1 }"
:style="{ top: (firstRow + i - 1) * ROW_H + 'px', height: ROW_H + 'px' }"
@click="select(firstRow + i - 1)"
@dblclick="toggleRun(rows[firstRow + i - 1].commit.hash)"
>
<template v-for="row in [rows[firstRow + i - 1]]" :key="row.commit.hash">
<div class="graph-col" :style="{ width: graphWidth + 'px' }">
<GraphCell
:row="graph.rows[firstRow + i - 1]"
:width="graph.width"
:height="ROW_H"
:is-head="row.commit.hash === repo?.head"
/>
</div>
<div class="subject">
<span class="text" :title="row.commit.subject">{{ row.commit.subject }}</span>
<button
v-if="row.hiddenCount"
class="more"
:style="{ color: colorOf(graph.rows[firstRow + i - 1].color) }"
title="expand (space)"
@click.stop="toggleRun(row.commit.hash)"
>
··· {{ row.hiddenCount }} commits
</button>
<span class="labels">
<span
v-for="l in labels.get(row.commit.hash) ?? []"
:key="l.name"
class="label"
:class="{ head: l.isHead }"
:title="l.name"
:style="{ '--c': colorOf(graph.rows[firstRow + i - 1].color) }"
>
<RefIcon :kind="l.kind" /><span class="label-text">{{ l.short }}</span>
</span>
</span>
</div>
<div class="author">{{ row.commit.author }}</div>
<div class="date">{{ formatTime(row.commit.time) }}</div>
<div class="hash">{{ row.commit.short }}</div>
</template>
</div>
</div>
</div>
</div>
<footer class="keys">
<span><kbd>↑</kbd><kbd>↓</kbd> / <kbd>j</kbd><kbd>k</kbd> move</span>
<span><kbd>g</kbd><kbd>G</kbd> top / bottom</span>
<span><kbd>space</kbd> fold run</span>
<span><kbd>c</kbd> fold all</span>
<span><kbd>y</kbd> copy hash</span>
<span><kbd>h</kbd> hide branch</span>
<span><kbd>/</kbd> filter</span>
<span><kbd>r</kbd> reload</span>
</footer>
<transition name="fade">
<div v-if="toast" class="toast">{{ toast }}</div>
</transition>
</div>
</template>
<style scoped>
.app { display: flex; flex-direction: column; height: 100%; }
.bar {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px;
border-bottom: 1px solid var(--border);
background: linear-gradient(90deg, #1a1733, var(--bg-side) 40%);
}
.logo { width: 24px; height: 24px; }
.title {
font-weight: 700;
font-size: 15px;
background: linear-gradient(90deg, #ffd43b, #ff6b6b, #b197fc, #4dabf7);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.path { color: var(--text-dim); font-family: ui-monospace, monospace; font-size: 12px; }
.spacer { flex: 1; }
.stats { color: var(--text-dim); font-size: 12px; }
.collapse { display: inline-flex; align-items: center; gap: 6px; }
.collapse.on { border-color: var(--accent); color: var(--accent); background: #b197fc1a; }
.main { flex: 1; display: flex; min-height: 0; }
.list { flex: 1; overflow: auto; position: relative; }
.spacer-rows { position: relative; min-width: 700px; }
.row {
position: absolute;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 14px;
padding-right: 12px;
cursor: default;
}
.row.alt { background: var(--bg-row-alt); }
.row:hover { background: var(--bg-hover); }
.row.sel { background: var(--bg-select); }
.graph-col { flex: none; height: 100%; overflow: hidden; }
.subject { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
.text { flex: 1 1 auto; min-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.more {
flex: none;
border: 1px dashed currentColor;
border-radius: 999px;
padding: 0 8px;
font-size: 11px;
opacity: 0.85;
}
.labels { margin-left: auto; display: flex; gap: 5px; flex: 0 1 auto; min-width: 0; justify-content: flex-end; }
.label {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 7px;
border-radius: 999px;
font-size: 11.5px;
color: var(--c);
border: 1px solid color-mix(in srgb, var(--c) 55%, transparent);
background: color-mix(in srgb, var(--c) 14%, transparent);
white-space: nowrap;
max-width: 230px;
flex: none;
}
.label-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.label.head { color: #10121c; background: var(--c); font-weight: 600; }
.author { flex: none; width: 140px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.date { flex: none; width: 118px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.hash { flex: none; width: 80px; font-family: ui-monospace, monospace; color: #9aa3c7; }
.error, .empty { padding: 40px; color: var(--text-dim); }
.error h2 { color: #ff8787; }
.error pre { color: var(--text); white-space: pre-wrap; }
.keys {
display: flex;
gap: 16px;
flex-wrap: wrap;
padding: 5px 12px;
border-top: 1px solid var(--border);
background: var(--bg-side);
color: var(--text-dim);
font-size: 11.5px;
}
.keys kbd { margin-right: 2px; }
.toast {
position: fixed;
bottom: 44px;
left: 50%;
transform: translateX(-50%);
background: #262b45;
border: 1px solid var(--accent);
padding: 6px 14px;
border-radius: 8px;
box-shadow: 0 6px 24px #0008;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{ shut: boolean }>();
</script>
<template>
<button class="eye" :class="{ shut }" :title="shut ? 'show' : 'hide'">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" />
<circle cx="8" cy="8" r="2" />
<path v-if="shut" d="M2 14L14 2" />
</svg>
</button>
</template>
<style scoped>
.eye { border: none; padding: 2px 4px; opacity: 0; display: flex; flex: none; }
.eye.shut { opacity: 1; color: #ff8787; }
</style>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { colorOf } from "../lib/palette";
import type { LaidOutRow } from "../lib/types";
const props = defineProps<{ row: LaidOutRow; width: number; height: number; isHead: boolean }>();
const LANE = 16;
const PAD = 10;
const x = (col: number) => PAD + col * LANE;
function path(s: LaidOutRow["segments"][number]) {
const h = props.height;
const x1 = x(s.x1), x2 = x(s.x2), y1 = s.y1 * h, y2 = s.y2 * h;
if (x1 === x2) return `M${x1} ${y1}V${y2}`;
const m = (y1 + y2) / 2;
return `M${x1} ${y1}C${x1} ${m} ${x2} ${m} ${x2} ${y2}`;
}
</script>
<template>
<svg class="graph" :width="PAD * 2 + (width - 1) * LANE" :height="height">
<path
v-for="(s, i) in row.segments"
:key="i"
:d="path(s)"
:stroke="colorOf(s.color)"
:stroke-dasharray="s.dotted ? '0.1 5' : undefined"
:stroke-width="s.dotted ? 2.6 : 2"
stroke-linecap="round"
fill="none"
/>
<g v-if="isHead">
<circle :cx="x(row.col)" :cy="height / 2" r="7" fill="var(--bg)" :stroke="colorOf(row.color)" stroke-width="2" />
<circle :cx="x(row.col)" :cy="height / 2" r="3.5" :fill="colorOf(row.color)" />
</g>
<circle
v-else
:cx="x(row.col)"
:cy="height / 2"
r="4.5"
:fill="colorOf(row.color)"
stroke="var(--bg)"
stroke-width="2"
/>
</svg>
</template>
<style scoped>
.graph { flex: none; display: block; overflow: visible; }
</style>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { RefKind } from "../lib/types";
defineProps<{ kind: RefKind }>();
</script>
<template>
<svg class="ref-icon" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6">
<template v-if="kind === 'tag'">
<path d="M2 2h6l6 6-6 6-6-6z" stroke-linejoin="round" />
<circle cx="5.5" cy="5.5" r="1.2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="kind === 'remote'">
<path d="M4.5 12.5h7.5a3 3 0 0 0 .3-6 4.2 4.2 0 0 0-8.1 1A2.5 2.5 0 0 0 4.5 12.5z" stroke-linejoin="round" />
</template>
<template v-else>
<circle cx="4" cy="3.5" r="1.6" /><circle cx="4" cy="12.5" r="1.6" /><circle cx="12" cy="5" r="1.6" />
<path d="M4 5.1v5.8M12 6.6c0 3-8 2-8 4.3" />
</template>
</svg>
</template>
<style scoped>
.ref-icon { flex: none; }
</style>
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { isRefVisible } from "../lib/filter";
import { refsUnder, type TreeNode } from "../lib/tree";
import type { FilterState, Ref, RefKind } from "../lib/types";
import EyeButton from "./EyeButton.vue";
import RefIcon from "./RefIcon.vue";
const props = defineProps<{
nodes: TreeNode[];
kind: RefKind;
depth: number;
filters: FilterState;
headRef: string | null;
/** Paths of folders the user closed. Shared by the whole tree. */
closed: Set<string>;
}>();
const emit = defineEmits<{ jump: [ref: Ref]; toggle: [names: string[]] }>();
const allHidden = (n: TreeNode) => refsUnder(n).every((r) => props.filters.hidden.includes(r.name));
const anyVisible = (n: TreeNode) => refsUnder(n).some((r) => isRefVisible(r.name, props.filters));
function toggleFolder(path: string) {
if (!props.closed.delete(path)) props.closed.add(path);
}
</script>
<template>
<template v-for="n in nodes" :key="n.path">
<div
class="item"
:class="{ off: !anyVisible(n), head: n.ref?.name === headRef, folder: n.children.length }"
:style="{ paddingLeft: 10 + depth * 14 + 'px' }"
@click="n.ref ? emit('jump', n.ref) : toggleFolder(n.path)"
>
<span
v-if="n.children.length"
class="arrow"
:class="{ open: !closed.has(n.path) }"
@click.stop="toggleFolder(n.path)"
>▸</span>
<span v-else class="arrow" />
<RefIcon v-if="n.ref" :kind="kind" />
<svg v-else class="folder-icon" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M1.5 4a1 1 0 0 1 1-1h3.5l1.5 1.5h6a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z" />
</svg>
<span class="name" :title="n.ref?.name ?? n.path">{{ n.label }}</span>
<EyeButton :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
</div>
<RefTree
v-if="n.children.length && !closed.has(n.path)"
:nodes="n.children"
:kind="kind"
:depth="depth + 1"
:filters="filters"
:head-ref="headRef"
:closed="closed"
@jump="emit('jump', $event)"
@toggle="emit('toggle', $event)"
/>
</template>
</template>
<style scoped>
.item {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px 2px 10px;
cursor: pointer;
color: var(--text);
}
.item:hover { background: var(--bg-hover); }
.item.off { color: var(--text-dim); opacity: 0.55; }
.item.head .name { font-weight: 600; color: #fff; }
.item.folder { color: #c5cae0; }
.folder-icon { flex: none; color: #ffd43b; opacity: 0.8; }
.arrow { width: 10px; flex: none; color: var(--text-dim); font-size: 10px; transition: transform 0.12s; }
.arrow.open { transform: rotate(90deg); }
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item:hover :deep(.eye) { opacity: 1; }
</style>
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { refKind, shortName } from "../lib/filter";
import { buildTree, type TreeNode } from "../lib/tree";
import type { FilterState, Ref, RefKind } from "../lib/types";
import RefTree from "./RefTree.vue";
const props = defineProps<{
refs: Ref[];
filters: FilterState;
headRef: string | null;
/** Commit time for each hash, used to put the newest tags first. */
timeOf: (hash: string) => number;
}>();
const emit = defineEmits<{ jump: [ref: Ref] }>();
const pattern = ref("");
const patternInput = ref<HTMLInputElement>();
defineExpose({ focusPattern: () => patternInput.value?.focus() });
const groups: { kind: RefKind; title: string }[] = [
{ kind: "local", title: "Local" },
{ kind: "remote", title: "Remote" },
{ kind: "tag", title: "Tags" },
];
const closed = reactive(new Set<string>());
const byKind = computed(() => {
const out: Record<RefKind, Ref[]> = { local: [], remote: [], tag: [] };
for (const r of props.refs) out[refKind(r.name)].push(r);
return out;
});
const trees = computed((): Record<RefKind, TreeNode[]> => {
const tree = (refs: Ref[]) => buildTree(refs.map((ref) => ({ short: shortName(ref.name), ref })));
// Tags stay a flat list, newest first; equal times fall back to the higher version.
const tags = [...byKind.value.tag].sort(
(a, b) =>
props.timeOf(b.target) - props.timeOf(a.target) ||
b.name.localeCompare(a.name, undefined, { numeric: true }),
);
return {
local: tree(byKind.value.local),
remote: tree(byKind.value.remote),
tag: tags.map((ref) => ({ label: shortName(ref.name), path: ref.name, children: [], ref })),
};
});
/** Hides all of `names`, or shows them all again if they are all hidden already. */
function toggle(names: string[]) {
const h = props.filters.hidden;
if (names.every((n) => h.includes(n))) {
props.filters.hidden = h.filter((n) => !names.includes(n));
} else {
for (const n of names) if (!h.includes(n)) h.push(n);
}
}
function addRule(mode: "hide" | "only") {
const p = pattern.value.trim();
if (!p) return;
props.filters.rules.push({ pattern: p, mode });
pattern.value = "";
}
function onPatternKey(e: KeyboardEvent) {
if (e.key === "Enter") addRule(e.shiftKey ? "only" : "hide");
if (e.key === "Escape") (e.target as HTMLInputElement).blur();
e.stopPropagation();
}
</script>
<template>
<aside class="side">
<section class="rules">
<h3>Filters</h3>
<input
ref="patternInput"
v-model="pattern"
placeholder="pattern, e.g. prototypes/*"
spellcheck="false"
@keydown="onPatternKey"
/>
<div class="rule-buttons">
<button title="Enter" @click="addRule('hide')">hide</button>
<button title="Shift+Enter" @click="addRule('only')">show only</button>
</div>
<div class="chips">
<span v-for="(r, i) in filters.rules" :key="i" class="chip" :class="r.mode">
<button class="mode" title="switch hide / show only" @click="r.mode = r.mode === 'hide' ? 'only' : 'hide'">
{{ r.mode === "hide" ? "hide" : "only" }}
</button>
<code>{{ r.pattern }}</code>
<button class="x" title="remove" @click="filters.rules.splice(i, 1)">×</button>
</span>
</div>
</section>
<section v-for="g in groups" v-show="byKind[g.kind].length" :key="g.kind" class="group">
<h3>{{ g.title }} <span class="count">{{ byKind[g.kind].length }}</span></h3>
<RefTree
:nodes="trees[g.kind]"
:kind="g.kind"
:depth="0"
:filters="filters"
:head-ref="headRef"
:closed="closed"
@jump="emit('jump', $event)"
@toggle="toggle"
/>
</section>
</aside>
</template>
<style scoped>
.side {
width: 270px;
flex: none;
background: var(--bg-side);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 10px 0 20px;
}
h3 {
margin: 12px 14px 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.count { font-weight: normal; opacity: 0.7; }
.rules { padding: 0 14px; }
.rules h3 { margin-left: 0; margin-top: 2px; }
.rules input { width: 100%; }
.rule-buttons { display: flex; gap: 6px; margin-top: 6px; }
.rule-buttons button { flex: 1; }
.chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid;
padding-left: 2px;
font-size: 12px;
}
.chip.hide { border-color: #ff6b6b88; background: #ff6b6b18; }
.chip.only { border-color: #69db7c88; background: #69db7c18; }
.chip button { border: none; padding: 1px 6px; border-radius: 999px; }
.chip .mode { font-size: 10px; text-transform: uppercase; color: var(--text-dim); }
.chip code { font-family: ui-monospace, monospace; }
</style>
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { buildRows } from "./collapse";
import { chain } from "./test-helpers";
const hashes = (r: ReturnType<typeof buildRows>) => r.rows.map((x) => x.commit.hash);
describe("buildRows", () => {
// top is labeled; p1..p4 are plain; root has no parent so it is not plain.
const commits = chain("top:p1", "p1:p2", "p2:p3", "p3:p4", "p4:root", "root");
const labeled = new Set(["top"]);
it("shows everything when not collapsing", () => {
const r = buildRows(commits, labeled, false, new Set());
expect(hashes(r)).toEqual(["top", "p1", "p2", "p3", "p4", "root"]);
expect(r.rows[1].parents).toEqual([{ hash: "p2", dotted: false }]);
});
it("keeps the first and last commit of a run and joins them with a dotted edge", () => {
const r = buildRows(commits, labeled, true, new Set());
expect(hashes(r)).toEqual(["top", "p1", "p4", "root"]);
expect(r.rows[1].parents).toEqual([{ hash: "p4", dotted: true }]);
expect(r.rows[1].hiddenCount).toBe(2);
});
it("gives every commit of the run the same run id", () => {
const r = buildRows(commits, labeled, true, new Set());
const id = r.runOf.get("p1");
expect(id).toBeDefined();
expect(["p2", "p3", "p4"].map((h) => r.runOf.get(h))).toEqual([id, id, id]);
expect(r.runOf.get("top")).toBeUndefined();
});
it("a toggled run does the opposite of the global setting", () => {
const id = buildRows(commits, labeled, true, new Set()).runOf.get("p1")!;
expect(hashes(buildRows(commits, labeled, true, new Set([id])))).toHaveLength(6);
expect(hashes(buildRows(commits, labeled, false, new Set([id])))).toEqual(["top", "p1", "p4", "root"]);
});
it("does not collapse runs shorter than three", () => {
const short = chain("top:p1", "p1:p2", "p2:root", "root");
expect(hashes(buildRows(short, new Set(["top"]), true, new Set()))).toHaveLength(4);
});
it("stops a run at merges, forks and labels", () => {
// b is a fork point (two children), m is a merge.
const c = chain("m:x1,y1", "y1:b", "x1:x2", "x2:x3", "x3:b", "b:z", "z");
const r = buildRows(c, new Set(["m"]), true, new Set());
expect(hashes(r)).toEqual(["m", "y1", "x1", "x3", "b", "z"]);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { Commit, DisplayRow } from "./types";
export interface RowsResult {
rows: DisplayRow[];
/** Commit hash → id of the run of plain commits it belongs to. */
runOf: Map<string, string>;
}
/**
* Builds the rows to show. A run is a chain of plain commits: one parent, one child,
* no label. A run of three or more is collapsed when `collapseAll` differs from
* `toggled.has(runId)`; only its first and last commits stay, joined by a dotted edge.
*/
export function buildRows(
commits: Commit[],
labeled: Set<string>,
collapseAll: boolean,
toggled: Set<string>,
): RowsResult {
const byHash = new Map(commits.map((c) => [c.hash, c]));
const childCount = new Map<string, number>();
const childOf = new Map<string, string>();
for (const c of commits) {
for (const p of c.parents) {
childCount.set(p, (childCount.get(p) ?? 0) + 1);
childOf.set(p, c.hash);
}
}
const plain = (h: string | undefined) => {
const c = h === undefined ? undefined : byHash.get(h);
return !!c && c.parents.length === 1 && childCount.get(c.hash) === 1 && !labeled.has(c.hash);
};
const runOf = new Map<string, string>();
const hidden = new Set<string>();
/** First commit of a collapsed run → [last commit, number hidden]. */
const jump = new Map<string, [string, number]>();
for (const c of commits) {
if (!plain(c.hash) || plain(childOf.get(c.hash))) continue;
// c starts a run: follow first parents while they stay plain.
const run = [c.hash];
let next = c.parents[0];
while (plain(next) && !runOf.has(next)) {
run.push(next);
next = byHash.get(next)!.parents[0];
}
const id = c.hash;
for (const h of run) runOf.set(h, id);
if (run.length >= 3 && collapseAll !== toggled.has(id)) {
for (const h of run.slice(1, -1)) hidden.add(h);
jump.set(c.hash, [run[run.length - 1], run.length - 2]);
}
}
const rows: DisplayRow[] = [];
for (const c of commits) {
if (hidden.has(c.hash)) continue;
const j = jump.get(c.hash);
rows.push(
j
? { commit: c, parents: [{ hash: j[0], dotted: true }], hiddenCount: j[1] }
: { commit: c, parents: c.parents.map((hash) => ({ hash, dotted: false })) },
);
}
return { rows, runOf };
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { globMatch, isRefVisible, lineOwners, reachable, refKind, shortName } from "./filter";
import { chain } from "./test-helpers";
describe("ref names", () => {
it("knows the kind and the short name", () => {
expect(refKind("refs/heads/main")).toBe("local");
expect(refKind("refs/remotes/origin/main")).toBe("remote");
expect(refKind("refs/tags/v1")).toBe("tag");
expect(shortName("refs/heads/feat/x")).toBe("feat/x");
expect(shortName("refs/remotes/origin/main")).toBe("origin/main");
expect(shortName("refs/tags/v1")).toBe("v1");
});
});
describe("globMatch", () => {
it("matches * across any characters, including /", () => {
expect(globMatch("prototypes/*", "prototypes/a")).toBe(true);
expect(globMatch("prototypes/*", "prototypes/a/b")).toBe(true);
expect(globMatch("prototypes/*", "main")).toBe(false);
expect(globMatch("*/main", "origin/main")).toBe(true);
});
it("matches ? as one character and treats other characters literally", () => {
expect(globMatch("v?.0", "v1.0")).toBe(true);
expect(globMatch("v?.0", "v1x0")).toBe(false);
expect(globMatch("a+b", "a+b")).toBe(true);
});
it("matches the whole name only", () => {
expect(globMatch("main", "origin/main")).toBe(false);
});
});
describe("isRefVisible", () => {
it("hides refs hidden with the eye toggle", () => {
const s = { hidden: ["refs/heads/x"], rules: [] };
expect(isRefVisible("refs/heads/x", s)).toBe(false);
expect(isRefVisible("refs/heads/y", s)).toBe(true);
});
it("hides refs that match a hide rule", () => {
const s = { hidden: [], rules: [{ pattern: "prototypes/*", mode: "hide" as const }] };
expect(isRefVisible("refs/heads/prototypes/a", s)).toBe(false);
expect(isRefVisible("refs/heads/main", s)).toBe(true);
});
it("with only rules, shows only refs that match one, and hide rules still win", () => {
const s = {
hidden: [],
rules: [
{ pattern: "origin/*", mode: "only" as const },
{ pattern: "origin/old", mode: "hide" as const },
],
};
expect(isRefVisible("refs/remotes/origin/main", s)).toBe(true);
expect(isRefVisible("refs/remotes/origin/old", s)).toBe(false);
expect(isRefVisible("refs/heads/main", s)).toBe(false);
});
});
describe("reachable", () => {
it("keeps only ancestors of the start commits, in order", () => {
// d is a side branch off b; e is an unrelated branch tip.
const commits = chain("e:a", "d:b", "c:b", "b:a", "a");
expect(reachable(commits, ["c"]).map((c) => c.hash)).toEqual(["c", "b", "a"]);
expect(reachable(commits, ["d", "e"]).map((c) => c.hash)).toEqual(["e", "d", "b", "a"]);
});
});
describe("lineOwners", () => {
it("gives each commit the ref whose first-parent line it is on, checked-out branch first", () => {
const commits = chain("m:c,f", "f:b", "c:b", "b:a", "a");
const refs = [
{ name: "refs/heads/feature", target: "f" },
{ name: "refs/heads/main", target: "m" },
];
const owners = lineOwners(commits, refs, "refs/heads/main");
expect(owners.get("m")).toBe("refs/heads/main");
expect(owners.get("c")).toBe("refs/heads/main");
expect(owners.get("a")).toBe("refs/heads/main");
expect(owners.get("f")).toBe("refs/heads/feature");
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { Commit, FilterState, Ref, RefKind } from "./types";
export function refKind(name: string): RefKind {
if (name.startsWith("refs/remotes/")) return "remote";
if (name.startsWith("refs/tags/")) return "tag";
return "local";
}
export function shortName(name: string): string {
return name.replace(/^refs\/(heads|remotes|tags)\//, "");
}
/** `*` matches any run of characters (including `/`), `?` matches one character. */
export function globMatch(pattern: string, text: string): boolean {
const re = pattern
.split("")
.map((ch) => (ch === "*" ? ".*" : ch === "?" ? "." : ch.replace(/[.+^${}()|[\]\\]/g, "\\$&")))
.join("");
return new RegExp(`^${re}$`).test(text);
}
export function isRefVisible(name: string, state: FilterState): boolean {
if (state.hidden.includes(name)) return false;
const short = shortName(name);
const only = state.rules.filter((r) => r.mode === "only");
if (only.length > 0 && !only.some((r) => globMatch(r.pattern, short))) return false;
return !state.rules.some((r) => r.mode === "hide" && globMatch(r.pattern, short));
}
/** Commits reachable from `starts`, in the original order. */
export function reachable(commits: Commit[], starts: Iterable<string>): Commit[] {
const byHash = new Map(commits.map((c) => [c.hash, c]));
const seen = new Set<string>();
const stack = [...starts];
while (stack.length > 0) {
const h = stack.pop()!;
if (seen.has(h)) continue;
const c = byHash.get(h);
if (!c) continue;
seen.add(h);
stack.push(...c.parents);
}
return commits.filter((c) => seen.has(c.hash));
}
const kindOrder: Record<RefKind, number> = { local: 0, remote: 1, tag: 2 };
/** For each commit, the ref whose first-parent line it lies on. */
export function lineOwners(commits: Commit[], refs: Ref[], headRef: string | null): Map<string, string> {
const byHash = new Map(commits.map((c) => [c.hash, c]));
const ordered = [...refs].sort(
(a, b) =>
Number(b.name === headRef) - Number(a.name === headRef) ||
kindOrder[refKind(a.name)] - kindOrder[refKind(b.name)],
);
const owners = new Map<string, string>();
for (const ref of ordered) {
let c = byHash.get(ref.target);
while (c && !owners.has(c.hash)) {
owners.set(c.hash, ref.name);
c = byHash.get(c.parents[0]);
}
}
return owners;
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { layout } from "./layout";
const row = (hash: string, ...parents: string[]) => ({
commit: { hash },
parents: parents.map((p) => ({ hash: p.replace("~", ""), dotted: p.startsWith("~") })),
});
describe("layout", () => {
it("puts a straight line in one column with one color", () => {
const r = layout([row("c", "b"), row("b", "a"), row("a")]);
expect(r.rows.map((x) => x.col)).toEqual([0, 0, 0]);
expect(new Set(r.rows.map((x) => x.color)).size).toBe(1);
expect(r.width).toBe(1);
});
it("opens a second column for a merged branch and joins it back", () => {
const r = layout([row("m", "a", "b"), row("b", "a"), row("a")]);
expect(r.rows.map((x) => x.col)).toEqual([0, 1, 0]);
expect(r.rows[1].color).not.toBe(r.rows[0].color);
expect(r.width).toBe(2);
// Below b, its line bends from column 1 back into column 0.
expect(r.rows[1].segments).toContainEqual(
expect.objectContaining({ x1: 1, y1: 0.5, x2: 0, y2: 1 }),
);
});
it("draws a line that passes a row as a straight segment", () => {
const r = layout([row("m", "a", "b"), row("b", "a"), row("a")]);
// At row b, column 0 is still waiting for a.
expect(r.rows[1].segments).toContainEqual(
expect.objectContaining({ x1: 0, y1: 0, x2: 0, y2: 1 }),
);
});
it("starts a new column for a second branch tip", () => {
const r = layout([row("x", "a"), row("y", "a"), row("a")]);
expect(r.rows.map((x) => x.col)).toEqual([0, 1, 0]);
});
it("carries dotted edges down to the parent", () => {
const r = layout([row("m", "a", "~b"), row("z", "q"), row("q"), row("b", "a"), row("a")]);
const dotted = r.rows.flatMap((x) => x.segments).filter((s) => s.dotted);
expect(dotted.length).toBeGreaterThan(0);
expect(r.rows[3].segments.some((s) => s.dotted && s.y2 === 0.5)).toBe(true);
});
});
+62
View File
@@ -0,0 +1,62 @@
import type { LaidOutRow, Segment } from "./types";
type Lane = { hash: string; color: number; dotted: boolean } | null;
/**
* Assigns each row a column and computes the line pieces drawn in it. A column
* ("lane") holds the commit it is waiting for; rows must come children before parents.
*/
export function layout(rows: { commit: { hash: string }; parents: { hash: string; dotted: boolean }[] }[]): {
rows: LaidOutRow[];
width: number;
} {
const lanes: Lane[] = [];
let nextColor = 0;
let width = 0;
const firstFree = () => {
const i = lanes.indexOf(null);
return i >= 0 ? i : lanes.length;
};
const out = rows.map((row): LaidOutRow => {
const segments: Segment[] = [];
const hash = row.commit.hash;
let col = lanes.findIndex((l) => l?.hash === hash);
let color: number;
if (col < 0) {
col = firstFree();
color = nextColor++;
} else {
color = lanes[col]!.color;
}
// Top half: lines arriving at this commit, and lines passing by.
lanes.forEach((l, i) => {
if (!l) return;
if (l.hash === hash) {
segments.push({ x1: i, y1: 0, x2: col, y2: 0.5, color: l.color, dotted: l.dotted });
lanes[i] = null;
} else {
segments.push({ x1: i, y1: 0, x2: i, y2: 1, color: l.color, dotted: l.dotted });
}
});
// Bottom half: one line per parent.
row.parents.forEach((p, k) => {
let j = lanes.findIndex((l) => l?.hash === p.hash);
if (j < 0) {
j = k === 0 ? col : firstFree();
lanes[j] = { hash: p.hash, color: k === 0 ? color : nextColor++, dotted: p.dotted };
}
const lineColor = k === 0 ? color : lanes[j]!.color;
segments.push({ x1: col, y1: 0.5, x2: j, y2: 1, color: lineColor, dotted: p.dotted });
});
while (lanes.length > 0 && lanes[lanes.length - 1] === null) lanes.pop();
for (const s of segments) width = Math.max(width, s.x1 + 1, s.x2 + 1);
width = Math.max(width, col + 1);
return { col, color, segments };
});
return { rows: out, width };
}
+7
View File
@@ -0,0 +1,7 @@
/** Line colors, picked to stay bright and tell apart on the dark background. */
export const palette = [
"#4dabf7", "#ff6b6b", "#69db7c", "#ffa94d", "#b197fc", "#38d9a9",
"#f783ac", "#ffd43b", "#748ffc", "#ff8787", "#63e6be", "#e599f7",
];
export const colorOf = (i: number) => palette[i % palette.length];
+16
View File
@@ -0,0 +1,16 @@
import type { Commit } from "./types";
/** Builds commits from `"hash:parent1,parent2"` strings, newest first. */
export function chain(...specs: string[]): Commit[] {
return specs.map((s) => {
const [hash, parents = ""] = s.split(":");
return {
hash,
short: hash,
parents: parents ? parents.split(",") : [],
author: "a",
time: 0,
subject: hash,
};
});
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { buildTree, refsUnder } from "./tree";
const item = (short: string) => ({ short, ref: { name: `refs/heads/${short}`, target: short } });
const shape = (nodes: ReturnType<typeof buildTree>): unknown =>
nodes.map((n) => (n.children.length ? { [n.label]: shape(n.children) } : n.label));
describe("buildTree", () => {
it("groups names by / with folders first", () => {
const t = buildTree(["main", "feat/b", "feat/a", "prototypes/x/y", "dev"].map(item));
expect(shape(t)).toEqual([{ feat: ["a", "b"] }, { prototypes: [{ x: ["y"] }] }, "dev", "main"]);
});
it("keeps a ref that is also a folder", () => {
const t = buildTree(["a", "a/b"].map(item));
expect(t).toHaveLength(1);
expect(t[0].ref?.name).toBe("refs/heads/a");
expect(t[0].children[0].ref?.name).toBe("refs/heads/a/b");
});
it("gives each node its full path", () => {
const t = buildTree(["feat/x/y"].map(item));
expect(t[0].children[0].children[0].path).toBe("feat/x/y");
});
});
describe("refsUnder", () => {
it("collects every ref in a subtree", () => {
const t = buildTree(["feat/a", "feat/b/c", "main"].map(item));
expect(refsUnder(t[0]).map((r) => r.target).sort()).toEqual(["feat/a", "feat/b/c"]);
});
});
+42
View File
@@ -0,0 +1,42 @@
import type { Ref } from "./types";
export interface TreeNode {
/** One `/`-separated part of the name. */
label: string;
/** The name up to and including this part; unique within one tree. */
path: string;
children: TreeNode[];
/** Set when a ref ends here. A node can be both a ref and a folder (`a` and `a/b`). */
ref?: Ref;
}
/** Splits short names by `/` into a tree. Folders come before refs, each sorted by name. */
export function buildTree(items: { short: string; ref: Ref }[]): TreeNode[] {
const root: TreeNode = { label: "", path: "", children: [] };
for (const { short, ref } of items) {
let node = root;
for (const part of short.split("/")) {
const path = node.path ? `${node.path}/${part}` : part;
let child = node.children.find((c) => c.label === part);
if (!child) {
child = { label: part, path, children: [] };
node.children.push(child);
}
node = child;
}
node.ref = ref;
}
const sort = (nodes: TreeNode[]) => {
nodes.sort(
(a, b) => Number(b.children.length > 0) - Number(a.children.length > 0) || a.label.localeCompare(b.label),
);
nodes.forEach((n) => sort(n.children));
};
sort(root.children);
return root.children;
}
/** Every ref in the subtree. */
export function refsUnder(node: TreeNode): Ref[] {
return [...(node.ref ? [node.ref] : []), ...node.children.flatMap(refsUnder)];
}
+60
View File
@@ -0,0 +1,60 @@
export interface Commit {
hash: string;
short: string;
parents: string[];
author: string;
time: number;
subject: string;
}
export interface Ref {
/** Full name, e.g. `refs/heads/main`. */
name: string;
target: string;
}
export interface RepoData {
root: string;
commits: Commit[];
refs: Ref[];
head_ref: string | null;
head: string | null;
}
export type RefKind = "local" | "remote" | "tag";
export interface FilterRule {
pattern: string;
/** `hide` hides matching refs; `only` shows only refs that match some `only` rule. */
mode: "hide" | "only";
}
export interface FilterState {
/** Full ref names hidden with the eye toggle. */
hidden: string[];
rules: FilterRule[];
}
/** A commit as shown on screen, with its parent edges after collapsing. */
export interface DisplayRow {
commit: Commit;
parents: { hash: string; dotted: boolean }[];
/** Set on the first row of a collapsed run: how many commits are hidden below it. */
hiddenCount?: number;
}
export interface Segment {
/** Column numbers; y is 0 (row top), 0.5 (row middle) or 1 (row bottom). */
x1: number;
y1: number;
x2: number;
y2: number;
color: number;
dotted: boolean;
}
export interface LaidOutRow {
col: number;
color: number;
segments: Segment[];
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");
+49
View File
@@ -0,0 +1,49 @@
:root {
--bg: #12141f;
--bg-side: #0e1019;
--bg-row-alt: #151827;
--bg-hover: #1c2034;
--bg-select: #263056;
--border: #23273a;
--text: #d8dcef;
--text-dim: #7d84a3;
--accent: #b197fc;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #app { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
font: 13px/1.4 "Inter", "Noto Sans", system-ui, sans-serif;
overflow: hidden;
user-select: none;
}
button {
font: inherit;
color: inherit;
background: none;
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 9px;
cursor: pointer;
}
button:hover { background: var(--bg-hover); }
input {
font: inherit;
color: inherit;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 8px;
outline: none;
}
input:focus { border-color: var(--accent); }
kbd {
font: 11px ui-monospace, monospace;
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 4px;
padding: 0 4px;
color: var(--text);
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// @ts-expect-error type error without @types/node package
import process from "node:process";
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(() => ({
plugins: [vue()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));