#!/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_commit_details(commit_hash): """Get detailed information about a specific commit.""" # Get commit author author_cmd = ['git', 'show', '-s', '--format=%an <%ae>', commit_hash] author_result = subprocess.run(author_cmd, capture_output=True, text=True) author = author_result.stdout.strip() # Get branch information branch_cmd = ['git', 'branch', '--contains', commit_hash] branch_result = subprocess.run(branch_cmd, capture_output=True, text=True) branches = [b.strip() for b in branch_result.stdout.splitlines()] # Find the current branch (marked with *) current_branch = "detached" for branch in branches: if branch.startswith('*'): current_branch = branch[1:].strip() break # Get full commit message message_cmd = ['git', 'show', '-s', '--format=%B', commit_hash] message_result = subprocess.run(message_cmd, capture_output=True, text=True) message = message_result.stdout.strip() return { 'author': author, 'branch': current_branch, 'message': message } 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_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 # Commit details commit_hash: str = "" commit_message: str = "" commit_author: str = "" commit_branch: str = "" # Status bar settings status_bar_height: int = 2 # Default height for the status bar (2 lines) dragging_status_bar: bool = False # Flag to track if user is resizing the status bar # --- 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 - state.status_bar_height)) 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_additions or state.show_deletions: added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, state.filename) else: added_lines, deleted_lines = [], [] # Get commit details commit_details = get_commit_details(commit_hash) # Get commit message from the commit line commit_line = state.commits[state.selected_commit_idx] parts = commit_line.split(' ', 3) # Split into hash, date, time, message short_message = parts[3] if len(parts) >= 4 else "" # If commit_details['message'] is empty, use short_message as fallback if not commit_details['message'].strip(): commit_details['message'] = short_message max_scroll_new = max(0, len(file_lines) - (state.height - state.status_bar_height)) 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, commit_hash=commit_hash, commit_message=commit_details['message'], commit_author=commit_details['author'], commit_branch=commit_details['branch'] ) # 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 - state.status_bar_height: state.left_scroll_offset = state.selected_commit_idx - (state.height - state.status_bar_height - 1) visible_commits = state.commits[state.left_scroll_offset:state.left_scroll_offset + state.height - state.status_bar_height] 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 - state.status_bar_height)) 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 - state.status_bar_height] display_lines = [] deleted_line_map = {} if 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_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_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_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 - state.status_bar_height): # Don't draw through the status bars 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 # 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", ""), ("/", "Search in file"), ("n", "Next search match"), ("N", "Previous search match"), ("c", "Next change"), ("C", "Previous change"), ("d", "Toggle diff mode"), ("a", "Toggle diff additions"), ("x", "Toggle diff deletions"), ("s", "Toggle sidebar"), ("w", "Toggle line wrapping"), ("L", "Toggle line numbers"), ("q / Esc", "Quit"), ("?", "Show/hide this help") ] # Calculate popup dimensions and position popup_width = 60 # Calculate height based on number of help items plus margins popup_height = len(help_items) + 4 # 4 extra lines for borders, title, and footer popup_x = max(0, (state.width - popup_width) // 2) popup_y = max(0, (state.height - popup_height) // 2) # Fill the entire popup area with spaces to clear background for y in range(popup_y, popup_y + popup_height): for x in range(popup_x, popup_x + popup_width): try: stdscr.addch(y, x, ' ') except curses.error: pass # 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 # Draw help content for i, (key, desc) in enumerate(help_items): y = popup_y + 2 + i if y < popup_y + popup_height - 2: 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 + 22, 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): # Calculate the position of the status bars status_bar_start = state.height - state.status_bar_height visible_height = status_bar_start # If in search mode, draw the search input field instead of the normal status bars if state.search_mode: # Fill the top status bar with spaces for x in range(state.width - 1): try: stdscr.addch(status_bar_start, x, ' ') except curses.error: pass # Draw search prompt search_prompt = "Search: " try: stdscr.addstr(status_bar_start, 0, search_prompt) # Draw the search query stdscr.addstr(status_bar_start, len(search_prompt), state.search_query) # Draw cursor at the end of the query try: stdscr.move(status_bar_start, 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(status_bar_start, state.width - len(match_info) - 1, match_info) except curses.error: pass # Fill all remaining status bar lines with spaces for y in range(status_bar_start + 1, state.height): for x in range(state.width - 1): try: stdscr.addch(y, x, ' ', curses.A_REVERSE) except curses.error: pass return # --- First status bar (top) --- # Add indicators to status bar 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)}] " status_indicators = wrap_indicator + change_indicator + search_indicator # 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 = min(100, 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 = min(100, 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 all status bar lines with spaces using reverse video for y in range(status_bar_start, state.height): for x in range(state.width - 1): try: stdscr.addch(y, 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(status_bar_start, 0, f" {left_status} ", left_attr) except curses.error: pass # Add status indicators in the middle if status_indicators: # 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 if needed if len(status_indicators) > available_width: status_indicators = status_indicators[:available_width-3] + "..." try: stdscr.addstr(status_bar_start, left_margin, status_indicators, 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(status_bar_start, right_x, padded_right_status, right_attr) except curses.error: pass # --- Second status bar and additional lines for commit details --- # Draw the commit details in the status bar area # Draw the commit hash and message if state.commit_hash: commit_info = f" {state.commit_hash} " # Calculate available space for the commit message author_branch_info = f" {state.commit_author} [{state.commit_branch}] " available_width = state.width - len(commit_info) - len(author_branch_info) - 1 # Get the commit message (potentially multi-line) commit_message = state.commit_message # For multi-line messages, we'll show as many lines as we can fit in the status bar height message_lines = commit_message.splitlines() if not message_lines: message_lines = [""] # Calculate how many message lines we can display (reserve 1 line for the main status bar) available_message_lines = state.status_bar_height - 1 try: # Always draw the commit hash and first line of message on the bottom line bottom_line = state.height - 1 # Draw the commit hash with bold stdscr.addstr(bottom_line, 0, commit_info, curses.A_REVERSE | curses.A_BOLD) # Draw the author and branch on the right of the bottom line right_x = state.width - len(author_branch_info) if right_x >= 0: stdscr.addstr(bottom_line, right_x, author_branch_info, curses.A_REVERSE) # Draw the first line of the commit message on the same line as the hash first_line = message_lines[0] if message_lines else "" if len(first_line) > available_width: first_line = first_line[:available_width-3] + "..." stdscr.addstr(bottom_line, len(commit_info), first_line, curses.A_REVERSE) # Draw additional lines of the commit message if we have space and more lines for i in range(1, min(len(message_lines), available_message_lines + 1)): if i >= len(message_lines): break line = message_lines[i] # For continuation lines, we have more space since we don't need to show the hash line_available_width = state.width - 4 # Leave some margin if len(line) > line_available_width: line = line[:line_available_width-3] + "..." # Draw the line with some indentation (going upward from the bottom line) line_y = bottom_line - i if line_y >= status_bar_start: # Make sure we don't draw above the status bar area stdscr.addstr(line_y, 4, line, curses.A_REVERSE) except curses.error: pass # Draw a handle for resizing the status bar try: handle_char = "≡" if state.dragging_status_bar else "=" handle_text = handle_char * 5 # Make handle wider if state.status_bar_height > 2: handle_text += f" ({state.status_bar_height} lines)" stdscr.addstr(status_bar_start, state.width // 2 - len(handle_text) // 2, handle_text, curses.A_REVERSE | curses.A_BOLD) 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_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_additions: prefix = "+ " elif line_type == 'deleted' and state.show_deletions: prefix = "- " elif 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 update_status_bar_height(state: AppState, my: int) -> AppState: """Update the status bar height based on mouse position.""" # Calculate the minimum and maximum allowed heights min_height = 2 # Minimum 2 lines for the status bar max_height = min(10, state.height // 2) # Maximum 10 lines or half the screen # Calculate the new height based on mouse position # When dragging up, we want to increase the status bar height new_height = state.height - my # Clamp to allowed range new_height = max(min_height, min(new_height, max_height)) return replace(state, status_bar_height=new_height) 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) # Status bar position status_bar_start = state.height - state.status_bar_height # 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) elif my == status_bar_start and abs(mx - (state.width // 2)) <= 3: # Clicked on the status bar handle (wider click area) return replace(state, dragging_status_bar=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) elif state.dragging_status_bar: return replace(state, dragging_status_bar=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(status_bar_start, 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.dragging_status_bar: return update_status_bar_height(state, my) 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('d'): # 'd' toggles both additions and deletions together new_state = replace(state, show_additions=not (state.show_additions and state.show_deletions), show_deletions=not (state.show_additions and state.show_deletions)) return load_commit_content(new_state) elif key == ord('a'): # 'a' toggles just additions new_state = replace(state, show_additions=not state.show_additions) return load_commit_content(new_state) elif key == ord('x'): # 'x' toggles just deletions new_state = replace(state, show_deletions=not state.show_deletions) return load_commit_content(new_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_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_additions or state.show_deletions: return jump_to_prev_change(state) elif key in [112, ord('p')]: # ASCII code for 'p' if 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(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, 7, -1) # Regular line numbers height, width = stdscr.getmaxyx() # If show_diff is true, enable both additions and deletions if show_diff: show_add = True show_del = True state = AppState( filename=filename, width=width, height=height, show_additions=show_add, show_deletions=show_del, enable_mouse=mouse, commits=get_commits(filename), wrap_lines=wrap_lines, show_line_numbers=show_line_numbers ) 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)