From 58268c4015c0587b2c4244529aa8928ac104c93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 00:00:12 +0200 Subject: [PATCH] Commit preview panel at the bottom; open other repositories from a recent list or a folder picker --- package.json | 1 + pnpm-lock.yaml | 10 ++ src-tauri/Cargo.lock | 68 +++++++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/details.rs | 156 ++++++++++++++++++++ src-tauri/src/git.rs | 2 +- src-tauri/src/lib.rs | 35 ++++- src/App.vue | 220 +++++++++++++++++++++++----- src/components/PreviewPanel.vue | 136 +++++++++++++++++ src/components/RepoMenu.vue | 91 ++++++++++++ src/lib/recents.test.ts | 18 +++ src/lib/recents.ts | 6 + src/lib/types.ts | 23 +++ 14 files changed, 725 insertions(+), 45 deletions(-) create mode 100644 src-tauri/src/details.rs create mode 100644 src/components/PreviewPanel.vue create mode 100644 src/components/RepoMenu.vue create mode 100644 src/lib/recents.test.ts create mode 100644 src/lib/recents.ts diff --git a/package.json b/package.json index 856f3e2..6d74bd0 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bf09ee..ac0ef7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7b5f5fe..3acb960 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e7bf98f..1e68174 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4fd19a0..3e068e4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" ] } \ No newline at end of file diff --git a/src-tauri/src/details.rs b/src-tauri/src/details.rs new file mode 100644 index 0000000..acb3862 --- /dev/null +++ b/src-tauri/src/details.rs @@ -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, + /// Line counts; `None` for binary files. + pub added: Option, + pub deleted: Option, +} + +#[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, + /// The full message, subject line included. + message: String, + files: Vec, +} + +/// 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 { + 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 { + // 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 = 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, d: Option) -> 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![]); + } +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index d987fa6..e96ba3b 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -31,7 +31,7 @@ pub struct RepoData { head: Option, } -fn git(dir: &Path, args: &[&str]) -> Result { +pub fn git(dir: &Path, args: &[&str]) -> Result { let out = Command::new("git") .arg("-C") .arg(dir) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 67fcac3..1cda772 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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); + +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) -> Result { - git::load(&arg.0) +fn load_repo(dir: tauri::State) -> Result { + 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, path: String) -> Result { + 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, hash: String) -> Result { + 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, diff --git a/src/App.vue b/src/App.vue index 3b2b0bf..7740d02 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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(null); const sidebar = ref>(); -// ---- 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({ 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 | 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("load_repo"); - error.value = null; - if (filtersLoadedFor !== data.root) { - const saved = await invoke | 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("load_repo")); } catch (e) { error.value = String(e); } } +async function openRepo(path: string) { + menuOpen.value = false; + try { + await applyRepo(await invoke("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 | 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(); +const details = shallowRef(null); +const detailsError = ref(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("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 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(() => {
gites - {{ repo.root }} + + {{ visibleCommits.length }} commits · {{ visibleRefs.length }}/{{ repo.refs.length }} refs -
+
+ +
No commits to show. Check the filters on the left.
{
+ +
@@ -382,6 +512,8 @@ onUnmounted(() => { y copy hash h hide branch / filter + p preview + o open repo r reload
@@ -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; diff --git a/src/components/PreviewPanel.vue b/src/components/PreviewPanel.vue new file mode 100644 index 0000000..1feb5b4 --- /dev/null +++ b/src/components/PreviewPanel.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/src/components/RepoMenu.vue b/src/components/RepoMenu.vue new file mode 100644 index 0000000..d583d03 --- /dev/null +++ b/src/components/RepoMenu.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/src/lib/recents.test.ts b/src/lib/recents.test.ts new file mode 100644 index 0000000..8486120 --- /dev/null +++ b/src/lib/recents.test.ts @@ -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}`); + }); +}); diff --git a/src/lib/recents.ts b/src/lib/recents.ts new file mode 100644 index 0000000..57ba03f --- /dev/null +++ b/src/lib/recents.ts @@ -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); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 896b621..d53e2d5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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[]; +}