#!/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=short', '--', 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.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 - 1: state.left_scroll_offset = state.selected_commit_idx - (state.height - 2) visible_commits = state.commits[state.left_scroll_offset:state.left_scroll_offset + state.height - 1] for i, line in enumerate(visible_commits): display_index = i + state.left_scroll_offset if display_index == state.selected_commit_idx: stdscr.attron(curses.A_REVERSE) stdscr.addnstr(i, 0, line, state.divider_col - 1) if display_index == state.selected_commit_idx: stdscr.attroff(curses.A_REVERSE) def draw_right_pane(stdscr, state): right_width = state.width - state.divider_col - 3 max_scroll = max(0, len(state.file_lines) - (state.height - 1)) state.right_scroll_offset = min(state.right_scroll_offset, max_scroll) visible_lines = state.file_lines[state.right_scroll_offset:state.right_scroll_offset + state.height - 1] display_lines = [] deleted_line_map = {} if state.show_whole_diff or state.show_deletions: for del_line_num, del_content in state.deleted_lines: if del_line_num not in deleted_line_map: deleted_line_map[del_line_num] = [] deleted_line_map[del_line_num].append(del_content) for i, line in enumerate(visible_lines): line_num = i + state.right_scroll_offset + 1 if (state.show_whole_diff or state.show_deletions) and line_num in deleted_line_map: for del_content in deleted_line_map[line_num]: display_lines.append({'type': 'deleted', 'content': del_content}) is_added = False if state.show_whole_diff or state.show_additions: for added_line_num, _ in state.added_lines: if added_line_num == line_num: is_added = True break display_lines.append({'type': 'added' if is_added else 'regular', 'content': line}) display_row = 0 for line_info in display_lines: if display_row >= state.height - 1: 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: stdscr.addstr(display_row, state.divider_col + 2, " ") stdscr.addnstr(display_row, state.divider_col + 4, content, right_width - 2) display_row += 1 def draw_divider(stdscr, state): divider_char = "║" if state.dragging_divider else "│" for y in range(state.height): 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): visible_height = state.height - 1 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 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) for x in range(state.divider_col): stdscr.addch(state.height - 1, x, ' ', left_attr) for x in range(state.divider_col + 1, state.width - 1): stdscr.addch(state.height - 1, x, ' ', right_attr) stdscr.addstr(state.height - 1, 1, left_status, left_attr) stdscr.addstr(state.height - 1, state.width - len(right_status) - 1, right_status, right_attr) stdscr.addch(state.height - 1, state.divider_col, "│") 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 # If a drag/selection is in progress if state.is_selecting or state.dragging_divider: # Update coordinates for any event during drag if state.dragging_divider: state.update_divider(mx) elif state.is_selecting: state.selection_end_coord = (mx, my) # Redraw immediately to show selection highlight during drag draw_ui(stdscr, state) if bstate & curses.BUTTON1_RELEASED: # End of drag/selection if state.dragging_divider: state.dragging_divider = False elif state.is_selecting: # A simple click is a press and release at the same spot if state.selection_start_coord == state.selection_end_coord: state.focus = "left" if mx < state.divider_col else "right" else: # This was a drag, so copy the selection copy_selection_to_clipboard(stdscr, state) state.is_selecting = False state.selection_start_coord = None state.selection_end_coord = None # If no drag/selection is in progress, check for a new one starting elif bstate & curses.BUTTON1_PRESSED: if abs(mx - state.divider_col) <= 1: state.dragging_divider = True else: state.is_selecting = True state.selection_start_coord = (mx, my) state.selection_end_coord = (mx, my) # Redraw immediately to show selection highlight as soon as it starts draw_ui(stdscr, state) except curses.error: pass def handle_keyboard_input(key, state): if key in [ord('q'), 27]: 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): curses.curs_set(0) stdscr.keypad(True) if curses.has_colors(): curses.use_default_colors() curses.init_pair(1, 5, 7) curses.init_pair(2, curses.COLOR_WHITE, 8) curses.init_pair(3, curses.COLOR_GREEN, -1) curses.init_pair(4, curses.COLOR_RED, -1) 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 print("\033[?1003h", end="", flush=True) state.load_commit_content() # Initial draw before the main loop starts draw_ui(stdscr, state) while not state.should_exit: key = stdscr.getch() # Process input and update the application state if 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) # Disable mouse motion events when exiting if state.enable_mouse: print("\033[?1003l", end="", flush=True) 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)