Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58268c4015 |
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.4.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.4.2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.8.2",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+10
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@tauri-apps/plugin-clipboard-manager':
|
'@tauri-apps/plugin-clipboard-manager':
|
||||||
specifier: ^2.4.2
|
specifier: ^2.4.2
|
||||||
version: 2.4.2
|
version: 2.4.2
|
||||||
|
'@tauri-apps/plugin-dialog':
|
||||||
|
specifier: ^2.8.2
|
||||||
|
version: 2.8.2
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.13
|
specifier: ^3.5.13
|
||||||
version: 3.5.44(typescript@6.0.3)
|
version: 3.5.44(typescript@6.0.3)
|
||||||
@@ -250,6 +253,9 @@ packages:
|
|||||||
'@tauri-apps/plugin-clipboard-manager@2.4.2':
|
'@tauri-apps/plugin-clipboard-manager@2.4.2':
|
||||||
resolution: {integrity: sha512-bpQ6ONK2jcCVraBVtazdQ2X7bRK6P/vdi+D4jFDOJ9IbqR4cC8D6K3YGHR+c+cgZf2HQOE7hBEkRlybEVnCNPg==}
|
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':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
@@ -735,6 +741,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.12.3
|
'@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':
|
'@types/chai@5.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
|
|||||||
Generated
+68
@@ -1167,6 +1167,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-clipboard-manager",
|
"tauri-plugin-clipboard-manager",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2179,6 +2180,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -2699,6 +2701,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -3407,6 +3433,48 @@ dependencies = [
|
|||||||
"thiserror 2.0.21",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.12.2"
|
version = "2.12.2"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri-plugin-clipboard-manager = "2"
|
tauri-plugin-clipboard-manager = "2"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
|
||||||
|
|
||||||
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-set-title",
|
"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>,
|
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")
|
let out = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(dir)
|
.arg(dir)
|
||||||
|
|||||||
+30
-5
@@ -1,15 +1,37 @@
|
|||||||
|
mod details;
|
||||||
mod filters;
|
mod filters;
|
||||||
mod git;
|
mod git;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// The repository folder: the first command line argument, or the current folder.
|
/// The open repository folder. It starts as the first command line argument, or the
|
||||||
struct RepoArg(PathBuf);
|
/// 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]
|
#[tauri::command]
|
||||||
fn load_repo(arg: tauri::State<RepoArg>) -> Result<git::RepoData, String> {
|
fn load_repo(dir: tauri::State<RepoDir>) -> Result<git::RepoData, String> {
|
||||||
git::load(&arg.0)
|
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]
|
#[tauri::command]
|
||||||
@@ -41,9 +63,12 @@ pub fn run() {
|
|||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
.manage(RepoArg(path))
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.manage(RepoDir(Mutex::new(path)))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
load_repo,
|
load_repo,
|
||||||
|
open_repo,
|
||||||
|
commit_details,
|
||||||
load_filters,
|
load_filters,
|
||||||
save_filters,
|
save_filters,
|
||||||
load_settings,
|
load_settings,
|
||||||
|
|||||||
+182
-38
@@ -3,14 +3,18 @@ import { computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef,
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import { buildRows } from "./lib/collapse";
|
import { buildRows } from "./lib/collapse";
|
||||||
import { isRefVisible, lineOwners, reachable, refKind, shortName } from "./lib/filter";
|
import { isRefVisible, lineOwners, reachable, refKind, shortName } from "./lib/filter";
|
||||||
import { layout } from "./lib/layout";
|
import { layout } from "./lib/layout";
|
||||||
import { textWidth } from "./lib/measure";
|
import { textWidth } from "./lib/measure";
|
||||||
import { colorOf } from "./lib/palette";
|
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 GraphCell from "./components/GraphCell.vue";
|
||||||
import LabelStack from "./components/LabelStack.vue";
|
import LabelStack from "./components/LabelStack.vue";
|
||||||
|
import PreviewPanel from "./components/PreviewPanel.vue";
|
||||||
|
import RepoMenu from "./components/RepoMenu.vue";
|
||||||
import Sidebar from "./components/Sidebar.vue";
|
import Sidebar from "./components/Sidebar.vue";
|
||||||
|
|
||||||
const ROW_H = 28;
|
const ROW_H = 28;
|
||||||
@@ -25,39 +29,63 @@ const selected = ref(0);
|
|||||||
const toast = ref<string | null>(null);
|
const toast = ref<string | null>(null);
|
||||||
const sidebar = ref<InstanceType<typeof Sidebar>>();
|
const sidebar = ref<InstanceType<typeof Sidebar>>();
|
||||||
|
|
||||||
// ---- settings: the sidebar width -------------------------------------------
|
// ---- settings, saved in settings.json ---------------------------------------
|
||||||
|
|
||||||
const SIDEBAR_MIN = 160;
|
const SIDEBAR_MIN = 160;
|
||||||
const SIDEBAR_MAX = 700;
|
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;
|
let settingsLoaded = false;
|
||||||
|
|
||||||
async function loadSettings() {
|
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") {
|
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;
|
settingsLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let settingsTimer: number | undefined;
|
let settingsTimer: number | undefined;
|
||||||
watch(sidebarWidth, () => {
|
watch(
|
||||||
if (!settingsLoaded) return;
|
settings,
|
||||||
clearTimeout(settingsTimer);
|
() => {
|
||||||
settingsTimer = window.setTimeout(() => {
|
if (!settingsLoaded) return;
|
||||||
invoke("save_settings", { value: { sidebarWidth: sidebarWidth.value } }).catch((e) =>
|
clearTimeout(settingsTimer);
|
||||||
showToast(`could not save settings: ${e}`),
|
settingsTimer = window.setTimeout(() => {
|
||||||
);
|
invoke("save_settings", { value: { ...settings } }).catch((e) => showToast(`could not save settings: ${e}`));
|
||||||
}, 300);
|
}, 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;
|
const handle = e.currentTarget as HTMLElement;
|
||||||
handle.setPointerCapture(e.pointerId);
|
handle.setPointerCapture(e.pointerId);
|
||||||
const startX = e.clientX;
|
const start = axis === "x" ? e.clientX : e.clientY;
|
||||||
const startWidth = sidebarWidth.value;
|
const startValue = axis === "x" ? settings.sidebarWidth : settings.previewHeight;
|
||||||
const move = (ev: PointerEvent) => {
|
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 = () => {
|
const stop = () => {
|
||||||
handle.removeEventListener("pointermove", move);
|
handle.removeEventListener("pointermove", move);
|
||||||
@@ -73,24 +101,50 @@ let filtersLoadedFor: string | null = null;
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const data = await invoke<RepoData>("load_repo");
|
await applyRepo(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;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = String(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;
|
let saveTimer: number | undefined;
|
||||||
watch(
|
watch(
|
||||||
[filters, collapseAll],
|
[filters, collapseAll],
|
||||||
@@ -181,6 +235,52 @@ function select(i: number) {
|
|||||||
nextTick(scrollToSelected);
|
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 -------------------------------------------------------------
|
// ---- actions -------------------------------------------------------------
|
||||||
|
|
||||||
let toastTimer: number | undefined;
|
let toastTimer: number | undefined;
|
||||||
@@ -227,7 +327,7 @@ function jumpTo(r: Ref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
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> = {
|
const actions: Record<string, () => void> = {
|
||||||
ArrowDown: () => select(selected.value + 1),
|
ArrowDown: () => select(selected.value + 1),
|
||||||
j: () => select(selected.value + 1),
|
j: () => select(selected.value + 1),
|
||||||
@@ -247,6 +347,8 @@ function onKey(e: KeyboardEvent) {
|
|||||||
y: copyHash,
|
y: copyHash,
|
||||||
h: hideSelectedLine,
|
h: hideSelectedLine,
|
||||||
r: load,
|
r: load,
|
||||||
|
o: () => (menuOpen.value = true),
|
||||||
|
p: () => (settings.previewShown = !settings.previewShown),
|
||||||
"/": () => sidebar.value?.focusPattern(),
|
"/": () => sidebar.value?.focusPattern(),
|
||||||
};
|
};
|
||||||
const action = actions[e.key];
|
const action = actions[e.key];
|
||||||
@@ -285,7 +387,18 @@ onUnmounted(() => {
|
|||||||
<header class="bar">
|
<header class="bar">
|
||||||
<img src="/logo.svg" class="logo" alt="" />
|
<img src="/logo.svg" class="logo" alt="" />
|
||||||
<span class="title">gites</span>
|
<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 class="spacer" />
|
||||||
<span v-if="repo" class="stats">{{ visibleCommits.length }} commits · {{ visibleRefs.length }}/{{ repo.refs.length }} refs</span>
|
<span v-if="repo" class="stats">{{ visibleCommits.length }} commits · {{ visibleRefs.length }}/{{ repo.refs.length }} refs</span>
|
||||||
<button
|
<button
|
||||||
@@ -313,18 +426,21 @@ onUnmounted(() => {
|
|||||||
:filters="filters"
|
:filters="filters"
|
||||||
:head-ref="repo.head_ref"
|
:head-ref="repo.head_ref"
|
||||||
:time-of="timeOf"
|
:time-of="timeOf"
|
||||||
:style="{ width: sidebarWidth + 'px' }"
|
:style="{ width: settings.sidebarWidth + 'px' }"
|
||||||
@jump="jumpTo"
|
@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">
|
<div v-if="error" class="error">
|
||||||
<h2>Could not open the repository</h2>
|
<h2>Could not open the repository</h2>
|
||||||
<pre>{{ error }}</pre>
|
<pre>{{ error }}</pre>
|
||||||
<p>Start gites inside a git repository, or pass its path: <code>gites /path/to/repo</code></p>
|
<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>
|
||||||
|
|
||||||
<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 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 class="spacer-rows" :style="{ height: rows.length * ROW_H + 'px' }">
|
||||||
<div
|
<div
|
||||||
@@ -372,6 +488,20 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<footer class="keys">
|
<footer class="keys">
|
||||||
@@ -382,6 +512,8 @@ onUnmounted(() => {
|
|||||||
<span><kbd>y</kbd> copy hash</span>
|
<span><kbd>y</kbd> copy hash</span>
|
||||||
<span><kbd>h</kbd> hide branch</span>
|
<span><kbd>h</kbd> hide branch</span>
|
||||||
<span><kbd>/</kbd> filter</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>
|
<span><kbd>r</kbd> reload</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -410,7 +542,16 @@ onUnmounted(() => {
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
color: transparent;
|
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; }
|
.spacer { flex: 1; }
|
||||||
.stats { color: var(--text-dim); font-size: 12px; }
|
.stats { color: var(--text-dim); font-size: 12px; }
|
||||||
.collapse { display: inline-flex; align-items: center; gap: 6px; }
|
.collapse { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
@@ -427,7 +568,10 @@ onUnmounted(() => {
|
|||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
.splitter:hover, .splitter:active { background: var(--accent); opacity: 0.6; }
|
.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; }
|
.spacer-rows { position: relative; min-width: 700px; }
|
||||||
.row {
|
.row {
|
||||||
position: absolute;
|
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;
|
color: number;
|
||||||
segments: Segment[];
|
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