use std::path::{Path, PathBuf};
use super::log::{self, Commit};
#[derive(Debug, Clone)]
pub struct CommitDetail {
pub hash: String,
pub message: String,
pub files: Vec<(String, String)>,
}
pub struct GitGraphPane {
pub workspace: PathBuf,
pub commits: Vec<Commit>,
pub selected: usize,
pub scroll: usize,
pub detail: Option<CommitDetail>,
pub hash_filter: String,
pub hash_filter_mode: bool,
pub has_wip: bool,
pub embedded_diff: Option<crate::pane::DiffView>,
pub wip_commit: WipCommitInput,
pub filter: crate::git::log::LogFilter,
pub sort: Option<(SortColumn, bool)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortColumn {
Date,
Author,
Sha,
}
#[derive(Debug, Clone, Default)]
pub struct WipCommitInput {
pub text: String,
pub cursor: usize,
pub focused: bool,
pub scroll: usize,
pub ai_streaming: bool,
}
const LIMIT: usize = 800;
impl GitGraphPane {
pub fn open(workspace: &Path) -> Self {
let commits = log::load(workspace, LIMIT);
let has_wip = working_tree_has_changes(workspace);
let mut p = GitGraphPane {
workspace: workspace.to_path_buf(),
commits,
selected: 0,
scroll: 0,
detail: None,
hash_filter: String::new(),
hash_filter_mode: false,
has_wip,
embedded_diff: None,
wip_commit: WipCommitInput::default(),
filter: crate::git::log::LogFilter::default(),
sort: None,
};
p.reload_detail();
p
}
pub fn cycle_sort(&mut self, col: SortColumn) {
self.sort = match self.sort {
Some((c, true)) if c == col => Some((col, false)),
Some((c, false)) if c == col => None,
_ => Some((col, false)),
};
self.apply_sort();
self.selected = 0;
self.scroll = 0;
}
pub fn apply_sort(&mut self) {
let Some((col, asc)) = self.sort else {
return;
};
match col {
SortColumn::Date => self.commits.sort_by(|a, b| {
if asc {
a.time.cmp(&b.time)
} else {
b.time.cmp(&a.time)
}
}),
SortColumn::Author => self.commits.sort_by(|a, b| {
if asc {
a.author.cmp(&b.author)
} else {
b.author.cmp(&a.author)
}
}),
SortColumn::Sha => self.commits.sort_by(|a, b| {
if asc {
a.short.cmp(&b.short)
} else {
b.short.cmp(&a.short)
}
}),
}
}
pub fn total_rows(&self) -> usize {
self.commits.len() + usize::from(self.has_wip)
}
pub fn is_wip_selected(&self) -> bool {
self.has_wip && self.selected == 0
}
pub fn commit_index(&self) -> Option<usize> {
if self.is_wip_selected() {
return None;
}
let offset = usize::from(self.has_wip);
let idx = self.selected.checked_sub(offset)?;
if idx < self.commits.len() {
Some(idx)
} else {
None
}
}
pub fn find_by_hash_prefix(&self, prefix: &str) -> Option<usize> {
if prefix.is_empty() {
return None;
}
let needle = prefix.to_ascii_lowercase();
self.commits
.iter()
.position(|c| c.hash.to_ascii_lowercase().starts_with(&needle))
}
pub fn jump_to(&mut self, idx: usize) -> bool {
let total = self.total_rows();
if total == 0 {
return false;
}
let clamped = idx.min(total - 1);
if clamped == self.selected {
return false;
}
self.selected = clamped;
self.reload_detail();
true
}
pub fn jump_to_commit(&mut self, commit_idx: usize) -> bool {
self.jump_to(commit_idx + usize::from(self.has_wip))
}
pub fn tab_title(&self) -> String {
"git graph".to_string()
}
pub fn refresh(&mut self) {
self.commits = log::load_filtered(&self.workspace, LIMIT, &self.filter);
self.apply_sort();
self.has_wip = working_tree_has_changes(&self.workspace);
let total = self.total_rows();
if total == 0 {
self.selected = 0;
} else if self.selected >= total {
self.selected = total - 1;
}
self.reload_detail();
}
pub fn retarget(&mut self, workspace: &Path) {
self.workspace = workspace.to_path_buf();
self.selected = 0;
self.scroll = 0;
self.filter = crate::git::log::LogFilter::default();
self.commits = log::load_filtered(&self.workspace, LIMIT, &self.filter);
self.has_wip = working_tree_has_changes(&self.workspace);
self.reload_detail();
}
pub fn move_selection(&mut self, delta: isize) {
let total = self.total_rows();
if total == 0 {
return;
}
let n = total as isize;
let next = (self.selected as isize + delta).clamp(0, n - 1) as usize;
if next != self.selected {
self.selected = next;
self.reload_detail();
}
}
pub fn selected_commit(&self) -> Option<&Commit> {
let idx = self.commit_index()?;
self.commits.get(idx)
}
pub fn reload_detail(&mut self) {
let idx = self.commit_index();
self.detail = idx.and_then(|i| self.commits.get(i)).map(|c| CommitDetail {
hash: c.hash.clone(),
message: log::full_message(&self.workspace, &c.hash),
files: log::changed_files(&self.workspace, &c.hash),
});
}
}
impl WipCommitInput {
pub fn set_text(&mut self, text: String) {
self.cursor = text.len();
self.text = text;
self.scroll = 0;
}
pub fn insert_char(&mut self, ch: char) {
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
self.text.insert_str(self.cursor, s);
self.cursor += s.len();
}
pub fn insert_str(&mut self, s: &str) {
self.text.insert_str(self.cursor, s);
self.cursor += s.len();
}
pub fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let prev = prev_char_boundary(&self.text, self.cursor);
self.text.replace_range(prev..self.cursor, "");
self.cursor = prev;
}
pub fn delete_forward(&mut self) {
if self.cursor >= self.text.len() {
return;
}
let next = next_char_boundary(&self.text, self.cursor);
self.text.replace_range(self.cursor..next, "");
}
pub fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor = prev_char_boundary(&self.text, self.cursor);
}
}
pub fn move_right(&mut self) {
if self.cursor < self.text.len() {
self.cursor = next_char_boundary(&self.text, self.cursor);
}
}
pub fn move_line_start(&mut self) {
if let Some(nl) = self.text[..self.cursor].rfind('\n') {
self.cursor = nl + 1;
} else {
self.cursor = 0;
}
}
pub fn move_line_end(&mut self) {
if let Some(rel) = self.text[self.cursor..].find('\n') {
self.cursor += rel;
} else {
self.cursor = self.text.len();
}
}
pub fn clear(&mut self) {
self.text.clear();
self.cursor = 0;
self.scroll = 0;
}
pub fn delete_word_back(&mut self) {
if self.cursor == 0 {
return;
}
let line_start = self.text[..self.cursor]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let head = &self.text[line_start..self.cursor];
let trimmed = head.trim_end_matches(char::is_whitespace);
let cut = trimmed
.char_indices()
.rev()
.find(|&(_, c)| c.is_whitespace())
.map(|(i, c)| line_start + i + c.len_utf8())
.unwrap_or(line_start);
self.text.replace_range(cut..self.cursor, "");
self.cursor = cut;
}
pub fn delete_to_line_start(&mut self) {
let line_start = self.text[..self.cursor]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
if line_start == self.cursor {
return;
}
self.text.replace_range(line_start..self.cursor, "");
self.cursor = line_start;
}
pub fn delete_to_line_end(&mut self) {
let end = self.text[self.cursor..]
.find('\n')
.map(|rel| self.cursor + rel)
.unwrap_or(self.text.len());
if end == self.cursor {
return;
}
self.text.replace_range(self.cursor..end, "");
}
pub fn is_blank(&self) -> bool {
self.text.trim().is_empty()
}
}
fn prev_char_boundary(s: &str, mut at: usize) -> usize {
if at == 0 {
return 0;
}
at -= 1;
while at > 0 && !s.is_char_boundary(at) {
at -= 1;
}
at
}
fn next_char_boundary(s: &str, mut at: usize) -> usize {
let len = s.len();
if at >= len {
return len;
}
at += 1;
while at < len && !s.is_char_boundary(at) {
at += 1;
}
at
}
fn working_tree_has_changes(workspace: &Path) -> bool {
use std::process::Command;
match Command::new("git")
.args(["status", "--porcelain"])
.current_dir(workspace)
.output()
{
Ok(out) if out.status.success() => !out.stdout.is_empty(),
_ => false,
}
}