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
This commit is contained in:
n loewen (aider) 2025-06-08 02:29:25 +01:00
parent 386163a5b8
commit a2e78cbea9
1 changed files with 169 additions and 4 deletions

173
gtm
View File

@ -140,6 +140,12 @@ class AppState:
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) ---
def calculate_change_blocks(state: AppState) -> AppState:
@ -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)
@ -452,8 +521,41 @@ def draw_right_pane(stdscr, state):
if not state.wrap_lines and len(content) > available_width:
display_content = content[:available_width]
stdscr.addnstr(display_row, content_start, display_content, available_width, attr)
display_row += 1
# 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
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(