169 lines
6.1 KiB
Python
169 lines
6.1 KiB
Python
# by ChatGPT
|
|
|
|
import curses
|
|
import subprocess
|
|
import sys
|
|
|
|
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 main(stdscr, filename):
|
|
curses.curs_set(0)
|
|
curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
|
|
curses.mouseinterval(0)
|
|
stdscr.keypad(True)
|
|
|
|
commits = get_commits(filename)
|
|
selected_commit = 0
|
|
divider_col = 40
|
|
focus = "left"
|
|
scroll_offset = 0
|
|
|
|
dragging_divider = False
|
|
|
|
while True:
|
|
stdscr.clear()
|
|
height, width = stdscr.getmaxyx()
|
|
|
|
# Fetch file content for selected commit
|
|
commit_hash = commits[selected_commit].split()[0]
|
|
file_lines = get_file_at_commit(commit_hash, filename)
|
|
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]
|
|
|
|
# Draw commit list (left pane)
|
|
for i in range(min(len(commits), height - 1)):
|
|
line = commits[i]
|
|
if i == selected_commit:
|
|
stdscr.attron(curses.A_REVERSE) # Highlight selected commit
|
|
stdscr.addnstr(i, 0, line, divider_col - 1)
|
|
if i == 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)
|
|
for i, line in enumerate(visible_lines):
|
|
stdscr.addnstr(i, divider_col + 2, line, width - divider_col - 3)
|
|
|
|
# Status bar for right pane
|
|
visible_height = height - 1 # Reserve 1 line for the status bar
|
|
last_visible_line = scroll_offset + visible_height
|
|
|
|
if len(file_lines) > 0:
|
|
percent = int((last_visible_line / len(file_lines)) * 100)
|
|
percent = 100 if last_visible_line >= len(file_lines) else percent
|
|
else:
|
|
percent = 0
|
|
|
|
status = f"{percent}%"
|
|
x = width - len(status) - 1
|
|
stdscr.addnstr(height - 1, x, status, len(status), curses.A_REVERSE)
|
|
|
|
key = stdscr.getch()
|
|
|
|
# Mouse interaction
|
|
if key == curses.KEY_MOUSE:
|
|
try:
|
|
_, mx, my, _, bstate = curses.getmouse()
|
|
|
|
if bstate & curses.BUTTON1_PRESSED:
|
|
if mx == divider_col or (mx >= divider_col-1 and mx <= divider_col+1):
|
|
dragging_divider = True
|
|
|
|
# Always update divider position if dragging, regardless of button state
|
|
if dragging_divider:
|
|
min_col = 10
|
|
max_col = width - 20 # leave space for right pane
|
|
divider_col = max(min_col, min(mx, max_col))
|
|
|
|
if bstate & curses.BUTTON1_RELEASED:
|
|
dragging_divider = False
|
|
elif bstate & curses.BUTTON1_CLICKED and not dragging_divider:
|
|
focus = "left" if mx < divider_col else "right"
|
|
except curses.error:
|
|
pass
|
|
|
|
# Set timeout for more responsive updates during dragging
|
|
if dragging_divider:
|
|
stdscr.timeout(1) # Very short timeout for responsive updates
|
|
else:
|
|
stdscr.timeout(-1) # Blocking mode when not dragging
|
|
|
|
# Handle divider dragging - check mouse position every iteration when dragging
|
|
if dragging_divider:
|
|
try:
|
|
# Get current mouse position
|
|
curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
|
|
_, mx, my, _, bstate = curses.getmouse()
|
|
|
|
# Update divider position based on mouse x position
|
|
min_col = 10
|
|
max_col = width - 20 # leave space for right pane
|
|
divider_col = max(min_col, min(mx, max_col))
|
|
|
|
# Check if mouse button has been released
|
|
if not (bstate & curses.BUTTON1_PRESSED):
|
|
dragging_divider = False
|
|
except curses.error:
|
|
pass
|
|
|
|
# Exit on 'q' or ESC
|
|
if 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
|
|
scroll_offset = 0
|
|
elif key in [curses.KEY_UP, ord('k')]:
|
|
if selected_commit > 0:
|
|
selected_commit -= 1
|
|
scroll_offset = 0
|
|
|
|
# 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]: # Page Up or Shift+Space (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()
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 2:
|
|
print("Usage: python git_time_machine.py path/to/file")
|
|
sys.exit(1)
|
|
filename = sys.argv[1]
|
|
curses.wrapper(main, filename)
|
|
|