Files
gites/src/App.vue
T

767 lines
28 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { homeDir } from "@tauri-apps/api/path";
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 { addRecent, mergeRecents } from "./lib/recents";
import type { FileDiff, Layout } from "./lib/diff";
import type { ChangedFile, CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types";
import DiffView from "./components/DiffView.vue";
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";
import Welcome from "./components/Welcome.vue";
const ROW_H = 28;
const MESSAGE_FONT = '13px "Inter", "Noto Sans", system-ui, sans-serif';
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>>();
// ---- settings, saved in settings.json ---------------------------------------
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 700;
const PREVIEW_MIN = 80;
const LAYOUTS: Layout[] = ["unified", "split", "inline"];
interface Settings {
sidebarWidth: number;
previewHeight: number;
previewShown: boolean;
diffLayout: Layout;
/** Repository roots, most recently opened first. */
recents: string[];
}
const settings = reactive<Settings>({ sidebarWidth: 270, previewHeight: 220, previewShown: true, diffLayout: "unified", recents: [] });
let settingsLoaded = false;
async function loadSettings() {
const saved = await invoke<Partial<Settings> | null>("load_settings").catch(() => null);
if (typeof saved?.sidebarWidth === "number") {
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 (saved?.diffLayout && LAYOUTS.includes(saved.diffLayout)) settings.diffLayout = saved.diffLayout;
if (Array.isArray(saved?.recents)) settings.recents = mergeRecents(settings.recents, saved.recents);
settingsLoaded = true;
}
let settingsTimer: number | undefined;
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 },
);
/** 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 start = axis === "x" ? e.clientX : e.clientY;
const startValue = axis === "x" ? settings.sidebarWidth : settings.previewHeight;
const move = (ev: PointerEvent) => {
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);
handle.removeEventListener("pointerup", stop);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", stop);
}
// ---- loading and saving -------------------------------------------------
let filtersLoadedFor: string | null = null;
let loading = false;
/** False until the first load answers, so the welcome screen does not flash up at start. */
const started = ref(false);
async function load() {
// The window gets focus often; one reload at a time is enough.
if (loading) return;
loading = true;
try {
// null: gites was started outside a repository and none is open yet.
const data = await invoke<RepoData | null>("load_repo");
if (data) await applyRepo(data);
} catch (e) {
// A repository already on screen stays, for example when a connection drops.
if (repo.value) showToast(`could not reload: ${e}`);
else error.value = String(e);
} finally {
loading = false;
started.value = true;
}
}
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 home = await homeDir().catch(() => undefined);
const path = await openDialog({ directory: true, title: "Open a git repository", defaultPath: home }).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 ?? [];
filters.hideTags = saved?.hideTags ?? false;
collapseAll.value = saved?.collapseAll ?? false;
filtersLoadedFor = data.root;
toggled.value = new Set();
selected.value = 0;
detailsCache.clear();
diffCache.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],
() => {
if (!filtersLoadedFor) return;
const repoRoot = filtersLoadedFor;
clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
invoke("save_filters", {
repo: repoRoot,
value: { hidden: filters.hidden, rules: filters.rules, hideTags: filters.hideTags, 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 viewWidth = ref(1000);
// The message column is what is left after the graph and the fixed columns:
// author 140, date 118, hash 80, four 14px gaps and 12px right padding.
const subjectSpace = computed(() => Math.max(700, viewWidth.value) - graphWidth.value - 338 - 56 - 12);
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);
}
// ---- 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);
});
// ---- diff of one file, shown in place of the commit graph ---------------------
const diffCache = new Map<string, FileDiff>();
const diffFile = shallowRef<ChangedFile | null>(null);
const diffData = shallowRef<FileDiff | null>(null);
const diffError = ref<string | null>(null);
const diffView = ref<InstanceType<typeof DiffView>>();
async function openDiff(file: ChangedFile, force = false) {
const hash = details.value?.hash;
if (!hash) return;
diffFile.value = file;
diffError.value = null;
const key = `${hash}\0${file.path}`;
const cached = diffCache.get(key);
if (cached && !force) {
diffData.value = cached;
return;
}
diffData.value = null;
try {
const d = await invoke<FileDiff>("file_diff", { hash, path: file.path, oldPath: file.old_path, force });
diffCache.set(key, d);
if (diffFile.value === file && details.value?.hash === hash) diffData.value = d;
} catch (e) {
if (diffFile.value === file) diffError.value = String(e);
}
}
function closeDiff() {
diffFile.value = null;
nextTick(scrollToSelected);
}
/** Opens the diff of the next (1) or previous (-1) file of the commit. */
function stepFile(dir: 1 | -1) {
const files = details.value?.files ?? [];
const i = files.findIndex((f) => f.path === diffFile.value?.path);
const next = files[i + dir];
if (next) openDiff(next);
else showToast(dir > 0 ? "this is the last file" : "this is the first file");
}
/** Opens the diff of the selected commit's first file. */
function openFirstFile() {
if (!settings.previewShown) return showToast("show the preview panel first (p)");
const d = details.value;
if (!d || d.hash !== selectedHash.value) return;
if (!d.files.length) return showToast("this commit changed no files");
openDiff(d.files[0]);
}
function cycleLayout() {
settings.diffLayout = LAYOUTS[(LAYOUTS.indexOf(settings.diffLayout) + 1) % LAYOUTS.length];
}
// Another commit (from a parent link, or a reload) closes the diff.
watch(selectedHash, () => {
if (diffFile.value) closeDiff();
});
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;
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 ids = built.value.runsAt.get(hash);
if (!ids) return;
const next = new Set(toggled.value);
for (const id of ids) if (!next.delete(id)) next.add(id);
toggled.value = next;
}
function toggleCollapseAll() {
if (!built.value.collapsible) return showToast("nothing to fold: no 2 or more plain commits in a row");
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 (menuOpen.value || e.target instanceof HTMLInputElement || e.ctrlKey || e.metaKey || e.altKey) return;
const view = diffView.value;
const diffActions: Record<string, () => void> = {
ArrowDown: () => view?.scrollLines(1),
j: () => view?.scrollLines(1),
ArrowUp: () => view?.scrollLines(-1),
k: () => view?.scrollLines(-1),
PageDown: () => view?.scrollPages(1),
PageUp: () => view?.scrollPages(-1),
Home: () => view?.toTop(),
g: () => view?.toTop(),
End: () => view?.toBottom(),
G: () => view?.toBottom(),
n: () => view?.jumpHunk(1),
N: () => view?.jumpHunk(-1),
"]": () => stepFile(1),
"[": () => stepFile(-1),
v: cycleLayout,
Escape: closeDiff,
y: copyHash,
p: () => (settings.previewShown = !settings.previewShown),
};
const actions: Record<string, () => void> = diffFile.value ? diffActions : {
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,
o: () => (menuOpen.value = true),
p: () => (settings.previewShown = !settings.previewShown),
Enter: openFirstFile,
"/": () => 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;
viewWidth.value = scroller.value?.clientWidth ?? 1000;
});
if (scroller.value) resizeObserver.observe(scroller.value);
loadSettings();
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>
<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
class="collapse"
:class="{ on: collapseAll && built.collapsible }"
:disabled="!built.collapsible"
:title="built.collapsible ? 'c' : 'Nothing to fold: no 2 or more commits in a row without a branch, tag, fork or merge'"
@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"
:style="{ width: settings.sidebarWidth + 'px' }"
@jump="jumpTo"
/>
<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>
<Welcome
v-else-if="!repo && started"
:recents="settings.recents"
@pick="openRepo"
@browse="browse"
@remove="(p) => (settings.recents = settings.recents.filter((x) => x !== p))"
/>
<div v-else class="center">
<DiffView
v-if="diffFile"
ref="diffView"
:file="diffFile"
:diff="diffData"
:error="diffError"
:layout="settings.diffLayout"
@layout="(l) => (settings.diffLayout = l)"
@close="closeDiff"
@force="openDiff(diffFile!, true)"
/>
<div v-show="!diffFile" 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>
<LabelStack
v-if="labels.has(row.commit.hash)"
:labels="labels.get(row.commit.hash)!"
:color="colorOf(graph.rows[firstRow + i - 1].color)"
:message-width="textWidth(row.commit.subject, MESSAGE_FONT)"
:space="subjectSpace - (row.hiddenCount ? 100 : 0)"
:selected="firstRow + i - 1 === selected"
/>
</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>
<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"
:chosen="diffFile?.path ?? null"
@jump="jumpToHash"
@copy="copyText"
@open="openDiff"
/>
</template>
</div>
</div>
<footer v-if="diffFile" class="keys">
<span><kbd>↑</kbd><kbd>↓</kbd> / <kbd>j</kbd><kbd>k</kbd> scroll</span>
<span><kbd>PgUp</kbd><kbd>PgDn</kbd> page</span>
<span><kbd>g</kbd><kbd>G</kbd> top / bottom</span>
<span><kbd>n</kbd><kbd>N</kbd> next / previous change</span>
<span><kbd>[</kbd><kbd>]</kbd> previous / next file</span>
<span><kbd>v</kbd> switch layout</span>
<span><kbd>Esc</kbd> back to graph</span>
</footer>
<footer v-else 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>Enter</kbd> diff</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>p</kbd> preview</span>
<span><kbd>o</kbd> open repo</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;
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; }
.collapse:disabled { opacity: 0.4; cursor: default; background: none; }
.collapse.on { border-color: var(--accent); color: var(--accent); background: #b197fc1a; }
.main { flex: 1; display: flex; min-height: 0; }
.splitter {
flex: none;
width: 5px;
margin-left: -3px;
margin-right: -2px;
cursor: col-resize;
position: relative;
z-index: 5;
}
.splitter:hover, .splitter:active { background: var(--accent); opacity: 0.6; }
.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;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 14px;
padding-right: 12px;
cursor: default;
}
.row { --row-bg: var(--bg); background: var(--row-bg); }
.row.alt { --row-bg: var(--bg-row-alt); }
.row:hover { --row-bg: var(--bg-hover); }
.row.sel { --row-bg: var(--bg-select); }
.graph-col { flex: none; height: 100%; overflow: hidden; }
.subject { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; }
.text { flex: 1 4 auto; min-width: 4em; 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;
}
.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>