From 60b3af9108011d7765ec66a053b6f539945c6575 Mon Sep 17 00:00:00 2001 From: "n loewen (aider)" Date: Sun, 8 Jun 2025 01:56:52 +0100 Subject: [PATCH] feat: Add change navigation with visual markers and keyboard shortcuts --- gtm | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/gtm b/gtm index 69914c9..aba3b52 100755 --- a/gtm +++ b/gtm @@ -132,9 +132,28 @@ class AppState: # Line wrapping settings wrap_lines: bool = True + + # Change navigation + change_positions: List[int] = field(default_factory=list) + current_change_idx: int = -1 # -1 means no change is selected # --- Actions (Controller) --- +def calculate_change_positions(state: AppState) -> AppState: + """Calculate positions of all changes (additions and deletions) in the file.""" + positions = [] + # Add positions of added lines + for line_num, _ in state.added_lines: + if line_num not in positions: + positions.append(line_num) + # Add positions of deleted lines + for line_num, _ in state.deleted_lines: + if line_num not in positions: + positions.append(line_num) + # Sort positions + positions.sort() + return replace(state, change_positions=positions, current_change_idx=-1) + def load_commit_content(state: AppState) -> AppState: if not state.commits: return state @@ -173,12 +192,15 @@ def load_commit_content(state: AppState) -> AppState: right_scroll_offset = max(0, min(right_scroll_offset, max_scroll_new)) - return replace(state, + new_state = replace(state, file_lines=file_lines, added_lines=added_lines, deleted_lines=deleted_lines, right_scroll_offset=right_scroll_offset ) + + # Calculate change positions for navigation + return calculate_change_positions(new_state) def update_dimensions(state: AppState, height: int, width: int) -> AppState: return replace(state, height=height, width=width) @@ -240,6 +262,82 @@ def draw_left_pane(stdscr, state): if display_index == state.selected_commit_idx: stdscr.attroff(curses.A_REVERSE) +def jump_to_next_change(state: AppState) -> AppState: + """Jump to the next change after the current scroll position.""" + if not state.change_positions: + return state + + # Find the next change after current position + current_pos = state.right_scroll_offset + next_idx = -1 + for i, pos in enumerate(state.change_positions): + if pos > current_pos: + next_idx = i + break + + # If no next change found, wrap to the first change + if next_idx == -1 and state.change_positions: + next_idx = 0 + + if next_idx != -1: + new_scroll = state.change_positions[next_idx] + return replace(state, right_scroll_offset=new_scroll, current_change_idx=next_idx) + return state + +def jump_to_prev_change(state: AppState) -> AppState: + """Jump to the previous change before the current scroll position.""" + if not state.change_positions: + return state + + # Find the previous change before current position + current_pos = state.right_scroll_offset + prev_idx = -1 + for i in range(len(state.change_positions) - 1, -1, -1): + if state.change_positions[i] < current_pos: + prev_idx = i + break + + # If no previous change found, wrap to the last change + if prev_idx == -1 and state.change_positions: + prev_idx = len(state.change_positions) - 1 + + if prev_idx != -1: + new_scroll = state.change_positions[prev_idx] + return replace(state, right_scroll_offset=new_scroll, current_change_idx=prev_idx) + return state + +def draw_change_markers(stdscr, state): + """Draw markers in the scrollbar area to indicate where changes are located.""" + if not state.change_positions or not (state.show_whole_diff or state.show_additions or state.show_deletions): + return + + right_start = 0 if not state.show_sidebar else state.divider_col + 2 + right_width = state.width - right_start - 1 + marker_col = right_start + right_width - 1 + + # Calculate scaling factor for marker positions + total_lines = len(state.file_lines) + if total_lines <= 1: + return + + scale_factor = (state.height - 1) / total_lines + + # Draw markers + for i, pos in enumerate(state.change_positions): + marker_y = int(pos * scale_factor) + if 0 <= marker_y < state.height - 1: + # Determine marker color based on change type + is_addition = any(added_line_num == pos for added_line_num, _ in state.added_lines) + is_deletion = any(del_line_num == pos for del_line_num, _ in state.deleted_lines) + + attr = curses.color_pair(3) if is_addition else curses.color_pair(4) + marker_char = '>' if i == state.current_change_idx else '|' + + try: + stdscr.addch(marker_y, marker_col, marker_char, attr) + except curses.error: + pass + def draw_right_pane(stdscr, state): # If sidebar is hidden, right pane starts at column 0 right_start = 0 if not state.show_sidebar else state.divider_col + 2 @@ -415,9 +513,13 @@ def draw_status_bars(stdscr, state): if len(parts) >= 4: commit_message = parts[3] - # Add wrap indicator to commit message + # Add wrap indicator and change position to commit message wrap_indicator = " [W] " if state.wrap_lines else " [NW] " - commit_message = wrap_indicator + commit_message + change_indicator = "" + if state.change_positions and state.current_change_idx != -1: + change_indicator = f" [Change {state.current_change_idx + 1}/{len(state.change_positions)}] " + + commit_message = wrap_indicator + change_indicator + commit_message # Status bar percentages if len(state.file_lines) > 0: @@ -492,6 +594,7 @@ def draw_ui(stdscr, state): draw_left_pane(stdscr, state) draw_right_pane(stdscr, state) draw_divider(stdscr, state) + draw_change_markers(stdscr, state) draw_status_bars(stdscr, state) draw_selection(stdscr, state) stdscr.refresh() @@ -644,6 +747,12 @@ def handle_keyboard_input(key, state: AppState) -> AppState: return toggle_sidebar(state) elif key == ord('w'): return replace(state, wrap_lines=not state.wrap_lines) + elif key == ord('n'): + if state.focus == "right" and (state.show_whole_diff or state.show_additions or state.show_deletions): + return jump_to_next_change(state) + elif key == ord('p'): + if state.focus == "right" and (state.show_whole_diff or state.show_additions or state.show_deletions): + return jump_to_prev_change(state) elif key in [curses.KEY_LEFT, ord('h')]: if state.show_sidebar: return replace(state, focus="left")