634 lines
23 KiB
Vue
634 lines
23 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 } 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;
|
|
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;
|
|
|
|
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<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 (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(
|
|
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;
|
|
|
|
async function load() {
|
|
try {
|
|
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 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 ?? [];
|
|
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],
|
|
() => {
|
|
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 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);
|
|
});
|
|
|
|
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 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,
|
|
o: () => (menuOpen.value = true),
|
|
p: () => (settings.previewShown = !settings.previewShown),
|
|
"/": () => 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>
|
|
|
|
<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
|
|
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"
|
|
@jump="jumpToHash"
|
|
@copy="copyText"
|
|
/>
|
|
</template>
|
|
</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>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>
|