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:
parent
386163a5b8
commit
a2e78cbea9
169
gtm
169
gtm
|
|
@ -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,6 +521,39 @@ def draw_right_pane(stdscr, state):
|
|||
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
|
||||
|
||||
display_row += 1
|
||||
else:
|
||||
# Normal display without search highlighting
|
||||
stdscr.addnstr(display_row, content_start, display_content, available_width, attr)
|
||||
display_row += 1
|
||||
else:
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue