#!/usr/bin/env python3 import curses import difflib import os import subprocess import sys import argparse from dataclasses import dataclass, field, replace from typing import List, Optional, Tuple VERSION = "2025-06-07.3" # --- Data Fetching & Utility Functions (Pure) --- def is_file_tracked_by_git(filename): """Check if a file is tracked by git.""" cmd = ['git', 'ls-files', '--error-unmatch', filename] result = subprocess.run(cmd, capture_output=True, text=True) return result.returncode == 0 def get_commits(filename): cmd = ['git', 'log', '--pretty=format:%h %ad %s', '--date=format:%Y-%m-%d %H:%M', '--', filename] result = subprocess.run(cmd, capture_output=True, text=True) return result.stdout.splitlines() def get_file_at_commit(commit_hash, filename): cmd = ['git', 'show', f'{commit_hash}:{filename}'] result = subprocess.run(cmd, capture_output=True, text=True) return result.stdout.splitlines() def find_best_matching_line(reference_line, file_lines, max_lines=None): """Find the best matching line in file_lines that matches reference_line. Returns the line index or None if no good match is found.""" if not reference_line or not file_lines: return None # First try exact match for i, line in enumerate(file_lines): if line == reference_line: return i # If no exact match, use difflib to find the most similar line # Only search through a reasonable number of lines for performance search_lines = file_lines[:max_lines] if max_lines else file_lines best_match_idx = None best_ratio = 0.0 # Create a SequenceMatcher for the reference line, but reuse it for efficiency s = difflib.SequenceMatcher(None, reference_line) for i, line in enumerate(search_lines): s.set_seq2(line) ratio = s.ratio() if ratio > best_ratio: best_ratio = ratio best_match_idx = i # Only return a match if it's reasonably good (e.g., ratio > 0.6) if best_ratio > 0.6: return best_match_idx return None def get_diff_info(current_commit, prev_commit, filename): """Get diff information between two commits for a file""" if not prev_commit: return [], [] cmd = ['git', 'diff', prev_commit, current_commit, '--', filename] result = subprocess.run(cmd, capture_output=True, text=True) lines = result.stdout.splitlines() added_lines = [] deleted_lines = [] current_line_num = 0 i = 0 while i < len(lines): line = lines[i] if line.startswith('@@'): parts = line.split() if len(parts) >= 3: add_part = parts[2][1:] if ',' in add_part: current_line_num, _ = map(int, add_part.split(',')) else: current_line_num = int(add_part) elif line.startswith('+') and not line.startswith('+++'): added_lines.append((current_line_num, line[1:])) current_line_num += 1 elif line.startswith('-') and not line.startswith('---'): deleted_lines.append((current_line_num, line[1:])) elif not line.startswith('+') and not line.startswith('-') and not line.startswith('@@'): current_line_num += 1 i += 1 return added_lines, deleted_lines @dataclass class AppState: filename: str width: int height: int show_whole_diff: bool show_additions: bool show_deletions: bool enable_mouse: bool show_sidebar: bool = True commits: List[str] = field(default_factory=list) file_lines: List[str] = field(default_factory=list) added_lines: List[Tuple[int, str]] = field(default_factory=list) deleted_lines: List[Tuple[int, str]] = field(default_factory=list) focus: str = "left" divider_col: int = 40 selected_commit_idx: int = 0 left_scroll_offset: int = 0 right_scroll_offset: int = 0 dragging_divider: bool = False should_exit: bool = False is_selecting: bool = False selection_start_coord: Optional[Tuple[int, int]] = None selection_end_coord: Optional[Tuple[int, int]] = None click_position: Optional[Tuple[int, int]] = None last_bstate: int = 0 mouse_x: int = -1 mouse_y: int = -1 # 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 reference_line = None scroll_percentage = 0 if len(state.file_lines) > 0: max_scroll_old = max(0, len(state.file_lines) - (state.height - 1)) if max_scroll_old > 0: scroll_percentage = state.right_scroll_offset / max_scroll_old if state.right_scroll_offset < len(state.file_lines): reference_line = state.file_lines[state.right_scroll_offset] commit_hash = state.commits[state.selected_commit_idx].split()[0] file_lines = get_file_at_commit(commit_hash, state.filename) prev_commit_hash = None if state.selected_commit_idx < len(state.commits) - 1: prev_commit_hash = state.commits[state.selected_commit_idx + 1].split()[0] if state.show_whole_diff or state.show_additions or state.show_deletions: added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, state.filename) else: added_lines, deleted_lines = [], [] max_scroll_new = max(0, len(file_lines) - (state.height - 1)) right_scroll_offset = state.right_scroll_offset if reference_line: matching_line_idx = find_best_matching_line(reference_line, file_lines, 1000) if matching_line_idx is not None: right_scroll_offset = matching_line_idx else: right_scroll_offset = int(scroll_percentage * max_scroll_new) else: right_scroll_offset = int(scroll_percentage * max_scroll_new) right_scroll_offset = max(0, min(right_scroll_offset, max_scroll_new)) 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) def toggle_sidebar(state: AppState) -> AppState: new_show_sidebar = not state.show_sidebar new_focus = state.focus if not new_show_sidebar: new_focus = "right" return replace(state, show_sidebar=new_show_sidebar, focus=new_focus) def move_commit_selection(state: AppState, delta: int) -> AppState: new_idx = state.selected_commit_idx + delta if 0 <= new_idx < len(state.commits): new_state = replace(state, selected_commit_idx=new_idx) return load_commit_content(new_state) return state def page_commit_selection(state: AppState, direction: int) -> AppState: page_size = state.height - 1 delta = page_size * direction return move_commit_selection(state, delta) def scroll_right_pane(state: AppState, delta: int) -> AppState: max_scroll = max(0, len(state.file_lines) - (state.height - 1)) new_offset = state.right_scroll_offset + delta new_offset = max(0, min(new_offset, max_scroll)) return replace(state, right_scroll_offset=new_offset) def page_right_pane(state: AppState, direction: int) -> AppState: page_size = state.height - 1 delta = page_size * direction return scroll_right_pane(state, delta) def update_divider(state: AppState, mx: int) -> AppState: min_col = 10 max_col = state.width - 20 new_divider_col = max(min_col, min(mx, max_col)) return replace(state, divider_col=new_divider_col) # --- Rendering Functions (View) --- def draw_left_pane(stdscr, state): if not state.show_sidebar: return if state.selected_commit_idx < state.left_scroll_offset: state.left_scroll_offset = state.selected_commit_idx elif state.selected_commit_idx >= state.left_scroll_offset + state.height - 1: state.left_scroll_offset = state.selected_commit_idx - (state.height - 2) visible_commits = state.commits[state.left_scroll_offset:state.left_scroll_offset + state.height - 1] for i, line in enumerate(visible_commits): display_index = i + state.left_scroll_offset if display_index == state.selected_commit_idx: stdscr.attron(curses.A_REVERSE) stdscr.addnstr(i, 0, line, state.divider_col - 1) 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 right_width = state.width - right_start - 1 max_scroll = max(0, len(state.file_lines) - (state.height - 1)) state.right_scroll_offset = min(state.right_scroll_offset, max_scroll) visible_lines = state.file_lines[state.right_scroll_offset:state.right_scroll_offset + state.height - 1] display_lines = [] deleted_line_map = {} if state.show_whole_diff or state.show_deletions: for del_line_num, del_content in state.deleted_lines: if del_line_num not in deleted_line_map: deleted_line_map[del_line_num] = [] deleted_line_map[del_line_num].append(del_content) for i, line in enumerate(visible_lines): line_num = i + state.right_scroll_offset + 1 if (state.show_whole_diff or state.show_deletions) and line_num in deleted_line_map: for del_content in deleted_line_map[line_num]: display_lines.append({'type': 'deleted', 'content': del_content}) is_added = False if state.show_whole_diff or state.show_additions: for added_line_num, _ in state.added_lines: if added_line_num == line_num: is_added = True break display_lines.append({'type': 'added' if is_added else 'regular', 'content': line}) display_row = 0 for line_info in display_lines: if display_row >= state.height - 2: break line_type = line_info['type'] content = line_info['content'] # Determine prefix based on line type and diff mode prefix = "" if line_type == 'added': prefix = "+ " attr = curses.color_pair(3) elif line_type == 'deleted': prefix = "- " attr = curses.color_pair(4) else: if state.show_whole_diff or state.show_additions or state.show_deletions: prefix = " " attr = curses.A_NORMAL # Calculate available width for content content_start = right_start + len(prefix) available_width = right_width - len(prefix) # Handle line wrapping if len(content) <= available_width or not state.wrap_lines: # Line fits or wrapping is disabled if prefix: stdscr.addstr(display_row, right_start, prefix, attr) # If wrapping is disabled, just show what fits in the available width display_content = content 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 else: # Line needs wrapping and wrapping is enabled remaining = content first_line = True while remaining and display_row < state.height - 2: # For first line, add the prefix if first_line: if prefix: stdscr.addstr(display_row, right_start, prefix, attr) chunk = remaining[:available_width] stdscr.addnstr(display_row, content_start, chunk, available_width, attr) remaining = remaining[available_width:] first_line = False else: # For continuation lines, indent by 2 spaces indent = " " indent_start = right_start content_start = indent_start + len(indent) wrap_width = right_width - len(indent) stdscr.addstr(display_row, indent_start, indent) chunk = remaining[:wrap_width] stdscr.addnstr(display_row, content_start, chunk, wrap_width, attr) remaining = remaining[wrap_width:] display_row += 1 def draw_divider(stdscr, state): if not state.show_sidebar: return divider_char = "║" if state.dragging_divider else "│" for y in range(state.height - 1): # Don't draw through the status bar try: stdscr.addch(y, state.divider_col, divider_char) except curses.error: pass def draw_selection(stdscr, state): if not state.is_selecting or not state.selection_start_coord: return start_x, start_y = state.selection_start_coord end_x, end_y = state.selection_end_coord # Determine pane boundaries based on sidebar visibility if state.show_sidebar: # Determine pane from where selection started pane = 'left' if start_x < state.divider_col else 'right' if pane == 'left': pane_x1, pane_x2 = 0, state.divider_col - 1 else: # right pane_x1, pane_x2 = state.divider_col + 2, state.width - 1 else: # When sidebar is hidden, there's only the right pane pane = 'right' pane_x1, pane_x2 = 0, state.width - 1 # Determine drag direction to handle multi-line selection correctly if start_y < end_y or (start_y == end_y and start_x <= end_x): drag_start_x, drag_start_y = start_x, start_y drag_end_x, drag_end_y = end_x, end_y else: # upward drag or right-to-left on same line drag_start_x, drag_start_y = end_x, end_y drag_end_x, drag_end_y = start_x, start_y for y in range(drag_start_y, drag_end_y + 1): x1, x2 = -1, -1 if drag_start_y == drag_end_y: # single line selection x1, x2 = drag_start_x, drag_end_x elif y == drag_start_y: # first line of multi-line selection x1, x2 = drag_start_x, pane_x2 elif y == drag_end_y: # last line of multi-line selection x1, x2 = pane_x1, drag_end_x else: # middle line of multi-line selection x1, x2 = pane_x1, pane_x2 # Clamp selection to pane boundaries x1 = max(x1, pane_x1) x2 = min(x2, pane_x2) try: if x1 < state.width and x1 <= x2: length = x2 - x1 + 1 if length > 0: stdscr.chgat(y, x1, length, curses.A_REVERSE) except curses.error: pass def draw_status_bars(stdscr, state): # We'll use height-1 for the single status bar visible_height = state.height - 1 # Get commit message for the selected commit commit_message = "" if state.commits and state.selected_commit_idx < len(state.commits): # Format is now: hash date time message # We need to split on more than just the first two spaces commit_line = state.commits[state.selected_commit_idx] parts = commit_line.split(' ', 3) # Split into hash, date, time, message if len(parts) >= 4: commit_message = parts[3] # Add wrap indicator and change position to commit message wrap_indicator = " [W] " if state.wrap_lines else " [NW] " 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: last_visible_line = state.right_scroll_offset + visible_height right_percent = int((last_visible_line / len(state.file_lines)) * 100) right_percent = 100 if last_visible_line >= len(state.file_lines) else right_percent else: right_percent = 0 right_status = f"{right_percent}%" if len(state.commits) > 0 and state.show_sidebar: last_visible_commit = state.left_scroll_offset + visible_height left_percent = int((last_visible_commit / len(state.commits)) * 100) left_percent = 100 if last_visible_commit >= len(state.commits) else left_percent else: left_percent = 0 left_status = f"{left_percent}%" # Use terminal's default colors for the status bar status_attr = curses.A_NORMAL # Use terminal's default colors # Fill the status bar with spaces using reverse video for x in range(state.width - 1): try: stdscr.addch(state.height - 1, x, ' ', curses.A_REVERSE) except curses.error: pass # Add left percentage indicator (only if sidebar is visible) if state.show_sidebar: try: # Use normal video if left pane is active (since status bar is already reverse) left_attr = status_attr if state.focus == "left" else curses.A_REVERSE # Add a space before and after the percentage with the same highlighting stdscr.addstr(state.height - 1, 0, f" {left_status} ", left_attr) except curses.error: pass # Add commit message in the middle if commit_message: # Calculate available space left_margin = len(left_status) + 5 if state.show_sidebar else 1 # +5 for the spaces around percentage right_margin = len(right_status) + 5 # +5 for the spaces around percentage available_width = state.width - left_margin - right_margin # Truncate message if needed if len(commit_message) > available_width: commit_message = commit_message[:available_width-3] + "..." # Center the message in the available space message_x = left_margin try: stdscr.addstr(state.height - 1, message_x, commit_message, curses.A_REVERSE) except curses.error: pass # Add right percentage indicator with highlighting for active pane try: # Include spaces in the highlighted area padded_right_status = f" {right_status} " right_x = state.width - len(padded_right_status) if right_x >= 0: # Use normal video if right pane is active (since status bar is already reverse) right_attr = status_attr if state.focus == "right" else curses.A_REVERSE stdscr.addstr(state.height - 1, right_x, padded_right_status, right_attr) except curses.error: pass def draw_ui(stdscr, state): stdscr.erase() 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() # --- Input Handling Functions (Controller) --- def copy_selection_to_clipboard(stdscr, state): if not state.selection_start_coord or not state.selection_end_coord: return start_x, start_y = state.selection_start_coord end_x, end_y = state.selection_end_coord # Determine pane boundaries based on sidebar visibility if state.show_sidebar: # Determine pane from where selection started pane = 'left' if start_x < state.divider_col else 'right' if pane == 'left': pane_x1, pane_x2 = 0, state.divider_col - 1 else: # right pane_x1, pane_x2 = state.divider_col + 2, state.width - 1 else: # When sidebar is hidden, there's only the right pane pane = 'right' pane_x1, pane_x2 = 0, state.width - 1 # Determine drag direction to handle multi-line selection correctly if start_y < end_y or (start_y == end_y and start_x <= end_x): drag_start_x, drag_start_y = start_x, start_y drag_end_x, drag_end_y = end_x, end_y else: # upward drag or right-to-left on same line drag_start_x, drag_start_y = end_x, end_y drag_end_x, drag_end_y = start_x, start_y height, width = stdscr.getmaxyx() selected_text_parts = [] for y in range(drag_start_y, drag_end_y + 1): if not (0 <= y < height): continue x1, x2 = -1, -1 if drag_start_y == drag_end_y: # single line selection x1, x2 = drag_start_x, drag_end_x elif y == drag_start_y: # first line of multi-line selection x1, x2 = drag_start_x, pane_x2 elif y == drag_end_y: # last line of multi-line selection x1, x2 = pane_x1, drag_end_x else: # middle line of multi-line selection x1, x2 = pane_x1, pane_x2 # Clamp selection to pane boundaries and screen width x1 = max(x1, pane_x1) x2 = min(x2, pane_x2, width - 1) line_str = "" if x1 <= x2: for x in range(x1, x2 + 1): try: char_and_attr = stdscr.inch(y, x) char = char_and_attr & 0xFF line_str += chr(char) except curses.error: line_str += " " selected_text_parts.append(line_str) if selected_text_parts: text_to_copy = "\n".join(selected_text_parts) if text_to_copy.strip(): try: subprocess.run(['pbcopy'], input=text_to_copy, text=True, check=True) except (FileNotFoundError, subprocess.CalledProcessError): pass def handle_mouse_input(stdscr, state: AppState) -> AppState: try: _, mx, my, _, bstate = curses.getmouse() state = replace(state, last_bstate=bstate, mouse_x=mx, mouse_y=my) # Handle mouse button press if bstate & curses.BUTTON1_PRESSED: if state.show_sidebar and abs(mx - state.divider_col) <= 1: return replace(state, dragging_divider=True) else: focus = "right" if state.show_sidebar: focus = "left" if mx < state.divider_col else "right" return replace(state, focus=focus, is_selecting=True, selection_start_coord=(mx, my), selection_end_coord=(mx, my), click_position=(mx, my)) # Handle mouse button release if bstate & curses.BUTTON1_RELEASED: if state.dragging_divider: return replace(state, dragging_divider=False) if state.is_selecting: # Check if this was a click (press and release at same position) # or very close to the same position (within 1-2 pixels) if (state.click_position and abs(state.click_position[0] - mx) <= 2 and abs(state.click_position[1] - my) <= 2): # This was a click, so switch panes (only if sidebar is visible) focus = "right" if state.show_sidebar: focus = "left" if mx < state.divider_col else "right" new_state = replace(state, focus=focus) # If clicking in the left pane on a commit entry, select that commit if state.show_sidebar and mx < state.divider_col and my < min(state.height - 1, len(state.commits) - state.left_scroll_offset): new_commit_idx = my + state.left_scroll_offset if 0 <= new_commit_idx < len(state.commits): new_state = replace(new_state, selected_commit_idx=new_commit_idx) new_state = load_commit_content(new_state) # Reset selection state return replace(new_state, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None) elif state.selection_start_coord and state.selection_end_coord: # This was a drag selection, copy the text copy_selection_to_clipboard(stdscr, state) # Reset selection state return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None) # Handle mouse movement during drag operations if state.dragging_divider: return update_divider(state, mx) elif state.is_selecting: return replace(state, selection_end_coord=(mx, my)) except curses.error: pass return state def handle_keyboard_input(key, state: AppState) -> AppState: if key in [ord('q')]: return replace(state, should_exit=True) elif key == 27: # Escape key if state.is_selecting: return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None) else: return replace(state, should_exit=True) elif key == ord('s'): 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") elif key in [curses.KEY_RIGHT, ord('l')]: return replace(state, focus="right") elif state.focus == "left": if key in [curses.KEY_DOWN, ord('j')]: return move_commit_selection(state, 1) elif key in [curses.KEY_UP, ord('k')]: return move_commit_selection(state, -1) elif key in [curses.KEY_NPAGE, ord(' ')]: return page_commit_selection(state, 1) elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: return page_commit_selection(state, -1) elif state.focus == "right": if key in [curses.KEY_DOWN, ord('j')]: return scroll_right_pane(state, 1) elif key in [curses.KEY_UP, ord('k')]: return scroll_right_pane(state, -1) elif key in [curses.KEY_NPAGE, ord(' ')]: return page_right_pane(state, 1) elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: return page_right_pane(state, -1) return state # --- Main Application --- def main(stdscr, filename, show_diff, show_add, show_del, mouse, wrap_lines=True): try: curses.curs_set(0) except: pass stdscr.keypad(True) stdscr.timeout(50) # Less aggressive timeout (50ms instead of 10ms) if curses.has_colors(): curses.use_default_colors() # Use a safe background color (7 or less) to avoid "Color number is greater than COLORS-1" error curses.init_pair(1, curses.COLOR_BLACK, 7) # Active pane: black on white curses.init_pair(2, curses.COLOR_WHITE, 0) # Inactive pane: white on black curses.init_pair(3, curses.COLOR_GREEN, -1) curses.init_pair(4, curses.COLOR_RED, -1) height, width = stdscr.getmaxyx() state = AppState( filename=filename, width=width, height=height, show_whole_diff=show_diff, show_additions=show_add, show_deletions=show_del, enable_mouse=mouse, commits=get_commits(filename), wrap_lines=wrap_lines ) if state.enable_mouse: curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) # Enable mouse motion events for better drag tracking # This escape code is not supported by all terminals (e.g., nsterm) if os.environ.get("TERM") != "nsterm": try: print("\033[?1003h", end="", flush=True) except Exception: pass state = load_commit_content(state) # Initial draw before the main loop starts draw_ui(stdscr, state) while not state.should_exit: try: key = stdscr.getch() # Process input and update the application state if key == -1: # No input available (timeout) pass # Just redraw the UI and continue elif key == curses.KEY_RESIZE: h, w = stdscr.getmaxyx() state = update_dimensions(state, h, w) elif state.enable_mouse and key == curses.KEY_MOUSE: state = handle_mouse_input(stdscr, state) else: state = handle_keyboard_input(key, state) # After every action, redraw the UI to reflect changes immediately. # This is crucial for real-time feedback during mouse drags. draw_ui(stdscr, state) except Exception as e: # Prevent crashes by catching exceptions # Uncomment for debugging: # with open("/tmp/gtm_error.log", "a") as f: # f.write(f"{str(e)}\n") pass # Disable mouse motion events when exiting if state.enable_mouse: # This escape code is not supported by all terminals (e.g., nsterm) if os.environ.get("TERM") != "nsterm": try: print("\033[?1003l", end="", flush=True) except Exception: pass if __name__ == "__main__": parser = argparse.ArgumentParser(description="A \"Git Time Machine\" for viewing file history") parser.add_argument("-d", "--diff", action="store_true", help="Highlight newly added and deleted lines") parser.add_argument("--diff-additions", action="store_true", help="Highlight newly added lines in green") parser.add_argument("--diff-deletions", action="store_true", help="Show deleted lines in red") parser.add_argument("--no-mouse", action="store_true", help="Disable mouse support") parser.add_argument("--no-wrap", action="store_true", help="Disable line wrapping") parser.add_argument("-v", "--version", action="store_true", help="Show version number") parser.add_argument("filename", nargs="?", help="File to view history for") args = parser.parse_args() if args.version: print(f"gtm version {VERSION}") sys.exit(0) elif not args.filename: parser.print_help() sys.exit(1) filename = args.filename if not os.path.isfile(filename): print(f"Error: File '{filename}' does not exist") sys.exit(1) if not is_file_tracked_by_git(filename): print(f"Error: File '{filename}' is not tracked by git") print("Only files that are tracked in the git repository can be viewed.") print("Try adding and committing the file first:") print(f" git add {filename}") print(f" git commit -m 'Add {os.path.basename(filename)}'") sys.exit(1) curses.wrapper(main, filename, args.diff, args.diff_additions, args.diff_deletions, not args.no_mouse, not args.no_wrap)