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,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
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||