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
+1
View File
@@ -12,6 +12,7 @@
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.4.2",
"@tauri-apps/plugin-dialog": "^2.8.2",
"vue": "^3.5.13"
},
"devDependencies": {
+10
View File
@@ -14,6 +14,9 @@ importers:
'@tauri-apps/plugin-clipboard-manager':
specifier: ^2.4.2
version: 2.4.2
'@tauri-apps/plugin-dialog':
specifier: ^2.8.2
version: 2.8.2
vue:
specifier: ^3.5.13
version: 3.5.44(typescript@6.0.3)
@@ -250,6 +253,9 @@ packages:
'@tauri-apps/plugin-clipboard-manager@2.4.2':
resolution: {integrity: sha512-bpQ6ONK2jcCVraBVtazdQ2X7bRK6P/vdi+D4jFDOJ9IbqR4cC8D6K3YGHR+c+cgZf2HQOE7hBEkRlybEVnCNPg==}
'@tauri-apps/plugin-dialog@2.8.2':
resolution: {integrity: sha512-LFqp8OTx0bHOwhKRhMMDyRDlo/2IO5XxoOclpf8+yvjAfM2ytVdbB6tk63dBlicsR8pRw38maj/GxrU6cW8mXw==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -735,6 +741,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.12.3
'@tauri-apps/plugin-dialog@2.8.2':
dependencies:
'@tauri-apps/api': 2.12.3
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
+68
View File
@@ -1167,6 +1167,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
]
[[package]]
@@ -2179,6 +2180,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2699,6 +2701,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3407,6 +3433,48 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7461aaacf1901915ea7b9f5cd74f8224a351ed96af0a62c07fce934003cb34b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.21",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.21",
"toml 1.1.8+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-runtime"
version = "2.12.2"
+1
View File
@@ -23,6 +23,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-clipboard-manager = "2"
dirs = "6"
tauri-plugin-dialog = "2"
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
+2 -1
View File
@@ -8,6 +8,7 @@
"permissions": [
"core:default",
"core:window:allow-set-title",
"clipboard-manager:allow-write-text"
"clipboard-manager:allow-write-text",
"dialog:allow-open"
]
}
+156
View File
@@ -0,0 +1,156 @@
//! Everything the preview panel shows about one commit.
use crate::git::git;
use serde::Serialize;
use std::path::Path;
#[derive(Serialize, Debug, PartialEq)]
pub struct ChangedFile {
/// One letter: A added, M modified, D deleted, R renamed, C copied, T type changed.
pub status: String,
pub path: String,
/// The path before a rename or copy.
pub old_path: Option<String>,
/// Line counts; `None` for binary files.
pub added: Option<u32>,
pub deleted: Option<u32>,
}
#[derive(Serialize, Debug)]
pub struct CommitDetails {
hash: String,
author: String,
author_email: String,
author_time: i64,
committer: String,
committer_email: String,
commit_time: i64,
parents: Vec<String>,
/// The full message, subject line included.
message: String,
files: Vec<ChangedFile>,
}
/// Parses `git diff-tree -z --name-status` and `-z --numstat` output for the same diff.
pub fn parse_files(name_status: &str, numstat: &str) -> Vec<ChangedFile> {
let mut counts = Vec::new();
let mut num = numstat.split('\0').filter(|s| !s.is_empty());
while let Some(head) = num.next() {
let mut f = head.splitn(3, '\t');
let added = f.next().and_then(|n| n.parse().ok());
let deleted = f.next().and_then(|n| n.parse().ok());
// A rename has an empty path here, followed by the old and new paths.
if f.next().unwrap_or("").is_empty() {
num.next();
num.next();
}
counts.push((added, deleted));
}
let mut files = Vec::new();
let mut ns = name_status.split('\0').filter(|s| !s.is_empty());
while let Some(code) = ns.next() {
let status = code[..1].to_string();
let first = ns.next().unwrap_or_default().to_string();
let (path, old_path) = if status == "R" || status == "C" {
(ns.next().unwrap_or_default().to_string(), Some(first))
} else {
(first, None)
};
let (added, deleted) = counts.get(files.len()).copied().unwrap_or((None, None));
files.push(ChangedFile { status, path, old_path, added, deleted });
}
files
}
pub fn load(dir: &Path, hash: &str) -> Result<CommitDetails, String> {
// The hash comes from the window; refuse anything git could read as an option.
if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("not a commit hash: {hash}"));
}
let info = git(
dir,
&["show", "-s", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%P%x1f%B", hash, "--"],
)?;
let f: Vec<&str> = info.splitn(9, '\x1f').collect();
if f.len() < 9 {
return Err(format!("unexpected git show output for {hash}"));
}
let parents: Vec<String> = f[7].split_whitespace().map(String::from).collect();
// A merge is compared with its first parent; other commits with their only parent,
// or with nothing when they are the first commit.
let mut args = vec!["diff-tree", "-r", "-M", "-z"];
if let Some(first) = parents.first().filter(|_| parents.len() > 1) {
args.push(first);
} else {
args.extend(["--root", "--no-commit-id"]);
}
args.push(hash);
let with = |flag: &'static str| {
let mut a = args.clone();
a.insert(1, flag);
git(dir, &a)
};
let files = parse_files(&with("--name-status")?, &with("--numstat")?);
Ok(CommitDetails {
hash: f[0].to_string(),
author: f[1].to_string(),
author_email: f[2].to_string(),
author_time: f[3].parse().unwrap_or(0),
committer: f[4].to_string(),
committer_email: f[5].to_string(),
commit_time: f[6].parse().unwrap_or(0),
parents,
message: f[8].trim_end().to_string(),
files,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn file(status: &str, path: &str, old: Option<&str>, a: Option<u32>, d: Option<u32>) -> ChangedFile {
ChangedFile {
status: status.into(),
path: path.into(),
old_path: old.map(String::from),
added: a,
deleted: d,
}
}
#[test]
fn parses_plain_changes() {
let ns = "M\0src/a.ts\0A\0src/b.ts\0D\0old.txt\0";
let num = "3\t1\tsrc/a.ts\09\t0\tsrc/b.ts\00\t40\told.txt\0";
assert_eq!(
parse_files(ns, num),
vec![
file("M", "src/a.ts", None, Some(3), Some(1)),
file("A", "src/b.ts", None, Some(9), Some(0)),
file("D", "old.txt", None, Some(0), Some(40)),
]
);
}
#[test]
fn parses_renames_and_binary_files() {
let ns = "R087\0lib/old.rs\0lib/new.rs\0M\0logo.png\0";
let num = "2\t2\t\0lib/old.rs\0lib/new.rs\0-\t-\tlogo.png\0";
assert_eq!(
parse_files(ns, num),
vec![
file("R", "lib/new.rs", Some("lib/old.rs"), Some(2), Some(2)),
file("M", "logo.png", None, None, None),
]
);
}
#[test]
fn parses_an_empty_diff() {
assert_eq!(parse_files("", ""), vec![]);
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ pub struct RepoData {
head: Option<String>,
}
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
pub fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("git")
.arg("-C")
.arg(dir)
+30 -5
View File
@@ -1,15 +1,37 @@
mod details;
mod filters;
mod git;
mod settings;
use std::path::PathBuf;
use std::sync::Mutex;
/// The repository folder: the first command line argument, or the current folder.
struct RepoArg(PathBuf);
/// The open repository folder. It starts as the first command line argument, or the
/// current folder, and changes when the user opens another one.
struct RepoDir(Mutex<PathBuf>);
impl RepoDir {
fn get(&self) -> PathBuf {
self.0.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
#[tauri::command]
fn load_repo(arg: tauri::State<RepoArg>) -> Result<git::RepoData, String> {
git::load(&arg.0)
fn load_repo(dir: tauri::State<RepoDir>) -> Result<git::RepoData, String> {
git::load(&dir.get())
}
/// Switches to another repository. The current one stays open if `path` is not a repository.
#[tauri::command]
fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData, String> {
let data = git::load(&PathBuf::from(&path))?;
*dir.0.lock().unwrap_or_else(|e| e.into_inner()) = PathBuf::from(&path);
Ok(data)
}
#[tauri::command]
fn commit_details(dir: tauri::State<RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
details::load(&dir.get(), &hash)
}
#[tauri::command]
@@ -41,9 +63,12 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init())
.manage(RepoArg(path))
.plugin(tauri_plugin_dialog::init())
.manage(RepoDir(Mutex::new(path)))
.invoke_handler(tauri::generate_handler![
load_repo,
open_repo,
commit_details,
load_filters,
save_filters,
load_settings,
+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;
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed } from "vue";
import type { CommitDetails, ChangedFile } from "../lib/types";
import type { Label } from "./LabelStack.vue";
import RefIcon from "./RefIcon.vue";
const props = defineProps<{
details: CommitDetails | null;
error: string | null;
labels: Label[];
color: string;
formatTime: (t: number) => string;
}>();
const emit = defineEmits<{ jump: [hash: string]; copy: [text: string] }>();
const subject = computed(() => props.details?.message.split("\n")[0] ?? "");
const body = computed(() => props.details?.message.split("\n").slice(1).join("\n").trim() ?? "");
const sameCommitter = computed(
() =>
props.details &&
props.details.committer === props.details.author &&
props.details.committer_email === props.details.author_email,
);
const totals = computed(() =>
(props.details?.files ?? []).reduce((t, f) => ({ a: t.a + (f.added ?? 0), d: t.d + (f.deleted ?? 0) }), { a: 0, d: 0 }),
);
/** Five small blocks showing the share of added (green) and deleted (red) lines. */
function bars(f: ChangedFile) {
const a = f.added ?? 0, d = f.deleted ?? 0, total = a + d;
if (total === 0) return [];
const n = Math.min(5, Math.max(1, Math.round(Math.log2(total + 1))));
const green = Math.round((a / total) * n);
return Array.from({ length: 5 }, (_, i) => (i < green ? "a" : i < n ? "d" : ""));
}
function split(path: string) {
const i = path.lastIndexOf("/");
return i < 0 ? { dir: "", name: path } : { dir: path.slice(0, i + 1), name: path.slice(i + 1) };
}
</script>
<template>
<section class="preview">
<div v-if="error" class="state error">{{ error }}</div>
<div v-else-if="!details" class="state">Loading…</div>
<template v-else>
<div class="info">
<h3>{{ subject }}</h3>
<p v-if="body" class="body">{{ body }}</p>
<dl>
<dt>Author</dt>
<dd>{{ details.author }} <span class="dim">&lt;{{ details.author_email }}&gt;</span> · {{ formatTime(details.author_time) }}</dd>
<template v-if="!sameCommitter || details.commit_time !== details.author_time">
<dt>Committer</dt>
<dd>
<template v-if="!sameCommitter">{{ details.committer }} <span class="dim">&lt;{{ details.committer_email }}&gt;</span> · </template>
{{ formatTime(details.commit_time) }}
</dd>
</template>
<dt>Hash</dt>
<dd><button class="link mono" title="copy" @click="emit('copy', details.hash)">{{ details.hash }}</button></dd>
<dt>{{ details.parents.length > 1 ? "Parents" : "Parent" }}</dt>
<dd>
<span v-if="!details.parents.length" class="dim">none, this is the first commit</span>
<button v-for="p in details.parents" :key="p" class="link mono" title="go to this commit" @click="emit('jump', p)">
{{ p.slice(0, 10) }}
</button>
</dd>
<template v-if="labels.length">
<dt>Labels</dt>
<dd class="labels">
<span v-for="l in labels" :key="l.name" class="label" :style="{ '--c': color }"><RefIcon :kind="l.kind" />{{ l.short }}</span>
</dd>
</template>
</dl>
</div>
<div class="files">
<div class="files-head">
{{ details.files.length }} changed file{{ details.files.length === 1 ? "" : "s" }}
<span class="a">+{{ totals.a }}</span> <span class="d">−{{ totals.d }}</span>
<span v-if="details.parents.length > 1" class="dim">compared with the first parent</span>
</div>
<div v-for="f in details.files" :key="f.path" class="file" :title="f.old_path ? `${f.old_path} → ${f.path}` : f.path">
<span class="st" :class="f.status">{{ f.status }}</span>
<span class="path">
<template v-if="f.old_path"><span class="dim">{{ f.old_path }} → </span></template>
<span class="dim">{{ split(f.path).dir }}</span>{{ split(f.path).name }}
</span>
<span v-if="f.added === null" class="n dim">binary</span>
<span v-else class="n"><span class="a">+{{ f.added }}</span> <span class="d">−{{ f.deleted }}</span></span>
<span class="bars"><i v-for="(b, i) in bars(f)" :key="i" :class="b" /></span>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.preview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
background: var(--bg-side);
user-select: text;
}
.state { grid-column: 1 / -1; padding: 16px; color: var(--text-dim); }
.state.error { color: #ff8787; }
.info, .files { overflow: auto; padding: 12px 16px; min-width: 0; }
.files { border-left: 1px solid var(--border); }
h3 { margin: 0 0 6px; font-size: 14px; line-height: 1.35; }
.body { margin: 0 0 10px; white-space: pre-wrap; font-size: 12.5px; color: var(--text); }
dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 3px 12px; margin: 0; font-size: 12px; }
dt { color: var(--text-dim); }
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
.dim { color: var(--text-dim); }
.mono { font-family: ui-monospace, monospace; font-size: 11.5px; }
.link { border: none; padding: 0; color: #9aa3c7; background: none; cursor: pointer; }
.link:hover { color: var(--accent); background: none; text-decoration: underline; }
.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%, var(--bg)); background: color-mix(in srgb, var(--c) 16%, var(--bg));
}
.files-head { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
.files-head .dim { text-transform: none; letter-spacing: 0; }
.file { display: flex; align-items: center; gap: 8px; padding: 1px 4px; border-radius: 4px; font-size: 12px; }
.file:hover { background: var(--bg-hover); }
.st { width: 12px; flex: none; font: 700 11px ui-monospace, monospace; }
.st.A { color: #69db7c; } .st.M { color: #ffa94d; } .st.D { color: #ff6b6b; } .st.R, .st.C { color: #4dabf7; } .st.T { color: #b197fc; }
.path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 11.5px ui-monospace, monospace; }
.n { flex: none; font: 11px ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.a { color: #69db7c; } .d { color: #ff6b6b; }
.bars { display: inline-flex; gap: 1px; flex: none; width: 29px; }
.bars i { width: 5px; height: 8px; background: var(--border); }
.bars i.a { background: #69db7c; } .bars i.d { background: #ff6b6b; }
</style>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
const props = defineProps<{ recents: string[]; current: string | null }>();
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>();
// The current repository is not offered: picking it would do nothing.
const items = computed(() => props.recents.filter((p) => p !== props.current));
/** Index into items; items.length means the "Open folder…" entry. */
const active = ref(0);
const menu = ref<HTMLElement>();
onMounted(() => menu.value?.focus());
function choose(i: number) {
if (i >= items.value.length) emit("browse");
else emit("pick", items.value[i]);
}
function onKey(e: KeyboardEvent) {
// Keys stay in the menu while it is open.
e.stopPropagation();
const last = items.value.length;
if (e.key === "ArrowDown" || e.key === "j") active.value = Math.min(last, active.value + 1);
else if (e.key === "ArrowUp" || e.key === "k") active.value = Math.max(0, active.value - 1);
else if (e.key === "Enter") choose(active.value);
else if (e.key === "Delete" && active.value < last) emit("remove", items.value[active.value]);
else if (e.key === "Escape" || e.key === "o") emit("close");
else return;
e.preventDefault();
}
const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
</script>
<template>
<div class="backdrop" @click="emit('close')" />
<div ref="menu" class="menu" tabindex="-1" @keydown="onKey">
<div class="head">Recent repositories</div>
<div v-if="!items.length" class="none">No other repositories yet.</div>
<div
v-for="(p, i) in items"
:key="p"
class="item"
:class="{ active: i === active }"
@mouseenter="active = i"
@click="choose(i)"
>
<span class="name">{{ name(p) }}</span>
<span class="path">{{ p }}</span>
<button class="x" title="remove from the list (Delete)" @click.stop="emit('remove', p)">×</button>
</div>
<div
class="item browse"
:class="{ active: active === items.length }"
@mouseenter="active = items.length"
@click="choose(items.length)"
>
Open folder…
</div>
<div class="foot"><kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Enter</kbd> open · <kbd>Del</kbd> remove · <kbd>Esc</kbd> close</div>
</div>
</template>
<style scoped>
.backdrop { position: fixed; inset: 0; z-index: 90; }
.menu {
position: absolute;
top: 38px;
left: 70px;
z-index: 100;
width: min(560px, calc(100vw - 90px));
max-height: 70vh;
overflow-y: auto;
background: #181b2c;
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 40px #000a;
padding: 6px;
outline: none;
}
.head { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); padding: 4px 10px 6px; }
.none { padding: 6px 10px; color: var(--text-dim); }
.item { display: flex; align-items: baseline; gap: 10px; padding: 6px 10px; border-radius: 6px; cursor: pointer; }
.item.active { background: var(--bg-select); }
.name { font-weight: 600; flex: none; }
.path { flex: 1; min-width: 0; color: var(--text-dim); font: 11.5px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.x { border: none; padding: 0 6px; color: var(--text-dim); opacity: 0; }
.item.active .x { opacity: 1; }
.browse { color: var(--accent); border-top: 1px solid var(--border); border-radius: 0 0 6px 6px; margin-top: 4px; }
.foot { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); }
</style>
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { addRecent, MAX_RECENTS } from "./recents";
describe("addRecent", () => {
it("puts the path first", () => {
expect(addRecent(["/a", "/b"], "/c")).toEqual(["/c", "/a", "/b"]);
});
it("moves a path that is already there to the front instead of repeating it", () => {
expect(addRecent(["/a", "/b", "/c"], "/b")).toEqual(["/b", "/a", "/c"]);
});
it("keeps the list short", () => {
const many = Array.from({ length: MAX_RECENTS }, (_, i) => `/r${i}`);
const out = addRecent(many, "/new");
expect(out).toHaveLength(MAX_RECENTS);
expect(out[0]).toBe("/new");
expect(out).not.toContain(`/r${MAX_RECENTS - 1}`);
});
});
+6
View File
@@ -0,0 +1,6 @@
export const MAX_RECENTS = 12;
/** Puts `path` first, drops an older copy of it, and keeps at most MAX_RECENTS paths. */
export function addRecent(list: string[], path: string): string[] {
return [path, ...list.filter((p) => p !== path)].slice(0, MAX_RECENTS);
}
+23
View File
@@ -58,3 +58,26 @@ export interface LaidOutRow {
color: number;
segments: Segment[];
}
export interface ChangedFile {
/** One letter: A added, M modified, D deleted, R renamed, C copied, T type changed. */
status: string;
path: string;
old_path: string | null;
/** Line counts; null for binary files. */
added: number | null;
deleted: number | null;
}
export interface CommitDetails {
hash: string;
author: string;
author_email: string;
author_time: number;
committer: string;
committer_email: string;
commit_time: number;
parents: string[];
message: string;
files: ChangedFile[];
}