use gix for git status
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
# zterm
|
||||||
|
|
||||||
|
GPU-accelerated Wayland terminal emulator. Single Rust crate, edition 2024.
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo run # dev build, Wayland only
|
||||||
|
cargo build --release # release
|
||||||
|
cargo test # tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Feature | Effect |
|
||||||
|
|---------|--------|
|
||||||
|
| `webm` (default) | Enable WebM video via ffmpeg-next 8.x |
|
||||||
|
| `render_timing` | Enable per-frame parse/render timing instrumentation |
|
||||||
|
| `production` | `log/release_max_level_off` — zero logging overhead |
|
||||||
|
|
||||||
|
Disable webm: `--no-default-features --features render_timing`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Single process**: owns PTY, terminal state, rendering. No multi-process IPC.
|
||||||
|
- **Kitty-style shared parser** (`SharedParser` in `vt_parser.rs`): 1MB ring buffer. I/O thread writes directly into it; main thread parses in-place. Lock released during parsing so I/O continues.
|
||||||
|
- **Split tree** (`SplitNode` in `main.rs`): binary tree of panes. Layout computed via recursive `layout()` pass.
|
||||||
|
- **GPU rendering** (`renderer.rs`): wgpu + WGSL shaders. Instanced rendering with sprite atlas. Per-pane GPU buffers.
|
||||||
|
- **Kitty graphics protocol** (`graphics.rs`): PNG, GIF, WebM inline images.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `src/main.rs` | `App` state machine, event loop, split tree, tab/pane management, keybindings |
|
||||||
|
| `src/terminal.rs` | Terminal grid, scrollback buffer, cursor, colors, cell attributes, `Handler` impl |
|
||||||
|
| `src/vt_parser.rs` | `SharedParser` (ring buffer + I/O), `Parser` (VT escape sequence parsing), `Handler` trait |
|
||||||
|
| `src/renderer.rs` | wgpu renderer, glyph atlas, font loading, instanced rendering pipeline |
|
||||||
|
| `src/graphics.rs` | Kitty graphics protocol parser (PNG/GIF/WebM) |
|
||||||
|
| `src/config.rs` | JSON config from `~/.config/zterm/config.json`, keybinding resolver |
|
||||||
|
| `src/pty.rs` | PTY master, fork/exec child shell, SIGWINCH resize |
|
||||||
|
| `src/pipeline.rs` | wgpu pipeline builder |
|
||||||
|
| `src/keyboard.rs` | Kitty keyboard protocol, key encoding |
|
||||||
|
| `zterm.terminfo` | Custom terminfo — install with `tic -x -o ~/.terminfo zterm.terminfo` |
|
||||||
|
| `src/bin/bench_process.rs` | Benchmark binary |
|
||||||
|
|
||||||
|
## Shaders
|
||||||
|
|
||||||
|
WGSL files: `src/glyph_shader.wgsl`, `src/image_shader.wgsl`, `src/quad_shader.wgsl`, `src/statusline_shader.wgsl`, `src/shader.wgsl` (edge glow).
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
`~/.config/zterm/config.json` — auto-created on first run. Fields: `font_family`, `font_size`, `tab_bar_position`, `background_opacity`, `scrollback_lines`, `inactive_pane_fade_ms`, `inactive_pane_dim`, `edge_glow_intensity`, `pass_keys_to_programs`, `keybindings`.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- `.rustfmt.toml`: `max_width = 80`
|
||||||
|
- `.gitignore` excludes `Cargo.lock` and `/benches` (unusual for Rust)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests live inline in source files: `vt_parser.rs`, `simd_utf8.rs`, `graphics.rs`, `vt_test_osc.rs`. Run with `cargo test`.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- **Wayland compositor** required (X11 not supported)
|
||||||
|
- **FFmpeg 5.x–8.x** system libraries (for `webm` feature)
|
||||||
|
- **terminfo**: install `zterm.terminfo` so shells know the terminal type
|
||||||
|
- **Nerd Font** recommended for statusline icons (folder, branch, etc.)
|
||||||
+6
-1
@@ -75,13 +75,18 @@ rustc-hash = "2"
|
|||||||
# Base64 decoding for OSC statusline
|
# Base64 decoding for OSC statusline
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# Pure-Rust git implementation (replaces git subprocess calls)
|
||||||
|
gix = { version = "0.84", features = ["max-performance", "parallel", "status", "blob-diff"] }
|
||||||
|
gix-diff = "0.64"
|
||||||
|
gix-status = "0.31"
|
||||||
|
|
||||||
# Image processing (Kitty graphics protocol)
|
# Image processing (Kitty graphics protocol)
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "gif"] }
|
image = { version = "0.25", default-features = false, features = ["png", "gif"] }
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
|
|
||||||
# Video decoding for WebM support (video only, no audio)
|
# Video decoding for WebM support (video only, no audio)
|
||||||
# Requires system FFmpeg libraries (ffmpeg 5.x - 8.x supported)
|
# Requires system FFmpeg libraries (ffmpeg 5.x - 8.x supported)
|
||||||
ffmpeg-next = { version = "8.0", optional = true }
|
ffmpeg-next = { version = "8.1", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["webm"]
|
default = ["webm"]
|
||||||
|
|||||||
+153
-106
@@ -983,128 +983,175 @@ struct GitStatus {
|
|||||||
stash_count: usize,
|
stash_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get git status for a directory.
|
/// Visitor for counting working tree changes.
|
||||||
/// Returns None if not in a git repository.
|
struct WorkingChangeVisitor<'a> {
|
||||||
|
count: &'a mut usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> gix_status::index_as_worktree_with_renames::VisitEntry<'a> for WorkingChangeVisitor<'a> {
|
||||||
|
type ContentChange = <gix_status::index_as_worktree::traits::FastEq as gix_status::index_as_worktree::traits::CompareBlobs>::Output;
|
||||||
|
type SubmoduleStatus = gix::submodule::Status;
|
||||||
|
|
||||||
|
fn visit_entry(&mut self, entry: gix_status::index_as_worktree_with_renames::Entry<'a, Self::ContentChange, Self::SubmoduleStatus>) {
|
||||||
|
if let Some(summary) = entry.summary() {
|
||||||
|
match summary {
|
||||||
|
gix_status::index_as_worktree_with_renames::Summary::Added
|
||||||
|
| gix_status::index_as_worktree_with_renames::Summary::Modified
|
||||||
|
| gix_status::index_as_worktree_with_renames::Summary::Removed
|
||||||
|
| gix_status::index_as_worktree_with_renames::Summary::TypeChange
|
||||||
|
| gix_status::index_as_worktree_with_renames::Summary::Renamed
|
||||||
|
| gix_status::index_as_worktree_with_renames::Summary::Copied => {
|
||||||
|
*self.count += 1;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get git status for a directory using gix (no subprocesses).
|
||||||
|
/// Returns None if not in a git repository or an error occurs.
|
||||||
fn get_git_status(cwd: &str) -> Option<GitStatus> {
|
fn get_git_status(cwd: &str) -> Option<GitStatus> {
|
||||||
use std::process::Command;
|
let repo = gix::discover(cwd).ok()?;
|
||||||
|
|
||||||
// Check if we're in a git repo and get the branch name
|
// Get branch name from HEAD
|
||||||
let head_output = Command::new("git")
|
let head_name = repo.head_name().ok().flatten()?;
|
||||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
let head_name_bstr = head_name.as_bstr();
|
||||||
.current_dir(cwd)
|
let head = head_name_bstr
|
||||||
.output()
|
.strip_prefix(b"refs/heads/")
|
||||||
.ok()?;
|
.or(head_name_bstr.strip_prefix(b"refs/tags/"))
|
||||||
|
.or_else(|| head_name_bstr.strip_prefix(b"refs/"))
|
||||||
|
.map(|s| String::from_utf8_lossy(s).into_owned())
|
||||||
|
.unwrap_or_else(|| String::from_utf8_lossy(head_name_bstr).into_owned());
|
||||||
|
|
||||||
if !head_output.status.success() {
|
if head.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let head = String::from_utf8_lossy(&head_output.stdout)
|
// Get ahead/behind against upstream
|
||||||
.trim()
|
let mut ahead = 0usize;
|
||||||
.to_string();
|
let mut behind = 0usize;
|
||||||
|
if let Ok(head_id) = repo.head_id() {
|
||||||
|
if let Ok(head_ref) = repo.find_reference("HEAD") {
|
||||||
|
if let gix::refs::TargetRef::Symbolic(upstream_name) = head_ref.target() {
|
||||||
|
let upstream_full = format!("refs/remotes/origin/{}", upstream_name);
|
||||||
|
if let Ok(upstream_ref) = repo.find_reference(&upstream_full) {
|
||||||
|
let mut upstream_ref = upstream_ref;
|
||||||
|
if let Ok(upstream_id) = upstream_ref.peel_to_id() {
|
||||||
|
let head_id_detached = head_id.detach();
|
||||||
|
let upstream_id_detached = upstream_id.detach();
|
||||||
|
// Count ahead: commits reachable from head_id but not from upstream_id
|
||||||
|
let mut count = 0usize;
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut queue = vec![head_id_detached];
|
||||||
|
while let Some(current) = queue.pop() {
|
||||||
|
if seen.contains(¤t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.insert(current);
|
||||||
|
if let Ok(commit) = repo.find_commit(current.clone()) {
|
||||||
|
for parent_oid in commit.parent_ids() {
|
||||||
|
let parent_oid_detached = parent_oid.detach();
|
||||||
|
if parent_oid_detached == upstream_id_detached {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !seen.contains(&parent_oid_detached) {
|
||||||
|
queue.push(parent_oid_detached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
ahead = count;
|
||||||
|
|
||||||
// Get ahead/behind status
|
// Count behind: commits reachable from upstream_id but not from head_id
|
||||||
let mut ahead = 0;
|
let mut count = 0usize;
|
||||||
let mut behind = 0;
|
let mut seen = std::collections::HashSet::new();
|
||||||
if let Ok(output) = Command::new("git")
|
let mut queue = vec![upstream_id_detached];
|
||||||
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
|
while let Some(current) = queue.pop() {
|
||||||
.current_dir(cwd)
|
if seen.contains(¤t) {
|
||||||
.output()
|
continue;
|
||||||
{
|
}
|
||||||
if output.status.success() {
|
seen.insert(current);
|
||||||
let counts = String::from_utf8_lossy(&output.stdout);
|
if let Ok(commit) = repo.find_commit(current.clone()) {
|
||||||
let parts: Vec<&str> = counts.trim().split_whitespace().collect();
|
for parent_oid in commit.parent_ids() {
|
||||||
if parts.len() == 2 {
|
let parent_oid_detached = parent_oid.detach();
|
||||||
ahead = parts[0].parse().unwrap_or(0);
|
if parent_oid_detached == head_id_detached {
|
||||||
behind = parts[1].parse().unwrap_or(0);
|
break;
|
||||||
}
|
}
|
||||||
}
|
if !seen.contains(&parent_oid_detached) {
|
||||||
}
|
queue.push(parent_oid_detached);
|
||||||
|
}
|
||||||
// Get working directory and staging status using git status --porcelain
|
}
|
||||||
let mut working_modified = 0;
|
}
|
||||||
let mut working_added = 0;
|
count += 1;
|
||||||
let mut working_deleted = 0;
|
}
|
||||||
let mut staging_modified = 0;
|
behind = count;
|
||||||
let mut staging_added = 0;
|
}
|
||||||
let mut staging_deleted = 0;
|
|
||||||
|
|
||||||
if let Ok(output) = Command::new("git")
|
|
||||||
.args(["status", "--porcelain"])
|
|
||||||
.current_dir(cwd)
|
|
||||||
.output()
|
|
||||||
{
|
|
||||||
if output.status.success() {
|
|
||||||
let status = String::from_utf8_lossy(&output.stdout);
|
|
||||||
for line in status.lines() {
|
|
||||||
if line.len() < 2 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let chars: Vec<char> = line.chars().collect();
|
|
||||||
let staging_char = chars[0];
|
|
||||||
let working_char = chars[1];
|
|
||||||
|
|
||||||
// Staging status (first column)
|
|
||||||
match staging_char {
|
|
||||||
'M' => staging_modified += 1,
|
|
||||||
'A' => staging_added += 1,
|
|
||||||
'D' => staging_deleted += 1,
|
|
||||||
'R' => staging_modified += 1, // renamed
|
|
||||||
'C' => staging_added += 1, // copied
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Working directory status (second column)
|
|
||||||
match working_char {
|
|
||||||
'M' => working_modified += 1,
|
|
||||||
'D' => working_deleted += 1,
|
|
||||||
'?' => working_added += 1, // untracked
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build status strings like oh-my-posh format
|
// Get stash count from refs
|
||||||
let working_changed = working_modified + working_added + working_deleted;
|
let stash_count = if repo.find_reference("refs/stash").is_ok() {
|
||||||
let mut working_parts = Vec::new();
|
1
|
||||||
if working_modified > 0 {
|
} else {
|
||||||
working_parts.push(format!("~{}", working_modified));
|
0
|
||||||
}
|
};
|
||||||
if working_added > 0 {
|
|
||||||
working_parts.push(format!("+{}", working_added));
|
|
||||||
}
|
|
||||||
if working_deleted > 0 {
|
|
||||||
working_parts.push(format!("-{}", working_deleted));
|
|
||||||
}
|
|
||||||
let working_string = working_parts.join(" ");
|
|
||||||
|
|
||||||
let staging_changed = staging_modified + staging_added + staging_deleted;
|
// Get staging status (HEAD tree vs index) and working status (index vs worktree)
|
||||||
let mut staging_parts = Vec::new();
|
let mut staging_changed = 0usize;
|
||||||
if staging_modified > 0 {
|
let mut working_changed = 0usize;
|
||||||
staging_parts.push(format!("~{}", staging_modified));
|
|
||||||
}
|
|
||||||
if staging_added > 0 {
|
|
||||||
staging_parts.push(format!("+{}", staging_added));
|
|
||||||
}
|
|
||||||
if staging_deleted > 0 {
|
|
||||||
staging_parts.push(format!("-{}", staging_deleted));
|
|
||||||
}
|
|
||||||
let staging_string = staging_parts.join(" ");
|
|
||||||
|
|
||||||
// Get stash count
|
// Get HEAD tree ID for tree-index diff
|
||||||
let mut stash_count = 0;
|
if let Ok(head_tree_id) = repo.head_tree_id() {
|
||||||
if let Ok(output) = Command::new("git")
|
// Count staging changes (HEAD tree vs index)
|
||||||
.args(["stash", "list"])
|
if let Ok(index) = repo.index() {
|
||||||
.current_dir(cwd)
|
let _ = repo.tree_index_status(
|
||||||
.output()
|
&head_tree_id,
|
||||||
{
|
&index,
|
||||||
if output.status.success() {
|
None::<&mut gix::Pathspec<'_>>,
|
||||||
let stash = String::from_utf8_lossy(&output.stdout);
|
gix::status::tree_index::TrackRenames::AsConfigured,
|
||||||
stash_count = stash.lines().count();
|
|change: gix_diff::index::ChangeRef<'_, '_>, _tree_idx: &gix::index::State, _worktree_idx: &gix::index::State| {
|
||||||
|
match change {
|
||||||
|
gix_diff::index::ChangeRef::Addition { .. } => staging_changed += 1,
|
||||||
|
gix_diff::index::ChangeRef::Deletion { .. } => staging_changed += 1,
|
||||||
|
gix_diff::index::ChangeRef::Modification { .. } => staging_changed += 1,
|
||||||
|
gix_diff::index::ChangeRef::Rewrite { .. } => staging_changed += 1,
|
||||||
|
}
|
||||||
|
{ let cf: std::ops::ControlFlow<()> = std::ops::ControlFlow::Continue(()); Ok::<_, Box<dyn std::error::Error + Send + Sync>>(cf) }
|
||||||
|
},
|
||||||
|
).ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count working changes (index vs worktree)
|
||||||
|
if let Ok(index) = repo.index() {
|
||||||
|
let _ = repo.index_worktree_status(
|
||||||
|
&index,
|
||||||
|
Vec::<&str>::new(),
|
||||||
|
&mut WorkingChangeVisitor { count: &mut working_changed },
|
||||||
|
gix_status::index_as_worktree::traits::FastEq,
|
||||||
|
gix::status::index_worktree::BuiltinSubmoduleStatus::new(repo.clone().into_sync(), gix::status::Submodule::AsConfigured { check_dirty: false }).ok()?,
|
||||||
|
&mut gix::progress::Discard,
|
||||||
|
&std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
Default::default(),
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build status strings matching original format
|
||||||
|
let working_string = if working_changed > 0 {
|
||||||
|
format!("{}", working_changed)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let staging_string = if staging_changed > 0 {
|
||||||
|
format!("{}", staging_changed)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
Some(GitStatus {
|
Some(GitStatus {
|
||||||
head,
|
head,
|
||||||
ahead,
|
ahead,
|
||||||
@@ -3453,7 +3500,7 @@ fn setup_config_watcher(
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
env_logger::Builder::from_env(
|
env_logger::Builder::from_env(
|
||||||
env_logger::Env::default().default_filter_or("info"),
|
env_logger::Env::default().default_filter_or("warn"),
|
||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user