Commit preview panel at the bottom; open other repositories from a recent list or a folder picker
ci / check (push) Successful in 4m13s
release / release (push) Successful in 9m54s

This commit is contained in:
2026-10-15 00:00:12 +02:00
parent 0c5340aca4
commit 58268c4015
14 changed files with 725 additions and 45 deletions
+182 -38
View File
@@ -3,14 +3,18 @@ import { computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef,
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { buildRows } from "./lib/collapse";
import { isRefVisible, lineOwners, reachable, refKind, shortName } from "./lib/filter";
import { layout } from "./lib/layout";
import { textWidth } from "./lib/measure";
import { colorOf } from "./lib/palette";
import type { FilterState, Ref, RefKind, RepoData } from "./lib/types";
import { addRecent } from "./lib/recents";
import type { CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types";
import GraphCell from "./components/GraphCell.vue";
import LabelStack from "./components/LabelStack.vue";
import PreviewPanel from "./components/PreviewPanel.vue";
import RepoMenu from "./components/RepoMenu.vue";
import Sidebar from "./components/Sidebar.vue";
const ROW_H = 28;
@@ -25,39 +29,63 @@ const selected = ref(0);
const toast = ref<string | null>(null);
const sidebar = ref<InstanceType<typeof Sidebar>>();
// ---- settings: the sidebar width -------------------------------------------
// ---- settings, saved in settings.json ---------------------------------------
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 700;
const sidebarWidth = ref(270);
const PREVIEW_MIN = 80;
interface Settings {
sidebarWidth: number;
previewHeight: number;
previewShown: boolean;
/** Repository roots, most recently opened first. */
recents: string[];
}
const settings = reactive<Settings>({ sidebarWidth: 270, previewHeight: 220, previewShown: true, recents: [] });
let settingsLoaded = false;
async function loadSettings() {
const saved = await invoke<{ sidebarWidth?: number } | null>("load_settings").catch(() => null);
const saved = await invoke<Partial<Settings> | null>("load_settings").catch(() => null);
if (typeof saved?.sidebarWidth === "number") {
sidebarWidth.value = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, saved.sidebarWidth));
settings.sidebarWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, saved.sidebarWidth));
}
if (typeof saved?.previewHeight === "number") settings.previewHeight = Math.max(PREVIEW_MIN, saved.previewHeight);
if (typeof saved?.previewShown === "boolean") settings.previewShown = saved.previewShown;
if (Array.isArray(saved?.recents)) {
// A repository opened before the settings arrived is already at the front.
settings.recents = saved.recents.reduceRight((list, p) => (list.includes(p) ? list : [...list, p]), settings.recents);
}
settingsLoaded = true;
}
let settingsTimer: number | undefined;
watch(sidebarWidth, () => {
if (!settingsLoaded) return;
clearTimeout(settingsTimer);
settingsTimer = window.setTimeout(() => {
invoke("save_settings", { value: { sidebarWidth: sidebarWidth.value } }).catch((e) =>
showToast(`could not save settings: ${e}`),
);
}, 300);
});
watch(
settings,
() => {
if (!settingsLoaded) return;
clearTimeout(settingsTimer);
settingsTimer = window.setTimeout(() => {
invoke("save_settings", { value: { ...settings } }).catch((e) => showToast(`could not save settings: ${e}`));
}, 300);
},
{ deep: true },
);
function startResize(e: PointerEvent) {
/** Drag handler for both dividers: `axis` says which way it moves. */
function startDrag(e: PointerEvent, axis: "x" | "y") {
const handle = e.currentTarget as HTMLElement;
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startWidth = sidebarWidth.value;
const start = axis === "x" ? e.clientX : e.clientY;
const startValue = axis === "x" ? settings.sidebarWidth : settings.previewHeight;
const move = (ev: PointerEvent) => {
sidebarWidth.value = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startWidth + ev.clientX - startX));
if (axis === "x") {
settings.sidebarWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startValue + ev.clientX - start));
} else {
// Dragging up makes the panel taller; the list keeps at least a few rows.
const max = window.innerHeight - 220;
settings.previewHeight = Math.min(max, Math.max(PREVIEW_MIN, startValue - (ev.clientY - start)));
}
};
const stop = () => {
handle.removeEventListener("pointermove", move);
@@ -73,24 +101,50 @@ 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;
await applyRepo(await invoke<RepoData>("load_repo"));
} catch (e) {
error.value = String(e);
}
}
async function openRepo(path: string) {
menuOpen.value = false;
try {
await applyRepo(await invoke<RepoData>("open_repo", { path }));
} catch (e) {
// The repository that was open stays open.
showToast(`could not open ${path}: ${e}`);
}
}
async function browse() {
menuOpen.value = false;
const path = await openDialog({ directory: true, title: "Open a git repository" }).catch(() => null);
if (typeof path === "string") await openRepo(path);
}
const menuOpen = ref(false);
async function applyRepo(data: RepoData) {
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;
toggled.value = new Set();
selected.value = 0;
detailsCache.clear();
if (scroller.value) scroller.value.scrollTop = 0;
settings.recents = addRecent(settings.recents, data.root);
await getCurrentWindow().setTitle(`gites — ${data.root}`);
}
repo.value = data;
}
let saveTimer: number | undefined;
watch(
[filters, collapseAll],
@@ -181,6 +235,52 @@ function select(i: number) {
nextTick(scrollToSelected);
}
// ---- preview of the selected commit ------------------------------------------
const detailsCache = new Map<string, CommitDetails>();
const details = shallowRef<CommitDetails | null>(null);
const detailsError = ref<string | null>(null);
const selectedHash = computed(() => rows.value[selected.value]?.commit.hash ?? null);
let detailsTimer: number | undefined;
watch([selectedHash, () => settings.previewShown], ([hash, shown]) => {
clearTimeout(detailsTimer);
if (!hash || !shown) return;
const cached = detailsCache.get(hash);
if (cached) {
details.value = cached;
detailsError.value = null;
return;
}
// Wait a moment, so holding a key down does not run git for every row passed.
detailsTimer = window.setTimeout(async () => {
try {
const d = await invoke<CommitDetails>("commit_details", { hash });
detailsCache.set(hash, d);
if (selectedHash.value === hash) {
details.value = d;
detailsError.value = null;
}
} catch (e) {
if (selectedHash.value === hash) detailsError.value = String(e);
}
}, 120);
});
const selectedLabels = computed(() => (selectedHash.value ? labels.value.get(selectedHash.value) ?? [] : []));
const selectedColor = computed(() => colorOf(graph.value.rows[selected.value]?.color ?? 0));
function jumpToHash(hash: string) {
const i = rows.value.findIndex((x) => x.commit.hash === hash);
if (i >= 0) select(i);
else showToast("that commit is hidden by a filter or a fold");
}
async function copyText(text: string) {
await writeText(text);
showToast(`copied ${text}`);
}
// ---- actions -------------------------------------------------------------
let toastTimer: number | undefined;
@@ -227,7 +327,7 @@ function jumpTo(r: Ref) {
}
function onKey(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.ctrlKey || e.metaKey || e.altKey) return;
if (menuOpen.value || 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),
@@ -247,6 +347,8 @@ function onKey(e: KeyboardEvent) {
y: copyHash,
h: hideSelectedLine,
r: load,
o: () => (menuOpen.value = true),
p: () => (settings.previewShown = !settings.previewShown),
"/": () => sidebar.value?.focusPattern(),
};
const action = actions[e.key];
@@ -285,7 +387,18 @@ onUnmounted(() => {
<header class="bar">
<img src="/logo.svg" class="logo" alt="" />
<span class="title">gites</span>
<span v-if="repo" class="path">{{ repo.root }}</span>
<button class="path" title="open another repository (o)" @click="menuOpen = !menuOpen">
{{ repo?.root ?? "Open a repository" }} <span class="caret">▾</span>
</button>
<RepoMenu
v-if="menuOpen"
:recents="settings.recents"
:current="repo?.root ?? null"
@pick="openRepo"
@browse="browse"
@remove="(p) => (settings.recents = settings.recents.filter((x) => x !== p))"
@close="menuOpen = false"
/>
<span class="spacer" />
<span v-if="repo" class="stats">{{ visibleCommits.length }} commits · {{ visibleRefs.length }}/{{ repo.refs.length }} refs</span>
<button
@@ -313,18 +426,21 @@ onUnmounted(() => {
:filters="filters"
:head-ref="repo.head_ref"
:time-of="timeOf"
:style="{ width: sidebarWidth + 'px' }"
:style="{ width: settings.sidebarWidth + 'px' }"
@jump="jumpTo"
/>
<div v-if="repo" class="splitter" title="drag to resize" @pointerdown.prevent="startResize" />
<div v-if="repo" class="splitter" title="drag to resize" @pointerdown.prevent="startDrag($event, 'x')" />
<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>
<button @click="menuOpen = true">Open a repository…</button>
</div>
<div v-else ref="scroller" class="list" @scroll="scrollTop = ($event.target as HTMLElement).scrollTop">
<div v-else class="center">
<div 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
@@ -372,6 +488,20 @@ onUnmounted(() => {
</div>
</div>
</div>
<template v-if="settings.previewShown && repo && rows.length">
<div class="splitter-y" title="drag to resize" @pointerdown.prevent="startDrag($event, 'y')" />
<PreviewPanel
:style="{ height: settings.previewHeight + 'px' }"
:details="details"
:error="detailsError"
:labels="selectedLabels"
:color="selectedColor"
:format-time="formatTime"
@jump="jumpToHash"
@copy="copyText"
/>
</template>
</div>
</div>
<footer class="keys">
@@ -382,6 +512,8 @@ onUnmounted(() => {
<span><kbd>y</kbd> copy hash</span>
<span><kbd>h</kbd> hide branch</span>
<span><kbd>/</kbd> filter</span>
<span><kbd>p</kbd> preview</span>
<span><kbd>o</kbd> open repo</span>
<span><kbd>r</kbd> reload</span>
</footer>
@@ -410,7 +542,16 @@ onUnmounted(() => {
background-clip: text;
color: transparent;
}
.path { color: var(--text-dim); font-family: ui-monospace, monospace; font-size: 12px; }
.path {
color: var(--text-dim);
font-family: ui-monospace, monospace;
font-size: 12px;
border-color: transparent;
padding: 2px 8px;
}
.path:hover { color: var(--text); border-color: var(--border); }
.caret { font-size: 10px; opacity: 0.7; }
.bar { position: relative; }
.spacer { flex: 1; }
.stats { color: var(--text-dim); font-size: 12px; }
.collapse { display: inline-flex; align-items: center; gap: 6px; }
@@ -427,7 +568,10 @@ onUnmounted(() => {
z-index: 5;
}
.splitter:hover, .splitter:active { background: var(--accent); opacity: 0.6; }
.list { flex: 1; overflow: auto; position: relative; }
.center { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.splitter-y { flex: none; height: 5px; margin-top: -2px; margin-bottom: -3px; cursor: row-resize; position: relative; z-index: 5; border-top: 1px solid var(--border); }
.splitter-y:hover, .splitter-y:active { background: var(--accent); opacity: 0.6; }
.list { flex: 1; min-height: 0; overflow: auto; position: relative; }
.spacer-rows { position: relative; min-width: 700px; }
.row {
position: absolute;