From f130cd7db346169c13c8acff9642efc3ece8cc2a Mon Sep 17 00:00:00 2001 From: Andew Power Date: Wed, 17 Dec 2025 12:15:26 +0200 Subject: [PATCH 01/20] feat: add tag management, create (t) and delete (ctrl+t) tags - Create tag dialog: name input, optional message, push to origin checkbox - Delete tag dialog: select from list, delete from remote option - Tags sorted with semver awareness - UI updates immediately after tag operations --- assets/default-keybind.toml | 3 + src/app.rs | 126 +++++++++++++- src/event.rs | 16 ++ src/git.rs | 79 +++++++++ src/view.rs | 2 + src/view/create_tag.rs | 329 ++++++++++++++++++++++++++++++++++++ src/view/delete_tag.rs | 271 +++++++++++++++++++++++++++++ src/view/help.rs | 7 +- src/view/list.rs | 15 ++ src/view/views.rs | 50 +++++- src/widget/commit_list.rs | 45 ++++- 11 files changed, 926 insertions(+), 17 deletions(-) create mode 100644 src/view/create_tag.rs create mode 100644 src/view/delete_tag.rs diff --git a/assets/default-keybind.toml b/assets/default-keybind.toml index 6fa3583..fbdc621 100644 --- a/assets/default-keybind.toml +++ b/assets/default-keybind.toml @@ -37,3 +37,6 @@ user_command_view_toggle_1 = ["d"] # copy part of information, ex: copy the short commit hash not all short_copy = ["c"] full_copy = ["shift-c"] + +create_tag = ["t"] +delete_tag = ["ctrl-t"] diff --git a/src/app.rs b/src/app.rs index 9c7d588..8aae143 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,7 @@ use crate::{ config::{CoreConfig, CursorType, UiConfig}, event::{AppEvent, Receiver, Sender, UserEvent, UserEventWithCount}, external::copy_to_clipboard, - git::{Head, Repository}, + git::{CommitHash, Head, Ref, Repository}, graph::{CellWidthType, Graph, GraphImageManager}, keybind::KeyBind, protocol::ImageProtocol, @@ -76,9 +76,9 @@ impl<'a> App<'a> { .iter() .enumerate() .map(|(i, commit)| { - let refs = repository.refs(&commit.commit_hash); + let refs: Vec<_> = repository.refs(&commit.commit_hash).into_iter().cloned().collect(); for r in &refs { - ref_name_to_commit_index_map.insert(r.name(), i); + ref_name_to_commit_index_map.insert(r.name().to_string(), i); } let (pos_x, _) = graph.commit_pos_map[&commit.commit_hash]; let graph_color = graph_color_set.get(pos_x).to_ratatui_color(); @@ -171,10 +171,10 @@ impl App<'_> { self.numeric_prefix.clear(); } None => { - if let StatusLine::Input(_, _, _) = self.status_line { + let is_input_mode = matches!(self.status_line, StatusLine::Input(_, _, _)) + || matches!(self.view, View::CreateTag(_)); + if is_input_mode { // In input mode, pass all key events to the view - // fixme: currently, the only thing that processes key_event is searching the list, - // so this probably works, but it's not the right process... self.numeric_prefix.clear(); self.view.handle_event( UserEventWithCount::from_event(UserEvent::Unknown), @@ -221,6 +221,24 @@ impl App<'_> { AppEvent::CloseRefs => { self.close_refs(); } + AppEvent::OpenCreateTag => { + self.open_create_tag(); + } + AppEvent::CloseCreateTag => { + self.close_create_tag(); + } + AppEvent::AddTagToCommit { commit_hash, tag_name } => { + self.add_tag_to_commit(&commit_hash, &tag_name); + } + AppEvent::OpenDeleteTag => { + self.open_delete_tag(); + } + AppEvent::CloseDeleteTag => { + self.close_delete_tag(); + } + AppEvent::RemoveTagFromCommit { commit_hash, tag_name } => { + self.remove_tag_from_commit(&commit_hash, &tag_name); + } AppEvent::OpenHelp => { self.open_help(); } @@ -516,6 +534,102 @@ impl App<'_> { } } + fn open_create_tag(&mut self) { + if let View::List(ref mut view) = self.view { + let commit_list_state = view.take_list_state(); + let commit_hash = commit_list_state.selected_commit_hash().clone(); + self.view = View::of_create_tag( + commit_list_state, + commit_hash, + self.repository.path().to_path_buf(), + self.ui_config, + self.color_theme, + self.tx.clone(), + ); + } + } + + fn close_create_tag(&mut self) { + if let View::CreateTag(ref mut view) = self.view { + let commit_list_state = view.take_list_state(); + self.view = View::of_list( + commit_list_state, + self.ui_config, + self.color_theme, + self.tx.clone(), + ); + } + } + + fn add_tag_to_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) { + let new_tag = Ref::Tag { + name: tag_name.to_string(), + target: commit_hash.clone(), + }; + + match &mut self.view { + View::List(view) => { + view.add_ref_to_commit(commit_hash, new_tag); + } + View::CreateTag(view) => { + view.add_ref_to_commit(commit_hash, new_tag); + } + _ => {} + } + } + + fn open_delete_tag(&mut self) { + if let View::List(ref mut view) = self.view { + let commit_list_state = view.take_list_state(); + let commit_hash = commit_list_state.selected_commit_hash().clone(); + let tags = commit_list_state.selected_commit_refs(); + let has_tags = tags.iter().any(|r| matches!(r, Ref::Tag { .. })); + if !has_tags { + self.view = View::of_list( + commit_list_state, + self.ui_config, + self.color_theme, + self.tx.clone(), + ); + self.tx.send(AppEvent::NotifyWarn("No tags on this commit".into())); + return; + } + self.view = View::of_delete_tag( + commit_list_state, + commit_hash, + tags, + self.repository.path().to_path_buf(), + self.ui_config, + self.color_theme, + self.tx.clone(), + ); + } + } + + fn close_delete_tag(&mut self) { + if let View::DeleteTag(ref mut view) = self.view { + let commit_list_state = view.take_list_state(); + self.view = View::of_list( + commit_list_state, + self.ui_config, + self.color_theme, + self.tx.clone(), + ); + } + } + + fn remove_tag_from_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) { + match &mut self.view { + View::List(view) => { + view.remove_ref_from_commit(commit_hash, tag_name); + } + View::DeleteTag(view) => { + view.remove_ref_from_commit(commit_hash, tag_name); + } + _ => {} + } + } + fn open_help(&mut self) { let before_view = std::mem::take(&mut self.view); self.view = View::of_help( diff --git a/src/event.rs b/src/event.rs index 9584934..9875fd6 100644 --- a/src/event.rs +++ b/src/event.rs @@ -22,6 +22,18 @@ pub enum AppEvent { ClearUserCommand, OpenRefs, CloseRefs, + OpenCreateTag, + CloseCreateTag, + AddTagToCommit { + commit_hash: crate::git::CommitHash, + tag_name: String, + }, + OpenDeleteTag, + CloseDeleteTag, + RemoveTagFromCommit { + commit_hash: crate::git::CommitHash, + tag_name: String, + }, OpenHelp, CloseHelp, ClearHelp, @@ -126,6 +138,8 @@ pub enum UserEvent { FuzzyToggle, ShortCopy, FullCopy, + CreateTag, + DeleteTag, Unknown, } @@ -188,6 +202,8 @@ impl<'de> Deserialize<'de> for UserEvent { "fuzzy_toggle" => Ok(UserEvent::FuzzyToggle), "short_copy" => Ok(UserEvent::ShortCopy), "full_copy" => Ok(UserEvent::FullCopy), + "create_tag" => Ok(UserEvent::CreateTag), + "delete_tag" => Ok(UserEvent::DeleteTag), _ => { let msg = format!("Unknown user event: {}", value); Err(de::Error::custom(msg)) diff --git a/src/git.rs b/src/git.rs index 824bb77..08359b7 100644 --- a/src/git.rs +++ b/src/git.rs @@ -211,6 +211,10 @@ impl Repository { &self.head } + pub fn path(&self) -> &Path { + &self.path + } + pub fn commit_detail(&self, commit_hash: &CommitHash) -> (Commit, Vec) { let commit = self.commit(commit_hash).unwrap().clone(); let changes = if commit.parent_commit_hashes.is_empty() { @@ -674,3 +678,78 @@ pub fn get_initial_commit_additions(path: &Path, commit_hash: &CommitHash) -> Ve changes } + +pub fn create_tag( + path: &Path, + name: &str, + commit_hash: &CommitHash, + message: Option<&str>, +) -> std::result::Result<(), String> { + let mut cmd = Command::new("git"); + cmd.arg("tag"); + if let Some(msg) = message { + if !msg.is_empty() { + cmd.arg("-a").arg("-m").arg(msg); + } + } + cmd.arg(name).arg(commit_hash.as_str()).current_dir(path); + + let output = cmd + .output() + .map_err(|e| format!("Failed to execute git tag: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to create tag: {stderr}")); + } + Ok(()) +} + +pub fn push_tag(path: &Path, tag_name: &str) -> std::result::Result<(), String> { + let output = Command::new("git") + .arg("push") + .arg("origin") + .arg(tag_name) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to execute git push: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to push tag: {stderr}")); + } + Ok(()) +} + +pub fn delete_tag(path: &Path, tag_name: &str) -> std::result::Result<(), String> { + let output = Command::new("git") + .arg("tag") + .arg("-d") + .arg(tag_name) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to execute git tag -d: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to delete tag: {stderr}")); + } + Ok(()) +} + +pub fn delete_remote_tag(path: &Path, tag_name: &str) -> std::result::Result<(), String> { + let output = Command::new("git") + .arg("push") + .arg("origin") + .arg("--delete") + .arg(tag_name) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to execute git push --delete: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to delete remote tag: {stderr}")); + } + Ok(()) +} diff --git a/src/view.rs b/src/view.rs index 5b918f4..9316d8b 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1,5 +1,7 @@ mod views; +mod create_tag; +mod delete_tag; mod detail; mod help; mod list; diff --git a/src/view/create_tag.rs b/src/view/create_tag.rs new file mode 100644 index 0000000..db63673 --- /dev/null +++ b/src/view/create_tag.rs @@ -0,0 +1,329 @@ +use std::path::PathBuf; + +use ratatui::{ + crossterm::event::{Event, KeyEvent}, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Padding, Paragraph}, + Frame, +}; +use tui_input::{backend::crossterm::EventHandler, Input}; + +use crate::{ + color::ColorTheme, + config::UiConfig, + event::{AppEvent, Sender, UserEvent, UserEventWithCount}, + git::{create_tag, push_tag, CommitHash, Ref}, + widget::commit_list::{CommitList, CommitListState}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FocusedField { + TagName, + Message, + PushCheckbox, +} + +#[derive(Debug)] +pub struct CreateTagView<'a> { + commit_list_state: Option>, + commit_hash: CommitHash, + repo_path: PathBuf, + + tag_name_input: Input, + tag_message_input: Input, + push_to_remote: bool, + focused_field: FocusedField, + + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + tx: Sender, +} + +impl<'a> CreateTagView<'a> { + pub fn new( + commit_list_state: CommitListState<'a>, + commit_hash: CommitHash, + repo_path: PathBuf, + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + tx: Sender, + ) -> CreateTagView<'a> { + CreateTagView { + commit_list_state: Some(commit_list_state), + commit_hash, + repo_path, + tag_name_input: Input::default(), + tag_message_input: Input::default(), + push_to_remote: true, + focused_field: FocusedField::TagName, + ui_config, + color_theme, + tx, + } + } + + pub fn handle_event(&mut self, event_with_count: UserEventWithCount, key: KeyEvent) { + use ratatui::crossterm::event::KeyCode; + + // Handle Tab for focus switching (before UserEvent processing) + if key.code == KeyCode::Tab { + self.focus_next(); + return; + } + if key.code == KeyCode::BackTab { + self.focus_prev(); + return; + } + + // Handle Backspace for input (don't close dialog) + if key.code == KeyCode::Backspace { + self.handle_input(key); + return; + } + + let event = event_with_count.event; + + match event { + UserEvent::Cancel => { + self.tx.send(AppEvent::CloseCreateTag); + } + UserEvent::Confirm => { + self.submit(); + } + UserEvent::NavigateDown => { + self.focus_next(); + } + UserEvent::NavigateUp => { + self.focus_prev(); + } + UserEvent::NavigateRight | UserEvent::NavigateLeft => { + if self.focused_field == FocusedField::PushCheckbox { + self.push_to_remote = !self.push_to_remote; + } else { + self.handle_input(key); + } + } + _ => { + self.handle_input(key); + } + } + } + + fn handle_input(&mut self, key: KeyEvent) { + match self.focused_field { + FocusedField::TagName => { + self.tag_name_input.handle_event(&Event::Key(key)); + } + FocusedField::Message => { + self.tag_message_input.handle_event(&Event::Key(key)); + } + FocusedField::PushCheckbox => { + if key.code == ratatui::crossterm::event::KeyCode::Char(' ') { + self.push_to_remote = !self.push_to_remote; + } + } + } + } + + fn focus_next(&mut self) { + self.focused_field = match self.focused_field { + FocusedField::TagName => FocusedField::Message, + FocusedField::Message => FocusedField::PushCheckbox, + FocusedField::PushCheckbox => FocusedField::TagName, + }; + } + + fn focus_prev(&mut self) { + self.focused_field = match self.focused_field { + FocusedField::TagName => FocusedField::PushCheckbox, + FocusedField::Message => FocusedField::TagName, + FocusedField::PushCheckbox => FocusedField::Message, + }; + } + + fn submit(&mut self) { + let tag_name = self.tag_name_input.value().trim(); + if tag_name.is_empty() { + self.tx + .send(AppEvent::NotifyError("Tag name cannot be empty".into())); + return; + } + + let message = self.tag_message_input.value().trim(); + let message = if message.is_empty() { + None + } else { + Some(message) + }; + + if let Err(e) = create_tag(&self.repo_path, tag_name, &self.commit_hash, message) { + self.tx.send(AppEvent::NotifyError(e)); + return; + } + + if self.push_to_remote { + if let Err(e) = push_tag(&self.repo_path, tag_name) { + self.tx.send(AppEvent::NotifyError(e)); + return; + } + } + + // Update UI with new tag + self.tx.send(AppEvent::AddTagToCommit { + commit_hash: self.commit_hash.clone(), + tag_name: tag_name.to_string(), + }); + + let msg = if self.push_to_remote { + format!("Tag '{}' created and pushed to origin", tag_name) + } else { + format!("Tag '{}' created", tag_name) + }; + self.tx.send(AppEvent::NotifySuccess(msg)); + self.tx.send(AppEvent::CloseCreateTag); + } + + pub fn render(&mut self, f: &mut Frame, area: Rect) { + // Render commit list in background + let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); + f.render_stateful_widget(commit_list, area, self.as_mut_list_state()); + + // Dialog dimensions + let dialog_width = 50u16.min(area.width.saturating_sub(4)); + let dialog_height = 10u16.min(area.height.saturating_sub(2)); + + let dialog_x = (area.width.saturating_sub(dialog_width)) / 2; + let dialog_y = (area.height.saturating_sub(dialog_height)) / 2; + + let dialog_area = Rect::new( + area.x + dialog_x, + area.y + dialog_y, + dialog_width, + dialog_height, + ); + + f.render_widget(Clear, dialog_area); + + let block = Block::default() + .title(" Create Tag ") + .borders(Borders::ALL) + .border_style(Style::default().fg(self.color_theme.divider_fg)) + .style(Style::default().bg(self.color_theme.bg).fg(self.color_theme.fg)) + .padding(Padding::horizontal(1)); + + let inner_area = block.inner(dialog_area); + f.render_widget(block, dialog_area); + + let [commit_area, tag_name_area, message_area, push_area, hint_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(1), + ]) + .areas(inner_area); + + // Commit hash + let commit_line = Line::from(vec![ + Span::raw("Commit: ").fg(self.color_theme.fg), + Span::raw(self.commit_hash.as_short_hash()).fg(self.color_theme.list_hash_fg), + ]); + f.render_widget(Paragraph::new(commit_line), commit_area); + + // Tag name input + let tag_input_area = self.render_input_field(f, tag_name_area, "Tag name:", self.tag_name_input.value(), FocusedField::TagName); + + // Message input + let msg_input_area = self.render_input_field(f, message_area, "Message:", self.tag_message_input.value(), FocusedField::Message); + + // Push checkbox + let checkbox = if self.push_to_remote { "[x]" } else { "[ ]" }; + let checkbox_style = if self.focused_field == FocusedField::PushCheckbox { + Style::default().add_modifier(Modifier::BOLD).fg(self.color_theme.status_success_fg) + } else { + Style::default().fg(self.color_theme.fg) + }; + let push_line = Line::from(vec![ + Span::styled(checkbox, checkbox_style), + Span::raw(" Push to origin").fg(self.color_theme.fg), + ]); + f.render_widget(Paragraph::new(push_line), push_area); + + // Hints + let hint_line = Line::from(vec![ + Span::raw("Enter").fg(self.color_theme.help_key_fg), + Span::raw(" submit ").fg(self.color_theme.fg), + Span::raw("Esc").fg(self.color_theme.help_key_fg), + Span::raw(" cancel ").fg(self.color_theme.fg), + Span::raw("Tab/↑↓").fg(self.color_theme.help_key_fg), + Span::raw(" nav").fg(self.color_theme.fg), + ]); + f.render_widget(Paragraph::new(hint_line).centered(), hint_area); + + // Cursor positioning + if self.focused_field == FocusedField::TagName { + let cursor_x = tag_input_area.x + 1 + self.tag_name_input.visual_cursor() as u16; + f.set_cursor_position((cursor_x.min(tag_input_area.right().saturating_sub(1)), tag_input_area.y)); + } else if self.focused_field == FocusedField::Message { + let cursor_x = msg_input_area.x + 1 + self.tag_message_input.visual_cursor() as u16; + f.set_cursor_position((cursor_x.min(msg_input_area.right().saturating_sub(1)), msg_input_area.y)); + } + } + + fn render_input_field(&self, f: &mut Frame, area: Rect, label: &str, value: &str, field: FocusedField) -> Rect { + let is_focused = self.focused_field == field; + let label_style = if is_focused { + Style::default().add_modifier(Modifier::BOLD).fg(self.color_theme.status_success_fg) + } else { + Style::default().fg(self.color_theme.fg) + }; + + let [label_area, input_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(area); + + f.render_widget( + Paragraph::new(Line::from(Span::styled(label, label_style))), + label_area, + ); + + let input_style = if is_focused { + Style::default().bg(self.color_theme.list_selected_bg) + } else { + Style::default() + }; + + let max_width = input_area.width.saturating_sub(2) as usize; + let display_value = if value.len() > max_width { + &value[value.len() - max_width..] + } else { + value + }; + + f.render_widget( + Paragraph::new(Line::from(Span::raw(format!(" {}", display_value)))) + .style(input_style), + input_area, + ); + + input_area + } + + fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { + self.commit_list_state.as_mut().unwrap() + } +} + +impl<'a> CreateTagView<'a> { + pub fn take_list_state(&mut self) -> CommitListState<'a> { + self.commit_list_state.take().unwrap() + } + + pub fn add_ref_to_commit(&mut self, commit_hash: &CommitHash, new_ref: Ref) { + self.as_mut_list_state().add_ref_to_commit(commit_hash, new_ref); + } +} diff --git a/src/view/delete_tag.rs b/src/view/delete_tag.rs new file mode 100644 index 0000000..7fe2168 --- /dev/null +++ b/src/view/delete_tag.rs @@ -0,0 +1,271 @@ +use std::path::PathBuf; + +use ratatui::{ + crossterm::event::KeyEvent, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Padding, Paragraph}, + Frame, +}; + +use crate::{ + color::ColorTheme, + config::UiConfig, + event::{AppEvent, Sender, UserEvent, UserEventWithCount}, + git::{delete_remote_tag, delete_tag, CommitHash, Ref}, + widget::commit_list::{CommitList, CommitListState}, +}; + +#[derive(Debug)] +pub struct DeleteTagView<'a> { + commit_list_state: Option>, + commit_hash: CommitHash, + repo_path: PathBuf, + + tags: Vec, + selected_index: usize, + delete_from_remote: bool, + + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + tx: Sender, +} + +impl<'a> DeleteTagView<'a> { + pub fn new( + commit_list_state: CommitListState<'a>, + commit_hash: CommitHash, + tags: Vec, + repo_path: PathBuf, + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + tx: Sender, + ) -> DeleteTagView<'a> { + let mut tag_names: Vec = tags + .into_iter() + .filter_map(|r| match r { + Ref::Tag { name, .. } => Some(name), + _ => None, + }) + .collect(); + + tag_names.sort_by(|a, b| compare_semver(a, b)); + + DeleteTagView { + commit_list_state: Some(commit_list_state), + commit_hash, + repo_path, + tags: tag_names, + selected_index: 0, + delete_from_remote: true, + ui_config, + color_theme, + tx, + } + } + + pub fn handle_event(&mut self, event_with_count: UserEventWithCount, _key: KeyEvent) { + let event = event_with_count.event; + + match event { + UserEvent::Cancel => { + self.tx.send(AppEvent::CloseDeleteTag); + } + UserEvent::Confirm => { + self.delete_selected(); + } + UserEvent::NavigateDown | UserEvent::SelectDown => { + if self.selected_index < self.tags.len().saturating_sub(1) { + self.selected_index += 1; + } + } + UserEvent::NavigateUp | UserEvent::SelectUp => { + if self.selected_index > 0 { + self.selected_index -= 1; + } + } + UserEvent::NavigateRight | UserEvent::NavigateLeft => { + self.delete_from_remote = !self.delete_from_remote; + } + _ => {} + } + } + + fn delete_selected(&mut self) { + if self.tags.is_empty() { + return; + } + + let tag_name = &self.tags[self.selected_index]; + + if let Err(e) = delete_tag(&self.repo_path, tag_name) { + self.tx.send(AppEvent::NotifyError(e)); + return; + } + + if self.delete_from_remote { + if let Err(e) = delete_remote_tag(&self.repo_path, tag_name) { + self.tx.send(AppEvent::NotifyError(format!( + "Local tag deleted, but failed to delete from remote: {}", + e + ))); + } + } + + self.tx.send(AppEvent::RemoveTagFromCommit { + commit_hash: self.commit_hash.clone(), + tag_name: tag_name.clone(), + }); + + let msg = if self.delete_from_remote { + format!("Tag '{}' deleted from local and remote", tag_name) + } else { + format!("Tag '{}' deleted locally", tag_name) + }; + self.tx.send(AppEvent::NotifySuccess(msg)); + + self.tags.remove(self.selected_index); + if self.selected_index >= self.tags.len() && self.selected_index > 0 { + self.selected_index -= 1; + } + + if self.tags.is_empty() { + self.tx.send(AppEvent::CloseDeleteTag); + } + } + + pub fn render(&mut self, f: &mut Frame, area: Rect) { + let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); + f.render_stateful_widget(commit_list, area, self.as_mut_list_state()); + + let dialog_width = 50u16.min(area.width.saturating_sub(4)); + let list_height = (self.tags.len() as u16).min(8); + let dialog_height = (6 + list_height).min(area.height.saturating_sub(2)); + + let dialog_x = (area.width.saturating_sub(dialog_width)) / 2; + let dialog_y = (area.height.saturating_sub(dialog_height)) / 2; + + let dialog_area = Rect::new( + area.x + dialog_x, + area.y + dialog_y, + dialog_width, + dialog_height, + ); + + f.render_widget(Clear, dialog_area); + + let block = Block::default() + .title(" Delete Tag ") + .borders(Borders::ALL) + .border_style(Style::default().fg(self.color_theme.divider_fg)) + .style(Style::default().bg(self.color_theme.bg).fg(self.color_theme.fg)) + .padding(Padding::horizontal(1)); + + let inner_area = block.inner(dialog_area); + f.render_widget(block, dialog_area); + + let [commit_area, list_area, checkbox_area, hint_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(list_height), + Constraint::Length(2), + Constraint::Min(1), + ]) + .areas(inner_area); + + let commit_line = Line::from(vec![ + Span::raw("Commit: ").fg(self.color_theme.fg), + Span::raw(self.commit_hash.as_short_hash()).fg(self.color_theme.list_hash_fg), + ]); + f.render_widget(Paragraph::new(commit_line), commit_area); + + let tag_lines: Vec = self + .tags + .iter() + .enumerate() + .map(|(i, tag)| { + let is_selected = i == self.selected_index; + let prefix = if is_selected { "> " } else { " " }; + let style = if is_selected { + Style::default() + .bg(self.color_theme.list_selected_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + Line::from(Span::styled(format!("{}{}", prefix, tag), style)) + }) + .collect(); + + if tag_lines.is_empty() { + f.render_widget( + Paragraph::new(Line::from("No tags on this commit".fg(self.color_theme.fg))), + list_area, + ); + } else { + f.render_widget(Paragraph::new(tag_lines), list_area); + } + + let checkbox = if self.delete_from_remote { "[x]" } else { "[ ]" }; + let checkbox_style = Style::default().fg(self.color_theme.fg); + let checkbox_line = Line::from(vec![ + Span::styled(checkbox, checkbox_style), + Span::raw(" Delete from origin").fg(self.color_theme.fg), + ]); + f.render_widget(Paragraph::new(checkbox_line), checkbox_area); + + let hint_line = Line::from(vec![ + Span::raw("Enter").fg(self.color_theme.help_key_fg), + Span::raw(" delete ").fg(self.color_theme.fg), + Span::raw("Esc").fg(self.color_theme.help_key_fg), + Span::raw(" close ").fg(self.color_theme.fg), + Span::raw("↑↓").fg(self.color_theme.help_key_fg), + Span::raw(" select ").fg(self.color_theme.fg), + Span::raw("←→").fg(self.color_theme.help_key_fg), + Span::raw(" toggle").fg(self.color_theme.fg), + ]); + f.render_widget(Paragraph::new(hint_line).centered(), hint_area); + } + + fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { + self.commit_list_state.as_mut().unwrap() + } +} + +impl<'a> DeleteTagView<'a> { + pub fn take_list_state(&mut self) -> CommitListState<'a> { + self.commit_list_state.take().unwrap() + } + + pub fn remove_ref_from_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) { + self.as_mut_list_state().remove_ref_from_commit(commit_hash, tag_name); + } +} + +fn compare_semver(a: &str, b: &str) -> std::cmp::Ordering { + let parse_version = |s: &str| -> Option<(u64, u64, u64, String)> { + let s = s.strip_prefix('v').unwrap_or(s); + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() >= 3 { + let major = parts[0].parse::().ok()?; + let minor = parts[1].parse::().ok()?; + let patch_str = parts[2]; + let (patch_num, suffix) = if let Some(idx) = patch_str.find(|c: char| !c.is_ascii_digit()) { + let (num, suf) = patch_str.split_at(idx); + (num.parse::().ok()?, suf.to_string()) + } else { + (patch_str.parse::().ok()?, String::new()) + }; + Some((major, minor, patch_num, suffix)) + } else { + None + } + }; + + match (parse_version(a), parse_version(b)) { + (Some(va), Some(vb)) => va.cmp(&vb), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.cmp(b), + } +} diff --git a/src/view/help.rs b/src/view/help.rs index 067e3a0..7b41eba 100644 --- a/src/view/help.rs +++ b/src/view/help.rs @@ -274,10 +274,12 @@ fn build_lines( (vec![UserEvent::FuzzyToggle], "Toggle fuzzy match".into()), (vec![UserEvent::ShortCopy], "Copy commit short hash".into()), (vec![UserEvent::FullCopy], "Copy commit hash".into()), + (vec![UserEvent::CreateTag], "Create tag on commit".into()), + (vec![UserEvent::DeleteTag], "Delete tag from commit".into()), ]; list_helps.extend(user_command_view_toggle_helps.clone()); let (list_key_lines, list_value_lines) = build_block_lines("Commit List:", list_helps, color_theme, keybind); - + let mut detail_helps = vec![ (vec![UserEvent::Cancel, UserEvent::Close], "Close commit details".into()), (vec![UserEvent::NavigateDown], "Scroll down".into()), @@ -308,9 +310,10 @@ fn build_lines( (vec![UserEvent::ShortCopy], "Copy ref name".into()), ]; let (refs_key_lines, refs_value_lines) = build_block_lines("Refs List:", refs_helps, color_theme, keybind); - + let mut user_command_helps = vec![ (vec![UserEvent::Cancel, UserEvent::Close], "Close user command".into()), + (vec![UserEvent::CreateTag], "Create tag dialog".into()), (vec![UserEvent::NavigateDown], "Scroll down".into()), (vec![UserEvent::NavigateUp], "Scroll up".into()), (vec![UserEvent::PageDown], "Scroll page down".into()), diff --git a/src/view/list.rs b/src/view/list.rs index 7ae225d..aaf931e 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -4,6 +4,7 @@ use crate::{ color::ColorTheme, config::UiConfig, event::{AppEvent, Sender, UserEvent, UserEventWithCount}, + git::{CommitHash, Ref}, widget::commit_list::{CommitList, CommitListState, SearchState}, }; @@ -149,6 +150,12 @@ impl<'a> ListView<'a> { UserEvent::RefListToggle => { self.tx.send(AppEvent::OpenRefs); } + UserEvent::CreateTag => { + self.tx.send(AppEvent::OpenCreateTag); + } + UserEvent::DeleteTag => { + self.tx.send(AppEvent::OpenDeleteTag); + } _ => {} } } @@ -180,6 +187,14 @@ impl<'a> ListView<'a> { self.commit_list_state.take().unwrap() } + pub fn add_ref_to_commit(&mut self, commit_hash: &CommitHash, new_ref: Ref) { + self.as_mut_list_state().add_ref_to_commit(commit_hash, new_ref); + } + + pub fn remove_ref_from_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) { + self.as_mut_list_state().remove_ref_from_commit(commit_hash, tag_name); + } + fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { self.commit_list_state.as_mut().unwrap() } diff --git a/src/view/views.rs b/src/view/views.rs index 7320572..714c15b 100644 --- a/src/view/views.rs +++ b/src/view/views.rs @@ -1,13 +1,17 @@ use ratatui::{crossterm::event::KeyEvent, layout::Rect, Frame}; +use std::path::PathBuf; + use crate::{ color::ColorTheme, config::{CoreConfig, UiConfig}, event::{Sender, UserEventWithCount}, - git::{Commit, FileChange, Ref}, + git::{Commit, CommitHash, FileChange, Ref}, keybind::KeyBind, protocol::ImageProtocol, view::{ + create_tag::CreateTagView, + delete_tag::DeleteTagView, detail::DetailView, help::HelpView, list::ListView, @@ -25,6 +29,8 @@ pub enum View<'a> { Detail(Box>), UserCommand(Box>), Refs(Box>), + CreateTag(Box>), + DeleteTag(Box>), Help(Box>), } @@ -36,6 +42,8 @@ impl<'a> View<'a> { View::Detail(view) => view.handle_event(event_with_count, key_event), View::UserCommand(view) => view.handle_event(event_with_count, key_event), View::Refs(view) => view.handle_event(event_with_count, key_event), + View::CreateTag(view) => view.handle_event(event_with_count, key_event), + View::DeleteTag(view) => view.handle_event(event_with_count, key_event), View::Help(view) => view.handle_event(event_with_count, key_event), } } @@ -47,6 +55,8 @@ impl<'a> View<'a> { View::Detail(view) => view.render(f, area), View::UserCommand(view) => view.render(f, area), View::Refs(view) => view.render(f, area), + View::CreateTag(view) => view.render(f, area), + View::DeleteTag(view) => view.render(f, area), View::Help(view) => view.render(f, area), } } @@ -153,6 +163,44 @@ impl<'a> View<'a> { ))) } + pub fn of_create_tag( + commit_list_state: CommitListState<'a>, + commit_hash: CommitHash, + repo_path: PathBuf, + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + tx: Sender, + ) -> Self { + View::CreateTag(Box::new(CreateTagView::new( + commit_list_state, + commit_hash, + repo_path, + ui_config, + color_theme, + tx, + ))) + } + + pub fn of_delete_tag( + commit_list_state: CommitListState<'a>, + commit_hash: CommitHash, + tags: Vec, + repo_path: PathBuf, + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + tx: Sender, + ) -> Self { + View::DeleteTag(Box::new(DeleteTagView::new( + commit_list_state, + commit_hash, + tags, + repo_path, + ui_config, + color_theme, + tx, + ))) + } + pub fn of_help( before: View<'a>, color_theme: &'a ColorTheme, diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index b2107cd..04348d1 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -27,18 +27,22 @@ const ELLIPSIS: &str = "..."; #[derive(Debug)] pub struct CommitInfo<'a> { commit: &'a Commit, - refs: Vec<&'a Ref>, + refs: Vec, graph_color: Color, } impl<'a> CommitInfo<'a> { - pub fn new(commit: &'a Commit, refs: Vec<&'a Ref>, graph_color: Color) -> Self { + pub fn new(commit: &'a Commit, refs: Vec, graph_color: Color) -> Self { Self { commit, refs, graph_color, } } + + pub fn commit_hash(&self) -> &CommitHash { + &self.commit.commit_hash + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -86,11 +90,11 @@ struct SearchMatch { } impl SearchMatch { - fn new(c: &Commit, refs: &[&Ref], q: &str, ignore_case: bool, fuzzy: bool) -> Self { + fn new(c: &Commit, refs: &[Ref], q: &str, ignore_case: bool, fuzzy: bool) -> Self { let matcher = SearchMatcher::new(q, ignore_case, fuzzy); let refs = refs .iter() - .filter(|r| !matches!(*r, Ref::Stash { .. })) + .filter(|r| !matches!(r, Ref::Stash { .. })) .filter_map(|r| { matcher .matched_position(r.name()) @@ -185,7 +189,7 @@ pub struct CommitListState<'a> { graph_cell_width: u16, head: &'a Head, - ref_name_to_commit_index_map: HashMap<&'a str, usize>, + ref_name_to_commit_index_map: HashMap, search_state: SearchState, search_input: Input, @@ -206,7 +210,7 @@ impl<'a> CommitListState<'a> { graph_image_manager: GraphImageManager<'a>, graph_cell_width: u16, head: &'a Head, - ref_name_to_commit_index_map: HashMap<&'a str, usize>, + ref_name_to_commit_index_map: HashMap, default_ignore_case: bool, default_fuzzy: bool, ) -> CommitListState<'a> { @@ -233,6 +237,27 @@ impl<'a> CommitListState<'a> { self.graph_cell_width + 1 // right pad } + pub fn add_ref_to_commit(&mut self, commit_hash: &CommitHash, new_ref: Ref) { + for (index, commit_info) in self.commits.iter_mut().enumerate() { + if commit_info.commit_hash() == commit_hash { + self.ref_name_to_commit_index_map.insert(new_ref.name().to_string(), index); + commit_info.refs.push(new_ref); + commit_info.refs.sort(); + break; + } + } + } + + pub fn remove_ref_from_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) { + for commit_info in self.commits.iter_mut() { + if commit_info.commit_hash() == commit_hash { + self.ref_name_to_commit_index_map.remove(tag_name); + commit_info.refs.retain(|r| r.name() != tag_name); + break; + } + } + } + pub fn select_next(&mut self) { if self.selected < (self.total - 1).min(self.height - 1) { self.selected += 1; @@ -381,6 +406,10 @@ impl<'a> CommitListState<'a> { .commit_hash } + pub fn selected_commit_refs(&self) -> Vec { + self.commits[self.current_selected_index()].refs.clone() + } + fn current_selected_index(&self) -> usize { self.offset + self.selected } @@ -967,9 +996,9 @@ fn refs_spans<'a>( let refs = &commit_info.refs; if refs.len() == 1 { - if let Ref::Stash { name, .. } = refs[0] { + if let Ref::Stash { name, .. } = &refs[0] { return vec![ - Span::raw(name).fg(color_theme.list_ref_stash_fg).bold(), + Span::raw(name.clone()).fg(color_theme.list_ref_stash_fg).bold(), Span::raw(" "), ]; } From 3364f195850cce24a724be58e142646e4164b1a7 Mon Sep 17 00:00:00 2001 From: Andew Power Date: Wed, 17 Dec 2025 12:22:41 +0200 Subject: [PATCH 02/20] chore: update readme.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 376e3a0..1b4a9a8 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,8 @@ The default key bindings can be overridden. Please refer to [default-keybind.tom | Ctrl-g | Toggle ignore case (if searching) | `ignore_case_toggle` | | Ctrl-x | Toggle fuzzy match (if searching) | `fuzzy_toggle` | | c/C | Copy commit short/full hash | `short_copy` `full_copy` | +| t | Create tag on commit | `create_tag` | +| Ctrl-t | Delete tag from commit | `delete_tag` | | d | Toggle custom user command view | `user_command_view_toggle_1` | #### Commit Detail From 8d368cca4f511e4d535180533e73b3e73c74ceff Mon Sep 17 00:00:00 2001 From: Andew Power Date: Wed, 17 Dec 2025 15:43:17 +0200 Subject: [PATCH 03/20] chore: cargo fmt --- src/app.rs | 24 +++++++++++---- src/event.rs | 5 +++- src/view/create_tag.rs | 62 +++++++++++++++++++++++++++++---------- src/view/delete_tag.rs | 28 ++++++++++++------ src/view/list.rs | 6 ++-- src/widget/commit_list.rs | 7 +++-- 6 files changed, 96 insertions(+), 36 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8aae143..eea36e4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -76,7 +76,11 @@ impl<'a> App<'a> { .iter() .enumerate() .map(|(i, commit)| { - let refs: Vec<_> = repository.refs(&commit.commit_hash).into_iter().cloned().collect(); + let refs: Vec<_> = repository + .refs(&commit.commit_hash) + .into_iter() + .cloned() + .collect(); for r in &refs { ref_name_to_commit_index_map.insert(r.name().to_string(), i); } @@ -171,8 +175,9 @@ impl App<'_> { self.numeric_prefix.clear(); } None => { - let is_input_mode = matches!(self.status_line, StatusLine::Input(_, _, _)) - || matches!(self.view, View::CreateTag(_)); + let is_input_mode = + matches!(self.status_line, StatusLine::Input(_, _, _)) + || matches!(self.view, View::CreateTag(_)); if is_input_mode { // In input mode, pass all key events to the view self.numeric_prefix.clear(); @@ -227,7 +232,10 @@ impl App<'_> { AppEvent::CloseCreateTag => { self.close_create_tag(); } - AppEvent::AddTagToCommit { commit_hash, tag_name } => { + AppEvent::AddTagToCommit { + commit_hash, + tag_name, + } => { self.add_tag_to_commit(&commit_hash, &tag_name); } AppEvent::OpenDeleteTag => { @@ -236,7 +244,10 @@ impl App<'_> { AppEvent::CloseDeleteTag => { self.close_delete_tag(); } - AppEvent::RemoveTagFromCommit { commit_hash, tag_name } => { + AppEvent::RemoveTagFromCommit { + commit_hash, + tag_name, + } => { self.remove_tag_from_commit(&commit_hash, &tag_name); } AppEvent::OpenHelp => { @@ -591,7 +602,8 @@ impl App<'_> { self.color_theme, self.tx.clone(), ); - self.tx.send(AppEvent::NotifyWarn("No tags on this commit".into())); + self.tx + .send(AppEvent::NotifyWarn("No tags on this commit".into())); return; } self.view = View::of_delete_tag( diff --git a/src/event.rs b/src/event.rs index 9875fd6..bae453d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -40,7 +40,10 @@ pub enum AppEvent { SelectNewerCommit, SelectOlderCommit, SelectParentCommit, - CopyToClipboard { name: String, value: String }, + CopyToClipboard { + name: String, + value: String, + }, ClearStatusLine, UpdateStatusInput(String, Option, Option), NotifyInfo(String), diff --git a/src/view/create_tag.rs b/src/view/create_tag.rs index db63673..4f84dc1 100644 --- a/src/view/create_tag.rs +++ b/src/view/create_tag.rs @@ -210,7 +210,11 @@ impl<'a> CreateTagView<'a> { .title(" Create Tag ") .borders(Borders::ALL) .border_style(Style::default().fg(self.color_theme.divider_fg)) - .style(Style::default().bg(self.color_theme.bg).fg(self.color_theme.fg)) + .style( + Style::default() + .bg(self.color_theme.bg) + .fg(self.color_theme.fg), + ) .padding(Padding::horizontal(1)); let inner_area = block.inner(dialog_area); @@ -233,15 +237,29 @@ impl<'a> CreateTagView<'a> { f.render_widget(Paragraph::new(commit_line), commit_area); // Tag name input - let tag_input_area = self.render_input_field(f, tag_name_area, "Tag name:", self.tag_name_input.value(), FocusedField::TagName); + let tag_input_area = self.render_input_field( + f, + tag_name_area, + "Tag name:", + self.tag_name_input.value(), + FocusedField::TagName, + ); // Message input - let msg_input_area = self.render_input_field(f, message_area, "Message:", self.tag_message_input.value(), FocusedField::Message); + let msg_input_area = self.render_input_field( + f, + message_area, + "Message:", + self.tag_message_input.value(), + FocusedField::Message, + ); // Push checkbox let checkbox = if self.push_to_remote { "[x]" } else { "[ ]" }; let checkbox_style = if self.focused_field == FocusedField::PushCheckbox { - Style::default().add_modifier(Modifier::BOLD).fg(self.color_theme.status_success_fg) + Style::default() + .add_modifier(Modifier::BOLD) + .fg(self.color_theme.status_success_fg) } else { Style::default().fg(self.color_theme.fg) }; @@ -265,26 +283,38 @@ impl<'a> CreateTagView<'a> { // Cursor positioning if self.focused_field == FocusedField::TagName { let cursor_x = tag_input_area.x + 1 + self.tag_name_input.visual_cursor() as u16; - f.set_cursor_position((cursor_x.min(tag_input_area.right().saturating_sub(1)), tag_input_area.y)); + f.set_cursor_position(( + cursor_x.min(tag_input_area.right().saturating_sub(1)), + tag_input_area.y, + )); } else if self.focused_field == FocusedField::Message { let cursor_x = msg_input_area.x + 1 + self.tag_message_input.visual_cursor() as u16; - f.set_cursor_position((cursor_x.min(msg_input_area.right().saturating_sub(1)), msg_input_area.y)); + f.set_cursor_position(( + cursor_x.min(msg_input_area.right().saturating_sub(1)), + msg_input_area.y, + )); } } - fn render_input_field(&self, f: &mut Frame, area: Rect, label: &str, value: &str, field: FocusedField) -> Rect { + fn render_input_field( + &self, + f: &mut Frame, + area: Rect, + label: &str, + value: &str, + field: FocusedField, + ) -> Rect { let is_focused = self.focused_field == field; let label_style = if is_focused { - Style::default().add_modifier(Modifier::BOLD).fg(self.color_theme.status_success_fg) + Style::default() + .add_modifier(Modifier::BOLD) + .fg(self.color_theme.status_success_fg) } else { Style::default().fg(self.color_theme.fg) }; - let [label_area, input_area] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas(area); + let [label_area, input_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); f.render_widget( Paragraph::new(Line::from(Span::styled(label, label_style))), @@ -305,8 +335,7 @@ impl<'a> CreateTagView<'a> { }; f.render_widget( - Paragraph::new(Line::from(Span::raw(format!(" {}", display_value)))) - .style(input_style), + Paragraph::new(Line::from(Span::raw(format!(" {}", display_value)))).style(input_style), input_area, ); @@ -324,6 +353,7 @@ impl<'a> CreateTagView<'a> { } pub fn add_ref_to_commit(&mut self, commit_hash: &CommitHash, new_ref: Ref) { - self.as_mut_list_state().add_ref_to_commit(commit_hash, new_ref); + self.as_mut_list_state() + .add_ref_to_commit(commit_hash, new_ref); } } diff --git a/src/view/delete_tag.rs b/src/view/delete_tag.rs index 7fe2168..465544b 100644 --- a/src/view/delete_tag.rs +++ b/src/view/delete_tag.rs @@ -159,7 +159,11 @@ impl<'a> DeleteTagView<'a> { .title(" Delete Tag ") .borders(Borders::ALL) .border_style(Style::default().fg(self.color_theme.divider_fg)) - .style(Style::default().bg(self.color_theme.bg).fg(self.color_theme.fg)) + .style( + Style::default() + .bg(self.color_theme.bg) + .fg(self.color_theme.fg), + ) .padding(Padding::horizontal(1)); let inner_area = block.inner(dialog_area); @@ -206,7 +210,11 @@ impl<'a> DeleteTagView<'a> { f.render_widget(Paragraph::new(tag_lines), list_area); } - let checkbox = if self.delete_from_remote { "[x]" } else { "[ ]" }; + let checkbox = if self.delete_from_remote { + "[x]" + } else { + "[ ]" + }; let checkbox_style = Style::default().fg(self.color_theme.fg); let checkbox_line = Line::from(vec![ Span::styled(checkbox, checkbox_style), @@ -238,7 +246,8 @@ impl<'a> DeleteTagView<'a> { } pub fn remove_ref_from_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) { - self.as_mut_list_state().remove_ref_from_commit(commit_hash, tag_name); + self.as_mut_list_state() + .remove_ref_from_commit(commit_hash, tag_name); } } @@ -250,12 +259,13 @@ fn compare_semver(a: &str, b: &str) -> std::cmp::Ordering { let major = parts[0].parse::().ok()?; let minor = parts[1].parse::().ok()?; let patch_str = parts[2]; - let (patch_num, suffix) = if let Some(idx) = patch_str.find(|c: char| !c.is_ascii_digit()) { - let (num, suf) = patch_str.split_at(idx); - (num.parse::().ok()?, suf.to_string()) - } else { - (patch_str.parse::().ok()?, String::new()) - }; + let (patch_num, suffix) = + if let Some(idx) = patch_str.find(|c: char| !c.is_ascii_digit()) { + let (num, suf) = patch_str.split_at(idx); + (num.parse::().ok()?, suf.to_string()) + } else { + (patch_str.parse::().ok()?, String::new()) + }; Some((major, minor, patch_num, suffix)) } else { None diff --git a/src/view/list.rs b/src/view/list.rs index aaf931e..70ec457 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -188,11 +188,13 @@ impl<'a> ListView<'a> { } pub fn add_ref_to_commit(&mut self, commit_hash: &CommitHash, new_ref: Ref) { - self.as_mut_list_state().add_ref_to_commit(commit_hash, new_ref); + self.as_mut_list_state() + .add_ref_to_commit(commit_hash, new_ref); } pub fn remove_ref_from_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) { - self.as_mut_list_state().remove_ref_from_commit(commit_hash, tag_name); + self.as_mut_list_state() + .remove_ref_from_commit(commit_hash, tag_name); } fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index 04348d1..cf2e91d 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -240,7 +240,8 @@ impl<'a> CommitListState<'a> { pub fn add_ref_to_commit(&mut self, commit_hash: &CommitHash, new_ref: Ref) { for (index, commit_info) in self.commits.iter_mut().enumerate() { if commit_info.commit_hash() == commit_hash { - self.ref_name_to_commit_index_map.insert(new_ref.name().to_string(), index); + self.ref_name_to_commit_index_map + .insert(new_ref.name().to_string(), index); commit_info.refs.push(new_ref); commit_info.refs.sort(); break; @@ -998,7 +999,9 @@ fn refs_spans<'a>( if refs.len() == 1 { if let Ref::Stash { name, .. } = &refs[0] { return vec![ - Span::raw(name.clone()).fg(color_theme.list_ref_stash_fg).bold(), + Span::raw(name.clone()) + .fg(color_theme.list_ref_stash_fg) + .bold(), Span::raw(" "), ]; } From cff2c2ed0e1fde2ecffcc19f3d8d342dc3b4639f Mon Sep 17 00:00:00 2001 From: Andew Power Date: Wed, 17 Dec 2025 17:36:52 +0200 Subject: [PATCH 04/20] feat: pending windows for operations --- src/app.rs | 37 ++++++++++-- src/event.rs | 2 + src/view/create_tag.rs | 76 +++++++++++++++++-------- src/view/delete_tag.rs | 82 +++++++++++++++++---------- src/widget.rs | 1 + src/widget/commit_list.rs | 85 ++++++++++++++++++++++++---- src/widget/pending_overlay.rs | 103 ++++++++++++++++++++++++++++++++++ 7 files changed, 316 insertions(+), 70 deletions(-) create mode 100644 src/widget/pending_overlay.rs diff --git a/src/app.rs b/src/app.rs index eea36e4..a1332ff 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,7 +20,10 @@ use crate::{ keybind::KeyBind, protocol::ImageProtocol, view::View, - widget::commit_list::{CommitInfo, CommitListState}, + widget::{ + commit_list::{CommitInfo, CommitListState}, + pending_overlay::PendingOverlay, + }, }; #[derive(Debug)] @@ -43,6 +46,7 @@ pub struct App<'a> { repository: &'a Repository, view: View<'a>, status_line: StatusLine, + pending_message: Option, keybind: &'a KeyBind, core_config: &'a CoreConfig, @@ -76,11 +80,7 @@ impl<'a> App<'a> { .iter() .enumerate() .map(|(i, commit)| { - let refs: Vec<_> = repository - .refs(&commit.commit_hash) - .into_iter() - .cloned() - .collect(); + let refs = repository.refs(&commit.commit_hash); for r in &refs { ref_name_to_commit_index_map.insert(r.name().to_string(), i); } @@ -114,6 +114,7 @@ impl<'a> App<'a> { Self { repository, status_line: StatusLine::None, + pending_message: None, view, keybind, core_config, @@ -137,6 +138,19 @@ impl App<'_> { terminal.draw(|f| self.render(f))?; match rx.recv() { AppEvent::Key(key) => { + // Handle pending overlay - Esc hides it + if self.pending_message.is_some() { + if let Some(UserEvent::Cancel) = self.keybind.get(&key) { + self.pending_message = None; + self.tx.send(AppEvent::NotifyInfo( + "Operation continues in background".into(), + )); + continue; + } + // Block other keys while pending + continue; + } + match self.status_line { StatusLine::None | StatusLine::Input(_, _, _) => { // do nothing @@ -289,6 +303,12 @@ impl App<'_> { AppEvent::NotifyError(msg) => { self.error_notification(msg); } + AppEvent::ShowPendingOverlay { message } => { + self.pending_message = Some(message); + } + AppEvent::HidePendingOverlay => { + self.pending_message = None; + } } } } @@ -306,6 +326,11 @@ impl App<'_> { self.view.render(f, view_area); self.render_status_line(f, status_line_area); + + if let Some(message) = &self.pending_message { + let overlay = PendingOverlay::new(message, self.color_theme); + f.render_widget(overlay, f.area()); + } } } diff --git a/src/event.rs b/src/event.rs index bae453d..bd31af5 100644 --- a/src/event.rs +++ b/src/event.rs @@ -50,6 +50,8 @@ pub enum AppEvent { NotifySuccess(String), NotifyWarn(String), NotifyError(String), + ShowPendingOverlay { message: String }, + HidePendingOverlay, } #[derive(Clone)] diff --git a/src/view/create_tag.rs b/src/view/create_tag.rs index 4f84dc1..c5aacd8 100644 --- a/src/view/create_tag.rs +++ b/src/view/create_tag.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{path::PathBuf, thread}; use ratatui::{ crossterm::event::{Event, KeyEvent}, @@ -152,37 +152,67 @@ impl<'a> CreateTagView<'a> { } let message = self.tag_message_input.value().trim(); - let message = if message.is_empty() { + let message: Option = if message.is_empty() { None } else { - Some(message) + Some(message.to_string()) }; - if let Err(e) = create_tag(&self.repo_path, tag_name, &self.commit_hash, message) { - self.tx.send(AppEvent::NotifyError(e)); - return; - } + // Prepare data for background thread + let repo_path = self.repo_path.clone(); + let tag_name = tag_name.to_string(); + let commit_hash = self.commit_hash.clone(); + let push_to_remote = self.push_to_remote; + let tx = self.tx.clone(); - if self.push_to_remote { - if let Err(e) = push_tag(&self.repo_path, tag_name) { - self.tx.send(AppEvent::NotifyError(e)); + // Show pending overlay and close dialog + let pending_msg = if push_to_remote { + format!("Creating and pushing tag '{}'...", tag_name) + } else { + format!("Creating tag '{}'...", tag_name) + }; + self.tx + .send(AppEvent::ShowPendingOverlay { message: pending_msg }); + self.tx.send(AppEvent::CloseCreateTag); + + // Run git commands in background + thread::spawn(move || { + if let Err(e) = create_tag(&repo_path, &tag_name, &commit_hash, message.as_deref()) { + tx.send(AppEvent::HidePendingOverlay); + tx.send(AppEvent::NotifyError(e)); return; } - } - // Update UI with new tag - self.tx.send(AppEvent::AddTagToCommit { - commit_hash: self.commit_hash.clone(), - tag_name: tag_name.to_string(), - }); + if push_to_remote { + if let Err(e) = push_tag(&repo_path, &tag_name) { + tx.send(AppEvent::HidePendingOverlay); + tx.send(AppEvent::NotifyError(format!( + "Tag created locally, but push failed: {}", + e + ))); + // Still add tag to UI since local creation succeeded + tx.send(AppEvent::AddTagToCommit { + commit_hash, + tag_name, + }); + return; + } + } - let msg = if self.push_to_remote { - format!("Tag '{}' created and pushed to origin", tag_name) - } else { - format!("Tag '{}' created", tag_name) - }; - self.tx.send(AppEvent::NotifySuccess(msg)); - self.tx.send(AppEvent::CloseCreateTag); + // Success + tx.send(AppEvent::AddTagToCommit { + commit_hash, + tag_name: tag_name.clone(), + }); + + let msg = if push_to_remote { + format!("Tag '{}' created and pushed to origin", tag_name) + } else { + format!("Tag '{}' created", tag_name) + }; + tx.send(AppEvent::NotifySuccess(msg)); + tx.send(AppEvent::HidePendingOverlay); + }); } pub fn render(&mut self, f: &mut Frame, area: Rect) { diff --git a/src/view/delete_tag.rs b/src/view/delete_tag.rs index 465544b..d72103e 100644 --- a/src/view/delete_tag.rs +++ b/src/view/delete_tag.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{path::PathBuf, thread}; use ratatui::{ crossterm::event::KeyEvent, @@ -97,42 +97,62 @@ impl<'a> DeleteTagView<'a> { return; } - let tag_name = &self.tags[self.selected_index]; + let tag_name = self.tags[self.selected_index].clone(); - if let Err(e) = delete_tag(&self.repo_path, tag_name) { - self.tx.send(AppEvent::NotifyError(e)); - return; - } - - if self.delete_from_remote { - if let Err(e) = delete_remote_tag(&self.repo_path, tag_name) { - self.tx.send(AppEvent::NotifyError(format!( - "Local tag deleted, but failed to delete from remote: {}", - e - ))); - } - } - - self.tx.send(AppEvent::RemoveTagFromCommit { - commit_hash: self.commit_hash.clone(), - tag_name: tag_name.clone(), - }); + // Prepare data for background thread + let repo_path = self.repo_path.clone(); + let commit_hash = self.commit_hash.clone(); + let delete_from_remote = self.delete_from_remote; + let tx = self.tx.clone(); - let msg = if self.delete_from_remote { - format!("Tag '{}' deleted from local and remote", tag_name) + // Show pending overlay and close dialog + let pending_msg = if delete_from_remote { + format!("Deleting tag '{}' from local and remote...", tag_name) } else { - format!("Tag '{}' deleted locally", tag_name) + format!("Deleting tag '{}'...", tag_name) }; - self.tx.send(AppEvent::NotifySuccess(msg)); + self.tx + .send(AppEvent::ShowPendingOverlay { message: pending_msg }); + self.tx.send(AppEvent::CloseDeleteTag); + + // Run git commands in background + thread::spawn(move || { + if let Err(e) = delete_tag(&repo_path, &tag_name) { + tx.send(AppEvent::HidePendingOverlay); + tx.send(AppEvent::NotifyError(e)); + return; + } - self.tags.remove(self.selected_index); - if self.selected_index >= self.tags.len() && self.selected_index > 0 { - self.selected_index -= 1; - } + if delete_from_remote { + if let Err(e) = delete_remote_tag(&repo_path, &tag_name) { + tx.send(AppEvent::HidePendingOverlay); + tx.send(AppEvent::NotifyError(format!( + "Local tag deleted, but failed to delete from remote: {}", + e + ))); + // Still remove tag from UI since local deletion succeeded + tx.send(AppEvent::RemoveTagFromCommit { + commit_hash, + tag_name, + }); + return; + } + } - if self.tags.is_empty() { - self.tx.send(AppEvent::CloseDeleteTag); - } + // Success + tx.send(AppEvent::RemoveTagFromCommit { + commit_hash, + tag_name: tag_name.clone(), + }); + + let msg = if delete_from_remote { + format!("Tag '{}' deleted from local and remote", tag_name) + } else { + format!("Tag '{}' deleted locally", tag_name) + }; + tx.send(AppEvent::NotifySuccess(msg)); + tx.send(AppEvent::HidePendingOverlay); + }); } pub fn render(&mut self, f: &mut Frame, area: Rect) { diff --git a/src/widget.rs b/src/widget.rs index 4129d63..4f5f094 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1,4 +1,5 @@ pub mod commit_detail; pub mod commit_list; pub mod commit_user_command; +pub mod pending_overlay; pub mod ref_list; diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index cf2e91d..8c3e2a3 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -24,18 +24,78 @@ static FUZZY_MATCHER: Lazy = Lazy::new(|| SkimMatcherV2::default( const ELLIPSIS: &str = "..."; +#[derive(Debug)] +pub enum CommitRefs<'a> { + Borrowed(Vec<&'a Ref>), + Owned(Vec), +} + +impl<'a> CommitRefs<'a> { + fn make_owned(&mut self) { + if let CommitRefs::Borrowed(refs) = self { + *self = CommitRefs::Owned(refs.iter().map(|r| (*r).clone()).collect()); + } + } + + fn push(&mut self, r: Ref) { + self.make_owned(); + if let CommitRefs::Owned(refs) = self { + refs.push(r); + refs.sort(); + } + } + + fn retain(&mut self, f: F) + where + F: FnMut(&Ref) -> bool, + { + self.make_owned(); + if let CommitRefs::Owned(refs) = self { + refs.retain(f); + } + } + + fn iter(&self) -> Box + '_> { + match self { + CommitRefs::Borrowed(refs) => Box::new(refs.iter().copied()), + CommitRefs::Owned(refs) => Box::new(refs.iter()), + } + } + + fn len(&self) -> usize { + match self { + CommitRefs::Borrowed(refs) => refs.len(), + CommitRefs::Owned(refs) => refs.len(), + } + } + + fn get(&self, index: usize) -> Option<&Ref> { + match self { + CommitRefs::Borrowed(refs) => refs.get(index).copied(), + CommitRefs::Owned(refs) => refs.get(index), + } + } + + fn to_vec(&self) -> Vec { + match self { + CommitRefs::Borrowed(refs) => refs.iter().map(|r| (*r).clone()).collect(), + CommitRefs::Owned(refs) => refs.clone(), + } + } +} + #[derive(Debug)] pub struct CommitInfo<'a> { commit: &'a Commit, - refs: Vec, + refs: CommitRefs<'a>, graph_color: Color, } impl<'a> CommitInfo<'a> { - pub fn new(commit: &'a Commit, refs: Vec, graph_color: Color) -> Self { + pub fn new(commit: &'a Commit, refs: Vec<&'a Ref>, graph_color: Color) -> Self { Self { commit, - refs, + refs: CommitRefs::Borrowed(refs), graph_color, } } @@ -90,10 +150,15 @@ struct SearchMatch { } impl SearchMatch { - fn new(c: &Commit, refs: &[Ref], q: &str, ignore_case: bool, fuzzy: bool) -> Self { + fn new<'a>( + c: &Commit, + refs: impl Iterator, + q: &str, + ignore_case: bool, + fuzzy: bool, + ) -> Self { let matcher = SearchMatcher::new(q, ignore_case, fuzzy); let refs = refs - .iter() .filter(|r| !matches!(r, Ref::Stash { .. })) .filter_map(|r| { matcher @@ -243,7 +308,6 @@ impl<'a> CommitListState<'a> { self.ref_name_to_commit_index_map .insert(new_ref.name().to_string(), index); commit_info.refs.push(new_ref); - commit_info.refs.sort(); break; } } @@ -408,7 +472,7 @@ impl<'a> CommitListState<'a> { } pub fn selected_commit_refs(&self) -> Vec { - self.commits[self.current_selected_index()].refs.clone() + self.commits[self.current_selected_index()].refs.to_vec() } fn current_selected_index(&self) -> usize { @@ -611,7 +675,7 @@ impl<'a> CommitListState<'a> { for (i, commit_info) in self.commits.iter().enumerate() { let mut m = SearchMatch::new( commit_info.commit, - commit_info.refs.as_slice(), + commit_info.refs.iter(), q, ignore_case, fuzzy, @@ -997,7 +1061,7 @@ fn refs_spans<'a>( let refs = &commit_info.refs; if refs.len() == 1 { - if let Ref::Stash { name, .. } = &refs[0] { + if let Some(Ref::Stash { name, .. }) = refs.get(0) { return vec![ Span::raw(name.clone()) .fg(color_theme.list_ref_stash_fg) @@ -1053,6 +1117,7 @@ fn refs_spans<'a>( } } + let refs_len = refs.len(); for (i, ss) in ref_spans.into_iter().enumerate() { let (ref_spans, ref_name) = ss; if let Head::Branch { name } = head { @@ -1061,7 +1126,7 @@ fn refs_spans<'a>( } } spans.extend(ref_spans); - if i < refs.len() - 1 { + if i < refs_len - 1 { spans.push(Span::raw(", ").fg(color_theme.list_ref_paren_fg).bold()); } } diff --git a/src/widget/pending_overlay.rs b/src/widget/pending_overlay.rs new file mode 100644 index 0000000..c07e960 --- /dev/null +++ b/src/widget/pending_overlay.rs @@ -0,0 +1,103 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, +}; + +use crate::color::ColorTheme; + +pub struct PendingOverlay<'a> { + message: &'a str, + color_theme: &'a ColorTheme, +} + +impl<'a> PendingOverlay<'a> { + pub fn new(message: &'a str, color_theme: &'a ColorTheme) -> Self { + Self { + message, + color_theme, + } + } +} + +impl Widget for PendingOverlay<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let dialog_width = 40u16.min(area.width.saturating_sub(4)); + let max_text_width = dialog_width.saturating_sub(4) as usize; // borders + padding + + // Wrap message into lines + let message_lines: Vec = wrap_text(self.message, max_text_width) + .into_iter() + .map(|s| Line::from(Span::raw(s).add_modifier(Modifier::BOLD))) + .collect(); + + let dialog_height = (4 + message_lines.len() as u16).min(area.height.saturating_sub(2)); + + let dialog_x = (area.width.saturating_sub(dialog_width)) / 2; + let dialog_y = (area.height.saturating_sub(dialog_height)) / 2; + + let dialog_area = Rect::new( + area.x + dialog_x, + area.y + dialog_y, + dialog_width, + dialog_height, + ); + + Clear.render(dialog_area, buf); + + let block = Block::default() + .title(" Working... ") + .borders(Borders::ALL) + .border_style(Style::default().fg(self.color_theme.divider_fg)) + .style( + Style::default() + .bg(self.color_theme.bg) + .fg(self.color_theme.fg), + ) + .padding(Padding::horizontal(1)); + + let inner_area = block.inner(dialog_area); + block.render(dialog_area, buf); + + let mut lines = vec![Line::raw("")]; + lines.extend(message_lines); + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::raw("Esc").fg(self.color_theme.help_key_fg), + Span::raw(" hide").fg(self.color_theme.fg), + ])); + + Paragraph::new(lines) + .centered() + .render(inner_area, buf); + } +} + +fn wrap_text(text: &str, max_width: usize) -> Vec { + let mut lines = Vec::new(); + let mut current_line = String::new(); + + for word in text.split_whitespace() { + if current_line.is_empty() { + current_line = word.to_string(); + } else if current_line.len() + 1 + word.len() <= max_width { + current_line.push(' '); + current_line.push_str(word); + } else { + lines.push(current_line); + current_line = word.to_string(); + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + if lines.is_empty() { + lines.push(String::new()); + } + + lines +} From ceb69b8ecec1ce2a831d6d09c7675adf7b8f36cf Mon Sep 17 00:00:00 2001 From: Andew Power Date: Wed, 17 Dec 2025 18:10:10 +0200 Subject: [PATCH 05/20] feat: add refresh button, also refactor to Rc --- assets/default-keybind.toml | 2 + src/app.rs | 38 +++++----- src/event.rs | 3 + src/git.rs | 69 +++++++---------- src/graph/calc.rs | 116 ++++++++++++++-------------- src/graph/image.rs | 17 +++-- src/lib.rs | 6 +- src/view/create_tag.rs | 8 +- src/view/delete_tag.rs | 16 ++-- src/view/detail.rs | 22 +++--- src/view/list.rs | 13 ++-- src/view/refs.rs | 16 ++-- src/view/user_command.rs | 14 ++-- src/view/views.rs | 30 ++++---- src/widget/commit_detail.rs | 18 +++-- src/widget/commit_list.rs | 147 ++++++++++++------------------------ src/widget/ref_list.rs | 16 ++-- 17 files changed, 256 insertions(+), 295 deletions(-) diff --git a/assets/default-keybind.toml b/assets/default-keybind.toml index fbdc621..9aedfe2 100644 --- a/assets/default-keybind.toml +++ b/assets/default-keybind.toml @@ -40,3 +40,5 @@ full_copy = ["shift-c"] create_tag = ["t"] delete_tag = ["ctrl-t"] + +refresh = ["r"] diff --git a/src/app.rs b/src/app.rs index a1332ff..fd0cab0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -62,8 +62,8 @@ pub struct App<'a> { impl<'a> App<'a> { pub fn new( repository: &'a Repository, - graph_image_manager: GraphImageManager<'a>, - graph: &'a Graph, + graph_image_manager: GraphImageManager, + graph: &Graph, keybind: &'a KeyBind, core_config: &'a CoreConfig, ui_config: &'a UiConfig, @@ -86,7 +86,7 @@ impl<'a> App<'a> { } let (pos_x, _) = graph.commit_pos_map[&commit.commit_hash]; let graph_color = graph_color_set.get(pos_x).to_ratatui_color(); - CommitInfo::new(commit, refs, graph_color) + CommitInfo::new(commit.clone(), refs, graph_color) }) .collect(); let graph_cell_width = match cell_width_type { @@ -105,8 +105,8 @@ impl<'a> App<'a> { ); if let InitialSelection::Head = initial_selection { match repository.head() { - Head::Branch { name } => commit_list_state.select_ref(name), - Head::Detached { target } => commit_list_state.select_commit_hash(target), + Head::Branch { name } => commit_list_state.select_ref(&name), + Head::Detached { target } => commit_list_state.select_commit_hash(&target), } } let view = View::of_list(commit_list_state, ui_config, color_theme, tx.clone()); @@ -309,6 +309,9 @@ impl App<'_> { AppEvent::HidePendingOverlay => { self.pending_message = None; } + AppEvent::Refresh => { + self.refresh(); + } } } } @@ -405,12 +408,7 @@ impl App<'_> { let commit_list_state = view.take_list_state(); let selected = commit_list_state.selected_commit_hash().clone(); let (commit, changes) = self.repository.commit_detail(&selected); - let refs = self - .repository - .refs(&selected) - .into_iter() - .cloned() - .collect(); + let refs = self.repository.refs(&selected); self.view = View::of_detail( commit_list_state, commit, @@ -510,12 +508,7 @@ impl App<'_> { let commit_list_state = view.take_list_state(); let selected = commit_list_state.selected_commit_hash().clone(); let (commit, changes) = self.repository.commit_detail(&selected); - let refs = self - .repository - .refs(&selected) - .into_iter() - .cloned() - .collect(); + let refs = self.repository.refs(&selected); if view.before_view_is_list() { self.view = View::of_list( commit_list_state, @@ -547,7 +540,7 @@ impl App<'_> { fn open_refs(&mut self) { if let View::List(ref mut view) = self.view { let commit_list_state = view.take_list_state(); - let refs = self.repository.all_refs().into_iter().cloned().collect(); + let refs = self.repository.all_refs(); self.view = View::of_refs( commit_list_state, refs, @@ -619,7 +612,7 @@ impl App<'_> { let commit_list_state = view.take_list_state(); let commit_hash = commit_list_state.selected_commit_hash().clone(); let tags = commit_list_state.selected_commit_refs(); - let has_tags = tags.iter().any(|r| matches!(r, Ref::Tag { .. })); + let has_tags = tags.iter().any(|r| matches!(r.as_ref(), Ref::Tag { .. })); if !has_tags { self.view = View::of_list( commit_list_state, @@ -755,6 +748,13 @@ impl App<'_> { } } } + + fn refresh(&mut self) { + // TODO: Implement full refresh - requires App to own Repository and Graph + self.tx.send(AppEvent::NotifyInfo( + "Refresh: relaunch serie to reload repository".into(), + )); + } } fn process_numeric_prefix( diff --git a/src/event.rs b/src/event.rs index bd31af5..c8f8527 100644 --- a/src/event.rs +++ b/src/event.rs @@ -52,6 +52,7 @@ pub enum AppEvent { NotifyError(String), ShowPendingOverlay { message: String }, HidePendingOverlay, + Refresh, } #[derive(Clone)] @@ -145,6 +146,7 @@ pub enum UserEvent { FullCopy, CreateTag, DeleteTag, + Refresh, Unknown, } @@ -209,6 +211,7 @@ impl<'de> Deserialize<'de> for UserEvent { "full_copy" => Ok(UserEvent::FullCopy), "create_tag" => Ok(UserEvent::CreateTag), "delete_tag" => Ok(UserEvent::DeleteTag), + "refresh" => Ok(UserEvent::Refresh), _ => { let msg = format!("Unknown user event: {}", value); Err(de::Error::custom(msg)) diff --git a/src/git.rs b/src/git.rs index 08359b7..2e30d21 100644 --- a/src/git.rs +++ b/src/git.rs @@ -4,6 +4,7 @@ use std::{ io::{BufRead, BufReader}, path::{Path, PathBuf}, process::{Command, Stdio}, + rc::Rc, }; use chrono::{DateTime, FixedOffset}; @@ -104,14 +105,15 @@ pub enum SortCommit { Topological, } -type CommitMap = HashMap; +type CommitMap = HashMap>; type CommitsMap = HashMap>; -type RefMap = HashMap>; +type RefMap = HashMap>>; #[derive(Debug)] pub struct Repository { path: PathBuf, + sort: SortCommit, commit_map: CommitMap, parents_map: CommitsMap, @@ -140,42 +142,23 @@ impl Repository { let stash_ref_map = load_stashes_as_refs(path); merge_ref_maps(&mut ref_map, stash_ref_map); - Ok(Self::new( - path.to_path_buf(), + Ok(Self { + path: path.to_path_buf(), + sort, commit_map, parents_map, children_map, ref_map, head, commit_hashes, - )) - } - - pub fn new( - path: PathBuf, - commit_map: CommitMap, - parents_map: CommitsMap, - children_map: CommitsMap, - ref_map: RefMap, - head: Head, - commit_hashes: Vec, - ) -> Self { - Self { - path, - commit_map, - parents_map, - children_map, - ref_map, - head, - commit_hashes, - } + }) } - pub fn commit(&self, commit_hash: &CommitHash) -> Option<&Commit> { - self.commit_map.get(commit_hash) + pub fn commit(&self, commit_hash: &CommitHash) -> Option> { + self.commit_map.get(commit_hash).cloned() } - pub fn all_commits(&self) -> Vec<&Commit> { + pub fn all_commits(&self) -> Vec> { self.commit_hashes .iter() .filter_map(|hash| self.commit(hash)) @@ -196,27 +179,27 @@ impl Repository { .unwrap_or_default() } - pub fn refs(&self, commit_hash: &CommitHash) -> Vec<&Ref> { + pub fn refs(&self, commit_hash: &CommitHash) -> Vec> { self.ref_map .get(commit_hash) - .map(|refs| refs.iter().collect::>()) + .cloned() .unwrap_or_default() } - pub fn all_refs(&self) -> Vec<&Ref> { - self.ref_map.values().flatten().collect() + pub fn all_refs(&self) -> Vec> { + self.ref_map.values().flatten().cloned().collect() } - pub fn head(&self) -> &Head { - &self.head + pub fn head(&self) -> Head { + self.head.clone() } pub fn path(&self) -> &Path { &self.path } - pub fn commit_detail(&self, commit_hash: &CommitHash) -> (Commit, Vec) { - let commit = self.commit(commit_hash).unwrap().clone(); + pub fn commit_detail(&self, commit_hash: &CommitHash) -> (Rc, Vec) { + let commit = self.commit(commit_hash).unwrap(); let changes = if commit.parent_commit_hashes.is_empty() { get_initial_commit_additions(&self.path, commit_hash) } else { @@ -224,6 +207,10 @@ impl Repository { }; (commit, changes) } + + pub fn sort_order(&self) -> SortCommit { + self.sort + } } fn check_git_repository(path: &Path) -> Result<()> { @@ -407,7 +394,7 @@ fn build_commits_maps(commits: &Vec) -> (CommitsMap, CommitsMap) { fn to_commit_map(commits: Vec) -> CommitMap { commits .into_iter() - .map(|commit| (commit.commit_hash.clone(), commit)) + .map(|commit| (commit.commit_hash.clone(), Rc::new(commit))) .collect() } @@ -471,7 +458,7 @@ fn load_refs(path: &Path) -> (RefMap, Head) { }) }; } else if let Some(r) = parse_branch_refs(hash, refs) { - ref_map.entry(hash.into()).or_default().push(r); + ref_map.entry(hash.into()).or_default().push(Rc::new(r)); } else if let Some(r) = parse_tag_refs(hash, refs) { // if annotated tag exists, it will be overwritten by the following line of the same tag // this will make the tag point to the commit that the annotated tag points to @@ -482,10 +469,10 @@ fn load_refs(path: &Path) -> (RefMap, Head) { let head = head.expect("HEAD not found in `git show-ref --head` output"); for tag in tag_map.into_values() { - ref_map.entry(tag.target().clone()).or_default().push(tag); + ref_map.entry(tag.target().clone()).or_default().push(Rc::new(tag)); } - ref_map.values_mut().for_each(|refs| refs.sort()); + ref_map.values_mut().for_each(|refs| refs.sort_by(|a, b| a.cmp(b))); cmd.wait().unwrap(); @@ -528,7 +515,7 @@ fn load_stashes_as_refs(path: &Path) -> RefMap { target: hash.into(), }; - ref_map.entry(hash.into()).or_default().push(r); + ref_map.entry(hash.into()).or_default().push(Rc::new(r)); } cmd.wait().unwrap(); diff --git a/src/graph/calc.rs b/src/graph/calc.rs index ec40693..28a4059 100644 --- a/src/graph/calc.rs +++ b/src/graph/calc.rs @@ -1,13 +1,15 @@ +use std::rc::Rc; + use rustc_hash::FxHashMap; use crate::git::{Commit, CommitHash, Repository}; -type CommitPosMap<'a> = FxHashMap<&'a CommitHash, (usize, usize)>; +type CommitPosMap = FxHashMap; #[derive(Debug)] -pub struct Graph<'a> { - pub commits: Vec<&'a Commit>, - pub commit_pos_map: CommitPosMap<'a>, +pub struct Graph { + pub commits: Vec>, + pub commit_pos_map: CommitPosMap, pub edges: Vec>, pub max_pos_x: usize, } @@ -43,7 +45,7 @@ pub enum EdgeType { LeftBottom, // ╰ } -pub fn calc_graph(repository: &Repository) -> Graph<'_> { +pub fn calc_graph(repository: &Repository) -> Graph { let commits = repository.all_commits(); let commit_pos_map = calc_commit_positions(&commits, repository); @@ -57,22 +59,22 @@ pub fn calc_graph(repository: &Repository) -> Graph<'_> { } } -fn calc_commit_positions<'a>( - commits: &[&'a Commit], - repository: &'a Repository, -) -> CommitPosMap<'a> { +fn calc_commit_positions( + commits: &[Rc], + repository: &Repository, +) -> CommitPosMap { let mut commit_pos_map: CommitPosMap = FxHashMap::default(); - let mut commit_line_state: Vec> = Vec::new(); + let mut commit_line_state: Vec> = Vec::new(); for (pos_y, commit) in commits.iter().enumerate() { let filtered_children_hash = filtered_children_hash(commit, repository); if filtered_children_hash.is_empty() { let pos_x = get_first_vacant_line(&commit_line_state); add_commit_line(commit, &mut commit_line_state, pos_x); - commit_pos_map.insert(&commit.commit_hash, (pos_x, pos_y)); + commit_pos_map.insert(commit.commit_hash.clone(), (pos_x, pos_y)); } else { let pos_x = update_commit_line(commit, &mut commit_line_state, &filtered_children_hash); - commit_pos_map.insert(&commit.commit_hash, (pos_x, pos_y)); + commit_pos_map.insert(commit.commit_hash.clone(), (pos_x, pos_y)); } } @@ -80,7 +82,7 @@ fn calc_commit_positions<'a>( } fn filtered_children_hash<'a>( - commit: &'a Commit, + commit: &Commit, repository: &'a Repository, ) -> Vec<&'a CommitHash> { repository @@ -93,28 +95,28 @@ fn filtered_children_hash<'a>( .collect() } -fn get_first_vacant_line(commit_line_state: &[Option<&CommitHash>]) -> usize { +fn get_first_vacant_line(commit_line_state: &[Option]) -> usize { commit_line_state .iter() .position(|c| c.is_none()) .unwrap_or(commit_line_state.len()) } -fn add_commit_line<'a>( - commit: &'a Commit, - commit_line_state: &mut Vec>, +fn add_commit_line( + commit: &Commit, + commit_line_state: &mut Vec>, pos_x: usize, ) { if commit_line_state.len() <= pos_x { - commit_line_state.push(Some(&commit.commit_hash)); + commit_line_state.push(Some(commit.commit_hash.clone())); } else { - commit_line_state[pos_x] = Some(&commit.commit_hash); + commit_line_state[pos_x] = Some(commit.commit_hash.clone()); } } -fn update_commit_line<'a>( - commit: &'a Commit, - commit_line_state: &mut [Option<&'a CommitHash>], +fn update_commit_line( + commit: &Commit, + commit_line_state: &mut [Option], target_commit_hashes: &[&CommitHash], ) -> usize { if commit_line_state.is_empty() { @@ -124,7 +126,7 @@ fn update_commit_line<'a>( for target_hash in target_commit_hashes { for (pos_x, commit_hash) in commit_line_state.iter().enumerate() { if let Some(hash) = commit_hash { - if hash == target_hash { + if hash == *target_hash { commit_line_state[pos_x] = None; if min_pos_x > pos_x { min_pos_x = pos_x; @@ -134,22 +136,22 @@ fn update_commit_line<'a>( } } } - commit_line_state[min_pos_x] = Some(&commit.commit_hash); + commit_line_state[min_pos_x] = Some(commit.commit_hash.clone()); min_pos_x } #[derive(Debug, Clone)] -struct WrappedEdge<'a> { +struct WrappedEdge { edge: Edge, - edge_parent_hash: &'a CommitHash, + edge_parent_hash: CommitHash, } -impl<'a> WrappedEdge<'a> { +impl WrappedEdge { fn new( edge_type: EdgeType, pos_x: usize, line_pos_x: usize, - edge_parent_hash: &'a CommitHash, + edge_parent_hash: CommitHash, ) -> Self { Self { edge: Edge::new(edge_type, pos_x, line_pos_x), @@ -160,7 +162,7 @@ impl<'a> WrappedEdge<'a> { fn calc_edges( commit_pos_map: &CommitPosMap, - commits: &[&Commit], + commits: &[Rc], repository: &Repository, ) -> (Vec>, usize) { let mut max_pos_x = 0; @@ -175,11 +177,11 @@ fn calc_edges( if pos_x == child_pos_x { // commit - edges[pos_y].push(WrappedEdge::new(EdgeType::Up, pos_x, pos_x, hash)); + edges[pos_y].push(WrappedEdge::new(EdgeType::Up, pos_x, pos_x, hash.clone())); for y in ((child_pos_y + 1)..pos_y).rev() { - edges[y].push(WrappedEdge::new(EdgeType::Vertical, pos_x, pos_x, hash)); + edges[y].push(WrappedEdge::new(EdgeType::Vertical, pos_x, pos_x, hash.clone())); } - edges[child_pos_y].push(WrappedEdge::new(EdgeType::Down, pos_x, pos_x, hash)); + edges[child_pos_y].push(WrappedEdge::new(EdgeType::Down, pos_x, pos_x, hash.clone())); } else { let child_first_parent_hash = &commits[child_pos_y].parent_commit_hashes[0]; if *child_first_parent_hash == *hash { @@ -189,42 +191,42 @@ fn calc_edges( EdgeType::Right, pos_x, child_pos_x, - hash, + hash.clone(), )); for x in (pos_x + 1)..child_pos_x { edges[pos_y].push(WrappedEdge::new( EdgeType::Horizontal, x, child_pos_x, - hash, + hash.clone(), )); } edges[pos_y].push(WrappedEdge::new( EdgeType::RightBottom, child_pos_x, child_pos_x, - hash, + hash.clone(), )); } else { edges[pos_y].push(WrappedEdge::new( EdgeType::Left, pos_x, child_pos_x, - hash, + hash.clone(), )); for x in (child_pos_x + 1)..pos_x { edges[pos_y].push(WrappedEdge::new( EdgeType::Horizontal, x, child_pos_x, - hash, + hash.clone(), )); } edges[pos_y].push(WrappedEdge::new( EdgeType::LeftBottom, child_pos_x, child_pos_x, - hash, + hash.clone(), )); } for y in ((child_pos_y + 1)..pos_y).rev() { @@ -232,14 +234,14 @@ fn calc_edges( EdgeType::Vertical, child_pos_x, child_pos_x, - hash, + hash.clone(), )); } edges[child_pos_y].push(WrappedEdge::new( EdgeType::Down, child_pos_x, child_pos_x, - hash, + hash.clone(), )); } else { // merge @@ -285,7 +287,7 @@ fn calc_edges( .iter() .filter(|e| e.edge.pos_x == pos_x) .filter(|e| matches!(e.edge.edge_type, EdgeType::Vertical)) - .any(|e| e.edge_parent_hash != hash) + .any(|e| &e.edge_parent_hash != hash) { skip_judge_overlap = false; break; @@ -304,7 +306,7 @@ fn calc_edges( } for edge in &edges[y] { if edge.edge.pos_x >= new_pos_x - && edge.edge_parent_hash != hash + && &edge.edge_parent_hash != hash && matches!(edge.edge.edge_type, EdgeType::Vertical) { overlap = true; @@ -318,99 +320,99 @@ fn calc_edges( if overlap { // detour - edges[pos_y].push(WrappedEdge::new(EdgeType::Right, pos_x, pos_x, hash)); + edges[pos_y].push(WrappedEdge::new(EdgeType::Right, pos_x, pos_x, hash.clone())); for x in (pos_x + 1)..new_pos_x { edges[pos_y].push(WrappedEdge::new( EdgeType::Horizontal, x, pos_x, - hash, + hash.clone(), )); } edges[pos_y].push(WrappedEdge::new( EdgeType::RightBottom, new_pos_x, pos_x, - hash, + hash.clone(), )); for y in ((child_pos_y + 1)..pos_y).rev() { edges[y].push(WrappedEdge::new( EdgeType::Vertical, new_pos_x, pos_x, - hash, + hash.clone(), )); } edges[child_pos_y].push(WrappedEdge::new( EdgeType::RightTop, new_pos_x, pos_x, - hash, + hash.clone(), )); for x in (child_pos_x + 1)..new_pos_x { edges[child_pos_y].push(WrappedEdge::new( EdgeType::Horizontal, x, pos_x, - hash, + hash.clone(), )); } edges[child_pos_y].push(WrappedEdge::new( EdgeType::Right, child_pos_x, pos_x, - hash, + hash.clone(), )); if max_pos_x < new_pos_x { max_pos_x = new_pos_x; } } else { - edges[pos_y].push(WrappedEdge::new(EdgeType::Up, pos_x, pos_x, hash)); + edges[pos_y].push(WrappedEdge::new(EdgeType::Up, pos_x, pos_x, hash.clone())); for y in ((child_pos_y + 1)..pos_y).rev() { - edges[y].push(WrappedEdge::new(EdgeType::Vertical, pos_x, pos_x, hash)); + edges[y].push(WrappedEdge::new(EdgeType::Vertical, pos_x, pos_x, hash.clone())); } if pos_x < child_pos_x { edges[child_pos_y].push(WrappedEdge::new( EdgeType::LeftTop, pos_x, pos_x, - hash, + hash.clone(), )); for x in (pos_x + 1)..child_pos_x { edges[child_pos_y].push(WrappedEdge::new( EdgeType::Horizontal, x, pos_x, - hash, + hash.clone(), )); } edges[child_pos_y].push(WrappedEdge::new( EdgeType::Left, child_pos_x, pos_x, - hash, + hash.clone(), )); } else { edges[child_pos_y].push(WrappedEdge::new( EdgeType::RightTop, pos_x, pos_x, - hash, + hash.clone(), )); for x in (child_pos_x + 1)..pos_x { edges[child_pos_y].push(WrappedEdge::new( EdgeType::Horizontal, x, pos_x, - hash, + hash.clone(), )); } edges[child_pos_y].push(WrappedEdge::new( EdgeType::Right, child_pos_x, pos_x, - hash, + hash.clone(), )); } } diff --git a/src/graph/image.rs b/src/graph/image.rs index fd6ec09..4e5d970 100644 --- a/src/graph/image.rs +++ b/src/graph/image.rs @@ -1,6 +1,7 @@ use std::{ fmt::{self, Debug, Formatter}, io::Cursor, + rc::Rc, }; use rayon::iter::{IntoParallelIterator, ParallelIterator}; @@ -14,19 +15,19 @@ use crate::{ }; #[derive(Debug)] -pub struct GraphImageManager<'a> { +pub struct GraphImageManager { encoded_image_map: FxHashMap, - graph: &'a Graph<'a>, + graph: Rc, cell_width_type: CellWidthType, image_params: ImageParams, drawing_pixels: DrawingPixels, image_protocol: ImageProtocol, } -impl<'a> GraphImageManager<'a> { +impl GraphImageManager { pub fn new( - graph: &'a Graph, + graph: Rc, graph_color_set: &GraphColorSet, cell_width_type: CellWidthType, image_protocol: ImageProtocol, @@ -54,7 +55,7 @@ impl<'a> GraphImageManager<'a> { } pub fn load_all_encoded_image(&mut self) { - let graph_image = build_graph_image(self.graph, &self.image_params, &self.drawing_pixels); + let graph_image = build_graph_image(&self.graph, &self.image_params, &self.drawing_pixels); self.encoded_image_map = self .graph .commits @@ -74,7 +75,7 @@ impl<'a> GraphImageManager<'a> { return; } let graph_row_image = build_single_graph_row_image( - self.graph, + &self.graph, &self.image_params, &self.drawing_pixels, commit_hash, @@ -173,7 +174,7 @@ impl ImageParams { } fn build_single_graph_row_image( - graph: &Graph<'_>, + graph: &Graph, image_params: &ImageParams, drawing_pixels: &DrawingPixels, commit_hash: &CommitHash, @@ -187,7 +188,7 @@ fn build_single_graph_row_image( } pub fn build_graph_image( - graph: &Graph<'_>, + graph: &Graph, image_params: &ImageParams, drawing_pixels: &DrawingPixels, ) -> GraphImage { diff --git a/src/lib.rs b/src/lib.rs index 6847ef9..2383273 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,7 @@ mod keybind; mod view; mod widget; -use std::path::Path; +use std::{path::Path, rc::Rc}; use app::App; use clap::{Parser, ValueEnum}; @@ -124,12 +124,12 @@ pub fn run() -> Result<()> { let repository = git::Repository::load(Path::new("."), order)?; - let graph = graph::calc_graph(&repository); + let graph = Rc::new(graph::calc_graph(&repository)); let cell_width_type = check::decide_cell_width_type(&graph, graph_width)?; let graph_image_manager = GraphImageManager::new( - &graph, + Rc::clone(&graph), &graph_color_set, cell_width_type, image_protocol, diff --git a/src/view/create_tag.rs b/src/view/create_tag.rs index c5aacd8..1b6e4a8 100644 --- a/src/view/create_tag.rs +++ b/src/view/create_tag.rs @@ -27,7 +27,7 @@ enum FocusedField { #[derive(Debug)] pub struct CreateTagView<'a> { - commit_list_state: Option>, + commit_list_state: Option, commit_hash: CommitHash, repo_path: PathBuf, @@ -43,7 +43,7 @@ pub struct CreateTagView<'a> { impl<'a> CreateTagView<'a> { pub fn new( - commit_list_state: CommitListState<'a>, + commit_list_state: CommitListState, commit_hash: CommitHash, repo_path: PathBuf, ui_config: &'a UiConfig, @@ -372,13 +372,13 @@ impl<'a> CreateTagView<'a> { input_area } - fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { + fn as_mut_list_state(&mut self) -> &mut CommitListState { self.commit_list_state.as_mut().unwrap() } } impl<'a> CreateTagView<'a> { - pub fn take_list_state(&mut self) -> CommitListState<'a> { + pub fn take_list_state(&mut self) -> CommitListState { self.commit_list_state.take().unwrap() } diff --git a/src/view/delete_tag.rs b/src/view/delete_tag.rs index d72103e..2cfc12c 100644 --- a/src/view/delete_tag.rs +++ b/src/view/delete_tag.rs @@ -1,4 +1,4 @@ -use std::{path::PathBuf, thread}; +use std::{path::PathBuf, rc::Rc, thread}; use ratatui::{ crossterm::event::KeyEvent, @@ -19,7 +19,7 @@ use crate::{ #[derive(Debug)] pub struct DeleteTagView<'a> { - commit_list_state: Option>, + commit_list_state: Option, commit_hash: CommitHash, repo_path: PathBuf, @@ -34,9 +34,9 @@ pub struct DeleteTagView<'a> { impl<'a> DeleteTagView<'a> { pub fn new( - commit_list_state: CommitListState<'a>, + commit_list_state: CommitListState, commit_hash: CommitHash, - tags: Vec, + tags: Vec>, repo_path: PathBuf, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, @@ -44,8 +44,8 @@ impl<'a> DeleteTagView<'a> { ) -> DeleteTagView<'a> { let mut tag_names: Vec = tags .into_iter() - .filter_map(|r| match r { - Ref::Tag { name, .. } => Some(name), + .filter_map(|r| match r.as_ref() { + Ref::Tag { name, .. } => Some(name.clone()), _ => None, }) .collect(); @@ -255,13 +255,13 @@ impl<'a> DeleteTagView<'a> { f.render_widget(Paragraph::new(hint_line).centered(), hint_area); } - fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { + fn as_mut_list_state(&mut self) -> &mut CommitListState { self.commit_list_state.as_mut().unwrap() } } impl<'a> DeleteTagView<'a> { - pub fn take_list_state(&mut self) -> CommitListState<'a> { + pub fn take_list_state(&mut self) -> CommitListState { self.commit_list_state.take().unwrap() } diff --git a/src/view/detail.rs b/src/view/detail.rs index 2d8f5cf..cb93379 100644 --- a/src/view/detail.rs +++ b/src/view/detail.rs @@ -1,3 +1,5 @@ +use std::rc::Rc; + use ratatui::{ crossterm::event::KeyEvent, layout::{Constraint, Layout, Rect}, @@ -19,12 +21,12 @@ use crate::{ #[derive(Debug)] pub struct DetailView<'a> { - commit_list_state: Option>, + commit_list_state: Option, commit_detail_state: CommitDetailState, - commit: Commit, + commit: Rc, changes: Vec, - refs: Vec, + refs: Vec>, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, @@ -35,10 +37,10 @@ pub struct DetailView<'a> { impl<'a> DetailView<'a> { pub fn new( - commit_list_state: CommitListState<'a>, - commit: Commit, + commit_list_state: CommitListState, + commit: Rc, changes: Vec, - refs: Vec, + refs: Vec>, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, image_protocol: ImageProtocol, @@ -158,11 +160,11 @@ impl<'a> DetailView<'a> { } impl<'a> DetailView<'a> { - pub fn take_list_state(&mut self) -> CommitListState<'a> { + pub fn take_list_state(&mut self) -> CommitListState { self.commit_list_state.take().unwrap() } - fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { + fn as_mut_list_state(&mut self) -> &mut CommitListState { self.commit_list_state.as_mut().unwrap() } @@ -180,13 +182,13 @@ impl<'a> DetailView<'a> { fn update_selected_commit(&mut self, repository: &Repository, update_commit_list_state: F) where - F: FnOnce(&mut CommitListState<'a>), + F: FnOnce(&mut CommitListState), { let commit_list_state = self.as_mut_list_state(); update_commit_list_state(commit_list_state); let selected = commit_list_state.selected_commit_hash().clone(); let (commit, changes) = repository.commit_detail(&selected); - let refs = repository.refs(&selected).into_iter().cloned().collect(); + let refs = repository.refs(&selected); self.commit = commit; self.changes = changes; self.refs = refs; diff --git a/src/view/list.rs b/src/view/list.rs index 70ec457..a65a2b3 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -10,7 +10,7 @@ use crate::{ #[derive(Debug)] pub struct ListView<'a> { - commit_list_state: Option>, + commit_list_state: Option, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, @@ -19,7 +19,7 @@ pub struct ListView<'a> { impl<'a> ListView<'a> { pub fn new( - commit_list_state: CommitListState<'a>, + commit_list_state: CommitListState, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, tx: Sender, @@ -156,6 +156,9 @@ impl<'a> ListView<'a> { UserEvent::DeleteTag => { self.tx.send(AppEvent::OpenDeleteTag); } + UserEvent::Refresh => { + self.tx.send(AppEvent::Refresh); + } _ => {} } } @@ -183,7 +186,7 @@ impl<'a> ListView<'a> { } impl<'a> ListView<'a> { - pub fn take_list_state(&mut self) -> CommitListState<'a> { + pub fn take_list_state(&mut self) -> CommitListState { self.commit_list_state.take().unwrap() } @@ -197,11 +200,11 @@ impl<'a> ListView<'a> { .remove_ref_from_commit(commit_hash, tag_name); } - fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { + fn as_mut_list_state(&mut self) -> &mut CommitListState { self.commit_list_state.as_mut().unwrap() } - fn as_list_state(&self) -> &CommitListState<'a> { + fn as_list_state(&self) -> &CommitListState { self.commit_list_state.as_ref().unwrap() } diff --git a/src/view/refs.rs b/src/view/refs.rs index 052fcff..fdf3951 100644 --- a/src/view/refs.rs +++ b/src/view/refs.rs @@ -1,3 +1,5 @@ +use std::rc::Rc; + use ratatui::{ crossterm::event::KeyEvent, layout::{Constraint, Layout, Rect}, @@ -17,10 +19,10 @@ use crate::{ #[derive(Debug)] pub struct RefsView<'a> { - commit_list_state: Option>, + commit_list_state: Option, ref_list_state: RefListState, - refs: Vec, + refs: Vec>, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, @@ -29,8 +31,8 @@ pub struct RefsView<'a> { impl<'a> RefsView<'a> { pub fn new( - commit_list_state: CommitListState<'a>, - refs: Vec, + commit_list_state: CommitListState, + refs: Vec>, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, tx: Sender, @@ -110,15 +112,15 @@ impl<'a> RefsView<'a> { } impl<'a> RefsView<'a> { - pub fn take_list_state(&mut self) -> CommitListState<'a> { + pub fn take_list_state(&mut self) -> CommitListState { self.commit_list_state.take().unwrap() } - fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { + fn as_mut_list_state(&mut self) -> &mut CommitListState { self.commit_list_state.as_mut().unwrap() } - fn as_list_state(&self) -> &CommitListState<'a> { + fn as_list_state(&self) -> &CommitListState { self.commit_list_state.as_ref().unwrap() } diff --git a/src/view/user_command.rs b/src/view/user_command.rs index 4c3e16c..0f4e94a 100644 --- a/src/view/user_command.rs +++ b/src/view/user_command.rs @@ -1,3 +1,5 @@ +use std::rc::Rc; + use ansi_to_tui::IntoText as _; use ratatui::{ crossterm::event::KeyEvent, @@ -28,7 +30,7 @@ pub enum UserCommandViewBeforeView { #[derive(Debug)] pub struct UserCommandView<'a> { - commit_list_state: Option>, + commit_list_state: Option, commit_user_command_state: CommitUserCommandState, user_command_number: usize, @@ -45,8 +47,8 @@ pub struct UserCommandView<'a> { impl<'a> UserCommandView<'a> { pub fn new( - commit_list_state: CommitListState<'a>, - commit: Commit, + commit_list_state: CommitListState, + commit: Rc, user_command_number: usize, view_area: Rect, core_config: &'a CoreConfig, @@ -181,11 +183,11 @@ impl<'a> UserCommandView<'a> { } impl<'a> UserCommandView<'a> { - pub fn take_list_state(&mut self) -> CommitListState<'a> { + pub fn take_list_state(&mut self) -> CommitListState { self.commit_list_state.take().unwrap() } - fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { + fn as_mut_list_state(&mut self) -> &mut CommitListState { self.commit_list_state.as_mut().unwrap() } @@ -207,7 +209,7 @@ impl<'a> UserCommandView<'a> { view_area: Rect, update_commit_list_state: F, ) where - F: FnOnce(&mut CommitListState<'a>), + F: FnOnce(&mut CommitListState), { let commit_list_state = self.as_mut_list_state(); update_commit_list_state(commit_list_state); diff --git a/src/view/views.rs b/src/view/views.rs index 714c15b..aad9f35 100644 --- a/src/view/views.rs +++ b/src/view/views.rs @@ -1,6 +1,6 @@ -use ratatui::{crossterm::event::KeyEvent, layout::Rect, Frame}; +use std::{path::PathBuf, rc::Rc}; -use std::path::PathBuf; +use ratatui::{crossterm::event::KeyEvent, layout::Rect, Frame}; use crate::{ color::ColorTheme, @@ -62,7 +62,7 @@ impl<'a> View<'a> { } pub fn of_list( - commit_list_state: CommitListState<'a>, + commit_list_state: CommitListState, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, tx: Sender, @@ -76,10 +76,10 @@ impl<'a> View<'a> { } pub fn of_detail( - commit_list_state: CommitListState<'a>, - commit: Commit, + commit_list_state: CommitListState, + commit: Rc, changes: Vec, - refs: Vec, + refs: Vec>, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, image_protocol: ImageProtocol, @@ -98,8 +98,8 @@ impl<'a> View<'a> { } pub fn of_user_command_from_list( - commit_list_state: CommitListState<'a>, - commit: Commit, + commit_list_state: CommitListState, + commit: Rc, user_command_number: usize, view_area: Rect, core_config: &'a CoreConfig, @@ -123,8 +123,8 @@ impl<'a> View<'a> { } pub fn of_user_command_from_detail( - commit_list_state: CommitListState<'a>, - commit: Commit, + commit_list_state: CommitListState, + commit: Rc, user_command_number: usize, view_area: Rect, core_config: &'a CoreConfig, @@ -148,8 +148,8 @@ impl<'a> View<'a> { } pub fn of_refs( - commit_list_state: CommitListState<'a>, - refs: Vec, + commit_list_state: CommitListState, + refs: Vec>, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, tx: Sender, @@ -164,7 +164,7 @@ impl<'a> View<'a> { } pub fn of_create_tag( - commit_list_state: CommitListState<'a>, + commit_list_state: CommitListState, commit_hash: CommitHash, repo_path: PathBuf, ui_config: &'a UiConfig, @@ -182,9 +182,9 @@ impl<'a> View<'a> { } pub fn of_delete_tag( - commit_list_state: CommitListState<'a>, + commit_list_state: CommitListState, commit_hash: CommitHash, - tags: Vec, + tags: Vec>, repo_path: PathBuf, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, diff --git a/src/widget/commit_detail.rs b/src/widget/commit_detail.rs index 6052a14..9bf7543 100644 --- a/src/widget/commit_detail.rs +++ b/src/widget/commit_detail.rs @@ -1,3 +1,5 @@ +use std::rc::Rc; + use chrono::{DateTime, FixedOffset}; use ratatui::{ buffer::Buffer, @@ -56,7 +58,7 @@ impl CommitDetailState { pub struct CommitDetail<'a> { commit: &'a Commit, changes: &'a Vec, - refs: &'a Vec, + refs: &'a Vec>, config: &'a UiDetailConfig, color_theme: &'a ColorTheme, } @@ -65,7 +67,7 @@ impl<'a> CommitDetail<'a> { pub fn new( commit: &'a Commit, changes: &'a Vec, - refs: &'a Vec, + refs: &'a Vec>, config: &'a UiDetailConfig, color_theme: &'a ColorTheme, ) -> Self { @@ -220,19 +222,19 @@ impl CommitDetail<'_> { } fn refs_line(&self) -> Line<'_> { - let ref_spans = self.refs.iter().filter_map(|r| match r { + let ref_spans = self.refs.iter().filter_map(|r| match r.as_ref() { Ref::Branch { name, .. } => Some( - Span::raw(name) + Span::raw(name.as_str()) .fg(self.color_theme.detail_ref_branch_fg) .add_modifier(Modifier::BOLD), ), Ref::RemoteBranch { name, .. } => Some( - Span::raw(name) + Span::raw(name.as_str()) .fg(self.color_theme.detail_ref_remote_branch_fg) .add_modifier(Modifier::BOLD), ), Ref::Tag { name, .. } => Some( - Span::raw(name) + Span::raw(name.as_str()) .fg(self.color_theme.detail_ref_tag_fg) .add_modifier(Modifier::BOLD), ), @@ -320,10 +322,10 @@ fn has_parent(commit: &Commit) -> bool { !commit.parent_commit_hashes.is_empty() } -fn has_refs(refs: &[Ref]) -> bool { +fn has_refs(refs: &[Rc]) -> bool { refs.iter().any(|r| { matches!( - r, + r.as_ref(), Ref::Branch { .. } | Ref::RemoteBranch { .. } | Ref::Tag { .. } ) }) diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index 8c3e2a3..d2a0aa2 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, rc::Rc}; use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; use laurier::highlight::highlight_matched_text; @@ -25,77 +25,17 @@ static FUZZY_MATCHER: Lazy = Lazy::new(|| SkimMatcherV2::default( const ELLIPSIS: &str = "..."; #[derive(Debug)] -pub enum CommitRefs<'a> { - Borrowed(Vec<&'a Ref>), - Owned(Vec), -} - -impl<'a> CommitRefs<'a> { - fn make_owned(&mut self) { - if let CommitRefs::Borrowed(refs) = self { - *self = CommitRefs::Owned(refs.iter().map(|r| (*r).clone()).collect()); - } - } - - fn push(&mut self, r: Ref) { - self.make_owned(); - if let CommitRefs::Owned(refs) = self { - refs.push(r); - refs.sort(); - } - } - - fn retain(&mut self, f: F) - where - F: FnMut(&Ref) -> bool, - { - self.make_owned(); - if let CommitRefs::Owned(refs) = self { - refs.retain(f); - } - } - - fn iter(&self) -> Box + '_> { - match self { - CommitRefs::Borrowed(refs) => Box::new(refs.iter().copied()), - CommitRefs::Owned(refs) => Box::new(refs.iter()), - } - } - - fn len(&self) -> usize { - match self { - CommitRefs::Borrowed(refs) => refs.len(), - CommitRefs::Owned(refs) => refs.len(), - } - } - - fn get(&self, index: usize) -> Option<&Ref> { - match self { - CommitRefs::Borrowed(refs) => refs.get(index).copied(), - CommitRefs::Owned(refs) => refs.get(index), - } - } - - fn to_vec(&self) -> Vec { - match self { - CommitRefs::Borrowed(refs) => refs.iter().map(|r| (*r).clone()).collect(), - CommitRefs::Owned(refs) => refs.clone(), - } - } -} - -#[derive(Debug)] -pub struct CommitInfo<'a> { - commit: &'a Commit, - refs: CommitRefs<'a>, +pub struct CommitInfo { + commit: Rc, + refs: Vec>, graph_color: Color, } -impl<'a> CommitInfo<'a> { - pub fn new(commit: &'a Commit, refs: Vec<&'a Ref>, graph_color: Color) -> Self { +impl CommitInfo { + pub fn new(commit: Rc, refs: Vec>, graph_color: Color) -> Self { Self { commit, - refs: CommitRefs::Borrowed(refs), + refs, graph_color, } } @@ -103,6 +43,19 @@ impl<'a> CommitInfo<'a> { pub fn commit_hash(&self) -> &CommitHash { &self.commit.commit_hash } + + fn add_ref(&mut self, r: Rc) { + self.refs.push(r); + self.refs.sort_by(|a, b| a.cmp(b)); + } + + fn remove_ref(&mut self, name: &str) { + self.refs.retain(|r| r.name() != name); + } + + fn refs_to_vec(&self) -> Vec> { + self.refs.clone() + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -248,11 +201,11 @@ impl SearchMatcher { } #[derive(Debug)] -pub struct CommitListState<'a> { - commits: Vec>, - graph_image_manager: GraphImageManager<'a>, +pub struct CommitListState { + commits: Vec, + graph_image_manager: GraphImageManager, graph_cell_width: u16, - head: &'a Head, + head: Head, ref_name_to_commit_index_map: HashMap, @@ -269,16 +222,16 @@ pub struct CommitListState<'a> { default_fuzzy: bool, } -impl<'a> CommitListState<'a> { +impl CommitListState { pub fn new( - commits: Vec>, - graph_image_manager: GraphImageManager<'a>, + commits: Vec, + graph_image_manager: GraphImageManager, graph_cell_width: u16, - head: &'a Head, + head: Head, ref_name_to_commit_index_map: HashMap, default_ignore_case: bool, default_fuzzy: bool, - ) -> CommitListState<'a> { + ) -> CommitListState { let total = commits.len(); CommitListState { commits, @@ -307,7 +260,7 @@ impl<'a> CommitListState<'a> { if commit_info.commit_hash() == commit_hash { self.ref_name_to_commit_index_map .insert(new_ref.name().to_string(), index); - commit_info.refs.push(new_ref); + commit_info.add_ref(Rc::new(new_ref)); break; } } @@ -317,7 +270,7 @@ impl<'a> CommitListState<'a> { for commit_info in self.commits.iter_mut() { if commit_info.commit_hash() == commit_hash { self.ref_name_to_commit_index_map.remove(tag_name); - commit_info.refs.retain(|r| r.name() != tag_name); + commit_info.remove_ref(tag_name); break; } } @@ -471,8 +424,8 @@ impl<'a> CommitListState<'a> { .commit_hash } - pub fn selected_commit_refs(&self) -> Vec { - self.commits[self.current_selected_index()].refs.to_vec() + pub fn selected_commit_refs(&self) -> Vec> { + self.commits[self.current_selected_index()].refs_to_vec() } fn current_selected_index(&self) -> usize { @@ -674,8 +627,8 @@ impl<'a> CommitListState<'a> { let mut match_index = 1; for (i, commit_info) in self.commits.iter().enumerate() { let mut m = SearchMatch::new( - commit_info.commit, - commit_info.refs.iter(), + &commit_info.commit, + commit_info.refs.iter().map(|r| r.as_ref()), q, ignore_case, fuzzy, @@ -736,7 +689,7 @@ impl<'a> CommitListState<'a> { } } - fn encoded_image(&self, commit_info: &'a CommitInfo) -> &str { + fn encoded_image(&self, commit_info: &CommitInfo) -> &str { self.graph_image_manager .encoded_image(&commit_info.commit.commit_hash) } @@ -757,7 +710,7 @@ impl<'a> CommitList<'a> { } impl<'a> StatefulWidget for CommitList<'a> { - type State = CommitListState<'a>; + type State = CommitListState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { self.update_state(area, state); @@ -896,13 +849,13 @@ impl CommitList<'_> { .map(|(i, commit_info)| { let mut spans = refs_spans( commit_info, - state.head, + &state.head, &state.search_matches[state.offset + i].refs, self.color_theme, ); let ref_spans_width: usize = spans.iter().map(|s| s.width()).sum(); let max_width = max_width.saturating_sub(ref_spans_width); - let commit = commit_info.commit; + let commit = &commit_info.commit; if max_width > ELLIPSIS.len() { let truncate = console::measure_text_width(&commit.subject) > max_width; let subject = if truncate { @@ -1013,10 +966,10 @@ impl CommitList<'_> { Widget::render(List::new(items), area, buf); } - fn rendering_commit_info_iter<'a>( - &'a self, - state: &'a CommitListState, - ) -> impl Iterator)> { + fn rendering_commit_info_iter<'b>( + &'b self, + state: &'b CommitListState, + ) -> impl Iterator { state .commits .iter() @@ -1025,12 +978,12 @@ impl CommitList<'_> { .enumerate() } - fn rendering_commit_iter<'a>( - &'a self, - state: &'a CommitListState, - ) -> impl Iterator { + fn rendering_commit_iter<'b>( + &'b self, + state: &'b CommitListState, + ) -> impl Iterator { self.rendering_commit_info_iter(state) - .map(|(i, commit_info)| (i, commit_info.commit)) + .map(|(i, commit_info)| (i, commit_info.commit.as_ref())) } fn to_commit_list_item<'a, 'b>( @@ -1061,7 +1014,7 @@ fn refs_spans<'a>( let refs = &commit_info.refs; if refs.len() == 1 { - if let Some(Ref::Stash { name, .. }) = refs.get(0) { + if let Ref::Stash { name, .. } = refs[0].as_ref() { return vec![ Span::raw(name.clone()) .fg(color_theme.list_ref_stash_fg) @@ -1073,7 +1026,7 @@ fn refs_spans<'a>( let ref_spans: Vec<(Vec, &String)> = refs .iter() - .filter_map(|r| match r { + .filter_map(|r| match r.as_ref() { Ref::Branch { name, .. } => { let fg = color_theme.list_ref_branch_fg; Some((name, fg)) diff --git a/src/widget/ref_list.rs b/src/widget/ref_list.rs index e84a0a4..698fd98 100644 --- a/src/widget/ref_list.rs +++ b/src/widget/ref_list.rs @@ -1,3 +1,5 @@ +use std::rc::Rc; + use ratatui::{ buffer::Buffer, layout::Rect, @@ -89,7 +91,7 @@ pub struct RefList<'a> { } impl<'a> RefList<'a> { - pub fn new(refs: &'a [Ref], color_theme: &'a ColorTheme) -> RefList<'a> { + pub fn new(refs: &'a [Rc], color_theme: &'a ColorTheme) -> RefList<'a> { let items = build_ref_tree_items(refs, color_theme); RefList { items, color_theme } } @@ -120,7 +122,7 @@ impl StatefulWidget for RefList<'_> { } fn build_ref_tree_items<'a>( - refs: &'a [Ref], + refs: &'a [Rc], color_theme: &'a ColorTheme, ) -> Vec> { let mut branch_refs = Vec::new(); @@ -129,11 +131,11 @@ fn build_ref_tree_items<'a>( let mut stash_refs = Vec::new(); for r in refs { - match r { - Ref::Tag { name, .. } => tag_refs.push(name.into()), - Ref::Branch { name, .. } => branch_refs.push(name.into()), - Ref::RemoteBranch { name, .. } => remote_refs.push(name.into()), - Ref::Stash { name, message, .. } => stash_refs.push((name.into(), message.into())), + match r.as_ref() { + Ref::Tag { name, .. } => tag_refs.push(name.clone()), + Ref::Branch { name, .. } => branch_refs.push(name.clone()), + Ref::RemoteBranch { name, .. } => remote_refs.push(name.clone()), + Ref::Stash { name, message, .. } => stash_refs.push((name.clone(), message.clone())), } } From 4e4320a21ddae0814e6cd8f672259b238f10f3ef Mon Sep 17 00:00:00 2001 From: Andew Power Date: Thu, 18 Dec 2025 13:08:02 +0200 Subject: [PATCH 06/20] fix: prevent panics and fix search+filter index conflict in commit_list - Add guards for empty list/height in navigation methods (select_next, select_last, select_low, select_middle, scroll_up, scroll_down_height, select_parent, select_next_match, select_prev_match) - Fix search navigation when filter is active: search_matches uses real indices but total was filtered count, causing index out of bounds - Add is_index_visible() and select_real_index() helpers for proper index conversion between filtered and full commit lists - Track last_search_ignore_case/fuzzy to invalidate incremental search cache when settings change --- assets/default-keybind.toml | 1 + src/event.rs | 6 +- src/git.rs | 32 +- src/graph/calc.rs | 51 +-- src/graph/image.rs | 2 +- src/view/create_tag.rs | 5 +- src/view/delete_tag.rs | 5 +- src/view/list.rs | 240 ++++++++------ src/widget/commit_list.rs | 585 ++++++++++++++++++++++++++++------ src/widget/pending_overlay.rs | 4 +- 10 files changed, 694 insertions(+), 237 deletions(-) diff --git a/assets/default-keybind.toml b/assets/default-keybind.toml index 9aedfe2..dce9d0c 100644 --- a/assets/default-keybind.toml +++ b/assets/default-keybind.toml @@ -29,6 +29,7 @@ go_to_previous = ["shift-n"] confirm = ["enter"] ref_list_toggle = ["tab"] search = ["/"] +filter = ["f"] ignore_case_toggle = ["ctrl-g"] fuzzy_toggle = ["ctrl-x"] diff --git a/src/event.rs b/src/event.rs index c8f8527..0305015 100644 --- a/src/event.rs +++ b/src/event.rs @@ -50,7 +50,9 @@ pub enum AppEvent { NotifySuccess(String), NotifyWarn(String), NotifyError(String), - ShowPendingOverlay { message: String }, + ShowPendingOverlay { + message: String, + }, HidePendingOverlay, Refresh, } @@ -139,6 +141,7 @@ pub enum UserEvent { Confirm, RefListToggle, Search, + Filter, UserCommandViewToggle(usize), IgnoreCaseToggle, FuzzyToggle, @@ -205,6 +208,7 @@ impl<'de> Deserialize<'de> for UserEvent { "confirm" => Ok(UserEvent::Confirm), "ref_list_toggle" => Ok(UserEvent::RefListToggle), "search" => Ok(UserEvent::Search), + "filter" => Ok(UserEvent::Filter), "ignore_case_toggle" => Ok(UserEvent::IgnoreCaseToggle), "fuzzy_toggle" => Ok(UserEvent::FuzzyToggle), "short_copy" => Ok(UserEvent::ShortCopy), diff --git a/src/git.rs b/src/git.rs index 2e30d21..679328b 100644 --- a/src/git.rs +++ b/src/git.rs @@ -5,14 +5,16 @@ use std::{ path::{Path, PathBuf}, process::{Command, Stdio}, rc::Rc, + sync::Arc, }; use chrono::{DateTime, FixedOffset}; use crate::Result; -#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct CommitHash(String); +/// Arc for cheap cloning and Send trait (required by mpsc::Sender) +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CommitHash(Arc); impl CommitHash { pub fn as_short_hash(&self) -> String { @@ -24,9 +26,15 @@ impl CommitHash { } } +impl Default for CommitHash { + fn default() -> Self { + Self(Arc::from("")) + } +} + impl From<&str> for CommitHash { fn from(s: &str) -> Self { - Self(s.to_string()) + Self(Arc::from(s)) } } @@ -180,10 +188,7 @@ impl Repository { } pub fn refs(&self, commit_hash: &CommitHash) -> Vec> { - self.ref_map - .get(commit_hash) - .cloned() - .unwrap_or_default() + self.ref_map.get(commit_hash).cloned().unwrap_or_default() } pub fn all_refs(&self) -> Vec> { @@ -469,10 +474,13 @@ fn load_refs(path: &Path) -> (RefMap, Head) { let head = head.expect("HEAD not found in `git show-ref --head` output"); for tag in tag_map.into_values() { - ref_map.entry(tag.target().clone()).or_default().push(Rc::new(tag)); + ref_map + .entry(tag.target().clone()) + .or_default() + .push(Rc::new(tag)); } - ref_map.values_mut().for_each(|refs| refs.sort_by(|a, b| a.cmp(b))); + ref_map.values_mut().for_each(|refs| refs.sort()); cmd.wait().unwrap(); @@ -597,8 +605,8 @@ pub fn get_diff_summary(path: &Path, commit_hash: &CommitHash) -> Vec Ve .arg("ls-tree") .arg("--name-status") .arg("-r") // the empty tree hash - .arg(&commit_hash.0) + .arg(commit_hash.as_str()) .current_dir(path) .stdout(Stdio::piped()) .stderr(Stdio::null()) diff --git a/src/graph/calc.rs b/src/graph/calc.rs index 28a4059..6d8f9ae 100644 --- a/src/graph/calc.rs +++ b/src/graph/calc.rs @@ -59,10 +59,7 @@ pub fn calc_graph(repository: &Repository) -> Graph { } } -fn calc_commit_positions( - commits: &[Rc], - repository: &Repository, -) -> CommitPosMap { +fn calc_commit_positions(commits: &[Rc], repository: &Repository) -> CommitPosMap { let mut commit_pos_map: CommitPosMap = FxHashMap::default(); let mut commit_line_state: Vec> = Vec::new(); @@ -81,10 +78,7 @@ fn calc_commit_positions( commit_pos_map } -fn filtered_children_hash<'a>( - commit: &Commit, - repository: &'a Repository, -) -> Vec<&'a CommitHash> { +fn filtered_children_hash<'a>(commit: &Commit, repository: &'a Repository) -> Vec<&'a CommitHash> { repository .children_hash(&commit.commit_hash) .into_iter() @@ -102,11 +96,7 @@ fn get_first_vacant_line(commit_line_state: &[Option]) -> usize { .unwrap_or(commit_line_state.len()) } -fn add_commit_line( - commit: &Commit, - commit_line_state: &mut Vec>, - pos_x: usize, -) { +fn add_commit_line(commit: &Commit, commit_line_state: &mut Vec>, pos_x: usize) { if commit_line_state.len() <= pos_x { commit_line_state.push(Some(commit.commit_hash.clone())); } else { @@ -179,9 +169,19 @@ fn calc_edges( // commit edges[pos_y].push(WrappedEdge::new(EdgeType::Up, pos_x, pos_x, hash.clone())); for y in ((child_pos_y + 1)..pos_y).rev() { - edges[y].push(WrappedEdge::new(EdgeType::Vertical, pos_x, pos_x, hash.clone())); + edges[y].push(WrappedEdge::new( + EdgeType::Vertical, + pos_x, + pos_x, + hash.clone(), + )); } - edges[child_pos_y].push(WrappedEdge::new(EdgeType::Down, pos_x, pos_x, hash.clone())); + edges[child_pos_y].push(WrappedEdge::new( + EdgeType::Down, + pos_x, + pos_x, + hash.clone(), + )); } else { let child_first_parent_hash = &commits[child_pos_y].parent_commit_hashes[0]; if *child_first_parent_hash == *hash { @@ -320,7 +320,12 @@ fn calc_edges( if overlap { // detour - edges[pos_y].push(WrappedEdge::new(EdgeType::Right, pos_x, pos_x, hash.clone())); + edges[pos_y].push(WrappedEdge::new( + EdgeType::Right, + pos_x, + pos_x, + hash.clone(), + )); for x in (pos_x + 1)..new_pos_x { edges[pos_y].push(WrappedEdge::new( EdgeType::Horizontal, @@ -368,9 +373,19 @@ fn calc_edges( max_pos_x = new_pos_x; } } else { - edges[pos_y].push(WrappedEdge::new(EdgeType::Up, pos_x, pos_x, hash.clone())); + edges[pos_y].push(WrappedEdge::new( + EdgeType::Up, + pos_x, + pos_x, + hash.clone(), + )); for y in ((child_pos_y + 1)..pos_y).rev() { - edges[y].push(WrappedEdge::new(EdgeType::Vertical, pos_x, pos_x, hash.clone())); + edges[y].push(WrappedEdge::new( + EdgeType::Vertical, + pos_x, + pos_x, + hash.clone(), + )); } if pos_x < child_pos_x { edges[child_pos_y].push(WrappedEdge::new( diff --git a/src/graph/image.rs b/src/graph/image.rs index 4e5d970..3e4dc57 100644 --- a/src/graph/image.rs +++ b/src/graph/image.rs @@ -179,7 +179,7 @@ fn build_single_graph_row_image( drawing_pixels: &DrawingPixels, commit_hash: &CommitHash, ) -> GraphRowImage { - let (pos_x, pos_y) = graph.commit_pos_map[&commit_hash]; + let (pos_x, pos_y) = graph.commit_pos_map[commit_hash]; let edges = &graph.edges[pos_y]; let cell_count = graph.max_pos_x + 1; diff --git a/src/view/create_tag.rs b/src/view/create_tag.rs index 1b6e4a8..02387db 100644 --- a/src/view/create_tag.rs +++ b/src/view/create_tag.rs @@ -171,8 +171,9 @@ impl<'a> CreateTagView<'a> { } else { format!("Creating tag '{}'...", tag_name) }; - self.tx - .send(AppEvent::ShowPendingOverlay { message: pending_msg }); + self.tx.send(AppEvent::ShowPendingOverlay { + message: pending_msg, + }); self.tx.send(AppEvent::CloseCreateTag); // Run git commands in background diff --git a/src/view/delete_tag.rs b/src/view/delete_tag.rs index 2cfc12c..4fb243d 100644 --- a/src/view/delete_tag.rs +++ b/src/view/delete_tag.rs @@ -111,8 +111,9 @@ impl<'a> DeleteTagView<'a> { } else { format!("Deleting tag '{}'...", tag_name) }; - self.tx - .send(AppEvent::ShowPendingOverlay { message: pending_msg }); + self.tx.send(AppEvent::ShowPendingOverlay { + message: pending_msg, + }); self.tx.send(AppEvent::CloseDeleteTag); // Run git commands in background diff --git a/src/view/list.rs b/src/view/list.rs index a65a2b3..b4e2136 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -5,7 +5,7 @@ use crate::{ config::UiConfig, event::{AppEvent, Sender, UserEvent, UserEventWithCount}, git::{CommitHash, Ref}, - widget::commit_list::{CommitList, CommitListState, SearchState}, + widget::commit_list::{CommitList, CommitListState, FilterState, SearchState}, }; #[derive(Debug)] @@ -35,6 +35,35 @@ impl<'a> ListView<'a> { pub fn handle_event(&mut self, event_with_count: UserEventWithCount, key: KeyEvent) { let event = event_with_count.event; let count = event_with_count.count; + + // Handle filter mode input + if let FilterState::Filtering { .. } = self.as_list_state().filter_state() { + match event { + UserEvent::Confirm => { + self.as_mut_list_state().apply_filter(); + self.clear_filter_query(); + } + UserEvent::Cancel => { + self.as_mut_list_state().cancel_filter(); + self.clear_filter_query(); + } + UserEvent::IgnoreCaseToggle => { + self.as_mut_list_state().toggle_filter_ignore_case(); + self.update_filter_query(); + } + UserEvent::FuzzyToggle => { + self.as_mut_list_state().toggle_filter_fuzzy(); + self.update_filter_query(); + } + _ => { + self.as_mut_list_state().handle_filter_input(key); + self.update_filter_query(); + } + } + return; + } + + // Handle search mode input if let SearchState::Searching { .. } = self.as_list_state().search_state() { match event { UserEvent::Confirm => { @@ -59,108 +88,114 @@ impl<'a> ListView<'a> { } } return; - } else { - match event { - UserEvent::Quit => { - self.tx.send(AppEvent::Quit); - } - UserEvent::NavigateDown | UserEvent::SelectDown => { - for _ in 0..count { - self.as_mut_list_state().select_next(); - } - } - UserEvent::NavigateUp | UserEvent::SelectUp => { - for _ in 0..count { - self.as_mut_list_state().select_prev(); - } - } - UserEvent::GoToParent => { - for _ in 0..count { - self.as_mut_list_state().select_parent(); - } - } - UserEvent::GoToTop => { - self.as_mut_list_state().select_first(); - } - UserEvent::GoToBottom => { - self.as_mut_list_state().select_last(); - } - UserEvent::ScrollDown => { - for _ in 0..count { - self.as_mut_list_state().scroll_down(); - } - } - UserEvent::ScrollUp => { - for _ in 0..count { - self.as_mut_list_state().scroll_up(); - } - } - UserEvent::PageDown => { - for _ in 0..count { - self.as_mut_list_state().scroll_down_page(); - } - } - UserEvent::PageUp => { - for _ in 0..count { - self.as_mut_list_state().scroll_up_page(); - } - } - UserEvent::HalfPageDown => { - for _ in 0..count { - self.as_mut_list_state().scroll_down_half(); - } - } - UserEvent::HalfPageUp => { - for _ in 0..count { - self.as_mut_list_state().scroll_up_half(); - } - } - UserEvent::SelectTop => { - self.as_mut_list_state().select_high(); - } - UserEvent::SelectMiddle => { - self.as_mut_list_state().select_middle(); - } - UserEvent::SelectBottom => { - self.as_mut_list_state().select_low(); - } - UserEvent::ShortCopy => { - self.copy_commit_short_hash(); - } - UserEvent::FullCopy => { - self.copy_commit_hash(); - } - UserEvent::Search => { - self.as_mut_list_state().start_search(); - self.update_search_query(); + } + + // Normal mode + match event { + UserEvent::Quit => { + self.tx.send(AppEvent::Quit); + } + UserEvent::NavigateDown | UserEvent::SelectDown => { + for _ in 0..count { + self.as_mut_list_state().select_next(); } - UserEvent::UserCommandViewToggle(n) => { - self.tx.send(AppEvent::OpenUserCommand(n)); + } + UserEvent::NavigateUp | UserEvent::SelectUp => { + for _ in 0..count { + self.as_mut_list_state().select_prev(); } - UserEvent::HelpToggle => { - self.tx.send(AppEvent::OpenHelp); + } + UserEvent::GoToParent => { + for _ in 0..count { + self.as_mut_list_state().select_parent(); } - UserEvent::Cancel => { - self.as_mut_list_state().cancel_search(); - self.clear_search_query(); + } + UserEvent::GoToTop => { + self.as_mut_list_state().select_first(); + } + UserEvent::GoToBottom => { + self.as_mut_list_state().select_last(); + } + UserEvent::ScrollDown => { + for _ in 0..count { + self.as_mut_list_state().scroll_down(); } - UserEvent::Confirm => { - self.tx.send(AppEvent::OpenDetail); + } + UserEvent::ScrollUp => { + for _ in 0..count { + self.as_mut_list_state().scroll_up(); } - UserEvent::RefListToggle => { - self.tx.send(AppEvent::OpenRefs); + } + UserEvent::PageDown => { + for _ in 0..count { + self.as_mut_list_state().scroll_down_page(); } - UserEvent::CreateTag => { - self.tx.send(AppEvent::OpenCreateTag); + } + UserEvent::PageUp => { + for _ in 0..count { + self.as_mut_list_state().scroll_up_page(); } - UserEvent::DeleteTag => { - self.tx.send(AppEvent::OpenDeleteTag); + } + UserEvent::HalfPageDown => { + for _ in 0..count { + self.as_mut_list_state().scroll_down_half(); } - UserEvent::Refresh => { - self.tx.send(AppEvent::Refresh); + } + UserEvent::HalfPageUp => { + for _ in 0..count { + self.as_mut_list_state().scroll_up_half(); } - _ => {} } + UserEvent::SelectTop => { + self.as_mut_list_state().select_high(); + } + UserEvent::SelectMiddle => { + self.as_mut_list_state().select_middle(); + } + UserEvent::SelectBottom => { + self.as_mut_list_state().select_low(); + } + UserEvent::ShortCopy => { + self.copy_commit_short_hash(); + } + UserEvent::FullCopy => { + self.copy_commit_hash(); + } + UserEvent::Search => { + self.as_mut_list_state().start_search(); + self.update_search_query(); + } + UserEvent::Filter => { + self.as_mut_list_state().start_filter(); + self.update_filter_query(); + } + UserEvent::UserCommandViewToggle(n) => { + self.tx.send(AppEvent::OpenUserCommand(n)); + } + UserEvent::HelpToggle => { + self.tx.send(AppEvent::OpenHelp); + } + UserEvent::Cancel => { + self.as_mut_list_state().cancel_search(); + self.as_mut_list_state().cancel_filter(); + self.clear_search_query(); + } + UserEvent::Confirm => { + self.tx.send(AppEvent::OpenDetail); + } + UserEvent::RefListToggle => { + self.tx.send(AppEvent::OpenRefs); + } + UserEvent::CreateTag => { + self.tx.send(AppEvent::OpenCreateTag); + } + UserEvent::DeleteTag => { + self.tx.send(AppEvent::OpenDeleteTag); + } + UserEvent::Refresh => { + self.tx.send(AppEvent::Refresh); + } + _ => {} } if let SearchState::Applied { .. } = self.as_list_state().search_state() { @@ -227,6 +262,25 @@ impl<'a> ListView<'a> { self.tx.send(AppEvent::ClearStatusLine); } + fn update_filter_query(&self) { + if let FilterState::Filtering { .. } = self.as_list_state().filter_state() { + let list_state = self.as_list_state(); + if let Some(query) = list_state.filter_query_string() { + let cursor_pos = list_state.filter_query_cursor_position(); + let transient_msg = list_state.filter_transient_message_string(); + self.tx.send(AppEvent::UpdateStatusInput( + query, + Some(cursor_pos), + transient_msg, + )); + } + } + } + + fn clear_filter_query(&self) { + self.tx.send(AppEvent::ClearStatusLine); + } + fn update_matched_message(&self) { if let Some((msg, matched)) = self.as_list_state().matched_query_string() { if matched { diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index d2a0aa2..0fd834d 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -46,7 +46,7 @@ impl CommitInfo { fn add_ref(&mut self, r: Rc) { self.refs.push(r); - self.refs.sort_by(|a, b| a.cmp(b)); + self.refs.sort(); } fn remove_ref(&mut self, name: &str) { @@ -93,6 +93,16 @@ pub enum TransientMessage { FuzzyOn, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterState { + Inactive, + Filtering { + ignore_case: bool, + fuzzy: bool, + transient_message: TransientMessage, + }, +} + #[derive(Debug, Default, Clone)] struct SearchMatch { refs: HashMap, @@ -177,7 +187,29 @@ impl SearchMatcher { } } + /// Quick check if string matches without computing match positions + fn matches(&self, s: &str) -> bool { + if self.query.is_empty() { + return false; + } + if self.fuzzy { + let result = if self.ignore_case { + FUZZY_MATCHER.fuzzy_match(&s.to_lowercase(), &self.query) + } else { + FUZZY_MATCHER.fuzzy_match(s, &self.query) + }; + result.is_some() + } else if self.ignore_case { + s.to_lowercase().contains(&self.query) + } else { + s.contains(&self.query) + } + } + fn matched_position(&self, s: &str) -> Option { + if self.query.is_empty() { + return None; + } if self.fuzzy { let result = if self.ignore_case { FUZZY_MATCHER.fuzzy_indices(&s.to_lowercase(), &self.query) @@ -213,6 +245,17 @@ pub struct CommitListState { search_input: Input, search_matches: Vec, + // Optimization: track previous search for incremental search + last_search_query: String, + last_matched_indices: Vec, + last_search_ignore_case: bool, + last_search_fuzzy: bool, + + // Filter mode + filter_state: FilterState, + filter_input: Input, + filtered_indices: Vec, + selected: usize, offset: usize, total: usize, @@ -242,6 +285,13 @@ impl CommitListState { search_state: SearchState::Inactive, search_input: Input::default(), search_matches: vec![SearchMatch::default(); total], + last_search_query: String::new(), + last_matched_indices: Vec::new(), + last_search_ignore_case: false, + last_search_fuzzy: false, + filter_state: FilterState::Inactive, + filter_input: Input::default(), + filtered_indices: Vec::new(), selected: 0, offset: 0, total, @@ -277,6 +327,9 @@ impl CommitListState { } pub fn select_next(&mut self) { + if self.total == 0 || self.height == 0 { + return; + } if self.selected < (self.total - 1).min(self.height - 1) { self.selected += 1; } else if self.selected + self.offset < self.total - 1 { @@ -285,6 +338,9 @@ impl CommitListState { } pub fn select_parent(&mut self) { + if self.total == 0 { + return; + } if let Some(target_commit) = self.selected_commit_parent_hash().cloned() { while target_commit.as_str() != self.selected_commit_hash().as_str() { self.select_next(); @@ -293,6 +349,9 @@ impl CommitListState { } pub fn selected_commit_parent_hash(&self) -> Option<&CommitHash> { + if self.total == 0 { + return None; + } self.commits[self.current_selected_index()] .commit .parent_commit_hashes @@ -313,6 +372,9 @@ impl CommitListState { } pub fn select_last(&mut self) { + if self.total == 0 || self.height == 0 { + return; + } self.selected = (self.height - 1).min(self.total - 1); if self.height < self.total { self.offset = self.total - self.height; @@ -329,6 +391,9 @@ impl CommitListState { } pub fn scroll_up(&mut self) { + if self.height == 0 { + return; + } if self.offset > 0 { self.offset -= 1; if self.selected < self.height - 1 { @@ -354,6 +419,9 @@ impl CommitListState { } fn scroll_down_height(&mut self, scroll_height: usize) { + if self.total == 0 || self.height == 0 { + return; + } if self.offset + self.height + scroll_height < self.total { self.offset += scroll_height; } else { @@ -384,7 +452,10 @@ impl CommitListState { } pub fn select_middle(&mut self) { - if self.total > self.height { + if self.total == 0 { + return; + } + if self.total > self.height && self.height > 0 { self.selected = self.height / 2; } else { self.selected = self.total / 2; @@ -392,6 +463,9 @@ impl CommitListState { } pub fn select_low(&mut self) { + if self.total == 0 || self.height == 0 { + return; + } if self.total > self.height { self.selected = self.height - 1; } else { @@ -411,10 +485,16 @@ impl CommitListState { } pub fn select_next_match(&mut self) { + if self.commits.is_empty() { + return; + } self.select_next_match_index(self.current_selected_index()); } pub fn select_prev_match(&mut self) { + if self.commits.is_empty() { + return; + } self.select_prev_match_index(self.current_selected_index()); } @@ -428,8 +508,10 @@ impl CommitListState { self.commits[self.current_selected_index()].refs_to_vec() } + /// Returns the real commit index (in commits Vec) for the currently selected item fn current_selected_index(&self) -> usize { - self.offset + self.selected + let visible_idx = self.offset + self.selected; + self.real_commit_index(visible_idx) } pub fn select_ref(&mut self, ref_name: &str) { @@ -623,22 +705,110 @@ impl CommitListState { } fn update_search_matches(&mut self, ignore_case: bool, fuzzy: bool) { - let q = self.search_input.value(); + let query = self.search_input.value().to_string(); + + // Early return for empty query + if query.is_empty() { + self.clear_search_matches(); + self.last_search_query.clear(); + self.last_matched_indices.clear(); + return; + } + + let matcher = SearchMatcher::new(&query, ignore_case, fuzzy); + + // Determine if we can use incremental search: + // - New query extends the previous query (user typing more chars) + // - Same search settings (ignore_case, fuzzy) + let settings_unchanged = + ignore_case == self.last_search_ignore_case && fuzzy == self.last_search_fuzzy; + let can_use_incremental = settings_unchanged + && !self.last_search_query.is_empty() + && query.starts_with(&self.last_search_query) + && !self.last_matched_indices.is_empty(); + + let mut new_matched_indices = Vec::new(); let mut match_index = 1; - for (i, commit_info) in self.commits.iter().enumerate() { - let mut m = SearchMatch::new( - &commit_info.commit, - commit_info.refs.iter().map(|r| r.as_ref()), - q, - ignore_case, - fuzzy, - ); - if m.matched() { - m.match_index = match_index; - match_index += 1; + + if can_use_incremental { + // Incremental search: only check previously matched commits + // First, clear all previous matches + for &i in &self.last_matched_indices { + self.search_matches[i].clear(); } - self.search_matches[i] = m; + + // Then search only among previously matched + for &i in &self.last_matched_indices { + let commit_info = &self.commits[i]; + if Self::commit_quick_matches(&matcher, commit_info) { + let mut m = SearchMatch::new( + &commit_info.commit, + commit_info.refs.iter().map(|r| r.as_ref()), + &query, + ignore_case, + fuzzy, + ); + m.match_index = match_index; + match_index += 1; + self.search_matches[i] = m; + new_matched_indices.push(i); + } + } + } else { + // Full search: check all commits + self.clear_search_matches(); + + for (i, commit_info) in self.commits.iter().enumerate() { + // Quick check first to avoid creating SearchMatch for non-matching commits + if Self::commit_quick_matches(&matcher, commit_info) { + let mut m = SearchMatch::new( + &commit_info.commit, + commit_info.refs.iter().map(|r| r.as_ref()), + &query, + ignore_case, + fuzzy, + ); + m.match_index = match_index; + match_index += 1; + self.search_matches[i] = m; + new_matched_indices.push(i); + } + } + } + + self.last_search_query = query; + self.last_matched_indices = new_matched_indices; + self.last_search_ignore_case = ignore_case; + self.last_search_fuzzy = fuzzy; + } + + /// Quick check if commit matches any searchable field + fn commit_quick_matches(matcher: &SearchMatcher, commit_info: &CommitInfo) -> bool { + let commit = &commit_info.commit; + + // Check subject first (most likely match) + if matcher.matches(&commit.subject) { + return true; + } + + // Check author name + if matcher.matches(&commit.author_name) { + return true; + } + + // Check commit hash + if matcher.matches(&commit.commit_hash.as_short_hash()) { + return true; } + + // Check refs + for r in &commit_info.refs { + if !matches!(r.as_ref(), Ref::Stash { .. }) && matcher.matches(r.name()) { + return true; + } + } + + false } fn clear_search_matches(&mut self) { @@ -646,8 +816,8 @@ impl CommitListState { } fn select_current_or_next_match_index(&mut self, current_index: usize) { - if self.search_matches[current_index].matched() { - self.select_index(current_index); + if self.search_matches[current_index].matched() && self.is_index_visible(current_index) { + self.select_real_index(current_index); self.search_state .update_match_index(self.search_matches[current_index].match_index); } else { @@ -656,36 +826,57 @@ impl CommitListState { } fn select_next_match_index(&mut self, current_index: usize) { - let mut i = (current_index + 1) % self.total; + // Always iterate over full commits list since search_matches uses real indices + let len = self.commits.len(); + if len == 0 { + return; + } + let mut i = (current_index + 1) % len; while i != current_index { - if self.search_matches[i].matched() { - self.select_index(i); + if self.search_matches[i].matched() && self.is_index_visible(i) { + self.select_real_index(i); self.search_state .update_match_index(self.search_matches[i].match_index); return; } - if i == self.total - 1 { - i = 0; - } else { - i += 1; - } + i = (i + 1) % len; } } fn select_prev_match_index(&mut self, current_index: usize) { - let mut i = (current_index + self.total - 1) % self.total; + // Always iterate over full commits list since search_matches uses real indices + let len = self.commits.len(); + if len == 0 { + return; + } + let mut i = (current_index + len - 1) % len; while i != current_index { - if self.search_matches[i].matched() { - self.select_index(i); + if self.search_matches[i].matched() && self.is_index_visible(i) { + self.select_real_index(i); self.search_state .update_match_index(self.search_matches[i].match_index); return; } - if i == 0 { - i = self.total - 1; - } else { - i -= 1; - } + i = (i + len - 1) % len; + } + } + + /// Check if a real commit index is visible (considering filter) + fn is_index_visible(&self, real_index: usize) -> bool { + if self.filtered_indices.is_empty() { + true // No filter, all indices visible + } else { + self.filtered_indices.contains(&real_index) + } + } + + /// Select a commit by its real index (in commits Vec), converting to visible index + fn select_real_index(&mut self, real_index: usize) { + if self.filtered_indices.is_empty() { + self.select_index(real_index); + } else if let Some(visible_idx) = self.filtered_indices.iter().position(|&i| i == real_index) + { + self.select_index(visible_idx); } } @@ -693,6 +884,176 @@ impl CommitListState { self.graph_image_manager .encoded_image(&commit_info.commit.commit_hash) } + + // Filter mode methods + + pub fn filter_state(&self) -> FilterState { + self.filter_state + } + + pub fn start_filter(&mut self) { + if let FilterState::Inactive = self.filter_state { + // Filter mode uses fuzzy by default for better UX + self.filter_state = FilterState::Filtering { + ignore_case: true, + fuzzy: true, + transient_message: TransientMessage::None, + }; + self.filter_input.reset(); + self.filtered_indices.clear(); + self.update_filter(); + } + } + + pub fn handle_filter_input(&mut self, key: KeyEvent) { + if let FilterState::Filtering { + transient_message, .. + } = &mut self.filter_state + { + *transient_message = TransientMessage::None; + } + + if let FilterState::Filtering { + ignore_case, fuzzy, .. + } = self.filter_state + { + self.filter_input.handle_event(&Event::Key(key)); + self.update_filter_matches(ignore_case, fuzzy); + } + } + + pub fn cancel_filter(&mut self) { + self.filter_state = FilterState::Inactive; + self.filter_input.reset(); + self.filtered_indices.clear(); + // Reset to show all commits + self.total = self.commits.len(); + self.selected = 0; + self.offset = 0; + } + + pub fn apply_filter(&mut self) { + if let FilterState::Filtering { .. } = self.filter_state { + self.filter_state = FilterState::Inactive; + // Keep filtered_indices active + } + } + + pub fn toggle_filter_ignore_case(&mut self) { + if let FilterState::Filtering { + ignore_case, + fuzzy, + transient_message, + } = &mut self.filter_state + { + *ignore_case = !*ignore_case; + *transient_message = if *ignore_case { + TransientMessage::IgnoreCaseOn + } else { + TransientMessage::IgnoreCaseOff + }; + let ic = *ignore_case; + let fz = *fuzzy; + self.update_filter_matches(ic, fz); + } + } + + pub fn toggle_filter_fuzzy(&mut self) { + if let FilterState::Filtering { + ignore_case, + fuzzy, + transient_message, + } = &mut self.filter_state + { + *fuzzy = !*fuzzy; + *transient_message = if *fuzzy { + TransientMessage::FuzzyOn + } else { + TransientMessage::FuzzyOff + }; + let ic = *ignore_case; + let fz = *fuzzy; + self.update_filter_matches(ic, fz); + } + } + + pub fn filter_query_string(&self) -> Option { + if let FilterState::Filtering { .. } = self.filter_state { + Some(format!("filter: {}", self.filter_input.value())) + } else { + None + } + } + + pub fn filter_query_cursor_position(&self) -> u16 { + // "filter: " prefix is 8 chars + 8 + self.filter_input.visual_cursor() as u16 + } + + pub fn filter_transient_message_string(&self) -> Option { + if let FilterState::Filtering { + transient_message, .. + } = self.filter_state + { + match transient_message { + TransientMessage::None => None, + TransientMessage::IgnoreCaseOn => Some("Ignore case: ON ".to_string()), + TransientMessage::IgnoreCaseOff => Some("Ignore case: OFF".to_string()), + TransientMessage::FuzzyOn => Some("Fuzzy match: ON ".to_string()), + TransientMessage::FuzzyOff => Some("Fuzzy match: OFF".to_string()), + } + } else { + None + } + } + + fn update_filter(&mut self) { + if let FilterState::Filtering { + ignore_case, fuzzy, .. + } = self.filter_state + { + self.update_filter_matches(ignore_case, fuzzy); + } + } + + fn update_filter_matches(&mut self, ignore_case: bool, fuzzy: bool) { + let query = self.filter_input.value().to_string(); + + if query.is_empty() { + // Show all commits when filter is empty + self.filtered_indices.clear(); + self.total = self.commits.len(); + self.selected = 0; + self.offset = 0; + return; + } + + let matcher = SearchMatcher::new(&query, ignore_case, fuzzy); + self.filtered_indices.clear(); + + for (i, commit_info) in self.commits.iter().enumerate() { + if Self::commit_quick_matches(&matcher, commit_info) { + self.filtered_indices.push(i); + } + } + + self.total = if self.filtered_indices.is_empty() { + 0 + } else { + self.filtered_indices.len() + }; + self.selected = 0; + self.offset = 0; + } + + /// Map visible index to real commit index + fn real_commit_index(&self, visible_idx: usize) -> usize { + if self.filtered_indices.is_empty() { + visible_idx + } else { + self.filtered_indices[visible_idx] + } + } } pub struct CommitList<'a> { @@ -763,16 +1124,19 @@ impl CommitList<'_> { state.offset += diff; } - state - .commits - .iter() - .skip(state.offset) - .take(state.height) - .for_each(|commit_info| { - state - .graph_image_manager - .load_encoded_image(&commit_info.commit.commit_hash); - }); + // Load graph images for visible commits + let has_filter = !state.filtered_indices.is_empty(); + for display_idx in 0..state.height.min(state.total.saturating_sub(state.offset)) { + let visible_idx = state.offset + display_idx; + let real_idx = if has_filter { + state.filtered_indices[visible_idx] + } else { + visible_idx + }; + state + .graph_image_manager + .load_encoded_image(&state.commits[real_idx].commit.commit_hash); + } } fn calc_cell_widths( @@ -820,13 +1184,13 @@ impl CommitList<'_> { fn render_graph(&self, buf: &mut Buffer, area: Rect, state: &CommitListState) { self.rendering_commit_info_iter(state) - .for_each(|(i, commit_info)| { - buf[(area.left(), area.top() + i as u16)] + .for_each(|(display_i, _real_i, commit_info)| { + buf[(area.left(), area.top() + display_i as u16)] .set_symbol(state.encoded_image(commit_info)); // width - 1 for right pad for w in 1..area.width - 1 { - buf[(area.left() + w, area.top() + i as u16)].set_skip(true); + buf[(area.left() + w, area.top() + display_i as u16)].set_skip(true); } }); } @@ -834,7 +1198,7 @@ impl CommitList<'_> { fn render_marker(&self, buf: &mut Buffer, area: Rect, state: &CommitListState) { let items: Vec = self .rendering_commit_info_iter(state) - .map(|(_, commit_info)| ListItem::new("│".fg(commit_info.graph_color))) + .map(|(_, _, commit_info)| ListItem::new("│".fg(commit_info.graph_color))) .collect(); Widget::render(List::new(items), area, buf) } @@ -846,11 +1210,11 @@ impl CommitList<'_> { } let items: Vec = self .rendering_commit_info_iter(state) - .map(|(i, commit_info)| { + .map(|(display_i, real_i, commit_info)| { let mut spans = refs_spans( commit_info, &state.head, - &state.search_matches[state.offset + i].refs, + &state.search_matches[real_i].refs, self.color_theme, ); let ref_spans_width: usize = spans.iter().map(|s| s.width()).sum(); @@ -864,23 +1228,23 @@ impl CommitList<'_> { commit.subject.to_string() }; - let sub_spans = - if let Some(pos) = state.search_matches[state.offset + i].subject.clone() { - highlighted_spans( - subject.into(), - pos, - self.color_theme.list_subject_fg, - Modifier::empty(), - self.color_theme, - truncate, - ) - } else { - vec![subject.fg(self.color_theme.list_subject_fg)] - }; + let sub_spans = if let Some(pos) = state.search_matches[real_i].subject.clone() + { + highlighted_spans( + subject.into(), + pos, + self.color_theme.list_subject_fg, + Modifier::empty(), + self.color_theme, + truncate, + ) + } else { + vec![subject.fg(self.color_theme.list_subject_fg)] + }; spans.extend(sub_spans) } - self.to_commit_list_item(i, spans, state) + self.to_commit_list_item(display_i, spans, state) }) .collect(); Widget::render(List::new(items), area, buf); @@ -893,27 +1257,26 @@ impl CommitList<'_> { } let items: Vec = self .rendering_commit_iter(state) - .map(|(i, commit)| { + .map(|(display_i, real_i, commit)| { let truncate = console::measure_text_width(&commit.author_name) > max_width; let name = if truncate { console::truncate_str(&commit.author_name, max_width, ELLIPSIS).to_string() } else { commit.author_name.to_string() }; - let spans = - if let Some(pos) = state.search_matches[state.offset + i].author_name.clone() { - highlighted_spans( - name.into(), - pos, - self.color_theme.list_name_fg, - Modifier::empty(), - self.color_theme, - truncate, - ) - } else { - vec![name.fg(self.color_theme.list_name_fg)] - }; - self.to_commit_list_item(i, spans, state) + let spans = if let Some(pos) = state.search_matches[real_i].author_name.clone() { + highlighted_spans( + name.into(), + pos, + self.color_theme.list_name_fg, + Modifier::empty(), + self.color_theme, + truncate, + ) + } else { + vec![name.fg(self.color_theme.list_name_fg)] + }; + self.to_commit_list_item(display_i, spans, state) }) .collect(); Widget::render(List::new(items), area, buf); @@ -925,22 +1288,21 @@ impl CommitList<'_> { } let items: Vec = self .rendering_commit_iter(state) - .map(|(i, commit)| { + .map(|(display_i, real_i, commit)| { let hash = commit.commit_hash.as_short_hash(); - let spans = - if let Some(pos) = state.search_matches[state.offset + i].commit_hash.clone() { - highlighted_spans( - hash.into(), - pos, - self.color_theme.list_hash_fg, - Modifier::empty(), - self.color_theme, - false, - ) - } else { - vec![hash.fg(self.color_theme.list_hash_fg)] - }; - self.to_commit_list_item(i, spans, state) + let spans = if let Some(pos) = state.search_matches[real_i].commit_hash.clone() { + highlighted_spans( + hash.into(), + pos, + self.color_theme.list_hash_fg, + Modifier::empty(), + self.color_theme, + false, + ) + } else { + vec![hash.fg(self.color_theme.list_hash_fg)] + }; + self.to_commit_list_item(display_i, spans, state) }) .collect(); Widget::render(List::new(items), area, buf); @@ -952,7 +1314,7 @@ impl CommitList<'_> { } let items: Vec = self .rendering_commit_iter(state) - .map(|(i, commit)| { + .map(|(display_i, _real_i, commit)| { let date = &commit.author_date; let date_str = if self.config.date_local { let local = date.with_timezone(&chrono::Local); @@ -960,30 +1322,43 @@ impl CommitList<'_> { } else { date.format(&self.config.date_format).to_string() }; - self.to_commit_list_item(i, vec![date_str.fg(self.color_theme.list_date_fg)], state) + self.to_commit_list_item( + display_i, + vec![date_str.fg(self.color_theme.list_date_fg)], + state, + ) }) .collect(); Widget::render(List::new(items), area, buf); } + /// Returns iterator of (display_idx, real_idx, &CommitInfo) + /// display_idx: position on screen (0, 1, 2, ...) + /// real_idx: actual index in commits Vec (for search_matches access) fn rendering_commit_info_iter<'b>( &'b self, state: &'b CommitListState, - ) -> impl Iterator { - state - .commits - .iter() - .skip(state.offset) - .take(state.height) - .enumerate() + ) -> impl Iterator { + let has_filter = !state.filtered_indices.is_empty(); + (0..state.height.min(state.total.saturating_sub(state.offset))).map(move |display_idx| { + let visible_idx = state.offset + display_idx; + let real_idx = if has_filter { + state.filtered_indices[visible_idx] + } else { + visible_idx + }; + (display_idx, real_idx, &state.commits[real_idx]) + }) } fn rendering_commit_iter<'b>( &'b self, state: &'b CommitListState, - ) -> impl Iterator { + ) -> impl Iterator { self.rendering_commit_info_iter(state) - .map(|(i, commit_info)| (i, commit_info.commit.as_ref())) + .map(|(display_i, real_i, commit_info)| { + (display_i, real_i, commit_info.commit.as_ref()) + }) } fn to_commit_list_item<'a, 'b>( diff --git a/src/widget/pending_overlay.rs b/src/widget/pending_overlay.rs index c07e960..3b9bfae 100644 --- a/src/widget/pending_overlay.rs +++ b/src/widget/pending_overlay.rs @@ -69,9 +69,7 @@ impl Widget for PendingOverlay<'_> { Span::raw(" hide").fg(self.color_theme.fg), ])); - Paragraph::new(lines) - .centered() - .render(inner_area, buf); + Paragraph::new(lines).centered().render(inner_area, buf); } } From 8a48497cb1446db3d4ed2559cd5944c965b0adf6 Mon Sep 17 00:00:00 2001 From: Andew Power Date: Thu, 18 Dec 2025 13:25:38 +0200 Subject: [PATCH 07/20] feat: display context-aware hotkey hints in footer - Show relevant hotkeys based on current view (list, detail, refs, etc.) - Change ignore_case_toggle keybind from Ctrl-g to Alt-c (industry standard) --- assets/default-keybind.toml | 2 +- src/app.rs | 52 +++++++++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/assets/default-keybind.toml b/assets/default-keybind.toml index dce9d0c..949debf 100644 --- a/assets/default-keybind.toml +++ b/assets/default-keybind.toml @@ -30,7 +30,7 @@ confirm = ["enter"] ref_list_toggle = ["tab"] search = ["/"] filter = ["f"] -ignore_case_toggle = ["ctrl-g"] +ignore_case_toggle = ["alt-c"] fuzzy_toggle = ["ctrl-x"] user_command_view_toggle_1 = ["d"] diff --git a/src/app.rs b/src/app.rs index fd0cab0..b78cbeb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ use ratatui::{ crossterm::event::{KeyCode, KeyEvent}, layout::{Constraint, Layout, Rect}, style::{Modifier, Style, Stylize}, - text::Line, + text::{Line, Span}, widgets::{Block, Borders, Padding, Paragraph}, Frame, Terminal, }; @@ -342,7 +342,7 @@ impl App<'_> { let text: Line = match &self.status_line { StatusLine::None => { if self.numeric_prefix.is_empty() { - Line::raw("") + self.build_hotkey_hints() } else { Line::raw(self.numeric_prefix.as_str()) .fg(self.color_theme.status_input_transient_fg) @@ -396,6 +396,54 @@ impl App<'_> { } } } + + fn build_hotkey_hints(&self) -> Line<'static> { + let hints: Vec<(UserEvent, &str)> = match &self.view { + View::List(_) => vec![ + (UserEvent::Search, "search"), + (UserEvent::Filter, "filter"), + (UserEvent::IgnoreCaseToggle, "case"), + (UserEvent::CreateTag, "tag"), + (UserEvent::RefListToggle, "refs"), + (UserEvent::Refresh, "refresh"), + (UserEvent::HelpToggle, "help"), + ], + View::Detail(_) => vec![ + (UserEvent::ShortCopy, "copy"), + (UserEvent::Close, "close"), + (UserEvent::HelpToggle, "help"), + ], + View::Refs(_) => vec![ + (UserEvent::ShortCopy, "copy"), + (UserEvent::Close, "close"), + (UserEvent::HelpToggle, "help"), + ], + View::CreateTag(_) | View::DeleteTag(_) => vec![ + (UserEvent::Confirm, "confirm"), + (UserEvent::Cancel, "cancel"), + ], + View::Help(_) => vec![ + (UserEvent::Close, "close"), + ], + _ => vec![], + }; + + let key_fg = self.color_theme.help_key_fg; + let desc_fg = self.color_theme.status_input_transient_fg; + + let mut spans: Vec> = Vec::new(); + for (i, (event, desc)) in hints.iter().enumerate() { + if let Some(key) = self.keybind.keys_for_event(*event).first() { + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled(key.clone(), Style::default().fg(key_fg))); + spans.push(Span::raw(" ")); + spans.push(Span::styled((*desc).to_string(), Style::default().fg(desc_fg))); + } + } + Line::from(spans) + } } impl App<'_> { From 1215e7d10e16ec1788c3a9d264917c862e50c021 Mon Sep 17 00:00:00 2001 From: Andew Power Date: Thu, 18 Dec 2025 15:14:48 +0200 Subject: [PATCH 08/20] feat: delete branches and tags from refs panel with 'd' key --- src/app.rs | 61 +++++++- src/event.rs | 8 ++ src/git.rs | 78 ++++++++++- src/lib.rs | 6 +- src/view.rs | 1 + src/view/delete_ref.rs | 308 +++++++++++++++++++++++++++++++++++++++++ src/view/refs.rs | 58 +++++++- src/view/views.rs | 50 ++++++- src/widget/ref_list.rs | 30 ++++ 9 files changed, 588 insertions(+), 12 deletions(-) create mode 100644 src/view/delete_ref.rs diff --git a/src/app.rs b/src/app.rs index b78cbeb..7f3692c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,7 @@ use crate::{ config::{CoreConfig, CursorType, UiConfig}, event::{AppEvent, Receiver, Sender, UserEvent, UserEventWithCount}, external::copy_to_clipboard, - git::{CommitHash, Head, Ref, Repository}, + git::{CommitHash, Head, Ref, RefType, Repository}, graph::{CellWidthType, Graph, GraphImageManager}, keybind::KeyBind, protocol::ImageProtocol, @@ -264,6 +264,15 @@ impl App<'_> { } => { self.remove_tag_from_commit(&commit_hash, &tag_name); } + AppEvent::OpenDeleteRef { ref_name, ref_type } => { + self.open_delete_ref(ref_name, ref_type); + } + AppEvent::CloseDeleteRef => { + self.close_delete_ref(); + } + AppEvent::RemoveRefFromList { ref_name } => { + self.remove_ref_from_list(&ref_name); + } AppEvent::OpenHelp => { self.open_help(); } @@ -415,10 +424,11 @@ impl App<'_> { ], View::Refs(_) => vec![ (UserEvent::ShortCopy, "copy"), + (UserEvent::UserCommandViewToggle(1), "delete"), (UserEvent::Close, "close"), (UserEvent::HelpToggle, "help"), ], - View::CreateTag(_) | View::DeleteTag(_) => vec![ + View::CreateTag(_) | View::DeleteTag(_) | View::DeleteRef(_) => vec![ (UserEvent::Confirm, "confirm"), (UserEvent::Cancel, "cancel"), ], @@ -708,6 +718,53 @@ impl App<'_> { } } + fn open_delete_ref(&mut self, ref_name: String, ref_type: RefType) { + if let View::Refs(ref mut view) = self.view { + let commit_list_state = view.take_list_state(); + let ref_list_state = view.take_ref_list_state(); + let refs = view.take_refs(); + self.view = View::of_delete_ref( + commit_list_state, + ref_list_state, + refs, + self.repository.path().to_path_buf(), + ref_name, + ref_type, + self.ui_config, + self.color_theme, + self.tx.clone(), + ); + } + } + + fn close_delete_ref(&mut self) { + if let View::DeleteRef(ref mut view) = self.view { + let commit_list_state = view.take_list_state(); + let ref_list_state = view.take_ref_list_state(); + let refs = view.take_refs(); + self.view = View::of_refs_with_state( + commit_list_state, + ref_list_state, + refs, + self.ui_config, + self.color_theme, + self.tx.clone(), + ); + } + } + + fn remove_ref_from_list(&mut self, ref_name: &str) { + match &mut self.view { + View::Refs(view) => { + view.remove_ref(ref_name); + } + View::DeleteRef(view) => { + view.remove_ref(ref_name); + } + _ => {} + } + } + fn open_help(&mut self) { let before_view = std::mem::take(&mut self.view); self.view = View::of_help( diff --git a/src/event.rs b/src/event.rs index 0305015..aeba22a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -34,6 +34,14 @@ pub enum AppEvent { commit_hash: crate::git::CommitHash, tag_name: String, }, + OpenDeleteRef { + ref_name: String, + ref_type: crate::git::RefType, + }, + CloseDeleteRef, + RemoveRefFromList { + ref_name: String, + }, OpenHelp, CloseHelp, ClearHelp, diff --git a/src/git.rs b/src/git.rs index 679328b..b0c345b 100644 --- a/src/git.rs +++ b/src/git.rs @@ -60,6 +60,13 @@ pub struct Commit { pub commit_type: CommitType, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefType { + Tag, + Branch, + RemoteBranch, +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum Ref { Tag { @@ -227,23 +234,23 @@ fn check_git_repository(path: &Path) -> Result<()> { } fn is_inside_work_tree(path: &Path) -> bool { - let output = Command::new("git") + Command::new("git") .arg("rev-parse") .arg("--is-inside-work-tree") .current_dir(path) .output() - .unwrap(); - output.status.success() && output.stdout == b"true\n" + .map(|o| o.status.success() && o.stdout == b"true\n") + .unwrap_or(false) } fn is_bare_repository(path: &Path) -> bool { - let output = Command::new("git") + Command::new("git") .arg("rev-parse") .arg("--is-bare-repository") .current_dir(path) .output() - .unwrap(); - output.status.success() && output.stdout == b"true\n" + .map(|o| o.status.success() && o.stdout == b"true\n") + .unwrap_or(false) } fn load_all_commits(path: &Path, sort: SortCommit, stashes: &[Commit]) -> Vec { @@ -748,3 +755,62 @@ pub fn delete_remote_tag(path: &Path, tag_name: &str) -> std::result::Result<(), } Ok(()) } + +pub fn delete_branch(path: &Path, branch_name: &str) -> std::result::Result<(), String> { + let output = Command::new("git") + .arg("branch") + .arg("-d") + .arg(branch_name) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to execute git branch -d: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to delete branch: {stderr}")); + } + Ok(()) +} + +pub fn delete_branch_force(path: &Path, branch_name: &str) -> std::result::Result<(), String> { + let output = Command::new("git") + .arg("branch") + .arg("-D") + .arg(branch_name) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to execute git branch -D: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to force delete branch: {stderr}")); + } + Ok(()) +} + +pub fn delete_remote_branch(path: &Path, branch_name: &str) -> std::result::Result<(), String> { + // branch_name for remote branches is like "origin/feature" - we need to split + let parts: Vec<&str> = branch_name.splitn(2, '/').collect(); + if parts.len() != 2 { + return Err(format!( + "Invalid remote branch name format: {branch_name}" + )); + } + let remote = parts[0]; + let branch = parts[1]; + + let output = Command::new("git") + .arg("push") + .arg(remote) + .arg("--delete") + .arg(branch) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to execute git push --delete: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to delete remote branch: {stderr}")); + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 2383273..859d588 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,10 @@ use serde::Deserialize; #[derive(Parser)] #[command(version)] struct Args { + /// Path to git repository [default: current directory] + #[arg(default_value = ".")] + path: String, + /// Image protocol to render graph [default: auto] #[arg(short, long, value_name = "TYPE")] protocol: Option, @@ -122,7 +126,7 @@ pub fn run() -> Result<()> { let graph_color_set = color::GraphColorSet::new(&graph_config.color); - let repository = git::Repository::load(Path::new("."), order)?; + let repository = git::Repository::load(Path::new(&args.path), order)?; let graph = Rc::new(graph::calc_graph(&repository)); diff --git a/src/view.rs b/src/view.rs index 9316d8b..c33c1b1 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1,6 +1,7 @@ mod views; mod create_tag; +mod delete_ref; mod delete_tag; mod detail; mod help; diff --git a/src/view/delete_ref.rs b/src/view/delete_ref.rs new file mode 100644 index 0000000..1526aa5 --- /dev/null +++ b/src/view/delete_ref.rs @@ -0,0 +1,308 @@ +use std::{path::PathBuf, rc::Rc, thread}; + +use ratatui::{ + crossterm::event::KeyEvent, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Padding, Paragraph}, + Frame, +}; + +use crate::{ + color::ColorTheme, + config::UiConfig, + event::{AppEvent, Sender, UserEvent, UserEventWithCount}, + git::{ + delete_branch, delete_branch_force, delete_remote_branch, delete_remote_tag, delete_tag, + Ref, RefType, + }, + widget::{ + commit_list::{CommitList, CommitListState}, + ref_list::RefListState, + }, +}; + +#[derive(Debug)] +pub struct DeleteRefView<'a> { + commit_list_state: Option, + ref_list_state: RefListState, + refs: Vec>, + repo_path: PathBuf, + + ref_name: String, + ref_type: RefType, + delete_from_remote: bool, + force_delete: bool, + + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + tx: Sender, +} + +impl<'a> DeleteRefView<'a> { + pub fn new( + commit_list_state: CommitListState, + ref_list_state: RefListState, + refs: Vec>, + repo_path: PathBuf, + ref_name: String, + ref_type: RefType, + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + tx: Sender, + ) -> DeleteRefView<'a> { + DeleteRefView { + commit_list_state: Some(commit_list_state), + ref_list_state, + refs, + repo_path, + ref_name, + ref_type, + delete_from_remote: ref_type == RefType::RemoteBranch, + force_delete: false, + ui_config, + color_theme, + tx, + } + } + + pub fn handle_event(&mut self, event_with_count: UserEventWithCount, _key: KeyEvent) { + let event = event_with_count.event; + + match event { + UserEvent::Cancel => { + self.tx.send(AppEvent::CloseDeleteRef); + } + UserEvent::Confirm => { + self.delete_ref(); + } + UserEvent::NavigateRight | UserEvent::NavigateLeft | UserEvent::NavigateDown => { + match self.ref_type { + RefType::Tag => { + self.delete_from_remote = !self.delete_from_remote; + } + RefType::Branch => { + self.force_delete = !self.force_delete; + } + RefType::RemoteBranch => {} + } + } + _ => {} + } + } + + fn delete_ref(&mut self) { + let ref_name = self.ref_name.clone(); + let ref_type = self.ref_type; + let repo_path = self.repo_path.clone(); + let delete_from_remote = self.delete_from_remote; + let force_delete = self.force_delete; + let tx = self.tx.clone(); + + let pending_msg = match ref_type { + RefType::Tag => { + if delete_from_remote { + format!("Deleting tag '{}' from local and remote...", ref_name) + } else { + format!("Deleting tag '{}'...", ref_name) + } + } + RefType::Branch => { + if force_delete { + format!("Force deleting branch '{}'...", ref_name) + } else { + format!("Deleting branch '{}'...", ref_name) + } + } + RefType::RemoteBranch => { + format!("Deleting remote branch '{}'...", ref_name) + } + }; + + self.tx.send(AppEvent::ShowPendingOverlay { + message: pending_msg, + }); + self.tx.send(AppEvent::CloseDeleteRef); + + thread::spawn(move || { + let result = match ref_type { + RefType::Tag => { + if let Err(e) = delete_tag(&repo_path, &ref_name) { + Err(e) + } else if delete_from_remote { + delete_remote_tag(&repo_path, &ref_name).map_err(|e| { + format!( + "Local tag deleted, but failed to delete from remote: {}", + e + ) + }) + } else { + Ok(()) + } + } + RefType::Branch => { + if force_delete { + delete_branch_force(&repo_path, &ref_name) + } else { + delete_branch(&repo_path, &ref_name) + } + } + RefType::RemoteBranch => delete_remote_branch(&repo_path, &ref_name), + }; + + match result { + Ok(()) => { + let msg = match ref_type { + RefType::Tag => { + if delete_from_remote { + format!("Tag '{}' deleted from local and remote", ref_name) + } else { + format!("Tag '{}' deleted locally", ref_name) + } + } + RefType::Branch => { + format!("Branch '{}' deleted", ref_name) + } + RefType::RemoteBranch => { + format!("Remote branch '{}' deleted", ref_name) + } + }; + tx.send(AppEvent::RemoveRefFromList { + ref_name: ref_name.clone(), + }); + tx.send(AppEvent::NotifySuccess(msg)); + tx.send(AppEvent::HidePendingOverlay); + } + Err(e) => { + tx.send(AppEvent::HidePendingOverlay); + tx.send(AppEvent::NotifyError(e)); + } + } + }); + } + + pub fn render(&mut self, f: &mut Frame, area: Rect) { + let graph_width = self.as_list_state().graph_area_cell_width() + 1; + let refs_width = (area.width.saturating_sub(graph_width)).min(self.ui_config.refs.width); + + let [list_area, refs_area] = + Layout::horizontal([Constraint::Min(0), Constraint::Length(refs_width)]).areas(area); + + let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); + f.render_stateful_widget(commit_list, list_area, self.as_mut_list_state()); + + let ref_list = + crate::widget::ref_list::RefList::new(&self.refs, self.color_theme); + f.render_stateful_widget(ref_list, refs_area, &mut self.ref_list_state); + + let dialog_width = 50u16.min(area.width.saturating_sub(4)); + let dialog_height = 6u16.min(area.height.saturating_sub(2)); + + let dialog_x = (area.width.saturating_sub(dialog_width)) / 2; + let dialog_y = (area.height.saturating_sub(dialog_height)) / 2; + + let dialog_area = Rect::new( + area.x + dialog_x, + area.y + dialog_y, + dialog_width, + dialog_height, + ); + + f.render_widget(Clear, dialog_area); + + let title = match self.ref_type { + RefType::Tag => " Delete Tag ", + RefType::Branch => " Delete Branch ", + RefType::RemoteBranch => " Delete Remote Branch ", + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(self.color_theme.divider_fg)) + .style( + Style::default() + .bg(self.color_theme.bg) + .fg(self.color_theme.fg), + ) + .padding(Padding::horizontal(1)); + + let inner_area = block.inner(dialog_area); + f.render_widget(block, dialog_area); + + let [name_area, checkbox_area, hint_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(2), + Constraint::Min(1), + ]) + .areas(inner_area); + + let name_line = Line::from(vec![ + Span::raw(&self.ref_name) + .fg(self.color_theme.fg) + .add_modifier(Modifier::BOLD), + ]); + f.render_widget(Paragraph::new(name_line), name_area); + + let checkbox_line = match self.ref_type { + RefType::Tag => { + let checkbox = if self.delete_from_remote { + "[x]" + } else { + "[ ]" + }; + Line::from(vec![ + Span::styled(checkbox, Style::default().fg(self.color_theme.fg)), + Span::raw(" Delete from origin").fg(self.color_theme.fg), + ]) + } + RefType::Branch => { + let checkbox = if self.force_delete { "[x]" } else { "[ ]" }; + Line::from(vec![ + Span::styled(checkbox, Style::default().fg(self.color_theme.fg)), + Span::raw(" Force delete (-D)").fg(self.color_theme.fg), + ]) + } + RefType::RemoteBranch => Line::from(vec![Span::raw("").fg(self.color_theme.fg)]), + }; + f.render_widget(Paragraph::new(checkbox_line), checkbox_area); + + let hint_line = Line::from(vec![ + Span::raw("Enter").fg(self.color_theme.help_key_fg), + Span::raw(" delete ").fg(self.color_theme.fg), + Span::raw("Esc").fg(self.color_theme.help_key_fg), + Span::raw(" cancel ").fg(self.color_theme.fg), + Span::raw("←→").fg(self.color_theme.help_key_fg), + Span::raw(" toggle").fg(self.color_theme.fg), + ]); + f.render_widget(Paragraph::new(hint_line).centered(), hint_area); + } + + fn as_mut_list_state(&mut self) -> &mut CommitListState { + self.commit_list_state.as_mut().unwrap() + } + + fn as_list_state(&self) -> &CommitListState { + self.commit_list_state.as_ref().unwrap() + } +} + +impl<'a> DeleteRefView<'a> { + pub fn take_list_state(&mut self) -> CommitListState { + self.commit_list_state.take().unwrap() + } + + pub fn take_ref_list_state(&mut self) -> RefListState { + std::mem::take(&mut self.ref_list_state) + } + + pub fn take_refs(&mut self) -> Vec> { + std::mem::take(&mut self.refs) + } + + pub fn remove_ref(&mut self, ref_name: &str) { + self.refs.retain(|r| r.name() != ref_name); + self.ref_list_state.adjust_selection_after_delete(); + } +} diff --git a/src/view/refs.rs b/src/view/refs.rs index fdf3951..2e23c81 100644 --- a/src/view/refs.rs +++ b/src/view/refs.rs @@ -10,7 +10,7 @@ use crate::{ color::ColorTheme, config::UiConfig, event::{AppEvent, Sender, UserEvent, UserEventWithCount}, - git::Ref, + git::{Ref, RefType}, widget::{ commit_list::{CommitList, CommitListState}, ref_list::{RefList, RefListState}, @@ -47,6 +47,24 @@ impl<'a> RefsView<'a> { } } + pub fn with_state( + commit_list_state: CommitListState, + ref_list_state: RefListState, + refs: Vec>, + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + tx: Sender, + ) -> RefsView<'a> { + RefsView { + commit_list_state: Some(commit_list_state), + ref_list_state, + refs, + ui_config, + color_theme, + tx, + } + } + pub fn handle_event(&mut self, event_with_count: UserEventWithCount, _: KeyEvent) { let event = event_with_count.event; let count = event_with_count.count; @@ -92,10 +110,35 @@ impl<'a> RefsView<'a> { UserEvent::HelpToggle => { self.tx.send(AppEvent::OpenHelp); } + UserEvent::UserCommandViewToggle(_) | UserEvent::DeleteTag => { + self.open_delete_ref(); + } _ => {} } } + fn open_delete_ref(&self) { + if let Some(name) = self.ref_list_state.selected_local_branch() { + self.tx.send(AppEvent::OpenDeleteRef { + ref_name: name, + ref_type: RefType::Branch, + }); + } else if let Some(name) = self.ref_list_state.selected_remote_branch() { + self.tx.send(AppEvent::OpenDeleteRef { + ref_name: name, + ref_type: RefType::RemoteBranch, + }); + } else if let Some(name) = self.ref_list_state.selected_tag() { + self.tx.send(AppEvent::OpenDeleteRef { + ref_name: name, + ref_type: RefType::Tag, + }); + } else { + self.tx + .send(AppEvent::NotifyWarn("Select a branch or tag to delete".into())); + } + } + pub fn render(&mut self, f: &mut Frame, area: Rect) { let graph_width = self.as_list_state().graph_area_cell_width() + 1; // graph area + marker let refs_width = (area.width.saturating_sub(graph_width)).min(self.ui_config.refs.width); @@ -116,6 +159,19 @@ impl<'a> RefsView<'a> { self.commit_list_state.take().unwrap() } + pub fn take_ref_list_state(&mut self) -> RefListState { + std::mem::take(&mut self.ref_list_state) + } + + pub fn take_refs(&mut self) -> Vec> { + std::mem::take(&mut self.refs) + } + + pub fn remove_ref(&mut self, ref_name: &str) { + self.refs.retain(|r| r.name() != ref_name); + self.ref_list_state.adjust_selection_after_delete(); + } + fn as_mut_list_state(&mut self) -> &mut CommitListState { self.commit_list_state.as_mut().unwrap() } diff --git a/src/view/views.rs b/src/view/views.rs index aad9f35..db6c96f 100644 --- a/src/view/views.rs +++ b/src/view/views.rs @@ -6,11 +6,12 @@ use crate::{ color::ColorTheme, config::{CoreConfig, UiConfig}, event::{Sender, UserEventWithCount}, - git::{Commit, CommitHash, FileChange, Ref}, + git::{Commit, CommitHash, FileChange, Ref, RefType}, keybind::KeyBind, protocol::ImageProtocol, view::{ create_tag::CreateTagView, + delete_ref::DeleteRefView, delete_tag::DeleteTagView, detail::DetailView, help::HelpView, @@ -18,7 +19,7 @@ use crate::{ refs::RefsView, user_command::{UserCommandView, UserCommandViewBeforeView}, }, - widget::commit_list::CommitListState, + widget::{commit_list::CommitListState, ref_list::RefListState}, }; #[derive(Debug, Default)] @@ -31,6 +32,7 @@ pub enum View<'a> { Refs(Box>), CreateTag(Box>), DeleteTag(Box>), + DeleteRef(Box>), Help(Box>), } @@ -44,6 +46,7 @@ impl<'a> View<'a> { View::Refs(view) => view.handle_event(event_with_count, key_event), View::CreateTag(view) => view.handle_event(event_with_count, key_event), View::DeleteTag(view) => view.handle_event(event_with_count, key_event), + View::DeleteRef(view) => view.handle_event(event_with_count, key_event), View::Help(view) => view.handle_event(event_with_count, key_event), } } @@ -57,6 +60,7 @@ impl<'a> View<'a> { View::Refs(view) => view.render(f, area), View::CreateTag(view) => view.render(f, area), View::DeleteTag(view) => view.render(f, area), + View::DeleteRef(view) => view.render(f, area), View::Help(view) => view.render(f, area), } } @@ -163,6 +167,24 @@ impl<'a> View<'a> { ))) } + pub fn of_refs_with_state( + commit_list_state: CommitListState, + ref_list_state: RefListState, + refs: Vec>, + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + tx: Sender, + ) -> Self { + View::Refs(Box::new(RefsView::with_state( + commit_list_state, + ref_list_state, + refs, + ui_config, + color_theme, + tx, + ))) + } + pub fn of_create_tag( commit_list_state: CommitListState, commit_hash: CommitHash, @@ -201,6 +223,30 @@ impl<'a> View<'a> { ))) } + pub fn of_delete_ref( + commit_list_state: CommitListState, + ref_list_state: RefListState, + refs: Vec>, + repo_path: PathBuf, + ref_name: String, + ref_type: RefType, + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + tx: Sender, + ) -> Self { + View::DeleteRef(Box::new(DeleteRefView::new( + commit_list_state, + ref_list_state, + refs, + repo_path, + ref_name, + ref_type, + ui_config, + color_theme, + tx, + ))) + } + pub fn of_help( before: View<'a>, color_theme: &'a ColorTheme, diff --git a/src/widget/ref_list.rs b/src/widget/ref_list.rs index 698fd98..2eb5a08 100644 --- a/src/widget/ref_list.rs +++ b/src/widget/ref_list.rs @@ -83,6 +83,36 @@ impl RefListState { None } } + + pub fn selected_local_branch(&self) -> Option { + let selected = self.tree_state.selected(); + if selected.len() > 1 && selected[0] == TREE_BRANCH_ROOT_IDENT { + selected.last().cloned() + } else { + None + } + } + + pub fn selected_remote_branch(&self) -> Option { + let selected = self.tree_state.selected(); + if selected.len() > 1 && selected[0] == TREE_REMOTE_ROOT_IDENT { + selected.last().cloned() + } else { + None + } + } + + pub fn adjust_selection_after_delete(&mut self) { + // After item deletion, tree_state may point to non-existent item + // The safest approach: just move selection, the tree widget will adjust + // Try down first (next item takes deleted item's place), then up as fallback + let before: Vec = self.tree_state.selected().to_vec(); + self.tree_state.key_down(); + if self.tree_state.selected() == before { + // key_down didn't move (we were at the end), try key_up + self.tree_state.key_up(); + } + } } pub struct RefList<'a> { From 17228879dedee054552bdecec97f10146f0625da Mon Sep 17 00:00:00 2001 From: Andew Power Date: Thu, 18 Dec 2025 15:23:31 +0200 Subject: [PATCH 09/20] chore: add script to generate test 10k commits repository with branches and tags --- scripts/generate_test_repo.sh | 259 ++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100755 scripts/generate_test_repo.sh diff --git a/scripts/generate_test_repo.sh b/scripts/generate_test_repo.sh new file mode 100755 index 0000000..76db58c --- /dev/null +++ b/scripts/generate_test_repo.sh @@ -0,0 +1,259 @@ +#!/bin/bash +# +# Generate test git repository with realistic branch patterns +# +# Usage: ./generate_test_repo.sh [path] [commit_count] +# ./generate_test_repo.sh /tmp/my-repo 5000 +# +# Creates a repository with: +# - Main branch with linear history +# - feature/xxx and bugfix/xxx branches forking from main +# - Each branch lives 2-8 commits before merging back +# - Merges use --no-ff to preserve merge commits +# - Max 5 concurrent branches +# - Semver tags (v0.1.0, v0.2.3, ...) +# - Multiple authors with realistic dates spread over 2 years +# +# Resulting graph looks like typical "christmas tree": +# +# * merge feature/auth-42 +# |\ +# | * feat(auth): add auth logic +# | * feat(auth): implement auth logic +# |/ +# * fix: merge bugfix/timeout-15 +# |\ +# | * fix(api): fix api logic +# |/ +# * feat(cache): update cache logic +# * chore: initial commit +# + +set -e + +REPO_DIR="${1:-/tmp/test-repo-10k}" +COMMIT_COUNT="${2:-10000}" + +rm -rf "$REPO_DIR" +mkdir -p "$REPO_DIR" +cd "$REPO_DIR" +git init +git config user.email "dev@example.com" +git config user.name "Developer" + +# Arrays for random content +AUTHORS=( + "Alice Smith:alice@example.com" + "Bob Johnson:bob@example.com" + "Charlie Brown:charlie@example.com" + "Diana Prince:diana@example.com" + "Eve Wilson:eve@example.com" +) + +PREFIXES=("feat" "fix" "refactor" "docs" "test" "chore" "perf") + +FEATURES=( + "auth" "api" "database" "cache" "search" "upload" "export" + "notifications" "settings" "dashboard" "reports" "billing" + "users" "permissions" "logging" "metrics" "config" "cli" +) + +BUGS=( + "login-redirect" "null-pointer" "memory-leak" "timeout" "encoding" + "validation" "race-condition" "deadlock" "overflow" "parsing" + "connection-drop" "cache-invalidation" "timezone" "locale" +) + +ACTIONS=("add" "update" "fix" "improve" "implement" "refactor" "optimize") + +random_element() { + local arr=("$@") + echo "${arr[$RANDOM % ${#arr[@]}]}" +} + +random_author() { + local info=$(random_element "${AUTHORS[@]}") + echo "$info" +} + +generate_commit_message() { + local prefix=$(random_element "${PREFIXES[@]}") + local feature=$(random_element "${FEATURES[@]}") + local action=$(random_element "${ACTIONS[@]}") + echo "$prefix($feature): $action ${feature} logic" +} + +generate_file_content() { + echo "// Updated at $(date +%s%N)" + echo "// Random: $RANDOM" + for i in {1..10}; do + echo "fn func_$RANDOM() { /* ... */ }" + done +} + +make_commit() { + local message="$1" + local author_info="$2" + local commit_date="$3" + + local author_name="${author_info%%:*}" + local author_email="${author_info##*:}" + + local component=$(random_element "${FEATURES[@]}") + local file_path="src/${component}.rs" + mkdir -p "$(dirname "$file_path")" + generate_file_content > "$file_path" + git add "$file_path" + + GIT_AUTHOR_NAME="$author_name" \ + GIT_AUTHOR_EMAIL="$author_email" \ + GIT_AUTHOR_DATE="$commit_date" \ + GIT_COMMITTER_NAME="$author_name" \ + GIT_COMMITTER_EMAIL="$author_email" \ + GIT_COMMITTER_DATE="$commit_date" \ + git commit -m "$message" --quiet 2>/dev/null || true +} + +echo "Generating $COMMIT_COUNT commits in $REPO_DIR..." + +# Initial commit +mkdir -p src +echo "# Test Repository" > README.md +echo "fn main() {}" > src/main.rs +git add . +git commit -m "chore: initial commit" --quiet + +start_time=$(date +%s) +base_date=$(date -d "2023-01-01" +%s 2>/dev/null || date -j -f "%Y-%m-%d" "2023-01-01" +%s 2>/dev/null || echo "1672531200") +seconds_per_commit=$(( 730 * 24 * 3600 / COMMIT_COUNT )) # spread over 2 years + +commit_num=0 +active_branches=() +next_branch_at=$((RANDOM % 20 + 5)) +branch_counter=0 +version_major=0 +version_minor=0 +version_patch=0 + +while [ $commit_num -lt $COMMIT_COUNT ]; do + # Progress + if [ $((commit_num % 500)) -eq 0 ] && [ $commit_num -gt 0 ]; then + elapsed=$(($(date +%s) - start_time)) + rate=$((commit_num / (elapsed + 1))) + echo "Progress: $commit_num/$COMMIT_COUNT ($rate/sec)" + fi + + current_date=$((base_date + commit_num * seconds_per_commit + RANDOM % 3600)) + commit_date=$(date -d "@$current_date" --iso-8601=seconds 2>/dev/null || date -r "$current_date" +%Y-%m-%dT%H:%M:%S 2>/dev/null || echo "2023-06-15T12:00:00") + author=$(random_author) + + # Decide: work on main or create/continue feature branch + if [ ${#active_branches[@]} -eq 0 ] || [ $((RANDOM % 3)) -eq 0 ]; then + # Work on main + git checkout main --quiet 2>/dev/null || git checkout -b main --quiet + + # Maybe start a new branch + if [ $commit_num -ge $next_branch_at ] && [ ${#active_branches[@]} -lt 5 ]; then + branch_counter=$((branch_counter + 1)) + + # Feature or bugfix? + if [ $((RANDOM % 3)) -eq 0 ]; then + bug=$(random_element "${BUGS[@]}") + branch_name="bugfix/${bug}-${branch_counter}" + branch_type="bugfix" + else + feature=$(random_element "${FEATURES[@]}") + branch_name="feature/${feature}-${branch_counter}" + branch_type="feature" + fi + + git checkout -b "$branch_name" --quiet + active_branches+=("$branch_name:$branch_type:1") + next_branch_at=$((commit_num + RANDOM % 30 + 10)) + + make_commit "$(generate_commit_message)" "$author" "$commit_date" + commit_num=$((commit_num + 1)) + else + # Regular main commit + make_commit "$(generate_commit_message)" "$author" "$commit_date" + commit_num=$((commit_num + 1)) + + # Maybe tag a release + if [ $((RANDOM % 100)) -eq 0 ]; then + version_patch=$((version_patch + 1)) + if [ $version_patch -ge 10 ]; then + version_patch=0 + version_minor=$((version_minor + 1)) + fi + if [ $version_minor -ge 10 ]; then + version_minor=0 + version_major=$((version_major + 1)) + fi + git tag "v${version_major}.${version_minor}.${version_patch}" 2>/dev/null || true + fi + fi + else + # Work on existing branch + idx=$((RANDOM % ${#active_branches[@]})) + branch_info="${active_branches[$idx]}" + branch_name="${branch_info%%:*}" + rest="${branch_info#*:}" + branch_type="${rest%%:*}" + branch_commits="${rest##*:}" + + git checkout "$branch_name" --quiet 2>/dev/null || continue + + # Add commit to branch + make_commit "$(generate_commit_message)" "$author" "$commit_date" + commit_num=$((commit_num + 1)) + branch_commits=$((branch_commits + 1)) + + # Update branch info + active_branches[$idx]="$branch_name:$branch_type:$branch_commits" + + # Maybe merge back to main (after 2-8 commits) + if [ $branch_commits -ge $((RANDOM % 7 + 2)) ]; then + git checkout main --quiet + + # Merge with descriptive message + if [ "$branch_type" = "bugfix" ]; then + merge_msg="fix: merge $branch_name" + else + merge_msg="feat: merge $branch_name" + fi + + git merge --no-ff "$branch_name" -m "$merge_msg" --quiet 2>/dev/null || { + git merge --abort 2>/dev/null || true + git checkout main --quiet + } + + git branch -d "$branch_name" --quiet 2>/dev/null || true + + # Remove from active branches + unset 'active_branches[$idx]' + active_branches=("${active_branches[@]}") + fi + fi +done + +# Merge remaining branches +git checkout main --quiet 2>/dev/null || true +for branch_info in "${active_branches[@]}"; do + branch_name="${branch_info%%:*}" + if git show-ref --verify --quiet "refs/heads/$branch_name" 2>/dev/null; then + git merge --no-ff "$branch_name" -m "feat: merge $branch_name" --quiet 2>/dev/null || git merge --abort 2>/dev/null || true + git branch -d "$branch_name" --quiet 2>/dev/null || true + fi +done + +end_time=$(date +%s) +elapsed=$((end_time - start_time)) + +echo "" +echo "Done!" +echo "Repository: $REPO_DIR" +echo "Commits: $(git rev-list --count HEAD)" +echo "Tags: $(git tag | wc -l)" +echo "Time: ${elapsed}s" +echo "" +echo "Test with: serie $REPO_DIR" From 5fd0c4157fd07a1880aa9badef05dae956778261 Mon Sep 17 00:00:00 2001 From: Andew Power Date: Thu, 18 Dec 2025 15:24:12 +0200 Subject: [PATCH 10/20] chore: format --- src/app.rs | 9 +++++---- src/git.rs | 4 +--- src/view/delete_ref.rs | 16 +++++----------- src/view/refs.rs | 5 +++-- src/widget/commit_list.rs | 3 ++- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/app.rs b/src/app.rs index 7f3692c..64a3649 100644 --- a/src/app.rs +++ b/src/app.rs @@ -432,9 +432,7 @@ impl App<'_> { (UserEvent::Confirm, "confirm"), (UserEvent::Cancel, "cancel"), ], - View::Help(_) => vec![ - (UserEvent::Close, "close"), - ], + View::Help(_) => vec![(UserEvent::Close, "close")], _ => vec![], }; @@ -449,7 +447,10 @@ impl App<'_> { } spans.push(Span::styled(key.clone(), Style::default().fg(key_fg))); spans.push(Span::raw(" ")); - spans.push(Span::styled((*desc).to_string(), Style::default().fg(desc_fg))); + spans.push(Span::styled( + (*desc).to_string(), + Style::default().fg(desc_fg), + )); } } Line::from(spans) diff --git a/src/git.rs b/src/git.rs index b0c345b..4bbb742 100644 --- a/src/git.rs +++ b/src/git.rs @@ -792,9 +792,7 @@ pub fn delete_remote_branch(path: &Path, branch_name: &str) -> std::result::Resu // branch_name for remote branches is like "origin/feature" - we need to split let parts: Vec<&str> = branch_name.splitn(2, '/').collect(); if parts.len() != 2 { - return Err(format!( - "Invalid remote branch name format: {branch_name}" - )); + return Err(format!("Invalid remote branch name format: {branch_name}")); } let remote = parts[0]; let branch = parts[1]; diff --git a/src/view/delete_ref.rs b/src/view/delete_ref.rs index 1526aa5..bdfcfba 100644 --- a/src/view/delete_ref.rs +++ b/src/view/delete_ref.rs @@ -132,10 +132,7 @@ impl<'a> DeleteRefView<'a> { Err(e) } else if delete_from_remote { delete_remote_tag(&repo_path, &ref_name).map_err(|e| { - format!( - "Local tag deleted, but failed to delete from remote: {}", - e - ) + format!("Local tag deleted, but failed to delete from remote: {}", e) }) } else { Ok(()) @@ -192,8 +189,7 @@ impl<'a> DeleteRefView<'a> { let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); f.render_stateful_widget(commit_list, list_area, self.as_mut_list_state()); - let ref_list = - crate::widget::ref_list::RefList::new(&self.refs, self.color_theme); + let ref_list = crate::widget::ref_list::RefList::new(&self.refs, self.color_theme); f.render_stateful_widget(ref_list, refs_area, &mut self.ref_list_state); let dialog_width = 50u16.min(area.width.saturating_sub(4)); @@ -238,11 +234,9 @@ impl<'a> DeleteRefView<'a> { ]) .areas(inner_area); - let name_line = Line::from(vec![ - Span::raw(&self.ref_name) - .fg(self.color_theme.fg) - .add_modifier(Modifier::BOLD), - ]); + let name_line = Line::from(vec![Span::raw(&self.ref_name) + .fg(self.color_theme.fg) + .add_modifier(Modifier::BOLD)]); f.render_widget(Paragraph::new(name_line), name_area); let checkbox_line = match self.ref_type { diff --git a/src/view/refs.rs b/src/view/refs.rs index 2e23c81..d211bf6 100644 --- a/src/view/refs.rs +++ b/src/view/refs.rs @@ -134,8 +134,9 @@ impl<'a> RefsView<'a> { ref_type: RefType::Tag, }); } else { - self.tx - .send(AppEvent::NotifyWarn("Select a branch or tag to delete".into())); + self.tx.send(AppEvent::NotifyWarn( + "Select a branch or tag to delete".into(), + )); } } diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index 0fd834d..53cd23f 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -874,7 +874,8 @@ impl CommitListState { fn select_real_index(&mut self, real_index: usize) { if self.filtered_indices.is_empty() { self.select_index(real_index); - } else if let Some(visible_idx) = self.filtered_indices.iter().position(|&i| i == real_index) + } else if let Some(visible_idx) = + self.filtered_indices.iter().position(|&i| i == real_index) { self.select_index(visible_idx); } From 831d72dc73cc3110c17b70baf82e7795d9dc37a3 Mon Sep 17 00:00:00 2001 From: Andew Power Date: Thu, 18 Dec 2025 16:48:47 +0200 Subject: [PATCH 11/20] refactor: improve error handling and remove panics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App owns Repository instead of borrowing (enables mutation) - Add add_ref/remove_ref methods to Repository for ref tracking - take_list_state() now returns Option, views handle gracefully - event.rs: channel errors handled without panic - ref_list: show error message on tree build failure - ref_list: fix O(n²) performance in refs_to_ref_tree_nodes --- src/app.rs | 92 ++++++++++++++++++++++++++++------------ src/event.rs | 4 +- src/git.rs | 15 +++++++ src/lib.rs | 2 +- src/view/create_tag.rs | 18 ++++---- src/view/delete_ref.rs | 25 +++++------ src/view/delete_tag.rs | 19 +++++---- src/view/detail.rs | 18 ++++---- src/view/list.rs | 33 +++++++++----- src/view/refs.rs | 29 +++++++------ src/view/user_command.rs | 18 ++++---- src/widget/ref_list.rs | 80 +++++++++++++++++----------------- 12 files changed, 217 insertions(+), 136 deletions(-) diff --git a/src/app.rs b/src/app.rs index 64a3649..ed3ab40 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, rc::Rc}; use ratatui::{ backend::Backend, @@ -43,7 +43,7 @@ pub enum InitialSelection { #[derive(Debug)] pub struct App<'a> { - repository: &'a Repository, + repository: Repository, view: View<'a>, status_line: StatusLine, pending_message: Option, @@ -61,7 +61,7 @@ pub struct App<'a> { impl<'a> App<'a> { pub fn new( - repository: &'a Repository, + repository: Repository, graph_image_manager: GraphImageManager, graph: &Graph, keybind: &'a KeyBind, @@ -464,7 +464,9 @@ impl App<'_> { fn open_detail(&mut self) { if let View::List(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; let selected = commit_list_state.selected_commit_hash().clone(); let (commit, changes) = self.repository.commit_detail(&selected); let refs = self.repository.refs(&selected); @@ -483,7 +485,9 @@ impl App<'_> { fn close_detail(&mut self) { if let View::Detail(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; self.view = View::of_list( commit_list_state, self.ui_config, @@ -501,7 +505,9 @@ impl App<'_> { fn open_user_command(&mut self, user_command_number: usize) { if let View::List(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; let selected = commit_list_state.selected_commit_hash().clone(); let (commit, _) = self.repository.commit_detail(&selected); self.view = View::of_user_command_from_list( @@ -516,7 +522,9 @@ impl App<'_> { self.tx.clone(), ); } else if let View::Detail(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; let selected = commit_list_state.selected_commit_hash().clone(); let (commit, _) = self.repository.commit_detail(&selected); self.view = View::of_user_command_from_detail( @@ -531,10 +539,13 @@ impl App<'_> { self.tx.clone(), ); } else if let View::UserCommand(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let before_view_is_list = view.before_view_is_list(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; let selected = commit_list_state.selected_commit_hash().clone(); let (commit, _) = self.repository.commit_detail(&selected); - if view.before_view_is_list() { + if before_view_is_list { self.view = View::of_user_command_from_list( commit_list_state, commit, @@ -564,11 +575,14 @@ impl App<'_> { fn close_user_command(&mut self) { if let View::UserCommand(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let before_view_is_list = view.before_view_is_list(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; let selected = commit_list_state.selected_commit_hash().clone(); let (commit, changes) = self.repository.commit_detail(&selected); let refs = self.repository.refs(&selected); - if view.before_view_is_list() { + if before_view_is_list { self.view = View::of_list( commit_list_state, self.ui_config, @@ -598,8 +612,10 @@ impl App<'_> { fn open_refs(&mut self) { if let View::List(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); - let refs = self.repository.all_refs(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; + let refs = self.get_current_refs(); self.view = View::of_refs( commit_list_state, refs, @@ -610,9 +626,15 @@ impl App<'_> { } } + fn get_current_refs(&self) -> Vec> { + self.repository.all_refs() + } + fn close_refs(&mut self) { if let View::Refs(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; self.view = View::of_list( commit_list_state, self.ui_config, @@ -624,7 +646,9 @@ impl App<'_> { fn open_create_tag(&mut self) { if let View::List(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; let commit_hash = commit_list_state.selected_commit_hash().clone(); self.view = View::of_create_tag( commit_list_state, @@ -639,7 +663,9 @@ impl App<'_> { fn close_create_tag(&mut self) { if let View::CreateTag(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; self.view = View::of_list( commit_list_state, self.ui_config, @@ -655,6 +681,8 @@ impl App<'_> { target: commit_hash.clone(), }; + self.repository.add_ref(new_tag.clone()); + match &mut self.view { View::List(view) => { view.add_ref_to_commit(commit_hash, new_tag); @@ -668,7 +696,9 @@ impl App<'_> { fn open_delete_tag(&mut self) { if let View::List(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; let commit_hash = commit_list_state.selected_commit_hash().clone(); let tags = commit_list_state.selected_commit_refs(); let has_tags = tags.iter().any(|r| matches!(r.as_ref(), Ref::Tag { .. })); @@ -697,7 +727,9 @@ impl App<'_> { fn close_delete_tag(&mut self) { if let View::DeleteTag(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; self.view = View::of_list( commit_list_state, self.ui_config, @@ -708,6 +740,8 @@ impl App<'_> { } fn remove_tag_from_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) { + self.repository.remove_ref(tag_name); + match &mut self.view { View::List(view) => { view.remove_ref_from_commit(commit_hash, tag_name); @@ -721,7 +755,9 @@ impl App<'_> { fn open_delete_ref(&mut self, ref_name: String, ref_type: RefType) { if let View::Refs(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; let ref_list_state = view.take_ref_list_state(); let refs = view.take_refs(); self.view = View::of_delete_ref( @@ -740,7 +776,9 @@ impl App<'_> { fn close_delete_ref(&mut self) { if let View::DeleteRef(ref mut view) = self.view { - let commit_list_state = view.take_list_state(); + let Some(commit_list_state) = view.take_list_state() else { + return; + }; let ref_list_state = view.take_ref_list_state(); let refs = view.take_refs(); self.view = View::of_refs_with_state( @@ -755,6 +793,8 @@ impl App<'_> { } fn remove_ref_from_list(&mut self, ref_name: &str) { + self.repository.remove_ref(ref_name); + match &mut self.view { View::Refs(view) => { view.remove_ref(ref_name); @@ -792,25 +832,25 @@ impl App<'_> { fn select_older_commit(&mut self) { if let View::Detail(ref mut view) = self.view { - view.select_older_commit(self.repository); + view.select_older_commit(&self.repository); } else if let View::UserCommand(ref mut view) = self.view { - view.select_older_commit(self.repository, self.view_area); + view.select_older_commit(&self.repository, self.view_area); } } fn select_newer_commit(&mut self) { if let View::Detail(ref mut view) = self.view { - view.select_newer_commit(self.repository); + view.select_newer_commit(&self.repository); } else if let View::UserCommand(ref mut view) = self.view { - view.select_newer_commit(self.repository, self.view_area); + view.select_newer_commit(&self.repository, self.view_area); } } fn select_parent_commit(&mut self) { if let View::Detail(ref mut view) = self.view { - view.select_parent_commit(self.repository); + view.select_parent_commit(&self.repository); } else if let View::UserCommand(ref mut view) = self.view { - view.select_parent_commit(self.repository, self.view_area); + view.select_parent_commit(&self.repository, self.view_area); } } diff --git a/src/event.rs b/src/event.rs index aeba22a..4b0aa4a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -72,7 +72,7 @@ pub struct Sender { impl Sender { pub fn send(&self, event: AppEvent) { - self.tx.send(event).unwrap(); + let _ = self.tx.send(event); } } @@ -88,7 +88,7 @@ pub struct Receiver { impl Receiver { pub fn recv(&self) -> AppEvent { - self.rx.recv().unwrap() + self.rx.recv().unwrap_or(AppEvent::Quit) } } diff --git a/src/git.rs b/src/git.rs index 4bbb742..eb87809 100644 --- a/src/git.rs +++ b/src/git.rs @@ -223,6 +223,21 @@ impl Repository { pub fn sort_order(&self) -> SortCommit { self.sort } + + pub fn add_ref(&mut self, new_ref: Ref) { + let target = new_ref.target().clone(); + let rc_ref = Rc::new(new_ref); + self.ref_map + .entry(target) + .or_default() + .push(rc_ref); + } + + pub fn remove_ref(&mut self, ref_name: &str) { + for refs in self.ref_map.values_mut() { + refs.retain(|r| r.name() != ref_name); + } + } } fn check_git_repository(path: &Path) -> Result<()> { diff --git a/src/lib.rs b/src/lib.rs index 859d588..bb0886f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -145,7 +145,7 @@ pub fn run() -> Result<()> { let (tx, rx) = event::init(); let mut app = App::new( - &repository, + repository, graph_image_manager, &graph, &key_bind, diff --git a/src/view/create_tag.rs b/src/view/create_tag.rs index 02387db..aec25ef 100644 --- a/src/view/create_tag.rs +++ b/src/view/create_tag.rs @@ -217,9 +217,13 @@ impl<'a> CreateTagView<'a> { } pub fn render(&mut self, f: &mut Frame, area: Rect) { + let Some(list_state) = self.commit_list_state.as_mut() else { + return; + }; + // Render commit list in background let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); - f.render_stateful_widget(commit_list, area, self.as_mut_list_state()); + f.render_stateful_widget(commit_list, area, list_state); // Dialog dimensions let dialog_width = 50u16.min(area.width.saturating_sub(4)); @@ -373,18 +377,16 @@ impl<'a> CreateTagView<'a> { input_area } - fn as_mut_list_state(&mut self) -> &mut CommitListState { - self.commit_list_state.as_mut().unwrap() - } } impl<'a> CreateTagView<'a> { - pub fn take_list_state(&mut self) -> CommitListState { - self.commit_list_state.take().unwrap() + pub fn take_list_state(&mut self) -> Option { + self.commit_list_state.take() } pub fn add_ref_to_commit(&mut self, commit_hash: &CommitHash, new_ref: Ref) { - self.as_mut_list_state() - .add_ref_to_commit(commit_hash, new_ref); + if let Some(list_state) = self.commit_list_state.as_mut() { + list_state.add_ref_to_commit(commit_hash, new_ref); + } } } diff --git a/src/view/delete_ref.rs b/src/view/delete_ref.rs index bdfcfba..39a03e0 100644 --- a/src/view/delete_ref.rs +++ b/src/view/delete_ref.rs @@ -180,14 +180,18 @@ impl<'a> DeleteRefView<'a> { } pub fn render(&mut self, f: &mut Frame, area: Rect) { - let graph_width = self.as_list_state().graph_area_cell_width() + 1; + let Some(list_state) = self.commit_list_state.as_mut() else { + return; + }; + + let graph_width = list_state.graph_area_cell_width() + 1; let refs_width = (area.width.saturating_sub(graph_width)).min(self.ui_config.refs.width); let [list_area, refs_area] = Layout::horizontal([Constraint::Min(0), Constraint::Length(refs_width)]).areas(area); let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); - f.render_stateful_widget(commit_list, list_area, self.as_mut_list_state()); + f.render_stateful_widget(commit_list, list_area, list_state); let ref_list = crate::widget::ref_list::RefList::new(&self.refs, self.color_theme); f.render_stateful_widget(ref_list, refs_area, &mut self.ref_list_state); @@ -272,19 +276,11 @@ impl<'a> DeleteRefView<'a> { ]); f.render_widget(Paragraph::new(hint_line).centered(), hint_area); } - - fn as_mut_list_state(&mut self) -> &mut CommitListState { - self.commit_list_state.as_mut().unwrap() - } - - fn as_list_state(&self) -> &CommitListState { - self.commit_list_state.as_ref().unwrap() - } } impl<'a> DeleteRefView<'a> { - pub fn take_list_state(&mut self) -> CommitListState { - self.commit_list_state.take().unwrap() + pub fn take_list_state(&mut self) -> Option { + self.commit_list_state.take() } pub fn take_ref_list_state(&mut self) -> RefListState { @@ -296,6 +292,11 @@ impl<'a> DeleteRefView<'a> { } pub fn remove_ref(&mut self, ref_name: &str) { + if let Some(target) = self.refs.iter().find(|r| r.name() == ref_name).map(|r| r.target().clone()) { + if let Some(list_state) = self.commit_list_state.as_mut() { + list_state.remove_ref_from_commit(&target, ref_name); + } + } self.refs.retain(|r| r.name() != ref_name); self.ref_list_state.adjust_selection_after_delete(); } diff --git a/src/view/delete_tag.rs b/src/view/delete_tag.rs index 4fb243d..f45642a 100644 --- a/src/view/delete_tag.rs +++ b/src/view/delete_tag.rs @@ -157,8 +157,12 @@ impl<'a> DeleteTagView<'a> { } pub fn render(&mut self, f: &mut Frame, area: Rect) { + let Some(list_state) = self.commit_list_state.as_mut() else { + return; + }; + let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); - f.render_stateful_widget(commit_list, area, self.as_mut_list_state()); + f.render_stateful_widget(commit_list, area, list_state); let dialog_width = 50u16.min(area.width.saturating_sub(4)); let list_height = (self.tags.len() as u16).min(8); @@ -255,20 +259,17 @@ impl<'a> DeleteTagView<'a> { ]); f.render_widget(Paragraph::new(hint_line).centered(), hint_area); } - - fn as_mut_list_state(&mut self) -> &mut CommitListState { - self.commit_list_state.as_mut().unwrap() - } } impl<'a> DeleteTagView<'a> { - pub fn take_list_state(&mut self) -> CommitListState { - self.commit_list_state.take().unwrap() + pub fn take_list_state(&mut self) -> Option { + self.commit_list_state.take() } pub fn remove_ref_from_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) { - self.as_mut_list_state() - .remove_ref_from_commit(commit_hash, tag_name); + if let Some(list_state) = self.commit_list_state.as_mut() { + list_state.remove_ref_from_commit(commit_hash, tag_name); + } } } diff --git a/src/view/detail.rs b/src/view/detail.rs index cb93379..f2e78a3 100644 --- a/src/view/detail.rs +++ b/src/view/detail.rs @@ -131,12 +131,16 @@ impl<'a> DetailView<'a> { } pub fn render(&mut self, f: &mut Frame, area: Rect) { + let Some(list_state) = self.commit_list_state.as_mut() else { + return; + }; + let detail_height = (area.height - 1).min(self.ui_config.detail.height); let [list_area, detail_area] = Layout::vertical([Constraint::Min(0), Constraint::Length(detail_height)]).areas(area); let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); - f.render_stateful_widget(commit_list, list_area, self.as_mut_list_state()); + f.render_stateful_widget(commit_list, list_area, list_state); if self.clear { f.render_widget(Clear, detail_area); @@ -160,12 +164,8 @@ impl<'a> DetailView<'a> { } impl<'a> DetailView<'a> { - pub fn take_list_state(&mut self) -> CommitListState { - self.commit_list_state.take().unwrap() - } - - fn as_mut_list_state(&mut self) -> &mut CommitListState { - self.commit_list_state.as_mut().unwrap() + pub fn take_list_state(&mut self) -> Option { + self.commit_list_state.take() } pub fn select_older_commit(&mut self, repository: &Repository) { @@ -184,7 +184,9 @@ impl<'a> DetailView<'a> { where F: FnOnce(&mut CommitListState), { - let commit_list_state = self.as_mut_list_state(); + let Some(commit_list_state) = self.commit_list_state.as_mut() else { + return; + }; update_commit_list_state(commit_list_state); let selected = commit_list_state.selected_commit_hash().clone(); let (commit, changes) = repository.commit_detail(&selected); diff --git a/src/view/list.rs b/src/view/list.rs index b4e2136..308d3df 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -33,6 +33,10 @@ impl<'a> ListView<'a> { } pub fn handle_event(&mut self, event_with_count: UserEventWithCount, key: KeyEvent) { + if self.commit_list_state.is_none() { + return; + } + let event = event_with_count.event; let count = event_with_count.count; @@ -215,37 +219,44 @@ impl<'a> ListView<'a> { } pub fn render(&mut self, f: &mut Frame, area: Rect) { + let Some(list_state) = self.commit_list_state.as_mut() else { + return; + }; let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); - f.render_stateful_widget(commit_list, area, self.as_mut_list_state()); + f.render_stateful_widget(commit_list, area, list_state); } } impl<'a> ListView<'a> { - pub fn take_list_state(&mut self) -> CommitListState { - self.commit_list_state.take().unwrap() + pub fn take_list_state(&mut self) -> Option { + self.commit_list_state.take() } pub fn add_ref_to_commit(&mut self, commit_hash: &CommitHash, new_ref: Ref) { - self.as_mut_list_state() - .add_ref_to_commit(commit_hash, new_ref); + if let Some(list_state) = self.commit_list_state.as_mut() { + list_state.add_ref_to_commit(commit_hash, new_ref); + } } pub fn remove_ref_from_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) { - self.as_mut_list_state() - .remove_ref_from_commit(commit_hash, tag_name); + if let Some(list_state) = self.commit_list_state.as_mut() { + list_state.remove_ref_from_commit(commit_hash, tag_name); + } } fn as_mut_list_state(&mut self) -> &mut CommitListState { - self.commit_list_state.as_mut().unwrap() + self.commit_list_state.as_mut().expect("commit_list_state already taken") } fn as_list_state(&self) -> &CommitListState { - self.commit_list_state.as_ref().unwrap() + self.commit_list_state.as_ref().expect("commit_list_state already taken") } fn update_search_query(&self) { - if let SearchState::Searching { .. } = self.as_list_state().search_state() { - let list_state = self.as_list_state(); + let Some(list_state) = self.commit_list_state.as_ref() else { + return; + }; + if let SearchState::Searching { .. } = list_state.search_state() { if let Some(query) = list_state.search_query_string() { let cursor_pos = list_state.search_query_cursor_position(); let transient_msg = list_state.transient_message_string(); diff --git a/src/view/refs.rs b/src/view/refs.rs index d211bf6..1da796a 100644 --- a/src/view/refs.rs +++ b/src/view/refs.rs @@ -141,14 +141,18 @@ impl<'a> RefsView<'a> { } pub fn render(&mut self, f: &mut Frame, area: Rect) { - let graph_width = self.as_list_state().graph_area_cell_width() + 1; // graph area + marker + let Some(list_state) = self.commit_list_state.as_mut() else { + return; + }; + + let graph_width = list_state.graph_area_cell_width() + 1; // graph area + marker let refs_width = (area.width.saturating_sub(graph_width)).min(self.ui_config.refs.width); let [list_area, refs_area] = Layout::horizontal([Constraint::Min(0), Constraint::Length(refs_width)]).areas(area); let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); - f.render_stateful_widget(commit_list, list_area, self.as_mut_list_state()); + f.render_stateful_widget(commit_list, list_area, list_state); let ref_list = RefList::new(&self.refs, self.color_theme); f.render_stateful_widget(ref_list, refs_area, &mut self.ref_list_state); @@ -156,8 +160,8 @@ impl<'a> RefsView<'a> { } impl<'a> RefsView<'a> { - pub fn take_list_state(&mut self) -> CommitListState { - self.commit_list_state.take().unwrap() + pub fn take_list_state(&mut self) -> Option { + self.commit_list_state.take() } pub fn take_ref_list_state(&mut self) -> RefListState { @@ -169,21 +173,20 @@ impl<'a> RefsView<'a> { } pub fn remove_ref(&mut self, ref_name: &str) { + if let Some(target) = self.refs.iter().find(|r| r.name() == ref_name).map(|r| r.target().clone()) { + if let Some(list_state) = self.commit_list_state.as_mut() { + list_state.remove_ref_from_commit(&target, ref_name); + } + } self.refs.retain(|r| r.name() != ref_name); self.ref_list_state.adjust_selection_after_delete(); } - fn as_mut_list_state(&mut self) -> &mut CommitListState { - self.commit_list_state.as_mut().unwrap() - } - - fn as_list_state(&self) -> &CommitListState { - self.commit_list_state.as_ref().unwrap() - } - fn update_commit_list_selected(&mut self) { if let Some(selected) = self.ref_list_state.selected_ref_name() { - self.as_mut_list_state().select_ref(&selected) + if let Some(list_state) = self.commit_list_state.as_mut() { + list_state.select_ref(&selected); + } } } diff --git a/src/view/user_command.rs b/src/view/user_command.rs index 0f4e94a..6b62cba 100644 --- a/src/view/user_command.rs +++ b/src/view/user_command.rs @@ -154,13 +154,17 @@ impl<'a> UserCommandView<'a> { } pub fn render(&mut self, f: &mut Frame, area: Rect) { + let Some(list_state) = self.commit_list_state.as_mut() else { + return; + }; + let user_command_height = (area.height - 1).min(self.ui_config.user_command.height); let [list_area, user_command_area] = Layout::vertical([Constraint::Min(0), Constraint::Length(user_command_height)]) .areas(area); let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); - f.render_stateful_widget(commit_list, list_area, self.as_mut_list_state()); + f.render_stateful_widget(commit_list, list_area, list_state); let commit_user_command = CommitUserCommand::new(&self.user_command_output_lines, self.color_theme); @@ -183,12 +187,8 @@ impl<'a> UserCommandView<'a> { } impl<'a> UserCommandView<'a> { - pub fn take_list_state(&mut self) -> CommitListState { - self.commit_list_state.take().unwrap() - } - - fn as_mut_list_state(&mut self) -> &mut CommitListState { - self.commit_list_state.as_mut().unwrap() + pub fn take_list_state(&mut self) -> Option { + self.commit_list_state.take() } pub fn select_older_commit(&mut self, repository: &Repository, view_area: Rect) { @@ -211,7 +211,9 @@ impl<'a> UserCommandView<'a> { ) where F: FnOnce(&mut CommitListState), { - let commit_list_state = self.as_mut_list_state(); + let Some(commit_list_state) = self.commit_list_state.as_mut() else { + return; + }; update_commit_list_state(commit_list_state); let selected = commit_list_state.selected_commit_hash().clone(); let (commit, _) = repository.commit_detail(&selected); diff --git a/src/widget/ref_list.rs b/src/widget/ref_list.rs index 2eb5a08..d9abbf0 100644 --- a/src/widget/ref_list.rs +++ b/src/widget/ref_list.rs @@ -4,7 +4,7 @@ use ratatui::{ buffer::Buffer, layout::Rect, style::{Style, Stylize}, - widgets::{Block, Borders, Padding, StatefulWidget}, + widgets::{Block, Borders, Padding, Paragraph, StatefulWidget, Widget}, }; use semver::Version; use tui_tree_widget::{Tree, TreeItem, TreeState}; @@ -131,8 +131,21 @@ impl StatefulWidget for RefList<'_> { type State = RefListState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let tree = Tree::new(&self.items) - .unwrap() + let make_block = || { + Block::default() + .borders(Borders::LEFT) + .style(Style::default().fg(self.color_theme.divider_fg)) + .padding(Padding::horizontal(1)) + }; + + let Ok(tree) = Tree::new(&self.items) else { + Paragraph::new("Error: failed to build ref tree") + .fg(self.color_theme.status_error_fg) + .block(make_block()) + .render(area, buf); + return; + }; + let tree = tree .node_closed_symbol("\u{25b8} ") // ▸ .node_open_symbol("\u{25be} ") // ▾ .node_no_children_symbol(" ") @@ -141,13 +154,8 @@ impl StatefulWidget for RefList<'_> { .bg(self.color_theme.ref_selected_bg) .fg(self.color_theme.ref_selected_fg), ) - .block( - Block::default() - .borders(Borders::LEFT) - .style(Style::default().fg(self.color_theme.divider_fg)) - .padding(Padding::horizontal(1)), - ); - tree.render(area, buf, &mut state.tree_state); + .block(make_block()); + StatefulWidget::render(tree, area, buf, &mut state.tree_state); } } @@ -184,7 +192,7 @@ fn build_ref_tree_items<'a>( let tag_items = ref_tree_nodes_to_tree_items(tag_nodes, color_theme); let stash_items = ref_tree_nodes_to_tree_items(stash_nodes, color_theme); - vec![ + [ tree_item( TREE_BRANCH_ROOT_IDENT.into(), TREE_BRANCH_ROOT_TEXT.into(), @@ -210,6 +218,9 @@ fn build_ref_tree_items<'a>( color_theme, ), ] + .into_iter() + .flatten() + .collect() } struct RefTreeNode { @@ -235,29 +246,29 @@ fn refs_to_ref_tree_nodes(ref_names: Vec) -> Vec { let mut nodes: Vec = Vec::new(); for ref_name in ref_names { - let mut parts = ref_name.split('/').collect::>(); let mut current_nodes = &mut nodes; let mut parent_identifier = String::new(); - while !parts.is_empty() { - let part = parts.remove(0); + for part in ref_name.split('/') { if let Some(index) = current_nodes.iter().position(|n| n.name == part) { let node = &mut current_nodes[index]; - current_nodes = &mut node.children; parent_identifier.clone_from(&node.identifier); + current_nodes = &mut node.children; } else { let identifier = if parent_identifier.is_empty() { part.to_string() } else { format!("{parent_identifier}/{part}") }; - let node = RefTreeNode { + current_nodes.push(RefTreeNode { identifier: identifier.clone(), name: part.to_string(), children: Vec::new(), + }); + let Some(last) = current_nodes.last_mut() else { + break; }; - current_nodes.push(node); - current_nodes = current_nodes.last_mut().unwrap().children.as_mut(); + current_nodes = &mut last.children; parent_identifier = identifier; } } @@ -270,16 +281,17 @@ fn ref_tree_nodes_to_tree_items( nodes: Vec, color_theme: &ColorTheme, ) -> Vec> { - let mut items = Vec::new(); - for node in nodes { - if node.children.is_empty() { - items.push(tree_leaf_item(node.identifier, node.name, color_theme)); - } else { - let children = ref_tree_nodes_to_tree_items(node.children, color_theme); - items.push(tree_item(node.identifier, node.name, children, color_theme)); - } - } - items + nodes + .into_iter() + .filter_map(|node| { + if node.children.is_empty() { + tree_item(node.identifier, node.name, Vec::new(), color_theme) + } else { + let children = ref_tree_nodes_to_tree_items(node.children, color_theme); + tree_item(node.identifier, node.name, children, color_theme) + } + }) + .collect() } fn sort_branch_tree_nodes(nodes: &mut [RefTreeNode]) { @@ -323,14 +335,6 @@ fn tree_item<'a>( name: String, children: Vec>, color_theme: &'a ColorTheme, -) -> TreeItem<'a, String> { - TreeItem::new(identifier, name.fg(color_theme.fg), children).unwrap() -} - -fn tree_leaf_item( - identifier: String, - name: String, - color_theme: &ColorTheme, -) -> TreeItem<'_, String> { - tree_item(identifier, name, Vec::new(), color_theme) +) -> Option> { + TreeItem::new(identifier, name.fg(color_theme.fg), children).ok() } From e7005aa53d5c82b4069fef2551461923a1b9b711 Mon Sep 17 00:00:00 2001 From: Andew Power Date: Thu, 18 Dec 2025 16:49:25 +0200 Subject: [PATCH 12/20] chore: format --- src/git.rs | 5 +---- src/view/create_tag.rs | 1 - src/view/delete_ref.rs | 7 ++++++- src/view/list.rs | 8 ++++++-- src/view/refs.rs | 7 ++++++- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/git.rs b/src/git.rs index eb87809..e5b57d8 100644 --- a/src/git.rs +++ b/src/git.rs @@ -227,10 +227,7 @@ impl Repository { pub fn add_ref(&mut self, new_ref: Ref) { let target = new_ref.target().clone(); let rc_ref = Rc::new(new_ref); - self.ref_map - .entry(target) - .or_default() - .push(rc_ref); + self.ref_map.entry(target).or_default().push(rc_ref); } pub fn remove_ref(&mut self, ref_name: &str) { diff --git a/src/view/create_tag.rs b/src/view/create_tag.rs index aec25ef..15181e8 100644 --- a/src/view/create_tag.rs +++ b/src/view/create_tag.rs @@ -376,7 +376,6 @@ impl<'a> CreateTagView<'a> { input_area } - } impl<'a> CreateTagView<'a> { diff --git a/src/view/delete_ref.rs b/src/view/delete_ref.rs index 39a03e0..4c644df 100644 --- a/src/view/delete_ref.rs +++ b/src/view/delete_ref.rs @@ -292,7 +292,12 @@ impl<'a> DeleteRefView<'a> { } pub fn remove_ref(&mut self, ref_name: &str) { - if let Some(target) = self.refs.iter().find(|r| r.name() == ref_name).map(|r| r.target().clone()) { + if let Some(target) = self + .refs + .iter() + .find(|r| r.name() == ref_name) + .map(|r| r.target().clone()) + { if let Some(list_state) = self.commit_list_state.as_mut() { list_state.remove_ref_from_commit(&target, ref_name); } diff --git a/src/view/list.rs b/src/view/list.rs index 308d3df..094c8b1 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -245,11 +245,15 @@ impl<'a> ListView<'a> { } fn as_mut_list_state(&mut self) -> &mut CommitListState { - self.commit_list_state.as_mut().expect("commit_list_state already taken") + self.commit_list_state + .as_mut() + .expect("commit_list_state already taken") } fn as_list_state(&self) -> &CommitListState { - self.commit_list_state.as_ref().expect("commit_list_state already taken") + self.commit_list_state + .as_ref() + .expect("commit_list_state already taken") } fn update_search_query(&self) { diff --git a/src/view/refs.rs b/src/view/refs.rs index 1da796a..c4d2076 100644 --- a/src/view/refs.rs +++ b/src/view/refs.rs @@ -173,7 +173,12 @@ impl<'a> RefsView<'a> { } pub fn remove_ref(&mut self, ref_name: &str) { - if let Some(target) = self.refs.iter().find(|r| r.name() == ref_name).map(|r| r.target().clone()) { + if let Some(target) = self + .refs + .iter() + .find(|r| r.name() == ref_name) + .map(|r| r.target().clone()) + { if let Some(list_state) = self.commit_list_state.as_mut() { list_state.remove_ref_from_commit(&target, ref_name); } From e42adf63d93b2674c093ba6486c22a013ba6e7b7 Mon Sep 17 00:00:00 2001 From: Andew Power Date: Thu, 18 Dec 2025 16:54:16 +0200 Subject: [PATCH 13/20] chore: update readme --- README.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1b4a9a8..998f835 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ The default key bindings can be overridden. Please refer to [default-keybind.tom | ------------------------------ | ----------- | --------------------- | | Ctrl-c q | Quit app | `force_quit` `quit` | | ? | Open help | `help_toggle` | +| r | Refresh | `refresh` | #### Commit List @@ -200,10 +201,11 @@ The default key bindings can be overridden. Please refer to [default-keybind.tom | Enter | Show commit details
Apply search (if searching) | `confirm` | | Tab | Open refs list | `ref_list_toggle` | | / | Start search | `search` | -| Esc | Cancel search | `cancel` | +| f | Start filter | `filter` | +| Esc | Cancel search/filter | `cancel` | | n/N | Go to next/previous search match | `go_to_next` `go_to_previous` | -| Ctrl-g | Toggle ignore case (if searching) | `ignore_case_toggle` | -| Ctrl-x | Toggle fuzzy match (if searching) | `fuzzy_toggle` | +| Alt-c | Toggle ignore case (if searching/filtering) | `ignore_case_toggle` | +| Ctrl-x | Toggle fuzzy match (if searching/filtering) | `fuzzy_toggle` | | c/C | Copy commit short/full hash | `short_copy` `full_copy` | | t | Create tag on commit | `create_tag` | | Ctrl-t | Delete tag from commit | `delete_tag` | @@ -225,14 +227,15 @@ The default key bindings can be overridden. Please refer to [default-keybind.tom #### Refs List -| Key | Description | Corresponding keybind | -| -------------------------------------------------- | ---------------- | ---------------------------------- | -| Esc Backspace Tab | Close refs list | `close` `cancel` `ref_list_toggle` | -| Down/Up j/k | Move down/up | `navigate_down` `navigate_up` | -| J/K | Move down/up | `select_down` `select_up` | -| g/G | Go to top/bottom | `go_to_top` `go_to_bottom` | -| Right/Left l/h | Open/Close node | `navigate_right` `navigate_left` | -| c | Copy ref name | `short_copy` | +| Key | Description | Corresponding keybind | +| -------------------------------------------------- | ------------------------- | ---------------------------------- | +| Esc Backspace Tab | Close refs list | `close` `cancel` `ref_list_toggle` | +| Down/Up j/k | Move down/up | `navigate_down` `navigate_up` | +| J/K | Move down/up | `select_down` `select_up` | +| g/G | Go to top/bottom | `go_to_top` `go_to_bottom` | +| Right/Left l/h | Open/Close node | `navigate_right` `navigate_left` | +| c | Copy ref name | `short_copy` | +| d | Delete branch/tag | `user_command_view_toggle_1` | #### User Command From 59a19855e0c420df8240baa5e233f9c5020b468d Mon Sep 17 00:00:00 2001 From: Andrew Panin Date: Sun, 21 Dec 2025 11:51:54 +0200 Subject: [PATCH 14/20] feat: implement full repository refresh - Add graph_color_set and cell_width_type fields to App struct - Reload Repository from disk on refresh - Recalculate Graph and rebuild GraphImageManager - Rebuild CommitListState with updated commits and refs - Show success/error notification after refresh --- src/app.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/src/app.rs b/src/app.rs index ed3ab40..bd14ab6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,7 @@ use crate::{ event::{AppEvent, Receiver, Sender, UserEvent, UserEventWithCount}, external::copy_to_clipboard, git::{CommitHash, Head, Ref, RefType, Repository}, - graph::{CellWidthType, Graph, GraphImageManager}, + graph::{calc_graph, CellWidthType, Graph, GraphImageManager}, keybind::KeyBind, protocol::ImageProtocol, view::View, @@ -52,6 +52,8 @@ pub struct App<'a> { core_config: &'a CoreConfig, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, + graph_color_set: &'a GraphColorSet, + cell_width_type: CellWidthType, image_protocol: ImageProtocol, tx: Sender, @@ -120,6 +122,8 @@ impl<'a> App<'a> { core_config, ui_config, color_theme, + graph_color_set, + cell_width_type, image_protocol, tx, numeric_prefix: String::new(), @@ -896,10 +900,74 @@ impl App<'_> { } fn refresh(&mut self) { - // TODO: Implement full refresh - requires App to own Repository and Graph - self.tx.send(AppEvent::NotifyInfo( - "Refresh: relaunch serie to reload repository".into(), - )); + // Reload repository from disk + let sort = self.repository.sort_order(); + let path = self.repository.path().to_path_buf(); + + let repository = match Repository::load(&path, sort) { + Ok(repo) => repo, + Err(e) => { + self.tx + .send(AppEvent::NotifyError(format!("Refresh failed: {}", e))); + return; + } + }; + + // Recalculate graph + let graph = Rc::new(calc_graph(&repository)); + + // Create new graph image manager + let graph_image_manager = GraphImageManager::new( + Rc::clone(&graph), + self.graph_color_set, + self.cell_width_type, + self.image_protocol, + false, // don't preload + ); + + // Build new commit list state + let mut ref_name_to_commit_index_map = HashMap::new(); + let commits = graph + .commits + .iter() + .enumerate() + .map(|(i, commit)| { + let refs = repository.refs(&commit.commit_hash); + for r in &refs { + ref_name_to_commit_index_map.insert(r.name().to_string(), i); + } + let (pos_x, _) = graph.commit_pos_map[&commit.commit_hash]; + let graph_color = self.graph_color_set.get(pos_x).to_ratatui_color(); + CommitInfo::new(commit.clone(), refs, graph_color) + }) + .collect(); + + let graph_cell_width = match self.cell_width_type { + CellWidthType::Double => (graph.max_pos_x + 1) as u16 * 2, + CellWidthType::Single => (graph.max_pos_x + 1) as u16, + }; + + let head = repository.head(); + let commit_list_state = CommitListState::new( + commits, + graph_image_manager, + graph_cell_width, + head, + ref_name_to_commit_index_map, + self.core_config.search.ignore_case, + self.core_config.search.fuzzy, + ); + + // Update app state + self.repository = repository; + self.view = View::of_list( + commit_list_state, + self.ui_config, + self.color_theme, + self.tx.clone(), + ); + + self.tx.send(AppEvent::NotifySuccess("Repository refreshed".into())); } } From 7ede8b772cb73c4971667e7467d6d1a34a0daae1 Mon Sep 17 00:00:00 2001 From: Andrew Panin Date: Sun, 21 Dec 2025 12:02:00 +0200 Subject: [PATCH 15/20] fix: prevent potential panic on UTF-8 multi-byte characters in tag name input Use char-based slicing instead of byte-based slicing when truncating long input values for display. --- src/view/create_tag.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/view/create_tag.rs b/src/view/create_tag.rs index 15181e8..3e7f881 100644 --- a/src/view/create_tag.rs +++ b/src/view/create_tag.rs @@ -363,10 +363,11 @@ impl<'a> CreateTagView<'a> { }; let max_width = input_area.width.saturating_sub(2) as usize; - let display_value = if value.len() > max_width { - &value[value.len() - max_width..] + let char_count = value.chars().count(); + let display_value: String = if char_count > max_width { + value.chars().skip(char_count - max_width).collect() } else { - value + value.to_string() }; f.render_widget( From f4f5fd21297782744479d23a3ccc4d832eddda8d Mon Sep 17 00:00:00 2001 From: Andrew Panin Date: Sun, 21 Dec 2025 12:02:55 +0200 Subject: [PATCH 16/20] fix: handle partial deletion failures and bounds checking - Update UI when local deletion succeeds but remote fails - Use safe bounds checking with .get() in delete_tag --- src/view/delete_ref.rs | 31 +++++++++++++++++++++++++------ src/view/delete_tag.rs | 6 ++---- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/view/delete_ref.rs b/src/view/delete_ref.rs index 4c644df..8a05579 100644 --- a/src/view/delete_ref.rs +++ b/src/view/delete_ref.rs @@ -126,24 +126,37 @@ impl<'a> DeleteRefView<'a> { self.tx.send(AppEvent::CloseDeleteRef); thread::spawn(move || { + // Track if local deletion succeeded (for UI update even if remote fails) + let mut local_deleted = false; + let result = match ref_type { RefType::Tag => { if let Err(e) = delete_tag(&repo_path, &ref_name) { Err(e) - } else if delete_from_remote { - delete_remote_tag(&repo_path, &ref_name).map_err(|e| { - format!("Local tag deleted, but failed to delete from remote: {}", e) - }) } else { - Ok(()) + local_deleted = true; + if delete_from_remote { + delete_remote_tag(&repo_path, &ref_name).map_err(|e| { + format!( + "Local tag deleted, but failed to delete from remote: {}", + e + ) + }) + } else { + Ok(()) + } } } RefType::Branch => { - if force_delete { + let res = if force_delete { delete_branch_force(&repo_path, &ref_name) } else { delete_branch(&repo_path, &ref_name) + }; + if res.is_ok() { + local_deleted = true; } + res } RefType::RemoteBranch => delete_remote_branch(&repo_path, &ref_name), }; @@ -172,6 +185,12 @@ impl<'a> DeleteRefView<'a> { tx.send(AppEvent::HidePendingOverlay); } Err(e) => { + // If local deletion succeeded, still update UI + if local_deleted { + tx.send(AppEvent::RemoveRefFromList { + ref_name: ref_name.clone(), + }); + } tx.send(AppEvent::HidePendingOverlay); tx.send(AppEvent::NotifyError(e)); } diff --git a/src/view/delete_tag.rs b/src/view/delete_tag.rs index f45642a..fb0ada5 100644 --- a/src/view/delete_tag.rs +++ b/src/view/delete_tag.rs @@ -93,11 +93,9 @@ impl<'a> DeleteTagView<'a> { } fn delete_selected(&mut self) { - if self.tags.is_empty() { + let Some(tag_name) = self.tags.get(self.selected_index).cloned() else { return; - } - - let tag_name = self.tags[self.selected_index].clone(); + }; // Prepare data for background thread let repo_path = self.repo_path.clone(); From 975c5b40b325b976455dbd013e0a0660e19eb376 Mon Sep 17 00:00:00 2001 From: Andrew Panin Date: Sun, 21 Dec 2025 12:06:29 +0200 Subject: [PATCH 17/20] feat: add ref name validation before creating tags --- src/git.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/git.rs b/src/git.rs index e5b57d8..ba4ef84 100644 --- a/src/git.rs +++ b/src/git.rs @@ -693,12 +693,53 @@ pub fn get_initial_commit_additions(path: &Path, commit_hash: &CommitHash) -> Ve changes } +/// Validates a git ref name according to git-check-ref-format rules. +/// Returns Ok(()) if valid, Err with message if invalid. +fn validate_ref_name(name: &str) -> std::result::Result<(), String> { + if name.is_empty() { + return Err("Ref name cannot be empty".into()); + } + if name.starts_with('-') { + return Err("Ref name cannot start with '-'".into()); + } + if name.starts_with('.') || name.ends_with('.') { + return Err("Ref name cannot start or end with '.'".into()); + } + if name.contains("..") { + return Err("Ref name cannot contain '..'".into()); + } + if name.contains("//") { + return Err("Ref name cannot contain '//'".into()); + } + if name.ends_with('/') { + return Err("Ref name cannot end with '/'".into()); + } + if name.contains(|c: char| c.is_ascii_control() || c == ' ' || c == '~' || c == '^' || c == ':') + { + return Err("Ref name contains invalid characters".into()); + } + if name.ends_with(".lock") { + return Err("Ref name cannot end with '.lock'".into()); + } + if name.contains("@{") { + return Err("Ref name cannot contain '@{'".into()); + } + if name == "@" { + return Err("Ref name cannot be '@'".into()); + } + if name.contains('\\') { + return Err("Ref name cannot contain '\\'".into()); + } + Ok(()) +} + pub fn create_tag( path: &Path, name: &str, commit_hash: &CommitHash, message: Option<&str>, ) -> std::result::Result<(), String> { + validate_ref_name(name)?; let mut cmd = Command::new("git"); cmd.arg("tag"); if let Some(msg) = message { From eb1ea515061270649513c48143f312559a20459a Mon Sep 17 00:00:00 2001 From: Andrew Panin Date: Sun, 21 Dec 2025 12:07:21 +0200 Subject: [PATCH 18/20] fix: handle edge cases in text wrapping for pending overlay --- src/widget/pending_overlay.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/widget/pending_overlay.rs b/src/widget/pending_overlay.rs index 3b9bfae..4190f83 100644 --- a/src/widget/pending_overlay.rs +++ b/src/widget/pending_overlay.rs @@ -74,13 +74,30 @@ impl Widget for PendingOverlay<'_> { } fn wrap_text(text: &str, max_width: usize) -> Vec { + if max_width == 0 { + return vec![String::new()]; + } + let mut lines = Vec::new(); let mut current_line = String::new(); for word in text.split_whitespace() { - if current_line.is_empty() { + // Handle words longer than max_width by splitting them + if word.chars().count() > max_width { + // Flush current line first + if !current_line.is_empty() { + lines.push(current_line); + current_line = String::new(); + } + // Split long word into chunks + let mut chars = word.chars().peekable(); + while chars.peek().is_some() { + let chunk: String = chars.by_ref().take(max_width).collect(); + lines.push(chunk); + } + } else if current_line.is_empty() { current_line = word.to_string(); - } else if current_line.len() + 1 + word.len() <= max_width { + } else if current_line.chars().count() + 1 + word.chars().count() <= max_width { current_line.push(' '); current_line.push_str(word); } else { From 372f86be7769a634fe6c2810836bf8e807814c7f Mon Sep 17 00:00:00 2001 From: Andrew Panin Date: Sun, 21 Dec 2025 12:08:21 +0200 Subject: [PATCH 19/20] fix: sort non-semver tags descending to match semver ordering --- src/widget/ref_list.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/widget/ref_list.rs b/src/widget/ref_list.rs index d9abbf0..e71dc3a 100644 --- a/src/widget/ref_list.rs +++ b/src/widget/ref_list.rs @@ -310,13 +310,14 @@ fn sort_tag_tree_nodes(nodes: &mut [RefTreeNode]) { nodes.sort_by(|a, b| { let a_version = parse_semantic_version_tag(&a.name); let b_version = parse_semantic_version_tag(&b.name); - if a_version.is_none() && b_version.is_none() { - // if both are not semantic versions, sort by name asc - a.name.cmp(&b.name) - } else { - // if both are semantic versions, sort by version desc - // if only one is a semantic version, it will be sorted first - b_version.cmp(&a_version) + match (a_version, b_version) { + // Both semver: sort by version descending (newest first) + (Some(av), Some(bv)) => bv.cmp(&av), + // Semver tags come before non-semver tags + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + // Both non-semver: sort by name descending (Z-A, consistent with semver) + (None, None) => b.name.cmp(&a.name), } }); } From 11165767e56a3b014c535cba9c40f0f5865836a0 Mon Sep 17 00:00:00 2001 From: Andrew Panin Date: Sun, 21 Dec 2025 12:13:27 +0200 Subject: [PATCH 20/20] fix: format --- src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index bd14ab6..45d6c10 100644 --- a/src/app.rs +++ b/src/app.rs @@ -967,7 +967,8 @@ impl App<'_> { self.tx.clone(), ); - self.tx.send(AppEvent::NotifySuccess("Repository refreshed".into())); + self.tx + .send(AppEvent::NotifySuccess("Repository refreshed".into())); } }