diff --git a/README.md b/README.md index 24edc94..6a8ecc3 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,11 +201,14 @@ 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` | | d | Toggle custom user command view | `user_command_view_toggle_1` | #### Commit Detail @@ -223,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 diff --git a/assets/default-keybind.toml b/assets/default-keybind.toml index 6fa3583..949debf 100644 --- a/assets/default-keybind.toml +++ b/assets/default-keybind.toml @@ -29,7 +29,8 @@ go_to_previous = ["shift-n"] confirm = ["enter"] ref_list_toggle = ["tab"] search = ["/"] -ignore_case_toggle = ["ctrl-g"] +filter = ["f"] +ignore_case_toggle = ["alt-c"] fuzzy_toggle = ["ctrl-x"] user_command_view_toggle_1 = ["d"] @@ -37,3 +38,8 @@ 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"] + +refresh = ["r"] 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" diff --git a/src/app.rs b/src/app.rs index 9c7d588..45d6c10 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,11 @@ -use std::collections::HashMap; +use std::{collections::HashMap, rc::Rc}; use ratatui::{ backend::Backend, crossterm::event::{KeyCode, KeyEvent}, layout::{Constraint, Layout, Rect}, style::{Modifier, Style, Stylize}, - text::Line, + text::{Line, Span}, widgets::{Block, Borders, Padding, Paragraph}, Frame, Terminal, }; @@ -15,12 +15,15 @@ use crate::{ config::{CoreConfig, CursorType, UiConfig}, event::{AppEvent, Receiver, Sender, UserEvent, UserEventWithCount}, external::copy_to_clipboard, - git::{Head, Repository}, - graph::{CellWidthType, Graph, GraphImageManager}, + git::{CommitHash, Head, Ref, RefType, Repository}, + graph::{calc_graph, CellWidthType, Graph, GraphImageManager}, keybind::KeyBind, protocol::ImageProtocol, view::View, - widget::commit_list::{CommitInfo, CommitListState}, + widget::{ + commit_list::{CommitInfo, CommitListState}, + pending_overlay::PendingOverlay, + }, }; #[derive(Debug)] @@ -40,14 +43,17 @@ pub enum InitialSelection { #[derive(Debug)] pub struct App<'a> { - repository: &'a Repository, + repository: Repository, view: View<'a>, status_line: StatusLine, + pending_message: Option, keybind: &'a KeyBind, 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, @@ -57,9 +63,9 @@ pub struct App<'a> { impl<'a> App<'a> { pub fn new( - repository: &'a Repository, - graph_image_manager: GraphImageManager<'a>, - graph: &'a Graph, + repository: Repository, + graph_image_manager: GraphImageManager, + graph: &Graph, keybind: &'a KeyBind, core_config: &'a CoreConfig, ui_config: &'a UiConfig, @@ -78,11 +84,11 @@ impl<'a> App<'a> { .map(|(i, commit)| { let refs = repository.refs(&commit.commit_hash); 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(); - CommitInfo::new(commit, refs, graph_color) + CommitInfo::new(commit.clone(), refs, graph_color) }) .collect(); let graph_cell_width = match cell_width_type { @@ -101,8 +107,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()); @@ -110,11 +116,14 @@ impl<'a> App<'a> { Self { repository, status_line: StatusLine::None, + pending_message: None, view, keybind, core_config, ui_config, color_theme, + graph_color_set, + cell_width_type, image_protocol, tx, numeric_prefix: String::new(), @@ -133,6 +142,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 @@ -171,10 +193,11 @@ 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 +244,39 @@ 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::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(); } @@ -260,6 +316,15 @@ impl App<'_> { AppEvent::NotifyError(msg) => { self.error_notification(msg); } + AppEvent::ShowPendingOverlay { message } => { + self.pending_message = Some(message); + } + AppEvent::HidePendingOverlay => { + self.pending_message = None; + } + AppEvent::Refresh => { + self.refresh(); + } } } } @@ -277,6 +342,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()); + } } } @@ -285,7 +355,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) @@ -339,6 +409,56 @@ 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::UserCommandViewToggle(1), "delete"), + (UserEvent::Close, "close"), + (UserEvent::HelpToggle, "help"), + ], + View::CreateTag(_) | View::DeleteTag(_) | View::DeleteRef(_) => 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<'_> { @@ -348,15 +468,12 @@ 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) - .into_iter() - .cloned() - .collect(); + let refs = self.repository.refs(&selected); self.view = View::of_detail( commit_list_state, commit, @@ -372,7 +489,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, @@ -390,7 +509,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( @@ -405,7 +526,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( @@ -420,10 +543,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, @@ -453,16 +579,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) - .into_iter() - .cloned() - .collect(); - if view.before_view_is_list() { + let refs = self.repository.refs(&selected); + if before_view_is_list { self.view = View::of_list( commit_list_state, self.ui_config, @@ -492,8 +616,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().into_iter().cloned().collect(); + 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, @@ -504,9 +630,110 @@ 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, + self.color_theme, + self.tx.clone(), + ); + } + } + + fn open_create_tag(&mut self) { + if let View::List(ref mut view) = self.view { + 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, + 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 Some(commit_list_state) = view.take_list_state() else { + return; + }; + 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(), + }; + + self.repository.add_ref(new_tag.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 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 { .. })); + 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 Some(commit_list_state) = view.take_list_state() else { + return; + }; self.view = View::of_list( commit_list_state, self.ui_config, @@ -516,6 +743,73 @@ 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); + } + View::DeleteTag(view) => { + view.remove_ref_from_commit(commit_hash, tag_name); + } + _ => {} + } + } + + fn open_delete_ref(&mut self, ref_name: String, ref_type: RefType) { + if let View::Refs(ref mut view) = self.view { + 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( + 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 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( + 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) { + self.repository.remove_ref(ref_name); + + 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( @@ -542,25 +836,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); } } @@ -604,6 +898,78 @@ impl App<'_> { } } } + + fn refresh(&mut self) { + // 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())); + } } fn process_numeric_prefix( diff --git a/src/event.rs b/src/event.rs index 9584934..4b0aa4a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -22,19 +22,47 @@ 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, + }, + OpenDeleteRef { + ref_name: String, + ref_type: crate::git::RefType, + }, + CloseDeleteRef, + RemoveRefFromList { + ref_name: String, + }, OpenHelp, CloseHelp, ClearHelp, SelectNewerCommit, SelectOlderCommit, SelectParentCommit, - CopyToClipboard { name: String, value: String }, + CopyToClipboard { + name: String, + value: String, + }, ClearStatusLine, UpdateStatusInput(String, Option, Option), NotifyInfo(String), NotifySuccess(String), NotifyWarn(String), NotifyError(String), + ShowPendingOverlay { + message: String, + }, + HidePendingOverlay, + Refresh, } #[derive(Clone)] @@ -44,7 +72,7 @@ pub struct Sender { impl Sender { pub fn send(&self, event: AppEvent) { - self.tx.send(event).unwrap(); + let _ = self.tx.send(event); } } @@ -60,7 +88,7 @@ pub struct Receiver { impl Receiver { pub fn recv(&self) -> AppEvent { - self.rx.recv().unwrap() + self.rx.recv().unwrap_or(AppEvent::Quit) } } @@ -121,11 +149,15 @@ pub enum UserEvent { Confirm, RefListToggle, Search, + Filter, UserCommandViewToggle(usize), IgnoreCaseToggle, FuzzyToggle, ShortCopy, FullCopy, + CreateTag, + DeleteTag, + Refresh, Unknown, } @@ -184,10 +216,14 @@ 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), "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 824bb77..ba4ef84 100644 --- a/src/git.rs +++ b/src/git.rs @@ -4,14 +4,17 @@ use std::{ io::{BufRead, BufReader}, 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 { @@ -23,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)) } } @@ -51,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 { @@ -104,14 +120,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 +157,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,23 +194,24 @@ impl Repository { .unwrap_or_default() } - pub fn refs(&self, commit_hash: &CommitHash) -> Vec<&Ref> { - self.ref_map - .get(commit_hash) - .map(|refs| refs.iter().collect::>()) - .unwrap_or_default() + pub fn refs(&self, commit_hash: &CommitHash) -> Vec> { + self.ref_map.get(commit_hash).cloned().unwrap_or_default() + } + + pub fn all_refs(&self) -> Vec> { + self.ref_map.values().flatten().cloned().collect() } - pub fn all_refs(&self) -> Vec<&Ref> { - self.ref_map.values().flatten().collect() + pub fn head(&self) -> Head { + self.head.clone() } - pub fn head(&self) -> &Head { - &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(); + 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 { @@ -220,6 +219,22 @@ impl Repository { }; (commit, changes) } + + 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<()> { @@ -231,23 +246,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 { @@ -403,7 +418,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() } @@ -467,7 +482,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 @@ -478,7 +493,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()); @@ -524,7 +542,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(); @@ -606,8 +624,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()) @@ -674,3 +692,176 @@ 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 { + 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(()) +} + +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/graph/calc.rs b/src/graph/calc.rs index ec40693..6d8f9ae 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,32 +59,26 @@ 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)); } } commit_pos_map } -fn filtered_children_hash<'a>( - commit: &'a 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() @@ -93,28 +89,24 @@ 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>, - 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)); + 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 +116,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 +126,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 +152,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 +167,21 @@ 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,114 @@ 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..3e4dc57 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,12 +174,12 @@ impl ImageParams { } fn build_single_graph_row_image( - graph: &Graph<'_>, + graph: &Graph, image_params: &ImageParams, 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; @@ -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..bb0886f 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}; @@ -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,14 +126,14 @@ 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 = 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, @@ -141,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.rs b/src/view.rs index 5b918f4..c33c1b1 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1,5 +1,8 @@ mod views; +mod create_tag; +mod delete_ref; +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..3e7f881 --- /dev/null +++ b/src/view/create_tag.rs @@ -0,0 +1,392 @@ +use std::{path::PathBuf, thread}; + +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, + 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: Option = if message.is_empty() { + None + } else { + Some(message.to_string()) + }; + + // 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(); + + // 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; + } + + 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; + } + } + + // 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) { + 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, 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 char_count = value.chars().count(); + let display_value: String = if char_count > max_width { + value.chars().skip(char_count - max_width).collect() + } else { + value.to_string() + }; + + f.render_widget( + Paragraph::new(Line::from(Span::raw(format!(" {}", display_value)))).style(input_style), + input_area, + ); + + input_area + } +} + +impl<'a> CreateTagView<'a> { + 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) { + 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 new file mode 100644 index 0000000..8a05579 --- /dev/null +++ b/src/view/delete_ref.rs @@ -0,0 +1,327 @@ +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 || { + // 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 { + 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 => { + 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), + }; + + 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) => { + // 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)); + } + } + }); + } + + pub fn render(&mut self, f: &mut Frame, area: Rect) { + 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, 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); + } +} + +impl<'a> DeleteRefView<'a> { + pub fn take_list_state(&mut self) -> Option { + self.commit_list_state.take() + } + + 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) { + 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 new file mode 100644 index 0000000..fb0ada5 --- /dev/null +++ b/src/view/delete_tag.rs @@ -0,0 +1,301 @@ +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_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, + 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.as_ref() { + Ref::Tag { name, .. } => Some(name.clone()), + _ => 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) { + let Some(tag_name) = self.tags.get(self.selected_index).cloned() else { + return; + }; + + // 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(); + + // Show pending overlay and close dialog + let pending_msg = if delete_from_remote { + format!("Deleting tag '{}' from local and remote...", tag_name) + } else { + format!("Deleting tag '{}'...", tag_name) + }; + 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; + } + + 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; + } + } + + // 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) { + 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, 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); + } +} + +impl<'a> DeleteTagView<'a> { + 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) { + if let Some(list_state) = self.commit_list_state.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/detail.rs b/src/view/detail.rs index 2d8f5cf..f2e78a3 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, @@ -129,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); @@ -158,12 +164,8 @@ impl<'a> DetailView<'a> { } impl<'a> DetailView<'a> { - pub fn take_list_state(&mut self) -> CommitListState<'a> { - self.commit_list_state.take().unwrap() - } - - fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { - 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) { @@ -180,13 +182,15 @@ 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(); + 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); - 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/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..094c8b1 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -4,12 +4,13 @@ use crate::{ color::ColorTheme, config::UiConfig, event::{AppEvent, Sender, UserEvent, UserEventWithCount}, - widget::commit_list::{CommitList, CommitListState, SearchState}, + git::{CommitHash, Ref}, + widget::commit_list::{CommitList, CommitListState, FilterState, SearchState}, }; #[derive(Debug)] pub struct ListView<'a> { - commit_list_state: Option>, + commit_list_state: Option, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, @@ -18,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, @@ -32,8 +33,41 @@ 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; + + // 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 => { @@ -58,99 +92,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(); + } + + // 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::ShortCopy => { - self.copy_commit_short_hash(); + } + UserEvent::NavigateUp | UserEvent::SelectUp => { + for _ in 0..count { + self.as_mut_list_state().select_prev(); } - UserEvent::FullCopy => { - self.copy_commit_hash(); + } + UserEvent::GoToParent => { + for _ in 0..count { + self.as_mut_list_state().select_parent(); } - UserEvent::Search => { - self.as_mut_list_state().start_search(); - self.update_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::UserCommandViewToggle(n) => { - self.tx.send(AppEvent::OpenUserCommand(n)); + } + UserEvent::ScrollUp => { + for _ in 0..count { + self.as_mut_list_state().scroll_up(); } - UserEvent::HelpToggle => { - self.tx.send(AppEvent::OpenHelp); + } + UserEvent::PageDown => { + for _ in 0..count { + self.as_mut_list_state().scroll_down_page(); } - UserEvent::Cancel => { - self.as_mut_list_state().cancel_search(); - self.clear_search_query(); + } + UserEvent::PageUp => { + for _ in 0..count { + self.as_mut_list_state().scroll_up_page(); } - UserEvent::Confirm => { - self.tx.send(AppEvent::OpenDetail); + } + UserEvent::HalfPageDown => { + for _ in 0..count { + self.as_mut_list_state().scroll_down_half(); } - UserEvent::RefListToggle => { - self.tx.send(AppEvent::OpenRefs); + } + 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() { @@ -170,27 +219,48 @@ 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<'a> { - 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) { + 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) { + 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<'a> { - self.commit_list_state.as_mut().unwrap() + fn as_mut_list_state(&mut self) -> &mut CommitListState { + self.commit_list_state + .as_mut() + .expect("commit_list_state already taken") } - fn as_list_state(&self) -> &CommitListState<'a> { - self.commit_list_state.as_ref().unwrap() + fn as_list_state(&self) -> &CommitListState { + 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(); @@ -207,6 +277,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/view/refs.rs b/src/view/refs.rs index 052fcff..c4d2076 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}, @@ -8,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}, @@ -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, @@ -45,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; @@ -90,19 +110,49 @@ 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 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); @@ -110,21 +160,38 @@ impl<'a> RefsView<'a> { } impl<'a> RefsView<'a> { - pub fn take_list_state(&mut self) -> CommitListState<'a> { - 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 { + std::mem::take(&mut self.ref_list_state) } - fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { - self.commit_list_state.as_mut().unwrap() + pub fn take_refs(&mut self) -> Vec> { + std::mem::take(&mut self.refs) } - fn as_list_state(&self) -> &CommitListState<'a> { - self.commit_list_state.as_ref().unwrap() + 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 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 4c3e16c..6b62cba 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, @@ -152,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); @@ -181,12 +187,8 @@ impl<'a> UserCommandView<'a> { } impl<'a> UserCommandView<'a> { - pub fn take_list_state(&mut self) -> CommitListState<'a> { - self.commit_list_state.take().unwrap() - } - - fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { - 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) { @@ -207,9 +209,11 @@ 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(); + 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/view/views.rs b/src/view/views.rs index 7320572..db6c96f 100644 --- a/src/view/views.rs +++ b/src/view/views.rs @@ -1,20 +1,25 @@ +use std::{path::PathBuf, rc::Rc}; + use ratatui::{crossterm::event::KeyEvent, layout::Rect, Frame}; use crate::{ color::ColorTheme, config::{CoreConfig, UiConfig}, event::{Sender, UserEventWithCount}, - git::{Commit, 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, list::ListView, refs::RefsView, user_command::{UserCommandView, UserCommandViewBeforeView}, }, - widget::commit_list::CommitListState, + widget::{commit_list::CommitListState, ref_list::RefListState}, }; #[derive(Debug, Default)] @@ -25,6 +30,9 @@ pub enum View<'a> { Detail(Box>), UserCommand(Box>), Refs(Box>), + CreateTag(Box>), + DeleteTag(Box>), + DeleteRef(Box>), Help(Box>), } @@ -36,6 +44,9 @@ 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::DeleteRef(view) => view.handle_event(event_with_count, key_event), View::Help(view) => view.handle_event(event_with_count, key_event), } } @@ -47,12 +58,15 @@ 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::DeleteRef(view) => view.render(f, area), View::Help(view) => view.render(f, area), } } pub fn of_list( - commit_list_state: CommitListState<'a>, + commit_list_state: CommitListState, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, tx: Sender, @@ -66,10 +80,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, @@ -88,8 +102,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, @@ -113,8 +127,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, @@ -138,8 +152,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, @@ -153,6 +167,86 @@ 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, + 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, + 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_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.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_detail.rs b/src/widget/commit_detail.rs index 5e3d9ae..aba5cef 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 { @@ -225,19 +227,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), ), @@ -325,10 +327,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 b2107cd..53cd23f 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,20 +25,37 @@ static FUZZY_MATCHER: Lazy = Lazy::new(|| SkimMatcherV2::default( const ELLIPSIS: &str = "..."; #[derive(Debug)] -pub struct CommitInfo<'a> { - commit: &'a Commit, - refs: Vec<&'a Ref>, +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, graph_color, } } + + pub fn commit_hash(&self) -> &CommitHash { + &self.commit.commit_hash + } + + fn add_ref(&mut self, r: Rc) { + self.refs.push(r); + self.refs.sort(); + } + + 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)] @@ -76,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, @@ -86,11 +113,16 @@ 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(|r| !matches!(r, Ref::Stash { .. })) .filter_map(|r| { matcher .matched_position(r.name()) @@ -155,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) @@ -179,18 +233,29 @@ 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<&'a str, usize>, + ref_name_to_commit_index_map: HashMap, search_state: SearchState, 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, @@ -200,16 +265,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, - ref_name_to_commit_index_map: HashMap<&'a str, usize>, + 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, @@ -220,6 +285,13 @@ impl<'a> CommitListState<'a> { 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, @@ -233,7 +305,31 @@ 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.add_ref(Rc::new(new_ref)); + 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.remove_ref(tag_name); + break; + } + } + } + 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 { @@ -242,6 +338,9 @@ impl<'a> CommitListState<'a> { } 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(); @@ -250,6 +349,9 @@ impl<'a> CommitListState<'a> { } 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 @@ -270,6 +372,9 @@ impl<'a> CommitListState<'a> { } 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; @@ -286,6 +391,9 @@ impl<'a> CommitListState<'a> { } pub fn scroll_up(&mut self) { + if self.height == 0 { + return; + } if self.offset > 0 { self.offset -= 1; if self.selected < self.height - 1 { @@ -311,6 +419,9 @@ impl<'a> CommitListState<'a> { } 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 { @@ -341,7 +452,10 @@ impl<'a> CommitListState<'a> { } 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; @@ -349,6 +463,9 @@ impl<'a> CommitListState<'a> { } 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 { @@ -368,10 +485,16 @@ impl<'a> CommitListState<'a> { } 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()); } @@ -381,8 +504,14 @@ impl<'a> CommitListState<'a> { .commit_hash } + pub fn selected_commit_refs(&self) -> Vec> { + 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) { @@ -576,22 +705,110 @@ impl<'a> CommitListState<'a> { } 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.as_slice(), - 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(); + } + + // 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.search_matches[i] = m; } + + 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) { @@ -599,8 +816,8 @@ impl<'a> CommitListState<'a> { } 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 { @@ -609,43 +826,235 @@ impl<'a> CommitListState<'a> { } 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); } } - 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) } + + // 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> { @@ -663,7 +1072,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); @@ -716,16 +1125,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( @@ -773,13 +1185,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); } }); } @@ -787,7 +1199,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) } @@ -799,16 +1211,16 @@ 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.head, + &state.search_matches[real_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 { @@ -817,23 +1229,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); @@ -846,27 +1258,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); @@ -878,22 +1289,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); @@ -905,7 +1315,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); @@ -913,30 +1323,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); } - fn rendering_commit_info_iter<'a>( - &'a self, - state: &'a CommitListState, - ) -> impl Iterator)> { - state - .commits - .iter() - .skip(state.offset) - .take(state.height) - .enumerate() + /// 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 { + 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<'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(|(display_i, real_i, commit_info)| { + (display_i, real_i, commit_info.commit.as_ref()) + }) } fn to_commit_list_item<'a, 'b>( @@ -967,9 +1390,11 @@ 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].as_ref() { 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(" "), ]; } @@ -977,7 +1402,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)) @@ -1021,6 +1446,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 { @@ -1029,7 +1455,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..4190f83 --- /dev/null +++ b/src/widget/pending_overlay.rs @@ -0,0 +1,118 @@ +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 { + 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() { + // 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.chars().count() + 1 + word.chars().count() <= 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 +} diff --git a/src/widget/ref_list.rs b/src/widget/ref_list.rs index e84a0a4..e71dc3a 100644 --- a/src/widget/ref_list.rs +++ b/src/widget/ref_list.rs @@ -1,8 +1,10 @@ +use std::rc::Rc; + 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}; @@ -81,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> { @@ -89,7 +121,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 } } @@ -99,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(" ") @@ -109,18 +154,13 @@ 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); } } 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 +169,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())), } } @@ -152,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(), @@ -178,6 +218,9 @@ fn build_ref_tree_items<'a>( color_theme, ), ] + .into_iter() + .flatten() + .collect() } struct RefTreeNode { @@ -203,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; } } @@ -238,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]) { @@ -266,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), } }); } @@ -291,14 +336,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() }