git-time-machine/gtm

1692 lines
69 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-08.1"
# --- 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:%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_commit_details(commit_hash):
"""Get detailed information about a specific commit."""
# Get commit author (name only, no email)
author_cmd = ['git', 'show', '-s', '--format=%an', commit_hash]
author_result = subprocess.run(author_cmd, capture_output=True, text=True)
author = author_result.stdout.strip()
# Get branch information
branch_cmd = ['git', 'branch', '--contains', commit_hash]
branch_result = subprocess.run(branch_cmd, capture_output=True, text=True)
branches = [b.strip() for b in branch_result.stdout.splitlines()]
# Find the current branch (marked with *)
current_branch = "detached"
for branch in branches:
if branch.startswith('*'):
current_branch = branch[1:].strip()
break
# Get full commit message
message_cmd = ['git', 'show', '-s', '--format=%B', commit_hash]
message_result = subprocess.run(message_cmd, capture_output=True, text=True)
message = message_result.stdout.strip()
return {
'author': author,
'branch': current_branch,
'message': message
}
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 StatusBarLayout:
"""Encapsulates all status bar layout calculations"""
total_height: int
start_y: int
main_status_y: int
commit_detail_start_y: int
commit_detail_end_y: int
available_commit_lines: int
screen_width: int
@classmethod
def create(cls, screen_height: int, screen_width: int, status_bar_height: int):
start_y = screen_height - status_bar_height
return cls(
total_height=status_bar_height,
start_y=start_y,
main_status_y=start_y, # Top line is main status
commit_detail_start_y=start_y + 1, # Commit details start below
commit_detail_end_y=screen_height - 1, # Bottom line
available_commit_lines=max(0, status_bar_height - 1),
screen_width=screen_width
)
@dataclass
class AppState:
filename: str
width: int
height: int
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)
# Warning message system
warning_message: Optional[str] = None
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
# Commit hash click tracking
commit_hash_clicked: bool = False
commit_hash_area: Optional[Tuple[int, int, int, int]] = None # (start_x, end_x, start_y, end_y)
# 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
# Help popup
show_help: bool = False
# Commit details
commit_hash: str = ""
commit_message: str = ""
commit_author: str = ""
commit_branch: str = ""
# Status bar settings
status_bar_height: int = 2 # Default height for the status bar (2 lines)
dragging_status_bar: bool = False # Flag to track if user is resizing the status bar
# --- 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 - state.status_bar_height))
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]
# Get the commit hash using git log with the same format but including hash
cmd = ['git', 'log', '--pretty=format:%h', '--date=format:%Y-%m-%d %H:%M', '--skip=' + str(state.selected_commit_idx), '-n', '1', '--', state.filename]
result = subprocess.run(cmd, capture_output=True, text=True)
commit_hash = result.stdout.strip()
file_lines = get_file_at_commit(commit_hash, state.filename)
prev_commit_hash = None
if state.selected_commit_idx < len(state.commits) - 1:
# Get the previous commit hash
cmd = ['git', 'log', '--pretty=format:%h', '--date=format:%Y-%m-%d %H:%M', '--skip=' + str(state.selected_commit_idx + 1), '-n', '1', '--', state.filename]
result = subprocess.run(cmd, capture_output=True, text=True)
prev_commit_hash = result.stdout.strip()
if 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 = [], []
# Get commit details
commit_details = get_commit_details(commit_hash)
# Get commit message from the commit line
commit_line = state.commits[state.selected_commit_idx]
parts = commit_line.split(' ', 3) # Split into date, time, message
short_message = parts[2] if len(parts) >= 3 else ""
# If commit_details['message'] is empty, use short_message as fallback
if not commit_details['message'].strip():
commit_details['message'] = short_message
# Ensure we have some commit message to display
if not commit_details['message']:
commit_details['message'] = short_message if short_message else "No commit message"
max_scroll_new = max(0, len(file_lines) - (state.height - state.status_bar_height))
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,
commit_hash=commit_hash,
commit_message=commit_details['message'],
commit_author=commit_details['author'],
commit_branch=commit_details['branch']
)
# 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):
# Clear selection when changing commits
new_state = replace(state, selected_commit_idx=new_idx, is_selecting=False,
selection_start_coord=None, selection_end_coord=None, click_position=None)
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:
# Calculate visible height accounting for status bar
visible_height = state.height - state.status_bar_height
# Calculate maximum scroll position
max_scroll = max(0, len(state.file_lines) - visible_height)
# Apply scroll delta and clamp to valid range
new_offset = state.right_scroll_offset + delta
new_offset = max(0, min(new_offset, max_scroll))
# Clear selection when scrolling
if state.is_selecting:
return replace(state, right_scroll_offset=new_offset, is_selecting=False,
selection_start_coord=None, selection_end_coord=None, click_position=None)
return replace(state, right_scroll_offset=new_offset)
def page_right_pane(state: AppState, direction: int) -> AppState:
# Calculate page size accounting for status bar
page_size = state.height - state.status_bar_height
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 - state.status_bar_height:
state.left_scroll_offset = state.selected_commit_idx - (state.height - state.status_bar_height - 1)
visible_commits = state.commits[state.left_scroll_offset:state.left_scroll_offset + state.height - state.status_bar_height]
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 that's in view or below current scroll
if matches:
current_scroll = state.right_scroll_offset
visible_height = state.height - state.status_bar_height
# Find the first match that's in the current view or below it
first_visible_match_idx = 0
for idx, match_line in enumerate(matches):
if match_line >= current_scroll:
first_visible_match_idx = idx
break
# If all matches are above current view, wrap around to the first match
new_state = replace(new_state, current_match_idx=first_visible_match_idx)
# Scroll to the match if it's not already visible
match_line = matches[first_visible_match_idx]
if match_line < current_scroll or match_line >= current_scroll + visible_height:
new_state = replace(new_state, right_scroll_offset=max(0, match_line - 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
# Calculate the visible height (accounting for status bar)
visible_height = state.height - state.status_bar_height
# Calculate maximum scroll position
max_scroll = max(0, len(state.file_lines) - visible_height)
state.right_scroll_offset = min(state.right_scroll_offset, max_scroll)
# Get visible lines based on the calculated visible height
# Add +1 to ensure we can see the last line
visible_lines = state.file_lines[state.right_scroll_offset:state.right_scroll_offset + visible_height + 1]
display_lines = []
deleted_line_map = {}
if 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_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_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 - state.status_bar_height:
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_additions or state.show_deletions:
prefix = " "
attr = curses.A_NORMAL
# If this is a search match, override the attribute for the content only
# (we'll apply this when drawing the content, not for the prefix)
search_match_attr = None
if is_search_match:
if is_current_match:
search_match_attr = curses.A_REVERSE | curses.A_BOLD
else:
search_match_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:
# Draw prefix with the original attribute (not search highlighting)
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]
# Use search_match_attr for content if it's a search match
content_attr = search_match_attr if search_match_attr else attr
# 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()
# First, draw the entire line with normal attributes
stdscr.addnstr(display_row, content_start, display_content, available_width)
# Then highlight each match
pos = 0
while pos < len(content_lower):
match_pos = content_lower.find(query, pos)
if match_pos == -1:
break
if match_pos + len(query) <= len(display_content):
# Draw just 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), content_attr)
# Move to position after this match to find the next one
pos = match_pos + len(query)
display_row += 1
else:
# Normal display without search highlighting
stdscr.addnstr(display_row, content_start, display_content, available_width, content_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 - state.status_bar_height): # Don't draw through the status bars
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_help_popup(stdscr, state):
"""Draw a popup with keyboard shortcut help."""
if not state.show_help:
return
# Define help content
help_items = [
("Navigation", ""),
("j / Down", "Scroll down"),
("k / Up", "Scroll up"),
("Space / Page Down", "Page down"),
("b / Page Up", "Page up"),
("h / Left", "Focus left pane"),
("l / Right", "Focus right pane"),
("", ""),
("Features", ""),
("/", "Search in file"),
("n", "Next search match"),
("N", "Previous search match"),
("c", "Next change"),
("C", "Previous change"),
("d", "Toggle diff mode"),
("a", "Toggle diff additions"),
("x", "Toggle diff deletions"),
("s", "Toggle sidebar"),
("w", "Toggle line wrapping"),
("L", "Toggle line numbers"),
("m", "Toggle mouse support"),
("q / Esc", "Quit"),
("?", "Show/hide this help")
]
# Calculate popup dimensions and position
popup_width = 60
# Calculate height based on number of help items plus margins
popup_height = len(help_items) + 4 # 4 extra lines for borders, title, and footer
popup_x = max(0, (state.width - popup_width) // 2)
popup_y = max(0, (state.height - popup_height) // 2)
# Fill the entire popup area with spaces to clear background
for y in range(popup_y, popup_y + popup_height):
for x in range(popup_x, popup_x + popup_width):
try:
stdscr.addch(y, x, ' ')
except curses.error:
pass
# Draw popup border
for y in range(popup_y, popup_y + popup_height):
for x in range(popup_x, popup_x + popup_width):
if y == popup_y or y == popup_y + popup_height - 1:
# Top and bottom borders
try:
stdscr.addch(y, x, curses.ACS_HLINE)
except curses.error:
pass
elif x == popup_x or x == popup_x + popup_width - 1:
# Left and right borders
try:
stdscr.addch(y, x, curses.ACS_VLINE)
except curses.error:
pass
# Draw corners
try:
stdscr.addch(popup_y, popup_x, curses.ACS_ULCORNER)
stdscr.addch(popup_y, popup_x + popup_width - 1, curses.ACS_URCORNER)
stdscr.addch(popup_y + popup_height - 1, popup_x, curses.ACS_LLCORNER)
stdscr.addch(popup_y + popup_height - 1, popup_x + popup_width - 1, curses.ACS_LRCORNER)
except curses.error:
pass
# Draw title
title = " Keyboard Shortcuts "
title_x = popup_x + (popup_width - len(title)) // 2
try:
stdscr.addstr(popup_y, title_x, title, curses.A_BOLD)
except curses.error:
pass
# Draw help content
for i, (key, desc) in enumerate(help_items):
y = popup_y + 2 + i
if y < popup_y + popup_height - 2:
if key == "Navigation" or key == "Features":
# Section headers
try:
stdscr.addstr(y, popup_x + 2, key, curses.A_BOLD | curses.A_UNDERLINE)
except curses.error:
pass
elif key:
# Key and description
try:
stdscr.addstr(y, popup_x + 2, key, curses.A_BOLD)
stdscr.addstr(y, popup_x + 22, desc)
except curses.error:
pass
# Draw footer
footer = " Press any key to close "
footer_x = popup_x + (popup_width - len(footer)) // 2
try:
stdscr.addstr(popup_y + popup_height - 1, footer_x, footer, curses.A_BOLD)
except curses.error:
pass
def draw_status_bar_background(stdscr, layout: StatusBarLayout):
"""Fill the entire status bar area with reverse video background"""
# Make sure we don't try to draw beyond the screen
end_y = min(layout.start_y + layout.total_height, layout.screen_width)
for y in range(layout.start_y, end_y):
try:
# Fill the entire line with spaces in reverse video
# This is more efficient than adding characters one by one
stdscr.addstr(y, 0, ' ' * layout.screen_width, curses.A_REVERSE)
except curses.error:
# If we can't fill the entire line at once, try character by character
for x in range(layout.screen_width):
try:
stdscr.addch(y, x, ' ', curses.A_REVERSE)
except curses.error:
pass
def draw_warning_message(stdscr, state: AppState, layout: StatusBarLayout):
"""Draw a warning message in red at the top of the status bar"""
if not state.warning_message:
return
# Initialize color pair for warning if not already done
if curses.has_colors():
curses.init_pair(8, curses.COLOR_RED, -1) # Red text on default background
# Use red text with bold for warning
warning_attr = curses.color_pair(8) | curses.A_BOLD
# Draw the warning message and dismiss instruction all in red
warning_text = f" WARNING: {state.warning_message} "
stdscr.addstr(layout.main_status_y, 0, warning_text, warning_attr)
# Add instruction to dismiss, also in red
dismiss_text = " (Press any key to dismiss) "
if len(warning_text) + len(dismiss_text) < layout.screen_width:
stdscr.addstr(layout.main_status_y, len(warning_text), dismiss_text, warning_attr)
def draw_main_status_line(stdscr, state: AppState, layout: StatusBarLayout):
"""Draw the main status line (percentages, indicators, etc.)"""
# If there's a warning message, draw it instead of the normal status line
if state.warning_message:
draw_warning_message(stdscr, state, layout)
return
visible_height = layout.start_y
# Add indicators to status bar
wrap_indicator = "" if state.wrap_lines else "[NW] "
mouse_indicator = "" if state.enable_mouse else "[NM] "
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)}] "
status_indicators = wrap_indicator + mouse_indicator + change_indicator + search_indicator
# 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 = min(100, 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 = min(100, 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
# 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(layout.main_status_y, 0, f" {left_status} ", left_attr)
except curses.error:
pass
# Add status indicators in the middle
if status_indicators:
# 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 = layout.screen_width - left_margin - right_margin
# Truncate if needed
if len(status_indicators) > available_width:
status_indicators = status_indicators[:available_width-3] + "..."
try:
stdscr.addstr(layout.main_status_y, left_margin, status_indicators, 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 = layout.screen_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(layout.main_status_y, right_x, padded_right_status, right_attr)
except curses.error:
pass
def draw_commit_details(stdscr, state: AppState, layout: StatusBarLayout):
"""Draw commit hash, message, author in the available space"""
if not state.commit_hash:
return
# Get the commit message (potentially multi-line)
message_lines = state.commit_message.splitlines()
if not message_lines:
message_lines = [""]
# Draw on the second line of the status bar (layout.commit_detail_start_y)
second_line = layout.commit_detail_start_y
# Draw the commit hash at the start
commit_info = f" {state.commit_hash} "
hash_length = len(commit_info)
# Store the commit hash area for click detection
state.commit_hash_area = (0, hash_length - 1, second_line, second_line)
# Use different style when the hash is being clicked
if state.commit_hash_clicked:
# When clicked, show in normal video (not reversed)
stdscr.addstr(second_line, 0, commit_info, curses.A_BOLD)
else:
# Normal display: reverse video
stdscr.addstr(second_line, 0, commit_info, curses.A_REVERSE | curses.A_BOLD)
# Calculate the author and branch info, ensuring it always fits
author_branch_info = f" {state.commit_author} [{state.commit_branch}] "
# If the author info is too long, truncate it but keep the branch
if len(author_branch_info) > layout.screen_width // 3: # Don't let it take more than 1/3 of screen
max_author_name_width = (layout.screen_width // 3) - len(f" [{state.commit_branch}] ")
if max_author_name_width > 5: # Only truncate if we have reasonable space
truncated_author = state.commit_author[:max_author_name_width-3] + "..."
author_branch_info = f" {truncated_author} [{state.commit_branch}] "
# Calculate positions
right_x = layout.screen_width - len(author_branch_info)
# Calculate the message area boundaries
message_start_x = len(commit_info)
message_end_x = right_x - 1
message_width = message_end_x - message_start_x
if right_x >= len(commit_info):
try:
stdscr.addstr(second_line, right_x, author_branch_info, curses.A_REVERSE)
except curses.error:
pass
# Draw the commit message in the remaining space
first_line = message_lines[0] if message_lines else ""
if message_width > 0:
if len(first_line) > message_width:
first_line = first_line[:message_width-3] + "..."
try:
stdscr.addstr(second_line, message_start_x, first_line, curses.A_REVERSE)
except curses.error:
pass # Silently fail if we can't draw the message
# Draw additional lines of the commit message if we have more space
for i, line in enumerate(message_lines[1:], 1):
line_y = layout.commit_detail_start_y + i
if line_y >= layout.start_y + layout.total_height:
break # No more space
if message_width > 0:
if len(line) > message_width:
line = line[:message_width-3] + "..."
try:
stdscr.addstr(line_y, message_start_x, line, curses.A_REVERSE)
except curses.error:
pass
def draw_search_mode(stdscr, state: AppState, layout: StatusBarLayout):
"""Draw search input interface"""
# Draw search prompt on the main status line
search_prompt = "Search: "
try:
stdscr.addstr(layout.main_status_y, 0, search_prompt)
# Draw the search query
stdscr.addstr(layout.main_status_y, len(search_prompt), state.search_query)
# Draw cursor at the end of the query
try:
stdscr.move(layout.main_status_y, 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(layout.main_status_y, layout.screen_width - len(match_info) - 1, match_info)
except curses.error:
pass
def draw_status_bars(stdscr, state):
layout = StatusBarLayout.create(state.height, state.width, state.status_bar_height)
draw_status_bar_background(stdscr, layout)
if state.search_mode:
draw_search_mode(stdscr, state, layout)
# Still show commit details in search mode if we have space
if layout.total_height > 1:
draw_commit_details(stdscr, state, layout)
else:
draw_main_status_line(stdscr, state, layout)
draw_commit_details(stdscr, state, layout)
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)
draw_help_popup(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_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_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_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_additions:
prefix = "+ "
elif line_type == 'deleted' and state.show_deletions:
prefix = "- "
elif 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():
# Log the text being copied for debugging
with open("/tmp/gtm_clipboard.log", "w") as f:
f.write(f"Copying to clipboard: {text_to_copy}\n")
# Try macOS pbcopy first (most likely on this system)
try:
proc = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE)
proc.communicate(text_to_copy.encode('utf-8'))
proc.wait()
return # Return early if successful
except (FileNotFoundError, subprocess.SubprocessError):
pass
# Try Linux xclip
try:
proc = subprocess.Popen(['xclip', '-selection', 'clipboard'], stdin=subprocess.PIPE)
proc.communicate(text_to_copy.encode('utf-8'))
proc.wait()
return # Return early if successful
except (FileNotFoundError, subprocess.SubprocessError):
pass
# Try Windows clip.exe
try:
proc = subprocess.Popen(['clip.exe'], stdin=subprocess.PIPE)
proc.communicate(text_to_copy.encode('utf-8'))
proc.wait()
except (FileNotFoundError, subprocess.SubprocessError):
# Log failure
with open("/tmp/gtm_clipboard.log", "a") as f:
f.write("Failed to copy to clipboard - no clipboard command available\n")
def update_status_bar_height(state: AppState, my: int) -> AppState:
"""Update the status bar height based on mouse position."""
# Calculate the minimum and maximum allowed heights
min_height = 2 # Minimum 2 lines for the status bar
max_height = min(10, state.height // 2) # Maximum 10 lines or half the screen
# Calculate the new height based on mouse position
# When dragging up, we want to increase the status bar height
new_height = state.height - my
# Clamp to allowed range
new_height = max(min_height, min(new_height, max_height))
# Ensure we're not hiding too much content
content_height = max(state.height - new_height, 5) # At least 5 lines for content
new_height = min(new_height, state.height - content_height)
return replace(state, status_bar_height=new_height)
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)
# Status bar position
status_bar_start = state.height - state.status_bar_height
# Handle mouse button press
if bstate & curses.BUTTON1_PRESSED:
# Check if click is on the commit hash
if state.commit_hash_area and state.commit_hash:
hash_start_x, hash_end_x, hash_start_y, hash_end_y = state.commit_hash_area
if hash_start_x <= mx <= hash_end_x and hash_start_y <= my <= hash_end_y:
# Clicked on commit hash
return replace(state, commit_hash_clicked=True)
if state.show_sidebar and abs(mx - state.divider_col) <= 1:
return replace(state, dragging_divider=True)
elif my == status_bar_start:
# Clicked anywhere on the top line of the status bar
return replace(state, dragging_status_bar=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.commit_hash_clicked:
# Copy commit hash to clipboard
if state.commit_hash:
# Try macOS pbcopy first (most likely on this system)
try:
proc = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE)
proc.communicate(state.commit_hash.encode('utf-8'))
proc.wait()
except (FileNotFoundError, subprocess.SubprocessError):
try:
# Try xclip for Linux systems
proc = subprocess.Popen(['xclip', '-selection', 'clipboard'], stdin=subprocess.PIPE)
proc.communicate(state.commit_hash.encode('utf-8'))
proc.wait()
except (FileNotFoundError, subprocess.SubprocessError):
try:
# Try clip.exe for Windows
proc = subprocess.Popen(['clip.exe'], stdin=subprocess.PIPE)
proc.communicate(state.commit_hash.encode('utf-8'))
proc.wait()
except (FileNotFoundError, subprocess.SubprocessError):
pass # Silently fail if no clipboard command is available
# Reset the clicked state
return replace(state, commit_hash_clicked=False)
if state.dragging_divider:
return replace(state, dragging_divider=False)
elif state.dragging_status_bar:
return replace(state, dragging_status_bar=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): # TODO don't do this <= 2 thing
# 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(status_bar_start, 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.dragging_status_bar:
return update_status_bar_height(state, my)
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 help popup is open, any key closes it
if state.show_help:
return replace(state, show_help=False)
# If there's a warning message, any key dismisses it
if state.warning_message:
return replace(state, warning_message=None)
# Handle Escape key immediately for search results
if key == 27: # Escape key
# Create a new state with all escape-related changes
new_state = state
# Clear search results
if state.search_matches:
new_state = replace(new_state, search_matches=[], current_match_idx=-1)
# Handle other Escape actions
if state.search_mode:
new_state = replace(new_state, search_mode=False)
elif state.is_selecting:
new_state = replace(new_state, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None)
# Return the updated state with all changes
return new_state
# If in search mode, handle search input
if state.search_mode:
if 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 == ord('?'): # Toggle help popup
return replace(state, show_help=not state.show_help)
elif key == ord('/'): # Start search
return replace(state, search_mode=True, search_query="")
elif key == ord('s'):
return toggle_sidebar(state)
elif key == ord('d'):
# 'd' toggles both additions and deletions together
new_state = replace(state,
show_additions=not (state.show_additions and state.show_deletions),
show_deletions=not (state.show_additions and state.show_deletions))
return load_commit_content(new_state)
elif key == ord('a'):
# 'a' toggles just additions
new_state = replace(state, show_additions=not state.show_additions)
return load_commit_content(new_state)
elif key == ord('x'):
# 'x' toggles just deletions
new_state = replace(state, show_deletions=not state.show_deletions)
return load_commit_content(new_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 == ord('m'):
# Toggle mouse support
new_mouse_state = not state.enable_mouse
if new_mouse_state:
# Enable mouse
curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
# Enable mouse motion events for better drag tracking
if os.environ.get("TERM") != "nsterm":
try:
print("\033[?1003h", end="", flush=True)
except Exception:
pass
else:
# Disable mouse
curses.mousemask(0)
# Disable mouse motion events
if os.environ.get("TERM") != "nsterm":
try:
print("\033[?1003l", end="", flush=True)
except Exception:
pass
return replace(state, enable_mouse=new_mouse_state)
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 [99, ord('c')]: # ASCII code for 'c'
if state.show_additions or state.show_deletions:
return jump_to_next_change(state)
elif key in [67, ord('C')]: # ASCII code for 'C' (uppercase)
if 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, 98, 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, 98, curses.KEY_SR]:
return page_right_pane(state, -1)
return state
# --- Main Application ---
def main(stdscr, filename, show_add, show_del, mouse=True, no_diff=False, wrap_lines=True, show_line_numbers=True):
try:
curses.curs_set(0)
except:
pass
stdscr.keypad(True)
stdscr.timeout(10)
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(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, 7, -1) # Regular line numbers
height, width = stdscr.getmaxyx()
# If no_diff is false, enable both additions and deletions
if not show_diff:
show_add = True
show_del = True
state = AppState(
filename=filename, width=width, height=height,
show_additions=show_add,
show_deletions=show_del, enable_mouse=True, # Always enable mouse by default
commits=get_commits(filename),
wrap_lines=wrap_lines,
show_line_numbers=show_line_numbers
)
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
# Load initial commit content so commit details are available immediately
if state.commits:
state = load_commit_content(state)
# Set warning message if we had to use a terminal fallback
if terminal_warning:
state = set_warning_message(state, terminal_warning)
# 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
def set_warning_message(state: AppState, message: str) -> AppState:
"""Set a warning message to be displayed in the status bar"""
return replace(state, warning_message=message)
def is_terminal_supported(term_name):
"""Check if a terminal type is supported by curses."""
import _curses
try:
# Try to set up the terminal without actually initializing the screen
curses.setupterm(term=term_name, fd=-1)
return True
except _curses.error:
return False
if __name__ == "__main__":
# Check if current terminal is supported, use fallback if needed
original_term = os.environ.get("TERM", "")
if original_term and not is_terminal_supported(original_term):
# We'll show this warning in the UI instead of console
terminal_warning = f"Terminal type '{original_term}' not recognized. Using 'xterm-256color'."
os.environ["TERM"] = "xterm-256color"
else:
terminal_warning = None
parser = argparse.ArgumentParser(description="A \"Git Time Machine\" for viewing file history")
parser.add_argument("--no-diff", action="store_true", help="Don't highlight added and deleted lines")
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("--no-numbers", action="store_true", help="Hide 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)
# Always enable mouse by default, only disable if --no-mouse is explicitly set
mouse_enabled = not args.no_mouse
# Set diff mode (enabled by default)
show_diff = not args.no_diff
# Set line numbers (enabled by default)
show_line_numbers = not args.no_numbers
# Set line wrapping (enabled by default)
wrap_lines = not args.no_wrap
curses.wrapper(main, filename, show_diff, show_diff, mouse_enabled, args.no_diff, wrap_lines, show_line_numbers)