git-time-machine/gtm2.py

690 lines
26 KiB
Python
Executable File

#!/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)