From a2e78cbea9c0876c9500c482a73f6390a8573382 Mon Sep 17 00:00:00 2001 From: "n loewen (aider)" Date: Sun, 8 Jun 2025 02:29:25 +0100 Subject: [PATCH] feat: Add minimal search functionality to gtm tool This commit introduces a basic search feature with the following capabilities: - Press '/' to enter search mode - Type search query and press Enter to search - Navigate through matches with 'n' and 'N' - Highlight search matches in the file view - Display search match count in status bar --- gtm | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 169 insertions(+), 4 deletions(-) diff --git a/gtm b/gtm index a725e28..4c01f21 100755 --- a/gtm +++ b/gtm @@ -139,6 +139,12 @@ class AppState: # Change navigation change_blocks: List[Tuple[int, int]] = field(default_factory=list) # (start_line, end_line) of each change block current_change_idx: int = -1 # -1 means no change is selected + + # Search functionality + search_mode: bool = False + search_query: str = "" + search_matches: List[int] = field(default_factory=list) # Line numbers of matches + current_match_idx: int = -1 # Index in search_matches # --- Actions (Controller) --- @@ -355,6 +361,54 @@ def jump_to_prev_change(state: AppState) -> AppState: return replace(state, right_scroll_offset=new_scroll, current_change_idx=prev_idx) return state +def perform_search(state: AppState) -> AppState: + """Search for the query in the file content and update matches.""" + if not state.search_query: + return replace(state, search_matches=[], current_match_idx=-1) + + matches = [] + query = state.search_query.lower() # Case-insensitive search + + for i, line in enumerate(state.file_lines): + if query in line.lower(): + matches.append(i) + + new_state = replace(state, search_matches=matches) + + # If we have matches, select the first one + if matches: + new_state = replace(new_state, current_match_idx=0) + # Scroll to the first match + new_state = replace(new_state, right_scroll_offset=max(0, matches[0] - 3)) + else: + new_state = replace(new_state, current_match_idx=-1) + + return new_state + +def jump_to_next_match(state: AppState) -> AppState: + """Jump to the next search match.""" + if not state.search_matches: + return state + + next_idx = (state.current_match_idx + 1) % len(state.search_matches) + new_state = replace(state, current_match_idx=next_idx) + + # Scroll to the match + match_line = state.search_matches[next_idx] + return replace(new_state, right_scroll_offset=max(0, match_line - 3)) + +def jump_to_prev_match(state: AppState) -> AppState: + """Jump to the previous search match.""" + if not state.search_matches: + return state + + prev_idx = (state.current_match_idx - 1) % len(state.search_matches) + new_state = replace(state, current_match_idx=prev_idx) + + # Scroll to the match + match_line = state.search_matches[prev_idx] + return replace(new_state, right_scroll_offset=max(0, match_line - 3)) + def draw_right_pane(stdscr, state): # If sidebar is hidden, right pane starts at column 0 @@ -411,6 +465,14 @@ def draw_right_pane(stdscr, state): content = line_info['content'] line_num = line_info.get('line_num', 0) + # Check if this is a search match + is_search_match = False + is_current_match = False + if state.search_matches and line_num - 1 in state.search_matches: # Adjust for 0-based indexing + match_idx = state.search_matches.index(line_num - 1) + is_search_match = True + is_current_match = (match_idx == state.current_match_idx) + # Draw line number if enabled if state.show_line_numbers: line_num_str = str(line_num).rjust(line_num_width - 1) + " " @@ -437,6 +499,13 @@ def draw_right_pane(stdscr, state): prefix = " " attr = curses.A_NORMAL + # If this is a search match, override the attribute + if is_search_match: + if is_current_match: + attr = curses.A_REVERSE | curses.A_BOLD + else: + attr = curses.A_UNDERLINE + # Calculate available width for content content_start = right_start + len(prefix) available_width = right_width - len(prefix) @@ -451,9 +520,42 @@ def draw_right_pane(stdscr, state): display_content = content if not state.wrap_lines and len(content) > available_width: display_content = content[:available_width] + + # If this is a search match, highlight just the matching part + if is_search_match and state.search_query: + # Find all occurrences of the search query in the content (case-insensitive) + query = state.search_query.lower() + content_lower = display_content.lower() + pos = 0 + while pos < len(content_lower): + match_pos = content_lower.find(query, pos) + if match_pos == -1: + break + + # Draw the matching part with highlighting + match_attr = attr + if match_pos + len(query) <= len(display_content): + # Draw text before match + if match_pos > 0: + stdscr.addnstr(display_row, content_start, display_content[:match_pos], match_pos) + + # Draw the match with highlighting + match_text = display_content[match_pos:match_pos + len(query)] + stdscr.addnstr(display_row, content_start + match_pos, match_text, len(query), match_attr) + + # Draw text after match + if match_pos + len(query) < len(display_content): + remaining_text = display_content[match_pos + len(query):] + stdscr.addnstr(display_row, content_start + match_pos + len(query), + remaining_text, len(remaining_text)) + + pos = match_pos + 1 - stdscr.addnstr(display_row, content_start, display_content, available_width, attr) - display_row += 1 + display_row += 1 + else: + # Normal display without search highlighting + stdscr.addnstr(display_row, content_start, display_content, available_width, attr) + display_row += 1 else: # Line needs wrapping and wrapping is enabled remaining = content @@ -548,6 +650,39 @@ def draw_status_bars(stdscr, state): # We'll use height-1 for the single status bar visible_height = state.height - 1 + # If in search mode, draw the search input field instead of the normal status bar + if state.search_mode: + # Fill the status bar with spaces + for x in range(state.width - 1): + try: + stdscr.addch(state.height - 1, x, ' ') + except curses.error: + pass + + # Draw search prompt + search_prompt = "Search: " + try: + stdscr.addstr(state.height - 1, 0, search_prompt) + + # Draw the search query + stdscr.addstr(state.height - 1, len(search_prompt), state.search_query) + + # Draw cursor at the end of the query + try: + stdscr.move(state.height - 1, len(search_prompt) + len(state.search_query)) + except curses.error: + pass + + # If we have search results, show count + if state.search_matches: + match_info = f" [{state.current_match_idx + 1}/{len(state.search_matches)}]" + stdscr.addstr(state.height - 1, state.width - len(match_info) - 1, match_info) + except curses.error: + pass + + return + + # Normal status bar (not in search mode) # Get commit message for the selected commit commit_message = "" if state.commits and state.selected_commit_idx < len(state.commits): @@ -564,7 +699,11 @@ def draw_status_bars(stdscr, state): if state.change_blocks and state.current_change_idx != -1: change_indicator = f"[Change {state.current_change_idx + 1}/{len(state.change_blocks)}] " - commit_message = wrap_indicator + change_indicator + commit_message + search_indicator = "" + if state.search_matches: + search_indicator = f"[Search {state.current_match_idx + 1}/{len(state.search_matches)}] " + + commit_message = wrap_indicator + change_indicator + search_indicator + commit_message # Status bar percentages if len(state.file_lines) > 0: @@ -833,6 +972,24 @@ def handle_mouse_input(stdscr, state: AppState) -> AppState: return state def handle_keyboard_input(key, state: AppState) -> AppState: + # If in search mode, handle search input + if state.search_mode: + if key == 27: # Escape key - exit search mode + return replace(state, search_mode=False) + elif key == 10 or key == 13: # Enter key - perform search + new_state = perform_search(state) + return replace(new_state, search_mode=False) + elif key == 8 or key == 127 or key == curses.KEY_BACKSPACE: # Backspace + if state.search_query: + return replace(state, search_query=state.search_query[:-1]) + elif key == curses.KEY_DC: # Delete key + if state.search_query: + return replace(state, search_query=state.search_query[:-1]) + elif 32 <= key <= 126: # Printable ASCII characters + return replace(state, search_query=state.search_query + chr(key)) + return state + + # Normal mode key handling if key in [ord('q')]: return replace(state, should_exit=True) elif key == 27: # Escape key @@ -840,6 +997,8 @@ def handle_keyboard_input(key, state: AppState) -> AppState: return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None) else: return replace(state, should_exit=True) + elif key == ord('/'): # Start search + return replace(state, search_mode=True, search_query="") elif key == ord('s'): return toggle_sidebar(state) elif key == ord('w'): @@ -847,8 +1006,13 @@ def handle_keyboard_input(key, state: AppState) -> AppState: elif key == ord('l'): return replace(state, show_line_numbers=not state.show_line_numbers) elif key in [110, ord('n')]: # ASCII code for 'n' - if state.show_whole_diff or state.show_additions or state.show_deletions: + if state.search_matches: + return jump_to_next_match(state) + elif state.show_whole_diff or state.show_additions or state.show_deletions: return jump_to_next_change(state) + elif key in [78, ord('N')]: # ASCII code for 'N' (uppercase) + if state.search_matches: + return jump_to_prev_match(state) elif key in [112, ord('p')]: # ASCII code for 'p' if state.show_whole_diff or state.show_additions or state.show_deletions: return jump_to_prev_change(state) @@ -899,6 +1063,7 @@ def main(stdscr, filename, show_diff, show_add, show_del, mouse, wrap_lines=True curses.init_pair(5, curses.COLOR_GREEN, -1) # Line numbers for added lines curses.init_pair(6, curses.COLOR_RED, -1) # Line numbers for deleted lines curses.init_pair(7, curses.COLOR_BLUE, -1) # Regular line numbers + curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW) # Search matches height, width = stdscr.getmaxyx() state = AppState(