#!/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-08.1" # --- 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:%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 (name only, no email) author_cmd = ['git', 'show', '-s', '--format=%an', 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 StatusBarLayout: """Encapsulates all status bar layout calculations""" total_height: int start_y: int main_status_y: int commit_detail_start_y: int commit_detail_end_y: int available_commit_lines: int screen_width: int @classmethod def create(cls, screen_height: int, screen_width: int, status_bar_height: int): start_y = screen_height - status_bar_height return cls( total_height=status_bar_height, start_y=start_y, main_status_y=start_y, # Top line is main status commit_detail_start_y=start_y + 1, # Commit details start below commit_detail_end_y=screen_height - 1, # Bottom line available_commit_lines=max(0, status_bar_height - 1), screen_width=screen_width ) @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) # Warning message system warning_message: Optional[str] = None 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 # Commit hash click tracking commit_hash_clicked: bool = False commit_hash_area: Optional[Tuple[int, int, int, int]] = None # (start_x, end_x, start_y, end_y) # 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] # Get the commit hash using git log with the same format but including hash cmd = ['git', 'log', '--pretty=format:%h', '--date=format:%Y-%m-%d %H:%M', '--skip=' + str(state.selected_commit_idx), '-n', '1', '--', state.filename] result = subprocess.run(cmd, capture_output=True, text=True) commit_hash = result.stdout.strip() file_lines = get_file_at_commit(commit_hash, state.filename) prev_commit_hash = None if state.selected_commit_idx < len(state.commits) - 1: # Get the previous commit hash cmd = ['git', 'log', '--pretty=format:%h', '--date=format:%Y-%m-%d %H:%M', '--skip=' + str(state.selected_commit_idx + 1), '-n', '1', '--', state.filename] result = subprocess.run(cmd, capture_output=True, text=True) prev_commit_hash = result.stdout.strip() 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 date, time, message short_message = parts[2] if len(parts) >= 3 else "" # If commit_details['message'] is empty, use short_message as fallback if not commit_details['message'].strip(): commit_details['message'] = short_message # Ensure we have some commit message to display if not commit_details['message']: commit_details['message'] = short_message if short_message else "No commit 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): # Clear selection when changing commits new_state = replace(state, selected_commit_idx=new_idx, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None) 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: # Calculate visible height accounting for status bar visible_height = state.height - state.status_bar_height # Calculate maximum scroll position max_scroll = max(0, len(state.file_lines) - visible_height) # Apply scroll delta and clamp to valid range new_offset = state.right_scroll_offset + delta new_offset = max(0, min(new_offset, max_scroll)) # Clear selection when scrolling if state.is_selecting: return replace(state, right_scroll_offset=new_offset, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None) return replace(state, right_scroll_offset=new_offset) def page_right_pane(state: AppState, direction: int) -> AppState: # Calculate page size accounting for status bar page_size = state.height - state.status_bar_height 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 that's in view or below current scroll if matches: current_scroll = state.right_scroll_offset visible_height = state.height - state.status_bar_height # Find the first match that's in the current view or below it first_visible_match_idx = 0 for idx, match_line in enumerate(matches): if match_line >= current_scroll: first_visible_match_idx = idx break # If all matches are above current view, wrap around to the first match new_state = replace(new_state, current_match_idx=first_visible_match_idx) # Scroll to the match if it's not already visible match_line = matches[first_visible_match_idx] if match_line < current_scroll or match_line >= current_scroll + visible_height: new_state = replace(new_state, right_scroll_offset=max(0, match_line - 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 # Calculate the visible height (accounting for status bar) visible_height = state.height - state.status_bar_height # Calculate maximum scroll position max_scroll = max(0, len(state.file_lines) - visible_height) state.right_scroll_offset = min(state.right_scroll_offset, max_scroll) # Get visible lines based on the calculated visible height # Add +1 to ensure we can see the last line visible_lines = state.file_lines[state.right_scroll_offset:state.right_scroll_offset + visible_height + 1] 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 - state.status_bar_height: 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 for the content only # (we'll apply this when drawing the content, not for the prefix) search_match_attr = None if is_search_match: if is_current_match: search_match_attr = curses.A_REVERSE | curses.A_BOLD else: search_match_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: # Draw prefix with the original attribute (not search highlighting) 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] # Use search_match_attr for content if it's a search match content_attr = search_match_attr if search_match_attr else attr # 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() # First, draw the entire line with normal attributes stdscr.addnstr(display_row, content_start, display_content, available_width) # Then highlight each match pos = 0 while pos < len(content_lower): match_pos = content_lower.find(query, pos) if match_pos == -1: break if match_pos + len(query) <= len(display_content): # Draw just 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), content_attr) # Move to position after this match to find the next one pos = match_pos + len(query) display_row += 1 else: # Normal display without search highlighting stdscr.addnstr(display_row, content_start, display_content, available_width, content_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"), ("m", "Toggle mouse support"), ("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_bar_background(stdscr, layout: StatusBarLayout): """Fill the entire status bar area with reverse video background""" # Make sure we don't try to draw beyond the screen end_y = min(layout.start_y + layout.total_height, layout.screen_width) for y in range(layout.start_y, end_y): try: # Fill the entire line with spaces in reverse video # This is more efficient than adding characters one by one stdscr.addstr(y, 0, ' ' * layout.screen_width, curses.A_REVERSE) except curses.error: # If we can't fill the entire line at once, try character by character for x in range(layout.screen_width): try: stdscr.addch(y, x, ' ', curses.A_REVERSE) except curses.error: pass def draw_warning_message(stdscr, state: AppState, layout: StatusBarLayout): """Draw a warning message in red at the top of the status bar""" if not state.warning_message: return # Initialize color pair for warning if not already done if curses.has_colors(): curses.init_pair(8, curses.COLOR_RED, -1) # Red text on default background # Use red text with bold for warning warning_attr = curses.color_pair(8) | curses.A_BOLD # Draw the warning message and dismiss instruction all in red warning_text = f" WARNING: {state.warning_message} " stdscr.addstr(layout.main_status_y, 0, warning_text, warning_attr) # Add instruction to dismiss, also in red dismiss_text = " (Press any key to dismiss) " if len(warning_text) + len(dismiss_text) < layout.screen_width: stdscr.addstr(layout.main_status_y, len(warning_text), dismiss_text, warning_attr) def draw_main_status_line(stdscr, state: AppState, layout: StatusBarLayout): """Draw the main status line (percentages, indicators, etc.)""" # If there's a warning message, draw it instead of the normal status line if state.warning_message: draw_warning_message(stdscr, state, layout) return visible_height = layout.start_y # Add indicators to status bar wrap_indicator = "" if state.wrap_lines else "[NW] " mouse_indicator = "" if state.enable_mouse else "[NM] " 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 + mouse_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 # 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(layout.main_status_y, 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 = layout.screen_width - left_margin - right_margin # Truncate if needed if len(status_indicators) > available_width: status_indicators = status_indicators[:available_width-3] + "..." try: stdscr.addstr(layout.main_status_y, 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 = layout.screen_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(layout.main_status_y, right_x, padded_right_status, right_attr) except curses.error: pass def draw_commit_details(stdscr, state: AppState, layout: StatusBarLayout): """Draw commit hash, message, author in the available space""" if not state.commit_hash: return # Get the commit message (potentially multi-line) message_lines = state.commit_message.splitlines() if not message_lines: message_lines = [""] # Draw on the second line of the status bar (layout.commit_detail_start_y) second_line = layout.commit_detail_start_y # Draw the commit hash at the start commit_info = f" {state.commit_hash} " hash_length = len(commit_info) # Store the commit hash area for click detection state.commit_hash_area = (0, hash_length - 1, second_line, second_line) # Use different style when the hash is being clicked if state.commit_hash_clicked: # When clicked, show in normal video (not reversed) stdscr.addstr(second_line, 0, commit_info, curses.A_BOLD) else: # Normal display: reverse video stdscr.addstr(second_line, 0, commit_info, curses.A_REVERSE | curses.A_BOLD) # Calculate the author and branch info, ensuring it always fits author_branch_info = f" {state.commit_author} [{state.commit_branch}] " # If the author info is too long, truncate it but keep the branch if len(author_branch_info) > layout.screen_width // 3: # Don't let it take more than 1/3 of screen max_author_name_width = (layout.screen_width // 3) - len(f" [{state.commit_branch}] ") if max_author_name_width > 5: # Only truncate if we have reasonable space truncated_author = state.commit_author[:max_author_name_width-3] + "..." author_branch_info = f" {truncated_author} [{state.commit_branch}] " # Calculate positions right_x = layout.screen_width - len(author_branch_info) # Calculate the message area boundaries message_start_x = len(commit_info) message_end_x = right_x - 1 message_width = message_end_x - message_start_x if right_x >= len(commit_info): try: stdscr.addstr(second_line, right_x, author_branch_info, curses.A_REVERSE) except curses.error: pass # Draw the commit message in the remaining space first_line = message_lines[0] if message_lines else "" if message_width > 0: if len(first_line) > message_width: first_line = first_line[:message_width-3] + "..." try: stdscr.addstr(second_line, message_start_x, first_line, curses.A_REVERSE) except curses.error: pass # Silently fail if we can't draw the message # Draw additional lines of the commit message if we have more space for i, line in enumerate(message_lines[1:], 1): line_y = layout.commit_detail_start_y + i if line_y >= layout.start_y + layout.total_height: break # No more space if message_width > 0: if len(line) > message_width: line = line[:message_width-3] + "..." try: stdscr.addstr(line_y, message_start_x, line, curses.A_REVERSE) except curses.error: pass def draw_search_mode(stdscr, state: AppState, layout: StatusBarLayout): """Draw search input interface""" # Draw search prompt on the main status line search_prompt = "Search: " try: stdscr.addstr(layout.main_status_y, 0, search_prompt) # Draw the search query stdscr.addstr(layout.main_status_y, len(search_prompt), state.search_query) # Draw cursor at the end of the query try: stdscr.move(layout.main_status_y, 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(layout.main_status_y, layout.screen_width - len(match_info) - 1, match_info) except curses.error: pass def draw_status_bars(stdscr, state): layout = StatusBarLayout.create(state.height, state.width, state.status_bar_height) draw_status_bar_background(stdscr, layout) if state.search_mode: draw_search_mode(stdscr, state, layout) # Still show commit details in search mode if we have space if layout.total_height > 1: draw_commit_details(stdscr, state, layout) else: draw_main_status_line(stdscr, state, layout) draw_commit_details(stdscr, state, layout) 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_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_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(): # Log the text being copied for debugging with open("/tmp/gtm_clipboard.log", "w") as f: f.write(f"Copying to clipboard: {text_to_copy}\n") # Try macOS pbcopy first (most likely on this system) try: proc = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE) proc.communicate(text_to_copy.encode('utf-8')) proc.wait() return # Return early if successful except (FileNotFoundError, subprocess.SubprocessError): pass # Try Linux xclip try: proc = subprocess.Popen(['xclip', '-selection', 'clipboard'], stdin=subprocess.PIPE) proc.communicate(text_to_copy.encode('utf-8')) proc.wait() return # Return early if successful except (FileNotFoundError, subprocess.SubprocessError): pass # Try Windows clip.exe try: proc = subprocess.Popen(['clip.exe'], stdin=subprocess.PIPE) proc.communicate(text_to_copy.encode('utf-8')) proc.wait() except (FileNotFoundError, subprocess.SubprocessError): # Log failure with open("/tmp/gtm_clipboard.log", "a") as f: f.write("Failed to copy to clipboard - no clipboard command available\n") 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)) # Ensure we're not hiding too much content content_height = max(state.height - new_height, 5) # At least 5 lines for content new_height = min(new_height, state.height - content_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: # Check if click is on the commit hash if state.commit_hash_area and state.commit_hash: hash_start_x, hash_end_x, hash_start_y, hash_end_y = state.commit_hash_area if hash_start_x <= mx <= hash_end_x and hash_start_y <= my <= hash_end_y: # Clicked on commit hash return replace(state, commit_hash_clicked=True) if state.show_sidebar and abs(mx - state.divider_col) <= 1: return replace(state, dragging_divider=True) elif my == status_bar_start: # Clicked anywhere on the top line of the status bar 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.commit_hash_clicked: # Copy commit hash to clipboard if state.commit_hash: # Try macOS pbcopy first (most likely on this system) try: proc = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE) proc.communicate(state.commit_hash.encode('utf-8')) proc.wait() except (FileNotFoundError, subprocess.SubprocessError): try: # Try xclip for Linux systems proc = subprocess.Popen(['xclip', '-selection', 'clipboard'], stdin=subprocess.PIPE) proc.communicate(state.commit_hash.encode('utf-8')) proc.wait() except (FileNotFoundError, subprocess.SubprocessError): try: # Try clip.exe for Windows proc = subprocess.Popen(['clip.exe'], stdin=subprocess.PIPE) proc.communicate(state.commit_hash.encode('utf-8')) proc.wait() except (FileNotFoundError, subprocess.SubprocessError): pass # Silently fail if no clipboard command is available # Reset the clicked state return replace(state, commit_hash_clicked=False) 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): # TODO don't do this <= 2 thing # 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 there's a warning message, any key dismisses it if state.warning_message: return replace(state, warning_message=None) # Handle Escape key immediately for search results if key == 27: # Escape key # Create a new state with all escape-related changes new_state = state # Clear search results if state.search_matches: new_state = replace(new_state, search_matches=[], current_match_idx=-1) # Handle other Escape actions if state.search_mode: new_state = replace(new_state, search_mode=False) elif state.is_selecting: new_state = replace(new_state, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None) # Return the updated state with all changes return new_state # If in search mode, handle search input if state.search_mode: if 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 == 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 == ord('m'): # Toggle mouse support new_mouse_state = not state.enable_mouse if new_mouse_state: # Enable mouse curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) # Enable mouse motion events for better drag tracking if os.environ.get("TERM") != "nsterm": try: print("\033[?1003h", end="", flush=True) except Exception: pass else: # Disable mouse curses.mousemask(0) # Disable mouse motion events if os.environ.get("TERM") != "nsterm": try: print("\033[?1003l", end="", flush=True) except Exception: pass return replace(state, enable_mouse=new_mouse_state) 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 [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, 98, 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, 98, curses.KEY_SR]: return page_right_pane(state, -1) return state # --- Main Application --- def main(stdscr, filename, show_add, show_del, mouse=True, no_diff=False, wrap_lines=True, show_line_numbers=True): try: curses.curs_set(0) except: pass stdscr.keypad(True) stdscr.timeout(10) 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 no_diff is false, enable both additions and deletions if not 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=True, # Always enable mouse by default 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 # Load initial commit content so commit details are available immediately if state.commits: state = load_commit_content(state) # Set warning message if we had to use a terminal fallback if terminal_warning: state = set_warning_message(state, terminal_warning) # 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 def set_warning_message(state: AppState, message: str) -> AppState: """Set a warning message to be displayed in the status bar""" return replace(state, warning_message=message) def is_terminal_supported(term_name): """Check if a terminal type is supported by curses.""" import _curses try: # Try to set up the terminal without actually initializing the screen curses.setupterm(term=term_name, fd=-1) return True except _curses.error: return False if __name__ == "__main__": # Check if current terminal is supported, use fallback if needed original_term = os.environ.get("TERM", "") if original_term and not is_terminal_supported(original_term): # We'll show this warning in the UI instead of console terminal_warning = f"Terminal type '{original_term}' not recognized. Using 'xterm-256color'." os.environ["TERM"] = "xterm-256color" else: terminal_warning = None parser = argparse.ArgumentParser(description="A \"Git Time Machine\" for viewing file history") parser.add_argument("--no-diff", action="store_true", help="Don't highlight added and deleted lines") 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("--no-numbers", action="store_true", help="Hide 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) # Always enable mouse by default, only disable if --no-mouse is explicitly set mouse_enabled = not args.no_mouse # Set diff mode (enabled by default) show_diff = not args.no_diff # Set line numbers (enabled by default) show_line_numbers = not args.no_numbers # Set line wrapping (enabled by default) wrap_lines = not args.no_wrap curses.wrapper(main, filename, show_diff, show_diff, mouse_enabled, args.no_diff, wrap_lines, show_line_numbers)