1632 lines
66 KiB
Python
Executable File
1632 lines
66 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)
|
|
|
|
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_main_status_line(stdscr, state: AppState, layout: StatusBarLayout):
|
|
"""Draw the main status line (percentages, indicators, etc.)"""
|
|
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)
|
|
|
|
# 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, 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_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)
|
|
|
|
# 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("--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)
|