From 1f309ab49a6432f9da7c609af060a12144b83e1e Mon Sep 17 00:00:00 2001 From: iptoux Date: Fri, 22 May 2026 21:19:29 +0200 Subject: [PATCH 1/9] refactor(git_graph): simplify commit graph structure and enhance gutter handling Refactored the Git graph implementation to replace the swim-lane layout with a native `git log --graph` representation. Updated the data structures to use `GitGraphEntry` for better clarity and streamlined the fetching of commit data. Improved gutter handling for better alignment and visual consistency in the sidebar. This change enhances the overall user experience by providing a more accurate representation of commit relationships. --- src-tauri/src/fs_entries.rs | 247 ++++++++++++++++++++ src-tauri/src/git_graph.rs | 410 +++++++++++++-------------------- src-tauri/src/lib.rs | 3 + src/tauri_bridge.rs | 16 +- src/workbench/git_graph/mod.rs | 183 ++------------- styles.css | 87 ++----- 6 files changed, 442 insertions(+), 504 deletions(-) diff --git a/src-tauri/src/fs_entries.rs b/src-tauri/src/fs_entries.rs index d01bd09..f06645d 100644 --- a/src-tauri/src/fs_entries.rs +++ b/src-tauri/src/fs_entries.rs @@ -1,9 +1,14 @@ //! Sandboxed directory listing for the sidebar project explorer. +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine as _; use std::fs; use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; const MAX_TEXT_PREVIEW_BYTES: u64 = 512 * 1024; +const MAX_IMAGE_PREVIEW_BYTES: u64 = 16 * 1024 * 1024; +const MAX_VIDEO_PREVIEW_BYTES: u64 = 64 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -21,6 +26,93 @@ pub struct TextFilePreview { pub byte_len: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FileKind { + Image, + Video, + Markdown, + Mermaid, + Text, + Binary, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileMeta { + pub name: String, + pub rel_path: String, + pub byte_len: u64, + pub modified_ms: Option, + pub kind: FileKind, + pub mime: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BinaryFilePreview { + pub base64: String, + pub mime: String, + pub byte_len: u64, + pub truncated: bool, +} + +/// Lowercased extension or empty string for files without a suffix. +fn ext_lower(path: &Path) -> String { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default() +} + +/// Maps a lowercased extension to a [`FileKind`] used by the preview dispatcher. +fn classify_kind(ext: &str) -> FileKind { + match ext { + "png" | "jpg" | "jpeg" | "webp" | "gif" | "avif" | "bmp" | "ico" | "svg" => FileKind::Image, + "mp4" | "webm" | "mov" | "m4v" | "mkv" => FileKind::Video, + "md" | "markdown" => FileKind::Markdown, + "mmd" | "mermaid" => FileKind::Mermaid, + "txt" | "log" | "rs" | "ts" | "tsx" | "js" | "jsx" | "json" | "toml" | "yaml" | "yml" + | "html" | "css" | "scss" | "py" | "go" | "java" | "c" | "h" | "cpp" | "hpp" | "rb" + | "sh" | "bash" | "zsh" | "fish" | "ps1" | "sql" | "xml" | "ini" | "conf" | "env" + | "lock" | "gitignore" | "dockerfile" => FileKind::Text, + _ => FileKind::Binary, + } +} + +/// Best-effort MIME guess from the lowercased extension. +fn mime_for_ext(ext: &str) -> Option<&'static str> { + Some(match ext { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "gif" => "image/gif", + "avif" => "image/avif", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + "svg" => "image/svg+xml", + "mp4" | "m4v" => "video/mp4", + "webm" => "video/webm", + "mov" => "video/quicktime", + "mkv" => "video/x-matroska", + "md" | "markdown" => "text/markdown", + "mmd" | "mermaid" => "text/vnd.mermaid", + "json" => "application/json", + "html" => "text/html", + "css" => "text/css", + "js" | "mjs" => "text/javascript", + "xml" => "application/xml", + "txt" | "log" | "ini" | "conf" | "env" => "text/plain", + _ => return None, + }) +} + +fn modified_ms(meta: &fs::Metadata) -> Option { + let modified = meta.modified().ok()?; + let dur = modified.duration_since(UNIX_EPOCH).ok()?; + i64::try_from(dur.as_millis()).ok() +} + fn canonical_root(workspace_root: &str) -> Result { let trimmed = workspace_root.trim(); if trimmed.is_empty() { @@ -117,6 +209,91 @@ pub fn read_workspace_text_file( }) } +/// Lightweight metadata for the file preview topbar. +/// Returns name, relative path (as supplied by the caller), byte size, +/// modification timestamp (Unix ms, if available), classified [`FileKind`] +/// and a best-effort MIME guess. Errors mirror the existing sandbox path. +#[tauri::command] +pub fn stat_workspace_file(workspace_root: String, path: String) -> Result { + let root = canonical_root(&workspace_root)?; + let file = resolve_under_root(&root, &path)?; + if !file.is_file() { + return Err("not a file".into()); + } + let meta = fs::metadata(&file).map_err(|e| e.to_string())?; + let ext = ext_lower(&file); + let kind = classify_kind(&ext); + let mime = mime_for_ext(&ext).map(str::to_string); + let name = file + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.clone()); + Ok(FileMeta { + name, + rel_path: path, + byte_len: meta.len(), + modified_ms: modified_ms(&meta), + kind, + mime, + }) +} + +fn read_binary_with_cap(file: &Path, cap: u64) -> Result { + if !file.is_file() { + return Err("not a file".into()); + } + let meta = fs::metadata(file).map_err(|e| e.to_string())?; + let byte_len = meta.len(); + let truncated = byte_len > cap; + let mut bytes = fs::read(file).map_err(|e| e.to_string())?; + if truncated { + bytes.truncate(cap as usize); + } + let ext = ext_lower(file); + let mime = mime_for_ext(&ext) + .map(str::to_string) + .unwrap_or_else(|| "application/octet-stream".to_string()); + let base64 = BASE64_STANDARD.encode(&bytes); + Ok(BinaryFilePreview { + base64, + mime, + byte_len, + truncated, + }) +} + +/// Reads an image file under `workspace_root` and returns it as base64 plus +/// an extension-derived MIME. Capped at [`MAX_IMAGE_PREVIEW_BYTES`]. +#[tauri::command] +pub fn read_workspace_image_file( + workspace_root: String, + path: String, +) -> Result { + let root = canonical_root(&workspace_root)?; + let file = resolve_under_root(&root, &path)?; + let ext = ext_lower(&file); + if !matches!(classify_kind(&ext), FileKind::Image) { + return Err("not an image file".into()); + } + read_binary_with_cap(&file, MAX_IMAGE_PREVIEW_BYTES) +} + +/// Reads a video file under `workspace_root` and returns it as base64 plus +/// an extension-derived MIME. Capped at [`MAX_VIDEO_PREVIEW_BYTES`]. +#[tauri::command] +pub fn read_workspace_video_file( + workspace_root: String, + path: String, +) -> Result { + let root = canonical_root(&workspace_root)?; + let file = resolve_under_root(&root, &path)?; + let ext = ext_lower(&file); + if !matches!(classify_kind(&ext), FileKind::Video) { + return Err("not a video file".into()); + } + read_binary_with_cap(&file, MAX_VIDEO_PREVIEW_BYTES) +} + #[cfg(test)] mod tests { use super::*; @@ -181,4 +358,74 @@ mod tests { assert!(err.contains("path not found")); let _ = fs::remove_dir_all(tmp); } + + #[test] + fn classify_kind_covers_expected_extensions() { + assert!(matches!(classify_kind("png"), FileKind::Image)); + assert!(matches!(classify_kind("svg"), FileKind::Image)); + assert!(matches!(classify_kind("mp4"), FileKind::Video)); + assert!(matches!(classify_kind("md"), FileKind::Markdown)); + assert!(matches!(classify_kind("markdown"), FileKind::Markdown)); + assert!(matches!(classify_kind("mmd"), FileKind::Mermaid)); + assert!(matches!(classify_kind("mermaid"), FileKind::Mermaid)); + assert!(matches!(classify_kind("rs"), FileKind::Text)); + assert!(matches!(classify_kind("unknown"), FileKind::Binary)); + } + + #[test] + fn stat_workspace_file_returns_metadata() { + let tmp = std::env::temp_dir().join(format!("blx_fs_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).unwrap(); + fs::write(tmp.join("hello.md"), b"# hi").unwrap(); + let root = tmp.to_string_lossy().into_owned(); + let meta = stat_workspace_file(root, "hello.md".into()).unwrap(); + assert_eq!(meta.name, "hello.md"); + assert_eq!(meta.byte_len, 4); + assert!(matches!(meta.kind, FileKind::Markdown)); + assert_eq!(meta.mime.as_deref(), Some("text/markdown")); + assert!(meta.modified_ms.is_some()); + let _ = fs::remove_dir_all(tmp); + } + + #[test] + fn read_workspace_image_file_returns_base64() { + let tmp = std::env::temp_dir().join(format!("blx_fs_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).unwrap(); + // Minimal valid 1x1 PNG signature + IHDR + IDAT + IEND not required for the test; + // we only verify base64 round-trip and MIME classification. + let bytes: &[u8] = &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + fs::write(tmp.join("pixel.png"), bytes).unwrap(); + let root = tmp.to_string_lossy().into_owned(); + let preview = read_workspace_image_file(root, "pixel.png".into()).unwrap(); + assert_eq!(preview.mime, "image/png"); + assert_eq!(preview.byte_len, bytes.len() as u64); + assert!(!preview.truncated); + let decoded = BASE64_STANDARD.decode(&preview.base64).unwrap(); + assert_eq!(decoded, bytes); + let _ = fs::remove_dir_all(tmp); + } + + #[test] + fn read_workspace_image_file_rejects_non_image() { + let tmp = std::env::temp_dir().join(format!("blx_fs_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).unwrap(); + fs::write(tmp.join("a.txt"), b"hi").unwrap(); + let root = tmp.to_string_lossy().into_owned(); + let err = + read_workspace_image_file(root, "a.txt".into()).expect_err("non-image should fail"); + assert!(err.contains("not an image")); + let _ = fs::remove_dir_all(tmp); + } + + #[test] + fn read_workspace_video_file_rejects_non_video() { + let tmp = std::env::temp_dir().join(format!("blx_fs_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).unwrap(); + fs::write(tmp.join("a.png"), b"x").unwrap(); + let root = tmp.to_string_lossy().into_owned(); + let err = + read_workspace_video_file(root, "a.png".into()).expect_err("non-video should fail"); + assert!(err.contains("not a video")); + let _ = fs::remove_dir_all(tmp); + } } diff --git a/src-tauri/src/git_graph.rs b/src-tauri/src/git_graph.rs index 36decae..33e4cb0 100644 --- a/src-tauri/src/git_graph.rs +++ b/src-tauri/src/git_graph.rs @@ -1,13 +1,19 @@ -//! Commit graph layout for the sidebar (git CLI + swim-lane layout). +//! Commit graph for the sidebar via native `git log --graph` (no custom lane layout). use crate::git_info::{find_git_dir, git_cli_available}; use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; use std::path::Path; use std::process::Command; pub const GIT_MISSING_CODE: &str = "git_missing"; const DEFAULT_LIMIT: u32 = 100; +/// Matches `--pretty=format:…%x02` record terminator. +const RECORD_END: char = '\x02'; +/// Marks start of structured commit fields on a graph line. +const RECORD_START: char = '\x1e'; +const FIELD_SEP: char = '\x1f'; +/// Fixed graph column width (`git log -c log.graphWidth`). +const GRAPH_WIDTH: u32 = 14; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -29,22 +35,18 @@ pub struct GitCommitNode { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct GitGraphRow { - pub oid: String, - pub lane: usize, - pub lane_color_index: usize, - pub continues_up: bool, - pub continues_down: bool, - pub merge_from_lane: Option, - pub branch_from_lane: Option, - pub pass_through_lanes: Vec, +pub struct GitGraphEntry { + /// Left gutter from `git log --graph` (may span multiple lines for merges). + pub gutter: String, + pub commit: GitCommitNode, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitGraphLayout { - pub commits: Vec, - pub rows: Vec, + pub entries: Vec, + /// Max gutter line width (for monospace alignment). + pub gutter_cols: usize, } #[tauri::command] @@ -71,21 +73,23 @@ pub fn git_commit_graph(cwd: String, limit: Option) -> Result Result, String> { - let format = "%H\x1f%P\x1f%s\x1f%an\x1f%ar\x1f%D\x1e"; +fn fetch_graph_entries(work_tree: &Path, limit: u32) -> Result { + let pretty = format!( + "%x1e%H{FIELD_SEP}%P{FIELD_SEP}%s{FIELD_SEP}%an{FIELD_SEP}%ar{FIELD_SEP}%D%x02" + ); let out = Command::new("git") .arg("-C") .arg(work_tree) + .arg(format!("-c log.graphWidth={GRAPH_WIDTH}")) .args([ "log", + "--graph", "--topo-order", &format!("-n{limit}"), - &format!("--format={format}"), + &format!("--pretty=format:{pretty}"), ]) .output() .map_err(|e| format!("git log failed: {e}"))?; @@ -94,33 +98,99 @@ fn fetch_commits(work_tree: &Path, limit: u32) -> Result, Str return Err(format!("git log: {stderr}")); } let text = String::from_utf8_lossy(&out.stdout); - let mut commits = Vec::new(); - for record in text.split('\x1e').filter(|s| !s.trim().is_empty()) { - let parts: Vec<&str> = record.split('\x1f').collect(); - if parts.len() < 5 { - continue; + let mut entries = Vec::new(); + let mut gutter_pending = String::new(); + + for raw in text.split(RECORD_END).filter(|s| !s.is_empty()) { + for line in raw.lines() { + if let Some((gutter_line, record)) = split_graph_line(line) { + let mut gutter = gutter_pending.clone(); + gutter.push_str(gutter_line); + gutter_pending.clear(); + if let Some(commit) = parse_commit_record(record) { + entries.push(GitGraphEntry { + gutter: normalize_gutter(&gutter), + commit, + }); + } + } else if !line.trim().is_empty() { + gutter_pending.push_str(line); + gutter_pending.push('\n'); + } } - let oid = parts[0].trim().to_string(); - let parents: Vec = parts[1] - .split_whitespace() - .filter(|p| !p.is_empty()) - .map(str::to_string) - .collect(); - let subject = parts[2].trim().to_string(); - let author = parts[3].trim().to_string(); - let rel_time = parts[4].trim().to_string(); - let deco_raw = parts.get(5).copied().unwrap_or("").trim(); - let decorations = parse_decorations(deco_raw); - commits.push(GitCommitNode { - oid, - parents, - subject, - author, - rel_time, - decorations, - }); } - Ok(commits) + + let gutter_cols = entries + .iter() + .flat_map(|e| e.gutter.lines().map(|l| l.chars().count())) + .max() + .unwrap_or(2) + .max(2); + for entry in &mut entries { + entry.gutter = pad_gutter_lines(&entry.gutter, gutter_cols); + } + + Ok(GitGraphLayout { + entries, + gutter_cols, + }) +} + +fn split_graph_line(line: &str) -> Option<(&str, &str)> { + let idx = line.find(RECORD_START)?; + let gutter = line[..idx].trim_end(); + let record = line[idx + 1..].trim_start(); + if record.is_empty() { + return None; + } + Some((gutter, record)) +} + +fn parse_commit_record(record: &str) -> Option { + let parts: Vec<&str> = record.split(FIELD_SEP).collect(); + if parts.len() < 5 { + return None; + } + let oid = parts[0].trim().to_string(); + if oid.is_empty() { + return None; + } + let parents: Vec = parts[1] + .split_whitespace() + .filter(|p| !p.is_empty()) + .map(str::to_string) + .collect(); + Some(GitCommitNode { + oid, + parents, + subject: parts[2].trim().to_string(), + author: parts[3].trim().to_string(), + rel_time: parts[4].trim().to_string(), + decorations: parse_decorations(parts.get(5).copied().unwrap_or("").trim()), + }) +} + +fn normalize_gutter(gutter: &str) -> String { + let lines: Vec<&str> = gutter.lines().collect(); + if lines.is_empty() { + return "* ".to_string(); + } + lines.join("\n") +} + +fn pad_gutter_lines(gutter: &str, cols: usize) -> String { + gutter + .lines() + .map(|line| { + let n = line.chars().count(); + if n >= cols { + line.to_string() + } else { + format!("{line}{}", " ".repeat(cols - n)) + } + }) + .collect::>() + .join("\n") } fn parse_decorations(raw: &str) -> Vec { @@ -151,222 +221,24 @@ fn parse_decorations(raw: &str) -> Vec { .collect() } -/// Swim-lane layout: `commits` are newest-first (git log order). -fn compute_lane_layout(commits: &[GitCommitNode]) -> Vec { - if commits.is_empty() { - return Vec::new(); - } - - let mut children: HashMap<&str, Vec<&str>> = HashMap::new(); - for c in commits { - for p in &c.parents { - children.entry(p.as_str()).or_default().push(c.oid.as_str()); - } - } - - let mut oldest_first: Vec<&GitCommitNode> = commits.iter().collect(); - oldest_first.reverse(); - - let mut lane_of: HashMap = HashMap::new(); - let mut merge_from: HashMap = HashMap::new(); - let mut branch_from: HashMap = HashMap::new(); - let mut next_lane: usize = 0; - let mut alloc_lane = || { - let l = next_lane; - next_lane += 1; - l - }; - - for commit in oldest_first { - let oid = commit.oid.clone(); - let parents: Vec<&str> = commit.parents.iter().map(|s| s.as_str()).collect(); - - let (lane, branch_from_lane) = if parents.is_empty() { - (alloc_lane(), None) - } else { - let p0 = parents[0]; - match lane_of.get(p0).copied() { - Some(pl) => { - let fork = children.get(p0).is_some_and(|kids| { - kids.iter().any(|&other| { - other != oid.as_str() && lane_of.contains_key(other) - }) - }); - if fork { - (alloc_lane(), Some(pl)) - } else { - (pl, None) - } - } - None => (alloc_lane(), None), - } - }; - - lane_of.insert(oid.clone(), lane); - if let Some(from) = branch_from_lane { - branch_from.insert(oid.clone(), from); - } - - if parents.len() > 1 { - for p in parents.iter().skip(1) { - if let Some(&pl) = lane_of.get(*p) { - merge_from.insert(oid.clone(), pl); - } else { - let nl = alloc_lane(); - lane_of.insert((*p).to_string(), nl); - merge_from.insert(oid.clone(), nl); - } - } - } - } - - let mut carry_lanes: HashSet = HashSet::new(); - let mut rows = Vec::with_capacity(commits.len()); - - for (i, c) in commits.iter().enumerate() { - let lane = lane_of.get(&c.oid).copied().unwrap_or(0); - let merge_from_lane = merge_from.get(&c.oid).copied(); - let branch_from_lane = branch_from.get(&c.oid).copied(); - - let mut pass_through_lanes: Vec = carry_lanes - .iter() - .copied() - .filter(|l| *l != lane) - .collect(); - if let Some(mf) = merge_from_lane { - if !pass_through_lanes.contains(&mf) { - pass_through_lanes.push(mf); - } - } - - let continues_down = c - .parents - .first() - .and_then(|p| lane_of.get(p)) - .map(|&pl| pl == lane) - .unwrap_or(false) - && i + 1 < commits.len(); - - let continues_up = if i > 0 { - let newer = &commits[i - 1]; - let first_parent_child = newer - .parents - .first() - .map(|p| p == &c.oid) - .unwrap_or(false) - && lane_of.get(&newer.oid).copied() == Some(lane); - let merge_child = merge_from.get(&newer.oid).copied() == Some(lane); - first_parent_child || merge_child - } else { - false - }; - - rows.push(GitGraphRow { - oid: c.oid.clone(), - lane, - lane_color_index: lane % 6, - continues_up, - continues_down, - merge_from_lane, - branch_from_lane, - pass_through_lanes, - }); - - // Lanes carried into the next (older) row. A merge closes the incoming branch - // lane here — only the mainline (first-parent lane) may continue below. - carry_lanes.clear(); - if continues_down { - carry_lanes.insert(lane); - } - if let Some(bf) = branch_from_lane { - carry_lanes.insert(bf); - if continues_down { - carry_lanes.insert(lane); - } - } - } - - rows -} - #[cfg(test)] mod tests { use super::*; - fn node(oid: &str, parents: &[&str]) -> GitCommitNode { - GitCommitNode { - oid: oid.into(), - parents: parents.iter().map(|s| (*s).to_string()).collect(), - subject: format!("commit {oid}"), - author: "test".into(), - rel_time: "1 day ago".into(), - decorations: Vec::new(), - } - } - - fn row_for<'a>(rows: &'a [GitGraphRow], oid: &str) -> &'a GitGraphRow { - rows.iter().find(|r| r.oid == oid).expect("row") - } - #[test] - fn lane_layout_linear_chain() { - let commits = vec![node("c3", &["c2"]), node("c2", &["c1"]), node("c1", &[])]; - let rows = compute_lane_layout(&commits); - assert_eq!(rows.len(), 3); - assert_eq!(rows[0].lane, rows[1].lane); - assert_eq!(rows[1].lane, rows[2].lane); + fn split_graph_line_finds_record() { + let (g, r) = split_graph_line("* \x1eabc\x1fdef").expect("split"); + assert_eq!(g, "*"); + assert!(r.starts_with("abc")); } #[test] - fn lane_layout_merge_gets_second_parent_lane() { - let commits = vec![ - node("m", &["b", "a"]), - node("b", &["r"]), - node("a", &["r"]), - node("r", &[]), - ]; - let rows = compute_lane_layout(&commits); - assert_eq!(rows.len(), 4); - let m = row_for(&rows, "m"); - assert!(m.merge_from_lane.is_some()); - assert_ne!(m.lane, m.merge_from_lane.unwrap()); - } - - #[test] - fn lane_layout_fork_assigns_second_branch_lane() { - let commits = vec![ - node("d", &["b"]), - node("b", &["r"]), - node("c", &["a"]), - node("a", &["r"]), - node("r", &[]), - ]; - let rows = compute_lane_layout(&commits); - let b = row_for(&rows, "b"); - let a = row_for(&rows, "a"); - assert_ne!(b.lane, a.lane); - assert_eq!(b.branch_from_lane, Some(a.lane)); - } - - #[test] - fn lane_layout_merge_closes_side_lane_below() { - let commits = vec![ - node("m", &["a", "f"]), - node("f", &["r"]), - node("a", &["r"]), - node("r", &[]), - ]; - let rows = compute_lane_layout(&commits); - let f_lane = row_for(&rows, "f").lane; - let m = row_for(&rows, "m"); - assert_eq!(m.merge_from_lane, Some(f_lane)); - for oid in ["a", "r"] { - let row = row_for(&rows, oid); - assert!( - !row.pass_through_lanes.contains(&f_lane), - "lane {f_lane} should close at merge, still pass-through on {oid}" - ); - } + fn parse_commit_record_fields() { + let rec = format!("deadbeef{FIELD_SEP}{FIELD_SEP}subject{FIELD_SEP}author{FIELD_SEP}1 day ago{FIELD_SEP}"); + let c = parse_commit_record(&rec).expect("parse"); + assert_eq!(c.oid, "deadbeef"); + assert_eq!(c.subject, "subject"); + assert_eq!(c.author, "author"); } #[test] @@ -375,4 +247,38 @@ mod tests { assert_eq!(d.len(), 2); assert_eq!(d[0].label, "main"); } + + #[test] + fn gutter_buffer_accumulates_connector_lines() { + let sample = format!( + "* \x1em1{FIELD_SEP}{FIELD_SEP}merge{FIELD_SEP}a{FIELD_SEP}now{FIELD_SEP}{RECORD_END}\ + |\\ {RECORD_END}\ + | * \x1ef1{FIELD_SEP}{FIELD_SEP}feat{FIELD_SEP}a{FIELD_SEP}now{FIELD_SEP}{RECORD_END}\ + |/ {RECORD_END}\ + * \x1em2{FIELD_SEP}{FIELD_SEP}main{FIELD_SEP}a{FIELD_SEP}now{FIELD_SEP}{RECORD_END}" + ); + let mut entries = Vec::new(); + let mut gutter_pending = String::new(); + for raw in sample.split(RECORD_END).filter(|s| !s.is_empty()) { + for line in raw.lines() { + if let Some((gutter_line, record)) = split_graph_line(line) { + let mut gutter = gutter_pending.clone(); + gutter.push_str(gutter_line); + gutter_pending.clear(); + if let Some(commit) = parse_commit_record(record) { + entries.push(GitGraphEntry { + gutter: normalize_gutter(&gutter), + commit, + }); + } + } else if !line.trim().is_empty() { + gutter_pending.push_str(line); + gutter_pending.push('\n'); + } + } + } + assert_eq!(entries.len(), 3); + assert!(entries[1].gutter.contains('|')); + assert!(entries[1].gutter.contains('*')); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6239722..bac0a99 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -152,6 +152,9 @@ pub fn run() { git_graph::git_commit_graph, fs_entries::list_path_entries, fs_entries::read_workspace_text_file, + fs_entries::stat_workspace_file, + fs_entries::read_workspace_image_file, + fs_entries::read_workspace_video_file, install_agent_hooks, agent_hooks_status, uninstall_agent_hooks, diff --git a/src/tauri_bridge.rs b/src/tauri_bridge.rs index 6f01725..edf8346 100644 --- a/src/tauri_bridge.rs +++ b/src/tauri_bridge.rs @@ -1671,22 +1671,16 @@ pub struct GitCommitNode { #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] -pub struct GitGraphRow { - pub oid: String, - pub lane: usize, - pub lane_color_index: usize, - pub continues_up: bool, - pub continues_down: bool, - pub merge_from_lane: Option, - pub branch_from_lane: Option, - pub pass_through_lanes: Vec, +pub struct GitGraphEntry { + pub gutter: String, + pub commit: GitCommitNode, } #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct GitGraphLayout { - pub commits: Vec, - pub rows: Vec, + pub entries: Vec, + pub gutter_cols: usize, } pub const GIT_MISSING_CODE: &str = "git_missing"; diff --git a/src/workbench/git_graph/mod.rs b/src/workbench/git_graph/mod.rs index f7585cb..0b75627 100644 --- a/src/workbench/git_graph/mod.rs +++ b/src/workbench/git_graph/mod.rs @@ -1,26 +1,13 @@ -//! Sidebar git commit graph with swim-lane SVG. +//! Sidebar git commit graph — native `git log --graph` gutter + commit rows. use crate::i18n::I18nKey; use crate::service::I18nService; -use crate::tauri_bridge::{ - git_commit_graph, GitCommitNode, GitGraphLayout, GitGraphRow, GIT_MISSING_CODE, -}; +use crate::tauri_bridge::{git_commit_graph, GitGraphEntry, GitGraphLayout, GIT_MISSING_CODE}; use crate::workbench::sidebar_view_section::{SidebarSectionIconBtn, SidebarViewSection}; use crate::workbench::WorkbenchService; use leptos::prelude::*; use leptos::task::spawn_local; use leptos_icons::Icon as LxIcon; -use std::collections::HashMap; - -const LANE_PITCH: f64 = 14.0; -/// Left inset for the first swim-lane (kept small so the column isn't mostly empty). -const LANE_ORIGIN: f64 = 4.0; -/// SVG viewBox height; y-coordinates are 0..VIEW_H so lines stretch with each row. -const VIEW_H: f64 = 100.0; -/// Commit node sits on the subject line (~top fifth of a typical multi-line row). -const NODE_Y: f64 = 18.0; -const NODE_R: f64 = 3.5; -const LANE_COL_PAD: f64 = 4.0; #[component] pub fn GitGraphSection(git_repo_available: ReadSignal>) -> impl IntoView { @@ -149,7 +136,7 @@ fn GitGraphBody( let Some(g) = layout.get() else { return view! { }.into_any(); }; - if g.commits.is_empty() { + if g.entries.is_empty() { return view! { } @@ -169,56 +156,18 @@ fn GitGraphBody( } } -fn lane_x(lane: usize) -> f64 { - LANE_ORIGIN + f64::from(lane as u32) * LANE_PITCH -} - -fn lane_col_width(lane_count: usize) -> f64 { - (lane_x(lane_count.saturating_sub(1)) + NODE_R + LANE_COL_PAD).max(14.0) -} - -fn connector_path(x0: f64, y0: f64, x1: f64, y1: f64) -> String { - let mid_y = (y0 + y1) / 2.0; - format!("M {x0} {y0} C {x0} {mid_y}, {x1} {mid_y}, {x1} {y1}") -} - #[component] fn GitGraphList(layout: GitGraphLayout) -> impl IntoView { - let row_by_oid: HashMap = layout - .rows - .iter() - .map(|r| (r.oid.clone(), r.clone())) - .collect(); - let max_lane = layout - .rows - .iter() - .flat_map(|r| { - let mut lanes = vec![r.lane]; - if let Some(m) = r.merge_from_lane { - lanes.push(m); - } - if let Some(b) = r.branch_from_lane { - lanes.push(b); - } - lanes.extend(r.pass_through_lanes.iter().copied()); - lanes - }) - .max() - .unwrap_or(0); - let lane_count = max_lane + 1; - let lanes_w = lane_col_width(lane_count); + let gutter_ch = layout.gutter_cols.max(2); + let gutter_style = format!("--git-graph-cols: {gutter_ch}"); view! { -