Commit preview panel at the bottom; open other repositories from a recent list or a folder picker
This commit is contained in:
@@ -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": {
|
||||
|
||||
Generated
+10
@@ -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
|
||||
|
||||
Generated
+68
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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![]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"><{{ details.author_email }}></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"><{{ details.committer_email }}></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>
|
||||
@@ -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>
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user