Replace 'gtm' file with updated version from 'gtm2.py'
This commit is contained in:
parent
c4699bd275
commit
fb7ae549f4
913
gtm
913
gtm
|
|
@ -6,10 +6,12 @@ import subprocess
|
|||
import sys
|
||||
import argparse
|
||||
|
||||
VERSION = "2025-06-07.2"
|
||||
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]
|
||||
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()
|
||||
|
||||
|
|
@ -59,390 +61,639 @@ def find_best_matching_line(reference_line, file_lines, max_lines=None):
|
|||
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
|
||||
add_part = parts[2][1:]
|
||||
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, enable_mouse=True):
|
||||
curses.curs_set(0)
|
||||
if enable_mouse:
|
||||
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
|
||||
# --- 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.show_sidebar = True
|
||||
|
||||
self.commits = get_commits(filename)
|
||||
self.file_lines = []
|
||||
self.added_lines = []
|
||||
self.deleted_lines = []
|
||||
|
||||
self.focus = "left"
|
||||
self.divider_col = 40
|
||||
|
||||
# Initialize key variable
|
||||
key = 0
|
||||
self.selected_commit_idx = 0
|
||||
self.left_scroll_offset = 0
|
||||
self.right_scroll_offset = 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
|
||||
self.dragging_divider = False
|
||||
self.should_exit = False
|
||||
|
||||
dragging_divider = 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
|
||||
|
||||
# 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()
|
||||
def update_dimensions(self, height, width):
|
||||
self.height = height
|
||||
self.width = width
|
||||
|
||||
# Calculate current scroll position as percentage before changing commits
|
||||
scroll_percentage = 0
|
||||
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 toggle_sidebar(self):
|
||||
self.show_sidebar = not self.show_sidebar
|
||||
# When hiding sidebar, focus should be on right pane
|
||||
if not self.show_sidebar:
|
||||
self.focus = "right"
|
||||
|
||||
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
|
||||
if len(file_lines) > 0:
|
||||
max_scroll = max(0, len(file_lines) - (height - 1))
|
||||
if max_scroll > 0:
|
||||
scroll_percentage = scroll_offset / max_scroll
|
||||
|
||||
# Store the content of the top visible line as reference
|
||||
if scroll_offset < len(file_lines):
|
||||
reference_line = file_lines[scroll_offset]
|
||||
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]
|
||||
|
||||
# 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)
|
||||
|
||||
# Try to find the same line in the new file version
|
||||
if reference_line:
|
||||
# Limit search to first 1000 lines for performance
|
||||
matching_line_idx = find_best_matching_line(reference_line, file_lines, 1000)
|
||||
if matching_line_idx is not None:
|
||||
scroll_offset = matching_line_idx
|
||||
else:
|
||||
# Fall back to percentage-based scrolling
|
||||
max_scroll = max(0, len(file_lines) - (height - 1))
|
||||
if max_scroll > 0:
|
||||
scroll_offset = int(scroll_percentage * max_scroll)
|
||||
else:
|
||||
scroll_offset = 0
|
||||
else:
|
||||
# Fall back to percentage-based scrolling
|
||||
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]
|
||||
commit_hash = self.commits[self.selected_commit_idx].split()[0]
|
||||
self.file_lines = get_file_at_commit(commit_hash, self.filename)
|
||||
|
||||
# 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)
|
||||
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]
|
||||
|
||||
# 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
|
||||
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:
|
||||
right_percent = 0
|
||||
right_status = f"{right_percent}% "
|
||||
self.added_lines, self.deleted_lines = [], []
|
||||
|
||||
# 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
|
||||
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:
|
||||
left_percent = 0
|
||||
left_status = f"{left_percent}%"
|
||||
if enable_mouse:
|
||||
left_status += " [M]"
|
||||
self.right_scroll_offset = int(scroll_percentage * max_scroll_new)
|
||||
|
||||
# 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)
|
||||
self.right_scroll_offset = max(0, min(self.right_scroll_offset, max_scroll_new))
|
||||
|
||||
# Get input with a small timeout for smoother scrolling
|
||||
stdscr.timeout(50) # 50ms timeout
|
||||
key = stdscr.getch()
|
||||
# --- Rendering Functions (View) ---
|
||||
|
||||
def draw_left_pane(stdscr, state):
|
||||
if not state.show_sidebar:
|
||||
return
|
||||
|
||||
# If no key was pressed, continue the loop
|
||||
if key == -1:
|
||||
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):
|
||||
# If sidebar is hidden, right pane starts at column 0
|
||||
right_start = 0 if not state.show_sidebar else state.divider_col + 2
|
||||
right_width = state.width - right_start - 1
|
||||
|
||||
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 - 2:
|
||||
break
|
||||
|
||||
line_type = line_info['type']
|
||||
content = line_info['content']
|
||||
|
||||
if line_type == 'added':
|
||||
stdscr.addstr(display_row, right_start, "+ ", curses.color_pair(3))
|
||||
stdscr.addnstr(display_row, right_start + 2, content, right_width - 2, curses.color_pair(3))
|
||||
elif line_type == 'deleted':
|
||||
stdscr.addstr(display_row, right_start, "- ", curses.color_pair(4))
|
||||
stdscr.addnstr(display_row, right_start + 2, 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, right_start, " ")
|
||||
stdscr.addnstr(display_row, right_start + 2, content, right_width - 2)
|
||||
else:
|
||||
# No diff mode, so don't add the margin padding
|
||||
stdscr.addnstr(display_row, right_start, content, right_width)
|
||||
|
||||
display_row += 1
|
||||
|
||||
def draw_divider(stdscr, state):
|
||||
if not state.show_sidebar:
|
||||
return
|
||||
|
||||
divider_char = "║" if state.dragging_divider else "│"
|
||||
for y in range(state.height - 1): # Don't draw through the status 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 boundaries based on sidebar visibility
|
||||
if state.show_sidebar:
|
||||
# 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
|
||||
else:
|
||||
# When sidebar is hidden, there's only the right pane
|
||||
pane = 'right'
|
||||
pane_x1, pane_x2 = 0, 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-1 for the single status bar
|
||||
visible_height = state.height - 1
|
||||
|
||||
# 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 and state.show_sidebar:
|
||||
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}%"
|
||||
|
||||
# Use terminal's default colors for the status bar
|
||||
status_attr = curses.A_NORMAL # Use terminal's default colors
|
||||
|
||||
# Fill the status bar with spaces using reverse video
|
||||
for x in range(state.width - 1):
|
||||
try:
|
||||
stdscr.addch(state.height - 1, x, ' ', curses.A_REVERSE)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Add left percentage indicator (only if sidebar is visible)
|
||||
if state.show_sidebar:
|
||||
try:
|
||||
# Use normal video if left pane is active (since status bar is already reverse)
|
||||
left_attr = status_attr if state.focus == "left" else curses.A_REVERSE
|
||||
# Add a space before and after the percentage with the same highlighting
|
||||
stdscr.addstr(state.height - 1, 0, f" {left_status} ", left_attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Add commit message in the middle
|
||||
if commit_message:
|
||||
# Calculate available space
|
||||
left_margin = len(left_status) + 5 if state.show_sidebar else 1 # +5 for the spaces around percentage
|
||||
right_margin = len(right_status) + 5 # +5 for the spaces around percentage
|
||||
available_width = state.width - left_margin - right_margin
|
||||
|
||||
# Truncate message if needed
|
||||
if len(commit_message) > available_width:
|
||||
commit_message = commit_message[:available_width-3] + "..."
|
||||
|
||||
# Center the message in the available space
|
||||
message_x = left_margin
|
||||
|
||||
try:
|
||||
stdscr.addstr(state.height - 1, message_x, commit_message, curses.A_REVERSE)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Add right percentage indicator with highlighting for active pane
|
||||
try:
|
||||
# Include spaces in the highlighted area
|
||||
padded_right_status = f" {right_status} "
|
||||
right_x = state.width - len(padded_right_status)
|
||||
if right_x >= 0:
|
||||
# Use normal video if right pane is active (since status bar is already reverse)
|
||||
right_attr = status_attr if state.focus == "right" else curses.A_REVERSE
|
||||
stdscr.addstr(state.height - 1, right_x, padded_right_status, right_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 boundaries based on sidebar visibility
|
||||
if state.show_sidebar:
|
||||
# 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
|
||||
else:
|
||||
# When sidebar is hidden, there's only the right pane
|
||||
pane = 'right'
|
||||
pane_x1, pane_x2 = 0, 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
|
||||
|
||||
# Mouse interaction
|
||||
if enable_mouse and key == curses.KEY_MOUSE:
|
||||
try:
|
||||
_, mx, my, _, bstate = curses.getmouse()
|
||||
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)
|
||||
|
||||
# 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:
|
||||
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
|
||||
|
||||
# Exit on 'q' or ESC
|
||||
elif key in [ord('q'), 27]:
|
||||
break
|
||||
def handle_mouse_input(stdscr, state):
|
||||
try:
|
||||
_, mx, my, _, bstate = curses.getmouse()
|
||||
state.last_bstate = bstate
|
||||
state.mouse_x, state.mouse_y = mx, my
|
||||
|
||||
# Toggle mouse support
|
||||
elif key == ord('m'):
|
||||
enable_mouse = not enable_mouse
|
||||
if enable_mouse:
|
||||
curses.mousemask(curses.ALL_MOUSE_EVENTS)
|
||||
# Handle mouse button press
|
||||
if bstate & curses.BUTTON1_PRESSED:
|
||||
# Check if clicking near divider (only if sidebar is visible)
|
||||
if state.show_sidebar and abs(mx - state.divider_col) <= 1:
|
||||
state.dragging_divider = True
|
||||
else:
|
||||
curses.mousemask(0)
|
||||
# Switch panes immediately on click (only if sidebar is visible)
|
||||
if state.show_sidebar:
|
||||
state.focus = "left" if mx < state.divider_col else "right"
|
||||
else:
|
||||
state.focus = "right" # When sidebar is hidden, focus is always 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 (only if sidebar is visible)
|
||||
if state.show_sidebar:
|
||||
state.focus = "left" if mx < state.divider_col else "right"
|
||||
else:
|
||||
state.focus = "right" # When sidebar is hidden, focus is always right
|
||||
|
||||
# If clicking in the left pane on a commit entry, select that commit
|
||||
if state.show_sidebar and mx < state.divider_col and my < min(state.height - 1, len(state.commits) - state.left_scroll_offset):
|
||||
new_commit_idx = my + state.left_scroll_offset
|
||||
if 0 <= new_commit_idx < len(state.commits):
|
||||
state.selected_commit_idx = new_commit_idx
|
||||
state.load_commit_content()
|
||||
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)
|
||||
|
||||
# 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))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# 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))
|
||||
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 == ord('s'):
|
||||
state.toggle_sidebar()
|
||||
elif key in [curses.KEY_LEFT, ord('h')]:
|
||||
if state.show_sidebar:
|
||||
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)
|
||||
|
||||
# Pane switching
|
||||
if key in [curses.KEY_LEFT, ord('h')]:
|
||||
focus = "left"
|
||||
elif key in [curses.KEY_RIGHT, ord('l')]:
|
||||
focus = "right"
|
||||
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)
|
||||
|
||||
stdscr.refresh()
|
||||
# --- Main Application ---
|
||||
|
||||
def show_help():
|
||||
"""Display help information"""
|
||||
parser.print_help()
|
||||
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)
|
||||
|
||||
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
|
||||
# This escape code is not supported by all terminals (e.g., nsterm)
|
||||
if os.environ.get("TERM") != "nsterm":
|
||||
try:
|
||||
print("\033[?1003h", end="", flush=True)
|
||||
except Exception:
|
||||
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:
|
||||
# This escape code is not supported by all terminals (e.g., nsterm)
|
||||
if os.environ.get("TERM") != "nsterm":
|
||||
try:
|
||||
print("\033[?1003l", end="", flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="A \"Git Time Machine\" for viewing file history")
|
||||
|
|
@ -464,10 +715,8 @@ if __name__ == "__main__":
|
|||
|
||||
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, not args.no_mouse)
|
||||
|
||||
|
|
|
|||
722
gtm2.py
722
gtm2.py
|
|
@ -1,722 +0,0 @@
|
|||
#!/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.show_sidebar = True
|
||||
|
||||
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 toggle_sidebar(self):
|
||||
self.show_sidebar = not self.show_sidebar
|
||||
# When hiding sidebar, focus should be on right pane
|
||||
if not self.show_sidebar:
|
||||
self.focus = "right"
|
||||
|
||||
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 not state.show_sidebar:
|
||||
return
|
||||
|
||||
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):
|
||||
# If sidebar is hidden, right pane starts at column 0
|
||||
right_start = 0 if not state.show_sidebar else state.divider_col + 2
|
||||
right_width = state.width - right_start - 1
|
||||
|
||||
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 - 2:
|
||||
break
|
||||
|
||||
line_type = line_info['type']
|
||||
content = line_info['content']
|
||||
|
||||
if line_type == 'added':
|
||||
stdscr.addstr(display_row, right_start, "+ ", curses.color_pair(3))
|
||||
stdscr.addnstr(display_row, right_start + 2, content, right_width - 2, curses.color_pair(3))
|
||||
elif line_type == 'deleted':
|
||||
stdscr.addstr(display_row, right_start, "- ", curses.color_pair(4))
|
||||
stdscr.addnstr(display_row, right_start + 2, 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, right_start, " ")
|
||||
stdscr.addnstr(display_row, right_start + 2, content, right_width - 2)
|
||||
else:
|
||||
# No diff mode, so don't add the margin padding
|
||||
stdscr.addnstr(display_row, right_start, content, right_width)
|
||||
|
||||
display_row += 1
|
||||
|
||||
def draw_divider(stdscr, state):
|
||||
if not state.show_sidebar:
|
||||
return
|
||||
|
||||
divider_char = "║" if state.dragging_divider else "│"
|
||||
for y in range(state.height - 1): # Don't draw through the status 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 boundaries based on sidebar visibility
|
||||
if state.show_sidebar:
|
||||
# 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
|
||||
else:
|
||||
# When sidebar is hidden, there's only the right pane
|
||||
pane = 'right'
|
||||
pane_x1, pane_x2 = 0, 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-1 for the single status bar
|
||||
visible_height = state.height - 1
|
||||
|
||||
# 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 and state.show_sidebar:
|
||||
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}%"
|
||||
|
||||
# Use terminal's default colors for the status bar
|
||||
status_attr = curses.A_NORMAL # Use terminal's default colors
|
||||
|
||||
# Fill the status bar with spaces using reverse video
|
||||
for x in range(state.width - 1):
|
||||
try:
|
||||
stdscr.addch(state.height - 1, x, ' ', curses.A_REVERSE)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Add left percentage indicator (only if sidebar is visible)
|
||||
if state.show_sidebar:
|
||||
try:
|
||||
# Use normal video if left pane is active (since status bar is already reverse)
|
||||
left_attr = status_attr if state.focus == "left" else curses.A_REVERSE
|
||||
# Add a space before and after the percentage with the same highlighting
|
||||
stdscr.addstr(state.height - 1, 0, f" {left_status} ", left_attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Add commit message in the middle
|
||||
if commit_message:
|
||||
# Calculate available space
|
||||
left_margin = len(left_status) + 5 if state.show_sidebar else 1 # +5 for the spaces around percentage
|
||||
right_margin = len(right_status) + 5 # +5 for the spaces around percentage
|
||||
available_width = state.width - left_margin - right_margin
|
||||
|
||||
# Truncate message if needed
|
||||
if len(commit_message) > available_width:
|
||||
commit_message = commit_message[:available_width-3] + "..."
|
||||
|
||||
# Center the message in the available space
|
||||
message_x = left_margin
|
||||
|
||||
try:
|
||||
stdscr.addstr(state.height - 1, message_x, commit_message, curses.A_REVERSE)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Add right percentage indicator with highlighting for active pane
|
||||
try:
|
||||
# Include spaces in the highlighted area
|
||||
padded_right_status = f" {right_status} "
|
||||
right_x = state.width - len(padded_right_status)
|
||||
if right_x >= 0:
|
||||
# Use normal video if right pane is active (since status bar is already reverse)
|
||||
right_attr = status_attr if state.focus == "right" else curses.A_REVERSE
|
||||
stdscr.addstr(state.height - 1, right_x, padded_right_status, right_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 boundaries based on sidebar visibility
|
||||
if state.show_sidebar:
|
||||
# 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
|
||||
else:
|
||||
# When sidebar is hidden, there's only the right pane
|
||||
pane = 'right'
|
||||
pane_x1, pane_x2 = 0, 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 (only if sidebar is visible)
|
||||
if state.show_sidebar and abs(mx - state.divider_col) <= 1:
|
||||
state.dragging_divider = True
|
||||
else:
|
||||
# Switch panes immediately on click (only if sidebar is visible)
|
||||
if state.show_sidebar:
|
||||
state.focus = "left" if mx < state.divider_col else "right"
|
||||
else:
|
||||
state.focus = "right" # When sidebar is hidden, focus is always 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 (only if sidebar is visible)
|
||||
if state.show_sidebar:
|
||||
state.focus = "left" if mx < state.divider_col else "right"
|
||||
else:
|
||||
state.focus = "right" # When sidebar is hidden, focus is always right
|
||||
|
||||
# If clicking in the left pane on a commit entry, select that commit
|
||||
if state.show_sidebar and mx < state.divider_col and my < min(state.height - 1, len(state.commits) - state.left_scroll_offset):
|
||||
new_commit_idx = my + state.left_scroll_offset
|
||||
if 0 <= new_commit_idx < len(state.commits):
|
||||
state.selected_commit_idx = new_commit_idx
|
||||
state.load_commit_content()
|
||||
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 == ord('s'):
|
||||
state.toggle_sidebar()
|
||||
elif key in [curses.KEY_LEFT, ord('h')]:
|
||||
if state.show_sidebar:
|
||||
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)
|
||||
|
||||
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
|
||||
# This escape code is not supported by all terminals (e.g., nsterm)
|
||||
if os.environ.get("TERM") != "nsterm":
|
||||
try:
|
||||
print("\033[?1003h", end="", flush=True)
|
||||
except Exception:
|
||||
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:
|
||||
# This escape code is not supported by all terminals (e.g., nsterm)
|
||||
if os.environ.get("TERM") != "nsterm":
|
||||
try:
|
||||
print("\033[?1003l", end="", flush=True)
|
||||
except Exception:
|
||||
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)
|
||||
Loading…
Reference in New Issue