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
+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>