git-time-machine/gtm

1161 lines
48 KiB
Python
Executable File

#!/usr/bin/env python3
import curses
import difflib
import os
import subprocess
import sys
import argparse
from dataclasses import dataclass, field, replace
from typing import List, Optional, Tuple
VERSION = "2025-06-07.3"
# --- Data Fetching & Utility Functions (Pure) ---
def is_file_tracked_by_git(filename):
"""Check if a file is tracked by git."""
cmd = ['git', 'ls-files', '--error-unmatch', filename]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
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, use difflib 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_idx = None
best_ratio = 0.0
# Create a SequenceMatcher for the reference line, but reuse it for efficiency
s = difflib.SequenceMatcher(None, reference_line)
for i, line in enumerate(search_lines):
s.set_seq2(line)
ratio = s.ratio()
if ratio > best_ratio:
best_ratio = ratio
best_match_idx = i
# Only return a match if it's reasonably good (e.g., ratio > 0.6)
if best_ratio > 0.6:
return best_match_idx
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
@dataclass
class AppState:
filename: str
width: int
height: int
show_whole_diff: bool
show_additions: bool
show_deletions: bool
enable_mouse: bool
show_sidebar: bool = True
commits: List[str] = field(default_factory=list)
file_lines: List[str] = field(default_factory=list)
added_lines: List[Tuple[int, str]] = field(default_factory=list)
deleted_lines: List[Tuple[int, str]] = field(default_factory=list)
focus: str = "left"
divider_col: int = 40
selected_commit_idx: int = 0
left_scroll_offset: int = 0
right_scroll_offset: int = 0
dragging_divider: bool = False
should_exit: bool = False
is_selecting: bool = False
selection_start_coord: Optional[Tuple[int, int]] = None
selection_end_coord: Optional[Tuple[int, int]] = None
click_position: Optional[Tuple[int, int]] = None
last_bstate: int = 0
mouse_x: int = -1
mouse_y: int = -1
# Line wrapping settings
wrap_lines: bool = True
# Line numbers settings
show_line_numbers: bool = False
# Change navigation
change_blocks: List[Tuple[int, int]] = field(default_factory=list) # (start_line, end_line) of each change block
current_change_idx: int = -1 # -1 means no change is selected
# Search functionality
search_mode: bool = False
search_query: str = ""
search_matches: List[int] = field(default_factory=list) # Line numbers of matches
current_match_idx: int = -1 # Index in search_matches
# --- Actions (Controller) ---
def calculate_change_blocks(state: AppState) -> AppState:
"""Calculate positions of change blocks (consecutive changes) in the file."""
if not state.added_lines and not state.deleted_lines:
return replace(state, change_blocks=[], current_change_idx=-1)
# Collect all change positions
all_changes = []
for line_num, _ in state.added_lines:
all_changes.append(line_num)
for line_num, _ in state.deleted_lines:
all_changes.append(line_num)
# Sort all positions
all_changes.sort()
if not all_changes:
return replace(state, change_blocks=[], current_change_idx=-1)
# Group into blocks (changes within 3 lines of each other are considered one block)
blocks = []
current_block_start = all_changes[0]
current_block_end = all_changes[0]
for pos in all_changes[1:]:
if pos <= current_block_end + 3: # Within 3 lines of previous change
current_block_end = pos
else:
# Start a new block
blocks.append((current_block_start, current_block_end))
current_block_start = pos
current_block_end = pos
# Add the last block
blocks.append((current_block_start, current_block_end))
# Add debug output to a log file
with open("/tmp/gtm_debug.log", "a") as f:
f.write(f"Found {len(blocks)} change blocks\n")
for i, (start, end) in enumerate(blocks):
f.write(f" Block {i+1}: lines {start}-{end}\n")
f.write(f"Added lines: {state.added_lines}\n")
f.write(f"Deleted lines: {state.deleted_lines}\n")
return replace(state, change_blocks=blocks, current_change_idx=-1)
def load_commit_content(state: AppState) -> AppState:
if not state.commits:
return state
reference_line = None
scroll_percentage = 0
if len(state.file_lines) > 0:
max_scroll_old = max(0, len(state.file_lines) - (state.height - 1))
if max_scroll_old > 0:
scroll_percentage = state.right_scroll_offset / max_scroll_old
if state.right_scroll_offset < len(state.file_lines):
reference_line = state.file_lines[state.right_scroll_offset]
commit_hash = state.commits[state.selected_commit_idx].split()[0]
file_lines = get_file_at_commit(commit_hash, state.filename)
prev_commit_hash = None
if state.selected_commit_idx < len(state.commits) - 1:
prev_commit_hash = state.commits[state.selected_commit_idx + 1].split()[0]
if state.show_whole_diff or state.show_additions or state.show_deletions:
added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, state.filename)
else:
added_lines, deleted_lines = [], []
max_scroll_new = max(0, len(file_lines) - (state.height - 1))
right_scroll_offset = state.right_scroll_offset
if reference_line:
matching_line_idx = find_best_matching_line(reference_line, file_lines, 1000)
if matching_line_idx is not None:
right_scroll_offset = matching_line_idx
else:
right_scroll_offset = int(scroll_percentage * max_scroll_new)
else:
right_scroll_offset = int(scroll_percentage * max_scroll_new)
right_scroll_offset = max(0, min(right_scroll_offset, max_scroll_new))
new_state = replace(state,
file_lines=file_lines,
added_lines=added_lines,
deleted_lines=deleted_lines,
right_scroll_offset=right_scroll_offset
)
# Calculate change blocks for navigation
return calculate_change_blocks(new_state)
def update_dimensions(state: AppState, height: int, width: int) -> AppState:
return replace(state, height=height, width=width)
def toggle_sidebar(state: AppState) -> AppState:
new_show_sidebar = not state.show_sidebar
new_focus = state.focus
if not new_show_sidebar:
new_focus = "right"
return replace(state, show_sidebar=new_show_sidebar, focus=new_focus)
def move_commit_selection(state: AppState, delta: int) -> AppState:
new_idx = state.selected_commit_idx + delta
if 0 <= new_idx < len(state.commits):
new_state = replace(state, selected_commit_idx=new_idx)
return load_commit_content(new_state)
return state
def page_commit_selection(state: AppState, direction: int) -> AppState:
page_size = state.height - 1
delta = page_size * direction
return move_commit_selection(state, delta)
def scroll_right_pane(state: AppState, delta: int) -> AppState:
max_scroll = max(0, len(state.file_lines) - (state.height - 1))
new_offset = state.right_scroll_offset + delta
new_offset = max(0, min(new_offset, max_scroll))
return replace(state, right_scroll_offset=new_offset)
def page_right_pane(state: AppState, direction: int) -> AppState:
page_size = state.height - 1
delta = page_size * direction
return scroll_right_pane(state, delta)
def update_divider(state: AppState, mx: int) -> AppState:
min_col = 10
max_col = state.width - 20
new_divider_col = max(min_col, min(mx, max_col))
return replace(state, divider_col=new_divider_col)
# --- 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 jump_to_next_change(state: AppState) -> AppState:
"""Jump to the next change block after the current scroll position."""
if not state.change_blocks:
return state
# Find the next change block after current position or current change index
current_pos = state.right_scroll_offset
current_idx = state.current_change_idx
# If we're already on a change block, try to move to the next one
if current_idx != -1:
next_idx = (current_idx + 1) % len(state.change_blocks)
else:
# Otherwise find the next change block after current scroll position
next_idx = -1
for i, (start_pos, _) in enumerate(state.change_blocks):
if start_pos > current_pos:
next_idx = i
break
# If no next change found, wrap to the first change
if next_idx == -1 and state.change_blocks:
next_idx = 0
if next_idx != -1:
# Get the start position of the change block
new_scroll = max(0, state.change_blocks[next_idx][0] - 3) # Show 3 lines of context above
return replace(state, right_scroll_offset=new_scroll, current_change_idx=next_idx)
return state
def jump_to_prev_change(state: AppState) -> AppState:
"""Jump to the previous change block before the current scroll position."""
if not state.change_blocks:
return state
# Find the previous change block before current position or current change index
current_pos = state.right_scroll_offset
current_idx = state.current_change_idx
# If we're already on a change block, try to move to the previous one
if current_idx != -1:
prev_idx = (current_idx - 1) % len(state.change_blocks)
else:
# Otherwise find the previous change block before current scroll position
prev_idx = -1
for i in range(len(state.change_blocks) - 1, -1, -1):
if state.change_blocks[i][0] < current_pos:
prev_idx = i
break
# If no previous change found, wrap to the last change
if prev_idx == -1 and state.change_blocks:
prev_idx = len(state.change_blocks) - 1
if prev_idx != -1:
# Get the start position of the change block
new_scroll = max(0, state.change_blocks[prev_idx][0] - 3) # Show 3 lines of context above
return replace(state, right_scroll_offset=new_scroll, current_change_idx=prev_idx)
return state
def perform_search(state: AppState) -> AppState:
"""Search for the query in the file content and update matches."""
if not state.search_query:
return replace(state, search_matches=[], current_match_idx=-1)
matches = []
query = state.search_query.lower() # Case-insensitive search
for i, line in enumerate(state.file_lines):
if query in line.lower():
matches.append(i)
new_state = replace(state, search_matches=matches)
# If we have matches, select the first one
if matches:
new_state = replace(new_state, current_match_idx=0)
# Scroll to the first match
new_state = replace(new_state, right_scroll_offset=max(0, matches[0] - 3))
else:
new_state = replace(new_state, current_match_idx=-1)
return new_state
def jump_to_next_match(state: AppState) -> AppState:
"""Jump to the next search match."""
if not state.search_matches:
return state
next_idx = (state.current_match_idx + 1) % len(state.search_matches)
new_state = replace(state, current_match_idx=next_idx)
# Scroll to the match
match_line = state.search_matches[next_idx]
return replace(new_state, right_scroll_offset=max(0, match_line - 3))
def jump_to_prev_match(state: AppState) -> AppState:
"""Jump to the previous search match."""
if not state.search_matches:
return state
prev_idx = (state.current_match_idx - 1) % len(state.search_matches)
new_state = replace(state, current_match_idx=prev_idx)
# Scroll to the match
match_line = state.search_matches[prev_idx]
return replace(new_state, right_scroll_offset=max(0, match_line - 3))
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
# Calculate line number width if enabled
line_num_width = 0
if state.show_line_numbers:
# Width based on the number of digits in the largest line number
max_line_num = len(state.file_lines)
line_num_width = len(str(max_line_num)) + 1 # +1 for spacing
# Adjust right pane start position and width for line numbers
if state.show_line_numbers:
right_start += line_num_width
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]:
# For deleted lines, use the same line number
display_lines.append({'type': 'deleted', 'content': del_content, 'line_num': line_num})
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, 'line_num': line_num})
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']
line_num = line_info.get('line_num', 0)
# Check if this is a search match
is_search_match = False
is_current_match = False
if state.search_matches and line_num - 1 in state.search_matches: # Adjust for 0-based indexing
match_idx = state.search_matches.index(line_num - 1)
is_search_match = True
is_current_match = (match_idx == state.current_match_idx)
# Draw line number if enabled
if state.show_line_numbers:
line_num_str = str(line_num).rjust(line_num_width - 1) + " "
line_num_pos = (0 if not state.show_sidebar else state.divider_col + 2)
# Use different color for line numbers
if line_type == 'added':
stdscr.addstr(display_row, line_num_pos, line_num_str, curses.color_pair(5))
elif line_type == 'deleted':
stdscr.addstr(display_row, line_num_pos, line_num_str, curses.color_pair(6))
else:
stdscr.addstr(display_row, line_num_pos, line_num_str, curses.color_pair(7))
# Determine prefix based on line type and diff mode
prefix = ""
if line_type == 'added':
prefix = "+ "
attr = curses.color_pair(3)
elif line_type == 'deleted':
prefix = "- "
attr = curses.color_pair(4)
else:
if state.show_whole_diff or state.show_additions or state.show_deletions:
prefix = " "
attr = curses.A_NORMAL
# If this is a search match, override the attribute
if is_search_match:
if is_current_match:
attr = curses.A_REVERSE | curses.A_BOLD
else:
attr = curses.A_UNDERLINE
# Calculate available width for content
content_start = right_start + len(prefix)
available_width = right_width - len(prefix)
# Handle line wrapping
if len(content) <= available_width or not state.wrap_lines:
# Line fits or wrapping is disabled
if prefix:
stdscr.addstr(display_row, right_start, prefix, attr)
# If wrapping is disabled, just show what fits in the available width
display_content = content
if not state.wrap_lines and len(content) > available_width:
display_content = content[:available_width]
# If this is a search match, highlight just the matching part
if is_search_match and state.search_query:
# Find all occurrences of the search query in the content (case-insensitive)
query = state.search_query.lower()
content_lower = display_content.lower()
pos = 0
while pos < len(content_lower):
match_pos = content_lower.find(query, pos)
if match_pos == -1:
break
# Draw the matching part with highlighting
match_attr = attr
if match_pos + len(query) <= len(display_content):
# Draw text before match
if match_pos > 0:
stdscr.addnstr(display_row, content_start, display_content[:match_pos], match_pos)
# Draw the match with highlighting
match_text = display_content[match_pos:match_pos + len(query)]
stdscr.addnstr(display_row, content_start + match_pos, match_text, len(query), match_attr)
# Draw text after match
if match_pos + len(query) < len(display_content):
remaining_text = display_content[match_pos + len(query):]
stdscr.addnstr(display_row, content_start + match_pos + len(query),
remaining_text, len(remaining_text))
pos = match_pos + 1
display_row += 1
else:
# Normal display without search highlighting
stdscr.addnstr(display_row, content_start, display_content, available_width, attr)
display_row += 1
else:
# Line needs wrapping and wrapping is enabled
remaining = content
first_line = True
while remaining and display_row < state.height - 2:
# For first line, add the prefix
if first_line:
if prefix:
stdscr.addstr(display_row, right_start, prefix, attr)
chunk = remaining[:available_width]
stdscr.addnstr(display_row, content_start, chunk, available_width, attr)
remaining = remaining[available_width:]
first_line = False
else:
# For continuation lines, indent by 2 spaces
indent = " "
indent_start = right_start
content_start = indent_start + len(indent)
wrap_width = right_width - len(indent)
stdscr.addstr(display_row, indent_start, indent)
chunk = remaining[:wrap_width]
stdscr.addnstr(display_row, content_start, chunk, wrap_width, attr)
remaining = remaining[wrap_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
# If in search mode, draw the search input field instead of the normal status bar
if state.search_mode:
# Fill the status bar with spaces
for x in range(state.width - 1):
try:
stdscr.addch(state.height - 1, x, ' ')
except curses.error:
pass
# Draw search prompt
search_prompt = "Search: "
try:
stdscr.addstr(state.height - 1, 0, search_prompt)
# Draw the search query
stdscr.addstr(state.height - 1, len(search_prompt), state.search_query)
# Draw cursor at the end of the query
try:
stdscr.move(state.height - 1, len(search_prompt) + len(state.search_query))
except curses.error:
pass
# If we have search results, show count
if state.search_matches:
match_info = f" [{state.current_match_idx + 1}/{len(state.search_matches)}]"
stdscr.addstr(state.height - 1, state.width - len(match_info) - 1, match_info)
except curses.error:
pass
return
# Normal status bar (not in search mode)
# 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]
# Add indicators to commit message
wrap_indicator = "" if state.wrap_lines else "[NW] "
change_indicator = ""
if state.change_blocks and state.current_change_idx != -1:
change_indicator = f"[Change {state.current_change_idx + 1}/{len(state.change_blocks)}] "
search_indicator = ""
if state.search_matches:
search_indicator = f"[Search {state.current_match_idx + 1}/{len(state.search_matches)}] "
commit_message = wrap_indicator + change_indicator + search_indicator + commit_message
# 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) + 3 if state.show_sidebar else 1 # +3 for the spaces around percentage
right_margin = len(right_status) + 3 # +3 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 from where selection started
if state.show_sidebar:
pane = 'left' if start_x < state.divider_col else 'right'
else:
pane = 'right' # When sidebar is hidden, there's only the right pane
# Determine drag direction to handle multi-line selection correctly
if start_y > end_y or (start_y == end_y and start_x > end_x):
# Swap coordinates if selection is bottom-to-top or right-to-left
start_x, start_y, end_x, end_y = end_x, end_y, start_x, start_y
# Get the selected text from the data model
selected_text_parts = []
if pane == 'left' and state.show_sidebar:
# Selection in the commits pane
visible_commits = state.commits[state.left_scroll_offset:state.left_scroll_offset + state.height - 1]
for i in range(start_y, min(end_y + 1, len(visible_commits))):
line_idx = i + state.left_scroll_offset
if 0 <= line_idx < len(state.commits):
line = state.commits[line_idx]
# Calculate character positions
start_char = 0 if i > start_y else start_x
end_char = len(line) if i < end_y else end_x + 1
# Get the substring
if start_char < len(line):
selected_text_parts.append(line[start_char:min(end_char, len(line))])
else:
# Selection in the file content pane
# First, build a list of all visible lines including deleted lines if shown
visible_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)
# Build the list of visible lines with their types
file_lines = state.file_lines[state.right_scroll_offset:state.right_scroll_offset + state.height - 1]
display_lines = []
for i, line in enumerate(file_lines):
line_num = i + state.right_scroll_offset + 1
# Add deleted lines if they should be shown
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})
# Determine if this is an added line
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})
# Now extract the selected text from these lines
right_start = 0 if not state.show_sidebar else state.divider_col + 2
for i in range(start_y, min(end_y + 1, len(display_lines))):
if i < 0 or i >= len(display_lines):
continue
line_info = display_lines[i]
content = line_info['content']
line_type = line_info['type']
# Add prefix based on line type if in diff mode
prefix = ""
if line_type == 'added' and (state.show_whole_diff or state.show_additions):
prefix = "+ "
elif line_type == 'deleted' and (state.show_whole_diff or state.show_deletions):
prefix = "- "
elif state.show_whole_diff or state.show_additions or state.show_deletions:
prefix = " "
# Calculate character positions, accounting for the prefix
content_with_prefix = prefix + content
# Adjust start_x and end_x to account for the right pane offset
adjusted_start_x = max(0, start_x - right_start) if i == start_y else 0
adjusted_end_x = end_x - right_start if i == end_y else len(content_with_prefix)
# Ensure we don't go out of bounds
adjusted_start_x = min(adjusted_start_x, len(content_with_prefix))
adjusted_end_x = min(adjusted_end_x, len(content_with_prefix))
if adjusted_start_x < adjusted_end_x:
selected_text_parts.append(content_with_prefix[adjusted_start_x:adjusted_end_x])
# Join the selected text parts and copy to clipboard
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):
try:
# Try xclip for Linux systems
subprocess.run(['xclip', '-selection', 'clipboard'], input=text_to_copy, text=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError):
try:
# Try clip.exe for Windows
subprocess.run(['clip.exe'], input=text_to_copy, text=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError):
pass # Silently fail if no clipboard command is available
def handle_mouse_input(stdscr, state: AppState) -> AppState:
try:
_, mx, my, _, bstate = curses.getmouse()
state = replace(state, last_bstate=bstate, mouse_x=mx, mouse_y=my)
# Handle mouse button press
if bstate & curses.BUTTON1_PRESSED:
if state.show_sidebar and abs(mx - state.divider_col) <= 1:
return replace(state, dragging_divider=True)
else:
focus = "right"
if state.show_sidebar:
focus = "left" if mx < state.divider_col else "right"
return replace(state,
focus=focus,
is_selecting=True,
selection_start_coord=(mx, my),
selection_end_coord=(mx, my),
click_position=(mx, my))
# Handle mouse button release
if bstate & curses.BUTTON1_RELEASED:
if state.dragging_divider:
return replace(state, dragging_divider=False)
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)
focus = "right"
if state.show_sidebar:
focus = "left" if mx < state.divider_col else "right"
new_state = replace(state, focus=focus)
# 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):
new_state = replace(new_state, selected_commit_idx=new_commit_idx)
new_state = load_commit_content(new_state)
# Reset selection state
return replace(new_state, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None)
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
return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None)
# Handle mouse movement during drag operations
if state.dragging_divider:
return update_divider(state, mx)
elif state.is_selecting:
return replace(state, selection_end_coord=(mx, my))
except curses.error:
pass
return state
def handle_keyboard_input(key, state: AppState) -> AppState:
# If in search mode, handle search input
if state.search_mode:
if key == 27: # Escape key - exit search mode
return replace(state, search_mode=False)
elif key == 10 or key == 13: # Enter key - perform search
new_state = perform_search(state)
return replace(new_state, search_mode=False)
elif key == 8 or key == 127 or key == curses.KEY_BACKSPACE: # Backspace
if state.search_query:
return replace(state, search_query=state.search_query[:-1])
elif key == curses.KEY_DC: # Delete key
if state.search_query:
return replace(state, search_query=state.search_query[:-1])
elif 32 <= key <= 126: # Printable ASCII characters
return replace(state, search_query=state.search_query + chr(key))
return state
# Normal mode key handling
if key in [ord('q')]:
return replace(state, should_exit=True)
elif key == 27: # Escape key
if state.is_selecting:
return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None)
else:
return replace(state, should_exit=True)
elif key == ord('/'): # Start search
return replace(state, search_mode=True, search_query="")
elif key == ord('s'):
return toggle_sidebar(state)
elif key == ord('w'):
return replace(state, wrap_lines=not state.wrap_lines)
elif key == ord('l'):
return replace(state, show_line_numbers=not state.show_line_numbers)
elif key in [110, ord('n')]: # ASCII code for 'n'
if state.search_matches:
return jump_to_next_match(state)
elif state.show_whole_diff or state.show_additions or state.show_deletions:
return jump_to_next_change(state)
elif key in [78, ord('N')]: # ASCII code for 'N' (uppercase)
if state.search_matches:
return jump_to_prev_match(state)
elif key in [112, ord('p')]: # ASCII code for 'p'
if state.show_whole_diff or state.show_additions or state.show_deletions:
return jump_to_prev_change(state)
elif key in [curses.KEY_LEFT, ord('h')]:
if state.show_sidebar:
return replace(state, focus="left")
elif key in [curses.KEY_RIGHT, ord('l')]:
return replace(state, focus="right")
elif state.focus == "left":
if key in [curses.KEY_DOWN, ord('j')]:
return move_commit_selection(state, 1)
elif key in [curses.KEY_UP, ord('k')]:
return move_commit_selection(state, -1)
elif key in [curses.KEY_NPAGE, ord(' ')]:
return page_commit_selection(state, 1)
elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]:
return page_commit_selection(state, -1)
elif state.focus == "right":
if key in [curses.KEY_DOWN, ord('j')]:
return scroll_right_pane(state, 1)
elif key in [curses.KEY_UP, ord('k')]:
return scroll_right_pane(state, -1)
elif key in [curses.KEY_NPAGE, ord(' ')]:
return page_right_pane(state, 1)
elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]:
return page_right_pane(state, -1)
return state
# --- Main Application ---
def main(stdscr, filename, show_diff, show_add, show_del, mouse, wrap_lines=True, show_line_numbers=False):
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) # Added lines
curses.init_pair(4, curses.COLOR_RED, -1) # Deleted lines
curses.init_pair(5, curses.COLOR_GREEN, -1) # Line numbers for added lines
curses.init_pair(6, curses.COLOR_RED, -1) # Line numbers for deleted lines
curses.init_pair(7, curses.COLOR_BLUE, -1) # Regular line numbers
curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW) # Search matches
height, width = stdscr.getmaxyx()
state = AppState(
filename=filename, width=width, height=height,
show_whole_diff=show_diff, show_additions=show_add,
show_deletions=show_del, enable_mouse=mouse,
commits=get_commits(filename),
wrap_lines=wrap_lines
)
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(state)
# 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(state, h, w)
elif state.enable_mouse and key == curses.KEY_MOUSE:
state = handle_mouse_input(stdscr, state)
else:
state = 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("--no-wrap", action="store_true", help="Disable line wrapping")
parser.add_argument("--line-numbers", action="store_true", help="Show line numbers")
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)
if not is_file_tracked_by_git(filename):
print(f"Error: File '{filename}' is not tracked by git")
print("Only files that are tracked in the git repository can be viewed.")
print("Try adding and committing the file first:")
print(f" git add {filename}")
print(f" git commit -m 'Add {os.path.basename(filename)}'")
sys.exit(1)
curses.wrapper(main, filename, args.diff, args.diff_additions, args.diff_deletions, not args.no_mouse, not args.no_wrap, args.line_numbers)