#!/usr/bin/env python3 import curses import os import subprocess import sys import argparse VERSION = "2025-06-07.3" # --- Data Fetching & Utility Functions (Pure) --- def get_commits(filename): cmd = ['git', 'log', '--pretty=format:%h %ad %s', '--date=format:%Y-%m-%d %H:%M', '--', filename] result = subprocess.run(cmd, capture_output=True, text=True) return result.stdout.splitlines() def get_file_at_commit(commit_hash, filename): cmd = ['git', 'show', f'{commit_hash}:{filename}'] result = subprocess.run(cmd, capture_output=True, text=True) return result.stdout.splitlines() def find_best_matching_line(reference_line, file_lines, max_lines=None): """Find the best matching line in file_lines that matches reference_line. Returns the line index or None if no good match is found.""" if not reference_line or not file_lines: return None # First try exact match for i, line in enumerate(file_lines): if line == reference_line: return i # If no exact match, try 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 = None best_score = 0 for i, line in enumerate(search_lines): # Simple similarity score: count of common characters score = sum(1 for a, b in zip(reference_line, line) if a == b) # Adjust score based on length difference length_diff = abs(len(reference_line) - len(line)) adjusted_score = score - (length_diff * 0.5) if adjusted_score > best_score: best_score = adjusted_score best_match = i # Only return a match if it's reasonably good # (at least 60% of the shorter string length) min_length = min(len(reference_line), 1) # Avoid division by zero if best_score > (min_length * 0.6): return best_match 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 # --- Application State Class --- class AppState: def __init__(self, filename, width, height, show_diff, show_add, show_del, mouse): self.filename = filename self.width = width self.height = height self.show_whole_diff = show_diff self.show_additions = show_add self.show_deletions = show_del self.enable_mouse = mouse self.commits = get_commits(filename) self.file_lines = [] self.added_lines = [] self.deleted_lines = [] self.focus = "left" self.divider_col = 40 self.selected_commit_idx = 0 self.left_scroll_offset = 0 self.right_scroll_offset = 0 self.dragging_divider = False self.should_exit = False self.is_selecting = False self.selection_start_coord = None self.selection_end_coord = None self.click_position = None # Store click position to detect clicks vs. drags self.last_bstate = 0 self.mouse_x = -1 self.mouse_y = -1 def update_dimensions(self, height, width): self.height = height self.width = width def toggle_mouse(self): self.enable_mouse = not self.enable_mouse if self.enable_mouse: curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) else: curses.mousemask(0) def move_commit_selection(self, delta): new_idx = self.selected_commit_idx + delta if 0 <= new_idx < len(self.commits): self.selected_commit_idx = new_idx self.load_commit_content() def page_commit_selection(self, direction): page_size = self.height - 1 delta = page_size * direction self.move_commit_selection(delta) def scroll_right_pane(self, delta): max_scroll = max(0, len(self.file_lines) - (self.height - 1)) new_offset = self.right_scroll_offset + delta self.right_scroll_offset = max(0, min(new_offset, max_scroll)) def page_right_pane(self, direction): page_size = self.height - 1 delta = page_size * direction self.scroll_right_pane(delta) def update_divider(self, mx): min_col = 10 max_col = self.width - 20 self.divider_col = max(min_col, min(mx, max_col)) def load_commit_content(self): if not self.commits: return reference_line = None scroll_percentage = 0 if len(self.file_lines) > 0: max_scroll_old = max(0, len(self.file_lines) - (self.height - 1)) if max_scroll_old > 0: scroll_percentage = self.right_scroll_offset / max_scroll_old if self.right_scroll_offset < len(self.file_lines): reference_line = self.file_lines[self.right_scroll_offset] commit_hash = self.commits[self.selected_commit_idx].split()[0] self.file_lines = get_file_at_commit(commit_hash, self.filename) prev_commit_hash = None if self.selected_commit_idx < len(self.commits) - 1: prev_commit_hash = self.commits[self.selected_commit_idx + 1].split()[0] if self.show_whole_diff or self.show_additions or self.show_deletions: self.added_lines, self.deleted_lines = get_diff_info(commit_hash, prev_commit_hash, self.filename) else: self.added_lines, self.deleted_lines = [], [] max_scroll_new = max(0, len(self.file_lines) - (self.height - 1)) if reference_line: matching_line_idx = find_best_matching_line(reference_line, self.file_lines, 1000) if matching_line_idx is not None: self.right_scroll_offset = matching_line_idx else: self.right_scroll_offset = int(scroll_percentage * max_scroll_new) else: self.right_scroll_offset = int(scroll_percentage * max_scroll_new) self.right_scroll_offset = max(0, min(self.right_scroll_offset, max_scroll_new)) # --- Rendering Functions (View) --- def draw_left_pane(stdscr, state): 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 - 2: state.left_scroll_offset = state.selected_commit_idx - (state.height - 3) visible_commits = state.commits[state.left_scroll_offset:state.left_scroll_offset + state.height - 2] 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 draw_right_pane(stdscr, state): right_width = state.width - state.divider_col - 3 max_scroll = max(0, len(state.file_lines) - (state.height - 2)) 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 - 2] display_lines = [] deleted_line_map = {} if state.show_whole_diff or state.show_deletions: for del_line_num, del_content in state.deleted_lines: if del_line_num not in deleted_line_map: deleted_line_map[del_line_num] = [] deleted_line_map[del_line_num].append(del_content) for i, line in enumerate(visible_lines): line_num = i + state.right_scroll_offset + 1 if (state.show_whole_diff or state.show_deletions) and line_num in deleted_line_map: for del_content in deleted_line_map[line_num]: display_lines.append({'type': 'deleted', 'content': del_content}) is_added = False if state.show_whole_diff or state.show_additions: for added_line_num, _ in state.added_lines: if added_line_num == line_num: is_added = True break display_lines.append({'type': 'added' if is_added else 'regular', 'content': line}) display_row = 0 for line_info in display_lines: if display_row >= state.height - 2: break line_type = line_info['type'] content = line_info['content'] if line_type == 'added': stdscr.addstr(display_row, state.divider_col + 2, "+ ", curses.color_pair(3)) stdscr.addnstr(display_row, state.divider_col + 4, content, right_width - 2, curses.color_pair(3)) elif line_type == 'deleted': stdscr.addstr(display_row, state.divider_col + 2, "- ", curses.color_pair(4)) stdscr.addnstr(display_row, state.divider_col + 4, content, right_width - 2, curses.color_pair(4)) else: if state.show_whole_diff or state.show_additions or state.show_deletions: stdscr.addstr(display_row, state.divider_col + 2, " ") stdscr.addnstr(display_row, state.divider_col + 4, content, right_width - 2) else: # No diff mode, so don't add the margin padding stdscr.addnstr(display_row, state.divider_col + 2, content, right_width) display_row += 1 def draw_divider(stdscr, state): divider_char = "║" if state.dragging_divider else "│" for y in range(state.height - 1): # Don't draw through the commit message bar try: stdscr.addch(y, state.divider_col, divider_char) except curses.error: pass def draw_selection(stdscr, state): if not state.is_selecting or not state.selection_start_coord: return start_x, start_y = state.selection_start_coord end_x, end_y = state.selection_end_coord # Determine pane 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 # Determine drag direction to handle multi-line selection correctly if start_y < end_y or (start_y == end_y and start_x <= end_x): drag_start_x, drag_start_y = start_x, start_y drag_end_x, drag_end_y = end_x, end_y else: # upward drag or right-to-left on same line drag_start_x, drag_start_y = end_x, end_y drag_end_x, drag_end_y = start_x, start_y for y in range(drag_start_y, drag_end_y + 1): x1, x2 = -1, -1 if drag_start_y == drag_end_y: # single line selection x1, x2 = drag_start_x, drag_end_x elif y == drag_start_y: # first line of multi-line selection x1, x2 = drag_start_x, pane_x2 elif y == drag_end_y: # last line of multi-line selection x1, x2 = pane_x1, drag_end_x else: # middle line of multi-line selection x1, x2 = pane_x1, pane_x2 # Clamp selection to pane boundaries x1 = max(x1, pane_x1) x2 = min(x2, pane_x2) try: if x1 < state.width and x1 <= x2: length = x2 - x1 + 1 if length > 0: stdscr.chgat(y, x1, length, curses.A_REVERSE) except curses.error: pass def draw_status_bars(stdscr, state): # We'll use height-2 for the original status bars and height-1 for the commit message bar visible_height = state.height - 2 # Get commit message for the selected commit commit_message = "" if state.commits and state.selected_commit_idx < len(state.commits): # Format is now: hash date time message # We need to split on more than just the first two spaces commit_line = state.commits[state.selected_commit_idx] parts = commit_line.split(' ', 3) # Split into hash, date, time, message if len(parts) >= 4: commit_message = parts[3] # Status bar percentages if len(state.file_lines) > 0: last_visible_line = state.right_scroll_offset + visible_height right_percent = int((last_visible_line / len(state.file_lines)) * 100) right_percent = 100 if last_visible_line >= len(state.file_lines) else right_percent else: right_percent = 0 right_status = f"{right_percent}% " if len(state.commits) > 0: last_visible_commit = state.left_scroll_offset + visible_height left_percent = int((last_visible_commit / len(state.commits)) * 100) left_percent = 100 if last_visible_commit >= len(state.commits) else left_percent else: left_percent = 0 left_status = f"{left_percent}%" if state.enable_mouse: mouse_status = f" [M] {state.mouse_x},{state.mouse_y} b:{state.last_bstate}" if state.dragging_divider: mouse_status += " DIV" elif state.is_selecting: mouse_status += " SEL" left_status += mouse_status # Draw original status bars for left and right panes # Both active and inactive panes now use their respective color pairs left_attr = curses.color_pair(1) if state.focus == "left" else curses.color_pair(2) right_attr = curses.color_pair(1) if state.focus == "right" else curses.color_pair(2) # Fill the original status bar with spaces for x in range(state.divider_col): try: stdscr.addch(state.height - 2, x, ' ', left_attr) except curses.error: pass for x in range(state.divider_col + 1, state.width - 1): try: stdscr.addch(state.height - 2, x, ' ', right_attr) except curses.error: pass # Add percentage indicators on left and right sides of original status bar try: stdscr.addstr(state.height - 2, 1, left_status, left_attr) except curses.error: pass try: right_x = state.width - len(right_status) - 1 if right_x >= 0: stdscr.addstr(state.height - 2, right_x, right_status, right_attr) except curses.error: pass # Draw divider in status bar try: stdscr.addch(state.height - 2, state.divider_col, "│") except curses.error: pass # Draw new full-width status bar with commit message below the original status bars status_attr = curses.color_pair(5) # Color pair for commit message bar # Fill the commit message bar with spaces for x in range(state.width - 1): try: stdscr.addch(state.height - 1, x, ' ', status_attr) except curses.error: pass # Add commit message in the commit message bar if commit_message: max_msg_width = state.width - 4 # Leave a small margin if len(commit_message) > max_msg_width: commit_message = commit_message[:max_msg_width-3] + "..." try: stdscr.addstr(state.height - 1, 1, commit_message, status_attr) except curses.error: pass def draw_ui(stdscr, state): stdscr.erase() draw_left_pane(stdscr, state) draw_right_pane(stdscr, state) draw_divider(stdscr, state) draw_status_bars(stdscr, state) draw_selection(stdscr, state) 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 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 # Determine drag direction to handle multi-line selection correctly if start_y < end_y or (start_y == end_y and start_x <= end_x): drag_start_x, drag_start_y = start_x, start_y drag_end_x, drag_end_y = end_x, end_y else: # upward drag or right-to-left on same line drag_start_x, drag_start_y = end_x, end_y drag_end_x, drag_end_y = start_x, start_y height, width = stdscr.getmaxyx() selected_text_parts = [] for y in range(drag_start_y, drag_end_y + 1): if not (0 <= y < height): continue x1, x2 = -1, -1 if drag_start_y == drag_end_y: # single line selection x1, x2 = drag_start_x, drag_end_x elif y == drag_start_y: # first line of multi-line selection x1, x2 = drag_start_x, pane_x2 elif y == drag_end_y: # last line of multi-line selection x1, x2 = pane_x1, drag_end_x else: # middle line of multi-line selection x1, x2 = pane_x1, pane_x2 # Clamp selection to pane boundaries and screen width x1 = max(x1, pane_x1) x2 = min(x2, pane_x2, width - 1) line_str = "" if x1 <= x2: for x in range(x1, x2 + 1): try: char_and_attr = stdscr.inch(y, x) char = char_and_attr & 0xFF line_str += chr(char) except curses.error: line_str += " " selected_text_parts.append(line_str) if selected_text_parts: text_to_copy = "\n".join(selected_text_parts) if text_to_copy.strip(): try: subprocess.run(['pbcopy'], input=text_to_copy, text=True, check=True) except (FileNotFoundError, subprocess.CalledProcessError): pass def handle_mouse_input(stdscr, state): try: _, mx, my, _, bstate = curses.getmouse() state.last_bstate = bstate state.mouse_x, state.mouse_y = mx, my # Handle mouse button press if bstate & curses.BUTTON1_PRESSED: # Check if clicking near divider if abs(mx - state.divider_col) <= 1: state.dragging_divider = True else: # Switch panes immediately on click state.focus = "left" if mx < state.divider_col else "right" # Also start a potential selection (in case this becomes a drag) state.is_selecting = True state.selection_start_coord = (mx, my) state.selection_end_coord = (mx, my) state.click_position = (mx, my) # Redraw immediately to show selection highlight and focus change draw_ui(stdscr, state) return # Handle mouse button release if bstate & curses.BUTTON1_RELEASED: # End of divider drag if state.dragging_divider: state.dragging_divider = False return # End of selection 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 state.focus = "left" if mx < state.divider_col else "right" 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 state.is_selecting = False state.selection_start_coord = None state.selection_end_coord = None state.click_position = None return # Handle mouse movement during drag operations if state.dragging_divider: # Update divider position during drag state.update_divider(mx) elif state.is_selecting: # Update selection end coordinates during drag state.selection_end_coord = (mx, my) # Redraw immediately to show selection highlight during drag draw_ui(stdscr, state) except curses.error: pass def handle_keyboard_input(key, state): if key in [ord('q')]: state.should_exit = True elif key == 27: # Escape key if state.is_selecting: state.is_selecting = False state.selection_start_coord = None state.selection_end_coord = None else: state.should_exit = True elif key == ord('m'): state.toggle_mouse() elif key in [curses.KEY_LEFT, ord('h')]: state.focus = "left" elif key in [curses.KEY_RIGHT, ord('l')]: state.focus = "right" elif state.focus == "left": if key in [curses.KEY_DOWN, ord('j')]: state.move_commit_selection(1) elif key in [curses.KEY_UP, ord('k')]: state.move_commit_selection(-1) elif key in [curses.KEY_NPAGE, ord(' ')]: state.page_commit_selection(1) elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: state.page_commit_selection(-1) elif state.focus == "right": if key in [curses.KEY_DOWN, ord('j')]: state.scroll_right_pane(1) elif key in [curses.KEY_UP, ord('k')]: state.scroll_right_pane(-1) elif key in [curses.KEY_NPAGE, ord(' ')]: state.page_right_pane(1) elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: state.page_right_pane(-1) # --- Main Application --- def main(stdscr, filename, show_diff, show_add, show_del, mouse): try: curses.curs_set(0) except: pass stdscr.keypad(True) stdscr.timeout(50) # Less aggressive timeout (50ms instead of 10ms) if curses.has_colors(): curses.use_default_colors() # Use a safe background color (7 or less) to avoid "Color number is greater than COLORS-1" error curses.init_pair(1, curses.COLOR_BLACK, 7) # Active pane: black on white curses.init_pair(2, curses.COLOR_WHITE, 0) # Inactive pane: white on black curses.init_pair(3, curses.COLOR_GREEN, -1) curses.init_pair(4, curses.COLOR_RED, -1) curses.init_pair(5, curses.COLOR_BLACK, 7) # Status bar color (white background) height, width = stdscr.getmaxyx() state = AppState(filename, width, height, show_diff, show_add, show_del, mouse) if state.enable_mouse: curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) # Enable mouse motion events for better drag tracking try: print("\033[?1003h", end="", flush=True) except: pass state.load_commit_content() # 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(h, w) elif state.enable_mouse and key == curses.KEY_MOUSE: handle_mouse_input(stdscr, state) else: 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: try: print("\033[?1003l", end="", flush=True) except: 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("-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) curses.wrapper(main, filename, args.diff, args.diff_additions, args.diff_deletions, not args.no_mouse)