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
173
gtm
173
gtm
|
|
@ -139,6 +139,12 @@ class AppState:
|
||||||
# Change navigation
|
# Change navigation
|
||||||
change_blocks: List[Tuple[int, int]] = field(default_factory=list) # (start_line, end_line) of each change block
|
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
|
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) ---
|
# --- 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 replace(state, right_scroll_offset=new_scroll, current_change_idx=prev_idx)
|
||||||
return state
|
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):
|
def draw_right_pane(stdscr, state):
|
||||||
# If sidebar is hidden, right pane starts at column 0
|
# If sidebar is hidden, right pane starts at column 0
|
||||||
|
|
@ -411,6 +465,14 @@ def draw_right_pane(stdscr, state):
|
||||||
content = line_info['content']
|
content = line_info['content']
|
||||||
line_num = line_info.get('line_num', 0)
|
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
|
# Draw line number if enabled
|
||||||
if state.show_line_numbers:
|
if state.show_line_numbers:
|
||||||
line_num_str = str(line_num).rjust(line_num_width - 1) + " "
|
line_num_str = str(line_num).rjust(line_num_width - 1) + " "
|
||||||
|
|
@ -437,6 +499,13 @@ def draw_right_pane(stdscr, state):
|
||||||
prefix = " "
|
prefix = " "
|
||||||
attr = curses.A_NORMAL
|
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
|
# Calculate available width for content
|
||||||
content_start = right_start + len(prefix)
|
content_start = right_start + len(prefix)
|
||||||
available_width = right_width - len(prefix)
|
available_width = right_width - len(prefix)
|
||||||
|
|
@ -451,9 +520,42 @@ def draw_right_pane(stdscr, state):
|
||||||
display_content = content
|
display_content = content
|
||||||
if not state.wrap_lines and len(content) > available_width:
|
if not state.wrap_lines and len(content) > available_width:
|
||||||
display_content = 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:
|
else:
|
||||||
# Line needs wrapping and wrapping is enabled
|
# Line needs wrapping and wrapping is enabled
|
||||||
remaining = content
|
remaining = content
|
||||||
|
|
@ -548,6 +650,39 @@ def draw_status_bars(stdscr, state):
|
||||||
# We'll use height-1 for the single status bar
|
# We'll use height-1 for the single status bar
|
||||||
visible_height = state.height - 1
|
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
|
# Get commit message for the selected commit
|
||||||
commit_message = ""
|
commit_message = ""
|
||||||
if state.commits and state.selected_commit_idx < len(state.commits):
|
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:
|
if state.change_blocks and state.current_change_idx != -1:
|
||||||
change_indicator = f"[Change {state.current_change_idx + 1}/{len(state.change_blocks)}] "
|
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
|
# Status bar percentages
|
||||||
if len(state.file_lines) > 0:
|
if len(state.file_lines) > 0:
|
||||||
|
|
@ -833,6 +972,24 @@ def handle_mouse_input(stdscr, state: AppState) -> AppState:
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def handle_keyboard_input(key, state: AppState) -> AppState:
|
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')]:
|
if key in [ord('q')]:
|
||||||
return replace(state, should_exit=True)
|
return replace(state, should_exit=True)
|
||||||
elif key == 27: # Escape key
|
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)
|
return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None)
|
||||||
else:
|
else:
|
||||||
return replace(state, should_exit=True)
|
return replace(state, should_exit=True)
|
||||||
|
elif key == ord('/'): # Start search
|
||||||
|
return replace(state, search_mode=True, search_query="")
|
||||||
elif key == ord('s'):
|
elif key == ord('s'):
|
||||||
return toggle_sidebar(state)
|
return toggle_sidebar(state)
|
||||||
elif key == ord('w'):
|
elif key == ord('w'):
|
||||||
|
|
@ -847,8 +1006,13 @@ def handle_keyboard_input(key, state: AppState) -> AppState:
|
||||||
elif key == ord('l'):
|
elif key == ord('l'):
|
||||||
return replace(state, show_line_numbers=not state.show_line_numbers)
|
return replace(state, show_line_numbers=not state.show_line_numbers)
|
||||||
elif key in [110, ord('n')]: # ASCII code for 'n'
|
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)
|
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'
|
elif key in [112, ord('p')]: # ASCII code for 'p'
|
||||||
if state.show_whole_diff or state.show_additions or state.show_deletions:
|
if state.show_whole_diff or state.show_additions or state.show_deletions:
|
||||||
return jump_to_prev_change(state)
|
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(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(6, curses.COLOR_RED, -1) # Line numbers for deleted lines
|
||||||
curses.init_pair(7, curses.COLOR_BLUE, -1) # Regular line numbers
|
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()
|
height, width = stdscr.getmaxyx()
|
||||||
state = AppState(
|
state = AppState(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue