#!/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 # Line numbers settings show_line_numbers: bool = False # Change navigation 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 # Help popup show_help: bool = False # --- Actions (Controller) --- def calculate_change_blocks(state: AppState) -> AppState: """Calculate positions of change blocks (consecutive changes) in the file.""" if not state.added_lines and not state.deleted_lines: return replace(state, change_blocks=[], current_change_idx=-1) # Collect all change positions all_changes = [] for line_num, _ in state.added_lines: all_changes.append(line_num) for line_num, _ in state.deleted_lines: all_changes.append(line_num) # Sort all positions all_changes.sort() if not all_changes: return replace(state, change_blocks=[], current_change_idx=-1) # Group into blocks (changes within 3 lines of each other are considered one block) blocks = [] current_block_start = all_changes[0] current_block_end = all_changes[0] for pos in all_changes[1:]: if pos <= current_block_end + 3: # Within 3 lines of previous change current_block_end = pos else: # Start a new block blocks.append((current_block_start, current_block_end)) current_block_start = pos current_block_end = pos # Add the last block blocks.append((current_block_start, current_block_end)) # Add debug output to a log file with open("/tmp/gtm_debug.log", "a") as f: f.write(f"Found {len(blocks)} change blocks\n") for i, (start, end) in enumerate(blocks): f.write(f" Block {i+1}: lines {start}-{end}\n") f.write(f"Added lines: {state.added_lines}\n") f.write(f"Deleted lines: {state.deleted_lines}\n") return replace(state, change_blocks=blocks, 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 blocks for navigation return calculate_change_blocks(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 block after the current scroll position.""" if not state.change_blocks: return state # Find the next change block after current position or current change index current_pos = state.right_scroll_offset current_idx = state.current_change_idx # If we're already on a change block, try to move to the next one if current_idx != -1: next_idx = (current_idx + 1) % len(state.change_blocks) else: # Otherwise find the next change block after current scroll position next_idx = -1 for i, (start_pos, _) in enumerate(state.change_blocks): if start_pos > current_pos: next_idx = i break # If no next change found, wrap to the first change if next_idx == -1 and state.change_blocks: next_idx = 0 if next_idx != -1: # Get the start position of the change block new_scroll = max(0, state.change_blocks[next_idx][0] - 3) # Show 3 lines of context above 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 block before the current scroll position.""" if not state.change_blocks: return state # Find the previous change block before current position or current change index current_pos = state.right_scroll_offset current_idx = state.current_change_idx # If we're already on a change block, try to move to the previous one if current_idx != -1: prev_idx = (current_idx - 1) % len(state.change_blocks) else: # Otherwise find the previous change block before current scroll position prev_idx = -1 for i in range(len(state.change_blocks) - 1, -1, -1): if state.change_blocks[i][0] < current_pos: prev_idx = i break # If no previous change found, wrap to the last change if prev_idx == -1 and state.change_blocks: prev_idx = len(state.change_blocks) - 1 if prev_idx != -1: # Get the start position of the change block new_scroll = max(0, state.change_blocks[prev_idx][0] - 3) # Show 3 lines of context above 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 right_start = 0 if not state.show_sidebar else state.divider_col + 2 # Calculate line number width if enabled line_num_width = 0 if state.show_line_numbers: # Width based on the number of digits in the largest line number max_line_num = len(state.file_lines) line_num_width = len(str(max_line_num)) + 1 # +1 for spacing # Adjust right pane start position and width for line numbers if state.show_line_numbers: right_start += line_num_width 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]: # For deleted lines, use the same line number display_lines.append({'type': 'deleted', 'content': del_content, 'line_num': line_num}) 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, 'line_num': line_num}) 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'] 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) + " " line_num_pos = (0 if not state.show_sidebar else state.divider_col + 2) # Use different color for line numbers if line_type == 'added': stdscr.addstr(display_row, line_num_pos, line_num_str, curses.color_pair(5)) elif line_type == 'deleted': stdscr.addstr(display_row, line_num_pos, line_num_str, curses.color_pair(6)) else: stdscr.addstr(display_row, line_num_pos, line_num_str, curses.color_pair(7)) # 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 # 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) # 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] # 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: # 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_help_popup(stdscr, state): """Draw a popup with keyboard shortcut help.""" if not state.show_help: return # Calculate popup dimensions and position popup_width = 60 popup_height = 20 popup_x = max(0, (state.width - popup_width) // 2) popup_y = max(0, (state.height - popup_height) // 2) # Draw popup border for y in range(popup_y, popup_y + popup_height): for x in range(popup_x, popup_x + popup_width): if y == popup_y or y == popup_y + popup_height - 1: # Top and bottom borders try: stdscr.addch(y, x, curses.ACS_HLINE) except curses.error: pass elif x == popup_x or x == popup_x + popup_width - 1: # Left and right borders try: stdscr.addch(y, x, curses.ACS_VLINE) except curses.error: pass # Draw corners try: stdscr.addch(popup_y, popup_x, curses.ACS_ULCORNER) stdscr.addch(popup_y, popup_x + popup_width - 1, curses.ACS_URCORNER) stdscr.addch(popup_y + popup_height - 1, popup_x, curses.ACS_LLCORNER) stdscr.addch(popup_y + popup_height - 1, popup_x + popup_width - 1, curses.ACS_LRCORNER) except curses.error: pass # Draw title title = " Keyboard Shortcuts " title_x = popup_x + (popup_width - len(title)) // 2 try: stdscr.addstr(popup_y, title_x, title, curses.A_BOLD) except curses.error: pass # Define help content help_items = [ ("Navigation", ""), ("j / Down", "Scroll down"), ("k / Up", "Scroll up"), ("Space / Page Down", "Page down"), ("b / Page Up", "Page up"), ("h / Left", "Focus left pane"), ("l / Right", "Focus right pane"), ("", ""), ("Features", ""), ("/ (slash)", "Search in file"), ("n", "Next search match"), ("N", "Previous search match"), ("c", "Next change"), ("C", "Previous change"), ("s", "Toggle sidebar"), ("w", "Toggle line wrapping"), ("L", "Toggle line numbers"), ("q / Esc", "Quit"), ("? (question mark)", "Show/hide this help") ] # Draw help content for i, (key, desc) in enumerate(help_items): y = popup_y + 2 + i if y < popup_y + popup_height - 1: if key == "Navigation" or key == "Features": # Section headers try: stdscr.addstr(y, popup_x + 2, key, curses.A_BOLD | curses.A_UNDERLINE) except curses.error: pass elif key: # Key and description try: stdscr.addstr(y, popup_x + 2, key, curses.A_BOLD) stdscr.addstr(y, popup_x + 16, desc) except curses.error: pass # Draw footer footer = " Press any key to close " footer_x = popup_x + (popup_width - len(footer)) // 2 try: stdscr.addstr(popup_y + popup_height - 1, footer_x, footer, curses.A_BOLD) 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 # 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): # 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 indicators to commit message wrap_indicator = "" if state.wrap_lines else "[NW] " change_indicator = "" if state.change_blocks and state.current_change_idx != -1: change_indicator = f"[Change {state.current_change_idx + 1}/{len(state.change_blocks)}] " 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: 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) + 3 if state.show_sidebar else 1 # +3 for the spaces around percentage right_margin = len(right_status) + 3 # +3 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_status_bars(stdscr, state) draw_selection(stdscr, state) draw_help_popup(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 from where selection started if state.show_sidebar: pane = 'left' if start_x < state.divider_col else 'right' else: pane = 'right' # When sidebar is hidden, there's only the right pane # Determine drag direction to handle multi-line selection correctly if start_y > end_y or (start_y == end_y and start_x > end_x): # Swap coordinates if selection is bottom-to-top or right-to-left start_x, start_y, end_x, end_y = end_x, end_y, start_x, start_y # Get the selected text from the data model selected_text_parts = [] if pane == 'left' and state.show_sidebar: # Selection in the commits pane visible_commits = state.commits[state.left_scroll_offset:state.left_scroll_offset + state.height - 1] for i in range(start_y, min(end_y + 1, len(visible_commits))): line_idx = i + state.left_scroll_offset if 0 <= line_idx < len(state.commits): line = state.commits[line_idx] # Calculate character positions start_char = 0 if i > start_y else start_x end_char = len(line) if i < end_y else end_x + 1 # Get the substring if start_char < len(line): selected_text_parts.append(line[start_char:min(end_char, len(line))]) else: # Selection in the file content pane # First, build a list of all visible lines including deleted lines if shown visible_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) # Build the list of visible lines with their types file_lines = state.file_lines[state.right_scroll_offset:state.right_scroll_offset + state.height - 1] display_lines = [] for i, line in enumerate(file_lines): line_num = i + state.right_scroll_offset + 1 # Add deleted lines if they should be shown 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}) # Determine if this is an added line 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}) # Now extract the selected text from these lines right_start = 0 if not state.show_sidebar else state.divider_col + 2 for i in range(start_y, min(end_y + 1, len(display_lines))): if i < 0 or i >= len(display_lines): continue line_info = display_lines[i] content = line_info['content'] line_type = line_info['type'] # Add prefix based on line type if in diff mode prefix = "" if line_type == 'added' and (state.show_whole_diff or state.show_additions): prefix = "+ " elif line_type == 'deleted' and (state.show_whole_diff or state.show_deletions): prefix = "- " elif state.show_whole_diff or state.show_additions or state.show_deletions: prefix = " " # Calculate character positions, accounting for the prefix content_with_prefix = prefix + content # Adjust start_x and end_x to account for the right pane offset adjusted_start_x = max(0, start_x - right_start) if i == start_y else 0 adjusted_end_x = end_x - right_start if i == end_y else len(content_with_prefix) # Ensure we don't go out of bounds adjusted_start_x = min(adjusted_start_x, len(content_with_prefix)) adjusted_end_x = min(adjusted_end_x, len(content_with_prefix)) if adjusted_start_x < adjusted_end_x: selected_text_parts.append(content_with_prefix[adjusted_start_x:adjusted_end_x]) # Join the selected text parts and copy to clipboard 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): try: # Try xclip for Linux systems subprocess.run(['xclip', '-selection', 'clipboard'], input=text_to_copy, text=True, check=True) except (FileNotFoundError, subprocess.CalledProcessError): try: # Try clip.exe for Windows subprocess.run(['clip.exe'], input=text_to_copy, text=True, check=True) except (FileNotFoundError, subprocess.CalledProcessError): pass # Silently fail if no clipboard command is available 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 help popup is open, any key closes it if state.show_help: return replace(state, show_help=False) # 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 if state.show_help: return replace(state, show_help=False) elif 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('?'): # Toggle help popup return replace(state, show_help=not state.show_help) 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'): return replace(state, wrap_lines=not state.wrap_lines) 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.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 [99, ord('c')]: # ASCII code for 'c' if state.show_whole_diff or state.show_additions or state.show_deletions: return jump_to_next_change(state) elif key in [67, ord('C')]: # ASCII code for 'C' (uppercase) if state.show_whole_diff or state.show_additions or state.show_deletions: return jump_to_prev_change(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) 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, show_line_numbers=False): 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) # Added lines curses.init_pair(4, curses.COLOR_RED, -1) # Deleted 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(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( 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("--line-numbers", action="store_true", help="Show line numbers") 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, args.line_numbers)