feat: Add change navigation with visual markers and keyboard shortcuts

This commit is contained in:
n loewen (aider) 2025-06-08 01:56:52 +01:00
parent 49bc53d16d
commit 60b3af9108
1 changed files with 112 additions and 3 deletions

115
gtm
View File

@ -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")