Commit preview panel at the bottom; open other repositories from a recent list or a folder picker
This commit is contained in:
+182
-38
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user