405 lines
16 KiB
Python
Executable File
405 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import curses
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import argparse
|
|
|
|
VERSION = "2025-06-07.1"
|
|
|
|
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 get_diff_info(current_commit, prev_commit, filename):
|
|
"""Get diff information between two commits for a file"""
|
|
if not prev_commit:
|
|
# For the first commit, we can't compare with a previous one
|
|
return [], []
|
|
|
|
# Use a more detailed diff format to better understand the changes
|
|
cmd = ['git', 'diff', prev_commit, current_commit, '--', filename]
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
# Parse the diff output to extract added and deleted lines
|
|
lines = result.stdout.splitlines()
|
|
|
|
# Store line changes with their positions
|
|
added_lines = []
|
|
deleted_lines = []
|
|
|
|
# Track the current position in the new file
|
|
current_line_num = 0
|
|
deletion_offset = 0
|
|
|
|
# Parse the diff output
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
|
|
# Parse hunk headers to get line numbers
|
|
if line.startswith('@@'):
|
|
# Format is like @@ -a,b +c,d @@
|
|
parts = line.split()
|
|
if len(parts) >= 3:
|
|
# Extract the starting line number in the new file
|
|
add_part = parts[2][1:] # Remove the + sign
|
|
if ',' in add_part:
|
|
current_line_num, _ = map(int, add_part.split(','))
|
|
else:
|
|
current_line_num = int(add_part)
|
|
|
|
# Reset deletion offset for this hunk
|
|
deletion_offset = 0
|
|
|
|
# Process added lines
|
|
elif line.startswith('+') and not line.startswith('+++'):
|
|
added_lines.append((current_line_num, line[1:]))
|
|
current_line_num += 1
|
|
|
|
# Process deleted lines
|
|
elif line.startswith('-') and not line.startswith('---'):
|
|
# Store deleted lines with the position where they would have been
|
|
# in the new file (before the next line)
|
|
deleted_lines.append((current_line_num, line[1:]))
|
|
deletion_offset += 1
|
|
|
|
# Process context lines
|
|
elif not line.startswith('+') and not line.startswith('-') and not line.startswith('@@'):
|
|
current_line_num += 1
|
|
|
|
i += 1
|
|
|
|
return added_lines, deleted_lines
|
|
|
|
def main(stdscr, filename, show_whole_diff=False, show_additions=False, show_deletions=False):
|
|
curses.curs_set(0)
|
|
curses.mousemask(curses.ALL_MOUSE_EVENTS)
|
|
curses.mouseinterval(0)
|
|
stdscr.keypad(True)
|
|
|
|
# Initialize colors if terminal supports them
|
|
if curses.has_colors():
|
|
curses.use_default_colors() # Use terminal's default colors
|
|
curses.init_pair(1, 5, 7) # Focused status bar
|
|
curses.init_pair(2, curses.COLOR_WHITE, 8) # Unfocused status bar
|
|
curses.init_pair(3, curses.COLOR_GREEN, -1) # Added lines
|
|
curses.init_pair(4, curses.COLOR_RED, -1) # Deleted lines
|
|
|
|
# Initialize key variable
|
|
key = 0
|
|
|
|
commits = get_commits(filename)
|
|
selected_commit = 0
|
|
divider_col = 40
|
|
focus = "left"
|
|
scroll_offset = 0
|
|
left_scroll_offset = 0 # Scroll position for left pane
|
|
|
|
dragging_divider = False
|
|
|
|
# Initialize file content
|
|
commit_hash = commits[selected_commit].split()[0]
|
|
file_lines = get_file_at_commit(commit_hash, filename)
|
|
|
|
# Get previous commit hash for diff
|
|
prev_commit_hash = None
|
|
if selected_commit < len(commits) - 1:
|
|
prev_commit_hash = commits[selected_commit + 1].split()[0]
|
|
|
|
# Get diff information if needed
|
|
added_lines = []
|
|
deleted_lines = []
|
|
if show_whole_diff or show_additions or show_deletions:
|
|
added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, filename)
|
|
|
|
# Enable nodelay for smoother scrolling
|
|
stdscr.nodelay(True)
|
|
|
|
while True:
|
|
stdscr.erase() # Use erase instead of clear for less flickering
|
|
height, width = stdscr.getmaxyx()
|
|
|
|
# Calculate current scroll position as percentage before changing commits
|
|
scroll_percentage = 0
|
|
if len(file_lines) > 0:
|
|
max_scroll = max(0, len(file_lines) - (height - 1))
|
|
if max_scroll > 0:
|
|
scroll_percentage = scroll_offset / max_scroll
|
|
|
|
# Only fetch file content when commit changes
|
|
if (key in [curses.KEY_DOWN, curses.KEY_UP, ord('j'), ord('k'),
|
|
curses.KEY_NPAGE, ord(' '), curses.KEY_PPAGE, 8, 127, curses.KEY_SR]) and focus == "left":
|
|
commit_hash = commits[selected_commit].split()[0]
|
|
file_lines = get_file_at_commit(commit_hash, filename)
|
|
|
|
# Update diff information when commit changes
|
|
prev_commit_hash = None
|
|
if selected_commit < len(commits) - 1:
|
|
prev_commit_hash = commits[selected_commit + 1].split()[0]
|
|
|
|
if show_whole_diff or show_additions or show_deletions:
|
|
added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, filename)
|
|
|
|
# Apply the saved scroll percentage to the new file
|
|
max_scroll = max(0, len(file_lines) - (height - 1))
|
|
if max_scroll > 0:
|
|
scroll_offset = int(scroll_percentage * max_scroll)
|
|
else:
|
|
scroll_offset = 0
|
|
|
|
# Recalculate max_scroll and ensure scroll_offset is within bounds
|
|
max_scroll = max(0, len(file_lines) - (height - 1))
|
|
scroll_offset = min(scroll_offset, max_scroll)
|
|
visible_lines = file_lines[scroll_offset:scroll_offset + height - 1]
|
|
|
|
# Calculate visible commits for left pane
|
|
left_max_scroll = max(0, len(commits) - (height - 1))
|
|
left_scroll_offset = min(left_scroll_offset, left_max_scroll)
|
|
|
|
# Ensure selected commit is visible
|
|
if selected_commit < left_scroll_offset:
|
|
left_scroll_offset = selected_commit
|
|
elif selected_commit >= left_scroll_offset + height - 1:
|
|
left_scroll_offset = selected_commit - (height - 2)
|
|
|
|
# Draw commit list (left pane)
|
|
visible_commits = commits[left_scroll_offset:left_scroll_offset + height - 1]
|
|
for i, line in enumerate(visible_commits):
|
|
display_index = i + left_scroll_offset
|
|
if display_index == selected_commit:
|
|
stdscr.attron(curses.A_REVERSE) # Highlight selected commit
|
|
stdscr.addnstr(i, 0, line, divider_col - 1)
|
|
if display_index == selected_commit:
|
|
stdscr.attroff(curses.A_REVERSE)
|
|
|
|
# Vertical divider
|
|
divider_char = "║" if dragging_divider else "│"
|
|
for y in range(height):
|
|
try:
|
|
stdscr.addch(y, divider_col, divider_char)
|
|
except curses.error:
|
|
# Avoid errors when drawing at the last column
|
|
pass
|
|
|
|
# Draw file content (right pane) - more efficiently
|
|
right_width = width - divider_col - 3
|
|
|
|
# First, collect all lines to display (regular, added, and deleted)
|
|
display_lines = []
|
|
|
|
# Create a map of line numbers to deleted lines for faster lookup
|
|
deleted_line_map = {}
|
|
if show_whole_diff or show_deletions:
|
|
for del_line_num, del_content in 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)
|
|
|
|
# Process each visible line
|
|
for i, line in enumerate(visible_lines):
|
|
line_num = i + scroll_offset + 1 # 1-based line number
|
|
|
|
# First add any deleted lines that come before this line
|
|
if (show_whole_diff or 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,
|
|
'line_num': line_num
|
|
})
|
|
|
|
# Check if this is an added line
|
|
is_added = False
|
|
if show_whole_diff or show_additions:
|
|
for added_line_num, _ in added_lines:
|
|
if added_line_num == line_num:
|
|
is_added = True
|
|
break
|
|
|
|
# Add the regular line to our display list
|
|
display_lines.append({
|
|
'type': 'added' if is_added else 'regular',
|
|
'content': line,
|
|
'line_num': line_num
|
|
})
|
|
|
|
# Now display all lines
|
|
display_row = 0
|
|
for line_info in display_lines:
|
|
# Stop if we've reached the bottom of the screen
|
|
if display_row >= height - 1:
|
|
break
|
|
|
|
line_type = line_info['type']
|
|
content = line_info['content']
|
|
|
|
if line_type == 'added':
|
|
# Green with + prefix for added lines
|
|
stdscr.addstr(display_row, divider_col + 2, "+ ", curses.color_pair(3))
|
|
stdscr.addnstr(display_row, divider_col + 4, content, right_width - 2, curses.color_pair(3))
|
|
elif line_type == 'deleted':
|
|
# Red with - prefix for deleted lines
|
|
stdscr.addstr(display_row, divider_col + 2, "- ", curses.color_pair(4))
|
|
stdscr.addnstr(display_row, divider_col + 4, content, right_width - 2, curses.color_pair(4))
|
|
else:
|
|
# Regular line - add padding to align with +/- lines
|
|
stdscr.addstr(display_row, divider_col + 2, " ") # Two spaces for alignment
|
|
stdscr.addnstr(display_row, divider_col + 4, content, right_width - 2)
|
|
|
|
display_row += 1
|
|
|
|
# Status bars for both panes
|
|
visible_height = height - 1 # Reserve 1 line for the status bar
|
|
|
|
# Right pane status
|
|
last_visible_line = scroll_offset + visible_height
|
|
if len(file_lines) > 0:
|
|
right_percent = int((last_visible_line / len(file_lines)) * 100)
|
|
right_percent = 100 if last_visible_line >= len(file_lines) else right_percent
|
|
else:
|
|
right_percent = 0
|
|
right_status = f"{right_percent}% "
|
|
|
|
# Left pane status
|
|
last_visible_commit = left_scroll_offset + visible_height
|
|
if len(commits) > 0:
|
|
left_percent = int((last_visible_commit / len(commits)) * 100)
|
|
left_percent = 100 if last_visible_commit >= len(commits) else left_percent
|
|
else:
|
|
left_percent = 0
|
|
left_status = f"{left_percent}%"
|
|
|
|
# Draw status bars - full width with different colors based on focus
|
|
left_attr = curses.color_pair(1) if focus == "left" else curses.color_pair(2)
|
|
right_attr = curses.color_pair(1) if focus == "right" else curses.color_pair(2)
|
|
|
|
# Fill the entire bottom row for each pane
|
|
for x in range(divider_col):
|
|
stdscr.addch(height - 1, x, ' ', left_attr)
|
|
for x in range(divider_col + 1, width - 1): # Avoid the last column
|
|
stdscr.addch(height - 1, x, ' ', right_attr)
|
|
|
|
# Add the percentage text
|
|
stdscr.addstr(height - 1, 1, left_status, left_attr)
|
|
stdscr.addstr(height - 1, width - len(right_status) - 1, right_status, right_attr)
|
|
|
|
# Add divider character at the bottom row
|
|
stdscr.addch(height - 1, divider_col, divider_char)
|
|
|
|
# Get input with a small timeout for smoother scrolling
|
|
stdscr.timeout(50) # 50ms timeout
|
|
key = stdscr.getch()
|
|
|
|
# If no key was pressed, continue the loop
|
|
if key == -1:
|
|
continue
|
|
|
|
# Mouse interaction
|
|
if key == curses.KEY_MOUSE:
|
|
try:
|
|
_, mx, my, _, bstate = curses.getmouse()
|
|
|
|
# Handle divider dragging
|
|
if bstate & curses.BUTTON1_PRESSED:
|
|
# Start dragging when clicked near divider
|
|
if abs(mx - divider_col) <= 1: # Allow clicking within 1 column of divider
|
|
dragging_divider = True
|
|
|
|
# If already dragging, update divider position
|
|
if dragging_divider:
|
|
# Update divider position while dragging
|
|
min_col = 10
|
|
max_col = width - 20 # leave space for right pane
|
|
divider_col = max(min_col, min(mx, max_col))
|
|
|
|
# Handle mouse release
|
|
if bstate & curses.BUTTON1_RELEASED:
|
|
if dragging_divider:
|
|
dragging_divider = False
|
|
else:
|
|
# Change focus on mouse click (when not dragging)
|
|
focus = "left" if mx < divider_col else "right"
|
|
except curses.error:
|
|
pass
|
|
|
|
# Exit on 'q' or ESC
|
|
elif key in [ord('q'), 27]:
|
|
break
|
|
|
|
# Left pane movement
|
|
elif focus == "left":
|
|
if key in [curses.KEY_DOWN, ord('j')]:
|
|
if selected_commit < len(commits) - 1:
|
|
selected_commit += 1
|
|
elif key in [curses.KEY_UP, ord('k')]:
|
|
if selected_commit > 0:
|
|
selected_commit -= 1
|
|
elif key in [curses.KEY_NPAGE, ord(' ')]:
|
|
# Page down in left pane
|
|
selected_commit = min(selected_commit + height - 1, len(commits) - 1)
|
|
elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]:
|
|
# Page up in left pane
|
|
selected_commit = max(0, selected_commit - (height - 1))
|
|
|
|
# Right pane scrolling
|
|
elif focus == "right":
|
|
if key in [curses.KEY_DOWN, ord('j')]:
|
|
if scroll_offset < max_scroll:
|
|
scroll_offset += 1
|
|
elif key in [curses.KEY_UP, ord('k')]:
|
|
if scroll_offset > 0:
|
|
scroll_offset -= 1
|
|
elif key in [curses.KEY_NPAGE, ord(' ')]:
|
|
scroll_offset = min(scroll_offset + height - 1, max_scroll)
|
|
elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: # Page Up, Backspace, Delete, or Shift+Up
|
|
scroll_offset = max(0, scroll_offset - (height - 1))
|
|
elif key == curses.KEY_BACKSPACE: # Another way to detect Shift+Space in some terminals
|
|
scroll_offset = max(0, scroll_offset - (height - 1))
|
|
|
|
# Pane switching
|
|
if key in [curses.KEY_LEFT, ord('h')]:
|
|
focus = "left"
|
|
elif key in [curses.KEY_RIGHT, ord('l')]:
|
|
focus = "right"
|
|
|
|
stdscr.refresh()
|
|
|
|
def show_help():
|
|
"""Display help information"""
|
|
parser.print_help()
|
|
|
|
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("-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
|
|
|
|
# Check if the file exists
|
|
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)
|
|
|