diff --git a/gtm b/gtm index 1c442a8..abeaba4 100755 --- a/gtm +++ b/gtm @@ -6,10 +6,12 @@ import subprocess import sys import argparse -VERSION = "2025-06-07.2" +VERSION = "2025-06-07.3" + +# --- Data Fetching & Utility Functions (Pure) --- def get_commits(filename): - cmd = ['git', 'log', '--pretty=format:%h %ad %s', '--date=short', '--', filename] + cmd = ['git', 'log', '--pretty=format:%h %ad %s', '--date=format:%Y-%m-%d %H:%M', '--', filename] result = subprocess.run(cmd, capture_output=True, text=True) return result.stdout.splitlines() @@ -59,390 +61,639 @@ def find_best_matching_line(reference_line, file_lines, max_lines=None): def get_diff_info(current_commit, prev_commit, filename): """Get diff information between two commits for a file""" if not prev_commit: - # For the first commit, we can't compare with a previous one return [], [] - # Use a more detailed diff format to better understand the changes cmd = ['git', 'diff', prev_commit, current_commit, '--', filename] result = subprocess.run(cmd, capture_output=True, text=True) - # Parse the diff output to extract added and deleted lines lines = result.stdout.splitlines() - - # Store line changes with their positions added_lines = [] deleted_lines = [] - - # Track the current position in the new file current_line_num = 0 - deletion_offset = 0 - - # Parse the diff output i = 0 while i < len(lines): line = lines[i] - - # Parse hunk headers to get line numbers if line.startswith('@@'): - # Format is like @@ -a,b +c,d @@ parts = line.split() if len(parts) >= 3: - # Extract the starting line number in the new file - add_part = parts[2][1:] # Remove the + sign + add_part = parts[2][1:] if ',' in add_part: current_line_num, _ = map(int, add_part.split(',')) else: current_line_num = int(add_part) - - # Reset deletion offset for this hunk - deletion_offset = 0 - - # Process added lines elif line.startswith('+') and not line.startswith('+++'): added_lines.append((current_line_num, line[1:])) current_line_num += 1 - - # Process deleted lines elif line.startswith('-') and not line.startswith('---'): - # Store deleted lines with the position where they would have been - # in the new file (before the next line) deleted_lines.append((current_line_num, line[1:])) - deletion_offset += 1 - - # Process context lines elif not line.startswith('+') and not line.startswith('-') and not line.startswith('@@'): current_line_num += 1 - i += 1 - return added_lines, deleted_lines -def main(stdscr, filename, show_whole_diff=False, show_additions=False, show_deletions=False, enable_mouse=True): - curses.curs_set(0) - if enable_mouse: - curses.mousemask(curses.ALL_MOUSE_EVENTS) - curses.mouseinterval(0) - stdscr.keypad(True) - - # Initialize colors if terminal supports them - if curses.has_colors(): - curses.use_default_colors() # Use terminal's default colors - curses.init_pair(1, 5, 7) # Focused status bar - curses.init_pair(2, curses.COLOR_WHITE, 8) # Unfocused status bar - curses.init_pair(3, curses.COLOR_GREEN, -1) # Added lines - curses.init_pair(4, curses.COLOR_RED, -1) # Deleted lines +# --- Application State Class --- + +class AppState: + def __init__(self, filename, width, height, show_diff, show_add, show_del, mouse): + self.filename = filename + self.width = width + self.height = height + self.show_whole_diff = show_diff + self.show_additions = show_add + self.show_deletions = show_del + self.enable_mouse = mouse + self.show_sidebar = True + + self.commits = get_commits(filename) + self.file_lines = [] + self.added_lines = [] + self.deleted_lines = [] + + self.focus = "left" + self.divider_col = 40 - # Initialize key variable - key = 0 + self.selected_commit_idx = 0 + self.left_scroll_offset = 0 + self.right_scroll_offset = 0 - commits = get_commits(filename) - selected_commit = 0 - divider_col = 40 - focus = "left" - scroll_offset = 0 - left_scroll_offset = 0 # Scroll position for left pane + self.dragging_divider = False + self.should_exit = False - dragging_divider = False + self.is_selecting = False + self.selection_start_coord = None + self.selection_end_coord = None + self.click_position = None # Store click position to detect clicks vs. drags + self.last_bstate = 0 + self.mouse_x = -1 + self.mouse_y = -1 - # Initialize file content - commit_hash = commits[selected_commit].split()[0] - file_lines = get_file_at_commit(commit_hash, filename) - - # Get previous commit hash for diff - prev_commit_hash = None - if selected_commit < len(commits) - 1: - prev_commit_hash = commits[selected_commit + 1].split()[0] - - # Get diff information if needed - added_lines = [] - deleted_lines = [] - if show_whole_diff or show_additions or show_deletions: - added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, filename) - - # Enable nodelay for smoother scrolling - stdscr.nodelay(True) - - while True: - stdscr.erase() # Use erase instead of clear for less flickering - height, width = stdscr.getmaxyx() + def update_dimensions(self, height, width): + self.height = height + self.width = width - # Calculate current scroll position as percentage before changing commits - scroll_percentage = 0 + def toggle_mouse(self): + self.enable_mouse = not self.enable_mouse + if self.enable_mouse: + curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) + else: + curses.mousemask(0) + + def toggle_sidebar(self): + self.show_sidebar = not self.show_sidebar + # When hiding sidebar, focus should be on right pane + if not self.show_sidebar: + self.focus = "right" + + def move_commit_selection(self, delta): + new_idx = self.selected_commit_idx + delta + if 0 <= new_idx < len(self.commits): + self.selected_commit_idx = new_idx + self.load_commit_content() + + def page_commit_selection(self, direction): + page_size = self.height - 1 + delta = page_size * direction + self.move_commit_selection(delta) + + def scroll_right_pane(self, delta): + max_scroll = max(0, len(self.file_lines) - (self.height - 1)) + new_offset = self.right_scroll_offset + delta + self.right_scroll_offset = max(0, min(new_offset, max_scroll)) + + def page_right_pane(self, direction): + page_size = self.height - 1 + delta = page_size * direction + self.scroll_right_pane(delta) + + def update_divider(self, mx): + min_col = 10 + max_col = self.width - 20 + self.divider_col = max(min_col, min(mx, max_col)) + + def load_commit_content(self): + if not self.commits: + return + reference_line = None - if len(file_lines) > 0: - max_scroll = max(0, len(file_lines) - (height - 1)) - if max_scroll > 0: - scroll_percentage = scroll_offset / max_scroll - - # Store the content of the top visible line as reference - if scroll_offset < len(file_lines): - reference_line = file_lines[scroll_offset] + scroll_percentage = 0 + if len(self.file_lines) > 0: + max_scroll_old = max(0, len(self.file_lines) - (self.height - 1)) + if max_scroll_old > 0: + scroll_percentage = self.right_scroll_offset / max_scroll_old + if self.right_scroll_offset < len(self.file_lines): + reference_line = self.file_lines[self.right_scroll_offset] - # Only fetch file content when commit changes - if (key in [curses.KEY_DOWN, curses.KEY_UP, ord('j'), ord('k'), - curses.KEY_NPAGE, ord(' '), curses.KEY_PPAGE, 8, 127, curses.KEY_SR]) and focus == "left": - commit_hash = commits[selected_commit].split()[0] - file_lines = get_file_at_commit(commit_hash, filename) - - # Update diff information when commit changes - prev_commit_hash = None - if selected_commit < len(commits) - 1: - prev_commit_hash = commits[selected_commit + 1].split()[0] - - if show_whole_diff or show_additions or show_deletions: - added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, filename) - - # Try to find the same line in the new file version - if reference_line: - # Limit search to first 1000 lines for performance - matching_line_idx = find_best_matching_line(reference_line, file_lines, 1000) - if matching_line_idx is not None: - scroll_offset = matching_line_idx - else: - # Fall back to percentage-based scrolling - max_scroll = max(0, len(file_lines) - (height - 1)) - if max_scroll > 0: - scroll_offset = int(scroll_percentage * max_scroll) - else: - scroll_offset = 0 - else: - # Fall back to percentage-based scrolling - max_scroll = max(0, len(file_lines) - (height - 1)) - if max_scroll > 0: - scroll_offset = int(scroll_percentage * max_scroll) - else: - scroll_offset = 0 - - # Recalculate max_scroll and ensure scroll_offset is within bounds - max_scroll = max(0, len(file_lines) - (height - 1)) - scroll_offset = min(scroll_offset, max_scroll) - visible_lines = file_lines[scroll_offset:scroll_offset + height - 1] + commit_hash = self.commits[self.selected_commit_idx].split()[0] + self.file_lines = get_file_at_commit(commit_hash, self.filename) - # Calculate visible commits for left pane - left_max_scroll = max(0, len(commits) - (height - 1)) - left_scroll_offset = min(left_scroll_offset, left_max_scroll) - - # Ensure selected commit is visible - if selected_commit < left_scroll_offset: - left_scroll_offset = selected_commit - elif selected_commit >= left_scroll_offset + height - 1: - left_scroll_offset = selected_commit - (height - 2) - - # Draw commit list (left pane) - visible_commits = commits[left_scroll_offset:left_scroll_offset + height - 1] - for i, line in enumerate(visible_commits): - display_index = i + left_scroll_offset - if display_index == selected_commit: - stdscr.attron(curses.A_REVERSE) # Highlight selected commit - stdscr.addnstr(i, 0, line, divider_col - 1) - if display_index == selected_commit: - stdscr.attroff(curses.A_REVERSE) + prev_commit_hash = None + if self.selected_commit_idx < len(self.commits) - 1: + prev_commit_hash = self.commits[self.selected_commit_idx + 1].split()[0] - # Vertical divider - divider_char = "║" if dragging_divider else "│" - for y in range(height): - try: - stdscr.addch(y, divider_col, divider_char) - except curses.error: - # Avoid errors when drawing at the last column - pass - - # Draw file content (right pane) - more efficiently - right_width = width - divider_col - 3 - - # First, collect all lines to display (regular, added, and deleted) - display_lines = [] - - # Create a map of line numbers to deleted lines for faster lookup - deleted_line_map = {} - if show_whole_diff or show_deletions: - for del_line_num, del_content in deleted_lines: - if del_line_num not in deleted_line_map: - deleted_line_map[del_line_num] = [] - deleted_line_map[del_line_num].append(del_content) - - # Process each visible line - for i, line in enumerate(visible_lines): - line_num = i + scroll_offset + 1 # 1-based line number - - # First add any deleted lines that come before this line - if (show_whole_diff or show_deletions) and line_num in deleted_line_map: - for del_content in deleted_line_map[line_num]: - display_lines.append({ - 'type': 'deleted', - 'content': del_content, - 'line_num': line_num - }) - - # Check if this is an added line - is_added = False - if show_whole_diff or show_additions: - for added_line_num, _ in added_lines: - if added_line_num == line_num: - is_added = True - break - - # Add the regular line to our display list - display_lines.append({ - 'type': 'added' if is_added else 'regular', - 'content': line, - 'line_num': line_num - }) - - # Now display all lines - display_row = 0 - for line_info in display_lines: - # Stop if we've reached the bottom of the screen - if display_row >= height - 1: - break - - line_type = line_info['type'] - content = line_info['content'] - - if line_type == 'added': - # Green with + prefix for added lines - stdscr.addstr(display_row, divider_col + 2, "+ ", curses.color_pair(3)) - stdscr.addnstr(display_row, divider_col + 4, content, right_width - 2, curses.color_pair(3)) - elif line_type == 'deleted': - # Red with - prefix for deleted lines - stdscr.addstr(display_row, divider_col + 2, "- ", curses.color_pair(4)) - stdscr.addnstr(display_row, divider_col + 4, content, right_width - 2, curses.color_pair(4)) - else: - # Regular line - add padding to align with +/- lines - stdscr.addstr(display_row, divider_col + 2, " ") # Two spaces for alignment - stdscr.addnstr(display_row, divider_col + 4, content, right_width - 2) - - display_row += 1 - - # Status bars for both panes - visible_height = height - 1 # Reserve 1 line for the status bar - - # Right pane status - last_visible_line = scroll_offset + visible_height - if len(file_lines) > 0: - right_percent = int((last_visible_line / len(file_lines)) * 100) - right_percent = 100 if last_visible_line >= len(file_lines) else right_percent + if self.show_whole_diff or self.show_additions or self.show_deletions: + self.added_lines, self.deleted_lines = get_diff_info(commit_hash, prev_commit_hash, self.filename) else: - right_percent = 0 - right_status = f"{right_percent}% " + self.added_lines, self.deleted_lines = [], [] - # Left pane status - last_visible_commit = left_scroll_offset + visible_height - if len(commits) > 0: - left_percent = int((last_visible_commit / len(commits)) * 100) - left_percent = 100 if last_visible_commit >= len(commits) else left_percent + max_scroll_new = max(0, len(self.file_lines) - (self.height - 1)) + if reference_line: + matching_line_idx = find_best_matching_line(reference_line, self.file_lines, 1000) + if matching_line_idx is not None: + self.right_scroll_offset = matching_line_idx + else: + self.right_scroll_offset = int(scroll_percentage * max_scroll_new) else: - left_percent = 0 - left_status = f"{left_percent}%" - if enable_mouse: - left_status += " [M]" + self.right_scroll_offset = int(scroll_percentage * max_scroll_new) - # Draw status bars - full width with different colors based on focus - left_attr = curses.color_pair(1) if focus == "left" else curses.color_pair(2) - right_attr = curses.color_pair(1) if focus == "right" else curses.color_pair(2) - - # Fill the entire bottom row for each pane - for x in range(divider_col): - stdscr.addch(height - 1, x, ' ', left_attr) - for x in range(divider_col + 1, width - 1): # Avoid the last column - stdscr.addch(height - 1, x, ' ', right_attr) - - # Add the percentage text - stdscr.addstr(height - 1, 1, left_status, left_attr) - stdscr.addstr(height - 1, width - len(right_status) - 1, right_status, right_attr) - - # Add divider character at the bottom row - stdscr.addch(height - 1, divider_col, divider_char) + self.right_scroll_offset = max(0, min(self.right_scroll_offset, max_scroll_new)) - # Get input with a small timeout for smoother scrolling - stdscr.timeout(50) # 50ms timeout - key = stdscr.getch() +# --- Rendering Functions (View) --- + +def draw_left_pane(stdscr, state): + if not state.show_sidebar: + return - # If no key was pressed, continue the loop - if key == -1: + if state.selected_commit_idx < state.left_scroll_offset: + state.left_scroll_offset = state.selected_commit_idx + elif state.selected_commit_idx >= state.left_scroll_offset + state.height - 1: + state.left_scroll_offset = state.selected_commit_idx - (state.height - 2) + + visible_commits = state.commits[state.left_scroll_offset:state.left_scroll_offset + state.height - 1] + for i, line in enumerate(visible_commits): + display_index = i + state.left_scroll_offset + if display_index == state.selected_commit_idx: + stdscr.attron(curses.A_REVERSE) + stdscr.addnstr(i, 0, line, state.divider_col - 1) + if display_index == state.selected_commit_idx: + stdscr.attroff(curses.A_REVERSE) + +def draw_right_pane(stdscr, state): + # If sidebar is hidden, right pane starts at column 0 + right_start = 0 if not state.show_sidebar else state.divider_col + 2 + right_width = state.width - right_start - 1 + + max_scroll = max(0, len(state.file_lines) - (state.height - 1)) + state.right_scroll_offset = min(state.right_scroll_offset, max_scroll) + + visible_lines = state.file_lines[state.right_scroll_offset:state.right_scroll_offset + state.height - 1] + + display_lines = [] + deleted_line_map = {} + if state.show_whole_diff or state.show_deletions: + for del_line_num, del_content in state.deleted_lines: + if del_line_num not in deleted_line_map: + deleted_line_map[del_line_num] = [] + deleted_line_map[del_line_num].append(del_content) + + for i, line in enumerate(visible_lines): + line_num = i + state.right_scroll_offset + 1 + if (state.show_whole_diff or state.show_deletions) and line_num in deleted_line_map: + for del_content in deleted_line_map[line_num]: + display_lines.append({'type': 'deleted', 'content': del_content}) + + is_added = False + if state.show_whole_diff or state.show_additions: + for added_line_num, _ in state.added_lines: + if added_line_num == line_num: + is_added = True + break + + display_lines.append({'type': 'added' if is_added else 'regular', 'content': line}) + + display_row = 0 + for line_info in display_lines: + if display_row >= state.height - 2: + break + + line_type = line_info['type'] + content = line_info['content'] + + if line_type == 'added': + stdscr.addstr(display_row, right_start, "+ ", curses.color_pair(3)) + stdscr.addnstr(display_row, right_start + 2, content, right_width - 2, curses.color_pair(3)) + elif line_type == 'deleted': + stdscr.addstr(display_row, right_start, "- ", curses.color_pair(4)) + stdscr.addnstr(display_row, right_start + 2, content, right_width - 2, curses.color_pair(4)) + else: + if state.show_whole_diff or state.show_additions or state.show_deletions: + stdscr.addstr(display_row, right_start, " ") + stdscr.addnstr(display_row, right_start + 2, content, right_width - 2) + else: + # No diff mode, so don't add the margin padding + stdscr.addnstr(display_row, right_start, content, right_width) + + display_row += 1 + +def draw_divider(stdscr, state): + if not state.show_sidebar: + return + + divider_char = "║" if state.dragging_divider else "│" + for y in range(state.height - 1): # Don't draw through the status bar + try: + stdscr.addch(y, state.divider_col, divider_char) + except curses.error: + pass + +def draw_selection(stdscr, state): + if not state.is_selecting or not state.selection_start_coord: + return + + start_x, start_y = state.selection_start_coord + end_x, end_y = state.selection_end_coord + + # Determine pane boundaries based on sidebar visibility + if state.show_sidebar: + # Determine pane from where selection started + pane = 'left' if start_x < state.divider_col else 'right' + if pane == 'left': + pane_x1, pane_x2 = 0, state.divider_col - 1 + else: # right + pane_x1, pane_x2 = state.divider_col + 2, state.width - 1 + else: + # When sidebar is hidden, there's only the right pane + pane = 'right' + pane_x1, pane_x2 = 0, state.width - 1 + + # Determine drag direction to handle multi-line selection correctly + if start_y < end_y or (start_y == end_y and start_x <= end_x): + drag_start_x, drag_start_y = start_x, start_y + drag_end_x, drag_end_y = end_x, end_y + else: # upward drag or right-to-left on same line + drag_start_x, drag_start_y = end_x, end_y + drag_end_x, drag_end_y = start_x, start_y + + for y in range(drag_start_y, drag_end_y + 1): + x1, x2 = -1, -1 + if drag_start_y == drag_end_y: # single line selection + x1, x2 = drag_start_x, drag_end_x + elif y == drag_start_y: # first line of multi-line selection + x1, x2 = drag_start_x, pane_x2 + elif y == drag_end_y: # last line of multi-line selection + x1, x2 = pane_x1, drag_end_x + else: # middle line of multi-line selection + x1, x2 = pane_x1, pane_x2 + + # Clamp selection to pane boundaries + x1 = max(x1, pane_x1) + x2 = min(x2, pane_x2) + + try: + if x1 < state.width and x1 <= x2: + length = x2 - x1 + 1 + if length > 0: + stdscr.chgat(y, x1, length, curses.A_REVERSE) + except curses.error: + pass + +def draw_status_bars(stdscr, state): + # We'll use height-1 for the single status bar + visible_height = state.height - 1 + + # Get commit message for the selected commit + commit_message = "" + if state.commits and state.selected_commit_idx < len(state.commits): + # Format is now: hash date time message + # We need to split on more than just the first two spaces + commit_line = state.commits[state.selected_commit_idx] + parts = commit_line.split(' ', 3) # Split into hash, date, time, message + if len(parts) >= 4: + commit_message = parts[3] + + # Status bar percentages + if len(state.file_lines) > 0: + last_visible_line = state.right_scroll_offset + visible_height + right_percent = int((last_visible_line / len(state.file_lines)) * 100) + right_percent = 100 if last_visible_line >= len(state.file_lines) else right_percent + else: + right_percent = 0 + right_status = f"{right_percent}%" + + if len(state.commits) > 0 and state.show_sidebar: + last_visible_commit = state.left_scroll_offset + visible_height + left_percent = int((last_visible_commit / len(state.commits)) * 100) + left_percent = 100 if last_visible_commit >= len(state.commits) else left_percent + else: + left_percent = 0 + left_status = f"{left_percent}%" + + # Use terminal's default colors for the status bar + status_attr = curses.A_NORMAL # Use terminal's default colors + + # Fill the status bar with spaces using reverse video + for x in range(state.width - 1): + try: + stdscr.addch(state.height - 1, x, ' ', curses.A_REVERSE) + except curses.error: + pass + + # Add left percentage indicator (only if sidebar is visible) + if state.show_sidebar: + try: + # Use normal video if left pane is active (since status bar is already reverse) + left_attr = status_attr if state.focus == "left" else curses.A_REVERSE + # Add a space before and after the percentage with the same highlighting + stdscr.addstr(state.height - 1, 0, f" {left_status} ", left_attr) + except curses.error: + pass + + # Add commit message in the middle + if commit_message: + # Calculate available space + left_margin = len(left_status) + 5 if state.show_sidebar else 1 # +5 for the spaces around percentage + right_margin = len(right_status) + 5 # +5 for the spaces around percentage + available_width = state.width - left_margin - right_margin + + # Truncate message if needed + if len(commit_message) > available_width: + commit_message = commit_message[:available_width-3] + "..." + + # Center the message in the available space + message_x = left_margin + + try: + stdscr.addstr(state.height - 1, message_x, commit_message, curses.A_REVERSE) + except curses.error: + pass + + # Add right percentage indicator with highlighting for active pane + try: + # Include spaces in the highlighted area + padded_right_status = f" {right_status} " + right_x = state.width - len(padded_right_status) + if right_x >= 0: + # Use normal video if right pane is active (since status bar is already reverse) + right_attr = status_attr if state.focus == "right" else curses.A_REVERSE + stdscr.addstr(state.height - 1, right_x, padded_right_status, right_attr) + except curses.error: + pass + +def draw_ui(stdscr, state): + stdscr.erase() + draw_left_pane(stdscr, state) + draw_right_pane(stdscr, state) + draw_divider(stdscr, state) + draw_status_bars(stdscr, state) + draw_selection(stdscr, state) + stdscr.refresh() + +# --- Input Handling Functions (Controller) --- + +def copy_selection_to_clipboard(stdscr, state): + if not state.selection_start_coord or not state.selection_end_coord: + return + + start_x, start_y = state.selection_start_coord + end_x, end_y = state.selection_end_coord + + # Determine pane boundaries based on sidebar visibility + if state.show_sidebar: + # Determine pane from where selection started + pane = 'left' if start_x < state.divider_col else 'right' + if pane == 'left': + pane_x1, pane_x2 = 0, state.divider_col - 1 + else: # right + pane_x1, pane_x2 = state.divider_col + 2, state.width - 1 + else: + # When sidebar is hidden, there's only the right pane + pane = 'right' + pane_x1, pane_x2 = 0, state.width - 1 + + # Determine drag direction to handle multi-line selection correctly + if start_y < end_y or (start_y == end_y and start_x <= end_x): + drag_start_x, drag_start_y = start_x, start_y + drag_end_x, drag_end_y = end_x, end_y + else: # upward drag or right-to-left on same line + drag_start_x, drag_start_y = end_x, end_y + drag_end_x, drag_end_y = start_x, start_y + + height, width = stdscr.getmaxyx() + selected_text_parts = [] + + for y in range(drag_start_y, drag_end_y + 1): + if not (0 <= y < height): continue - # Mouse interaction - if enable_mouse and key == curses.KEY_MOUSE: - try: - _, mx, my, _, bstate = curses.getmouse() + x1, x2 = -1, -1 + if drag_start_y == drag_end_y: # single line selection + x1, x2 = drag_start_x, drag_end_x + elif y == drag_start_y: # first line of multi-line selection + x1, x2 = drag_start_x, pane_x2 + elif y == drag_end_y: # last line of multi-line selection + x1, x2 = pane_x1, drag_end_x + else: # middle line of multi-line selection + x1, x2 = pane_x1, pane_x2 + + # Clamp selection to pane boundaries and screen width + x1 = max(x1, pane_x1) + x2 = min(x2, pane_x2, width - 1) - # Handle divider dragging - if bstate & curses.BUTTON1_PRESSED: - # Start dragging when clicked near divider - if abs(mx - divider_col) <= 1: # Allow clicking within 1 column of divider - dragging_divider = True - - # If already dragging, update divider position - if dragging_divider: - # Update divider position while dragging - min_col = 10 - max_col = width - 20 # leave space for right pane - divider_col = max(min_col, min(mx, max_col)) - - # Handle mouse release - if bstate & curses.BUTTON1_RELEASED: - if dragging_divider: - dragging_divider = False - else: - # Change focus on mouse click (when not dragging) - focus = "left" if mx < divider_col else "right" - except curses.error: + line_str = "" + if x1 <= x2: + for x in range(x1, x2 + 1): + try: + char_and_attr = stdscr.inch(y, x) + char = char_and_attr & 0xFF + line_str += chr(char) + except curses.error: + line_str += " " + selected_text_parts.append(line_str) + + if selected_text_parts: + text_to_copy = "\n".join(selected_text_parts) + if text_to_copy.strip(): + try: + subprocess.run(['pbcopy'], input=text_to_copy, text=True, check=True) + except (FileNotFoundError, subprocess.CalledProcessError): pass - # Exit on 'q' or ESC - elif key in [ord('q'), 27]: - break +def handle_mouse_input(stdscr, state): + try: + _, mx, my, _, bstate = curses.getmouse() + state.last_bstate = bstate + state.mouse_x, state.mouse_y = mx, my - # Toggle mouse support - elif key == ord('m'): - enable_mouse = not enable_mouse - if enable_mouse: - curses.mousemask(curses.ALL_MOUSE_EVENTS) + # Handle mouse button press + if bstate & curses.BUTTON1_PRESSED: + # Check if clicking near divider (only if sidebar is visible) + if state.show_sidebar and abs(mx - state.divider_col) <= 1: + state.dragging_divider = True else: - curses.mousemask(0) + # Switch panes immediately on click (only if sidebar is visible) + if state.show_sidebar: + state.focus = "left" if mx < state.divider_col else "right" + else: + state.focus = "right" # When sidebar is hidden, focus is always right + + # Also start a potential selection (in case this becomes a drag) + state.is_selecting = True + state.selection_start_coord = (mx, my) + state.selection_end_coord = (mx, my) + state.click_position = (mx, my) + + # Redraw immediately to show selection highlight and focus change + draw_ui(stdscr, state) + return + + # Handle mouse button release + if bstate & curses.BUTTON1_RELEASED: + # End of divider drag + if state.dragging_divider: + state.dragging_divider = False + return + + # End of selection + if state.is_selecting: + # Check if this was a click (press and release at same position) + # or very close to the same position (within 1-2 pixels) + if (state.click_position and + abs(state.click_position[0] - mx) <= 2 and + abs(state.click_position[1] - my) <= 2): + # This was a click, so switch panes (only if sidebar is visible) + if state.show_sidebar: + state.focus = "left" if mx < state.divider_col else "right" + else: + state.focus = "right" # When sidebar is hidden, focus is always right + + # If clicking in the left pane on a commit entry, select that commit + if state.show_sidebar and mx < state.divider_col and my < min(state.height - 1, len(state.commits) - state.left_scroll_offset): + new_commit_idx = my + state.left_scroll_offset + if 0 <= new_commit_idx < len(state.commits): + state.selected_commit_idx = new_commit_idx + state.load_commit_content() + elif state.selection_start_coord and state.selection_end_coord: + # This was a drag selection, copy the text + copy_selection_to_clipboard(stdscr, state) + + # Reset selection state + state.is_selecting = False + state.selection_start_coord = None + state.selection_end_coord = None + state.click_position = None + return + + # Handle mouse movement during drag operations + if state.dragging_divider: + # Update divider position during drag + state.update_divider(mx) + elif state.is_selecting: + # Update selection end coordinates during drag + state.selection_end_coord = (mx, my) + # Redraw immediately to show selection highlight during drag + draw_ui(stdscr, state) - # Left pane movement - elif focus == "left": - if key in [curses.KEY_DOWN, ord('j')]: - if selected_commit < len(commits) - 1: - selected_commit += 1 - elif key in [curses.KEY_UP, ord('k')]: - if selected_commit > 0: - selected_commit -= 1 - elif key in [curses.KEY_NPAGE, ord(' ')]: - # Page down in left pane - selected_commit = min(selected_commit + height - 1, len(commits) - 1) - elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: - # Page up in left pane - selected_commit = max(0, selected_commit - (height - 1)) + except curses.error: + pass - # Right pane scrolling - elif focus == "right": - if key in [curses.KEY_DOWN, ord('j')]: - if scroll_offset < max_scroll: - scroll_offset += 1 - elif key in [curses.KEY_UP, ord('k')]: - if scroll_offset > 0: - scroll_offset -= 1 - elif key in [curses.KEY_NPAGE, ord(' ')]: - scroll_offset = min(scroll_offset + height - 1, max_scroll) - elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: # Page Up, Backspace, Delete, or Shift+Up - scroll_offset = max(0, scroll_offset - (height - 1)) - elif key == curses.KEY_BACKSPACE: # Another way to detect Shift+Space in some terminals - scroll_offset = max(0, scroll_offset - (height - 1)) +def handle_keyboard_input(key, state): + if key in [ord('q')]: + state.should_exit = True + elif key == 27: # Escape key + if state.is_selecting: + state.is_selecting = False + state.selection_start_coord = None + state.selection_end_coord = None + else: + state.should_exit = True + elif key == ord('m'): + state.toggle_mouse() + elif key == ord('s'): + state.toggle_sidebar() + elif key in [curses.KEY_LEFT, ord('h')]: + if state.show_sidebar: + state.focus = "left" + elif key in [curses.KEY_RIGHT, ord('l')]: + state.focus = "right" + + elif state.focus == "left": + if key in [curses.KEY_DOWN, ord('j')]: + state.move_commit_selection(1) + elif key in [curses.KEY_UP, ord('k')]: + state.move_commit_selection(-1) + elif key in [curses.KEY_NPAGE, ord(' ')]: + state.page_commit_selection(1) + elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: + state.page_commit_selection(-1) - # Pane switching - if key in [curses.KEY_LEFT, ord('h')]: - focus = "left" - elif key in [curses.KEY_RIGHT, ord('l')]: - focus = "right" + elif state.focus == "right": + if key in [curses.KEY_DOWN, ord('j')]: + state.scroll_right_pane(1) + elif key in [curses.KEY_UP, ord('k')]: + state.scroll_right_pane(-1) + elif key in [curses.KEY_NPAGE, ord(' ')]: + state.page_right_pane(1) + elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: + state.page_right_pane(-1) - stdscr.refresh() +# --- Main Application --- -def show_help(): - """Display help information""" - parser.print_help() +def main(stdscr, filename, show_diff, show_add, show_del, mouse): + try: + curses.curs_set(0) + except: + pass + stdscr.keypad(True) + stdscr.timeout(50) # Less aggressive timeout (50ms instead of 10ms) + + if curses.has_colors(): + curses.use_default_colors() + # Use a safe background color (7 or less) to avoid "Color number is greater than COLORS-1" error + curses.init_pair(1, curses.COLOR_BLACK, 7) # Active pane: black on white + curses.init_pair(2, curses.COLOR_WHITE, 0) # Inactive pane: white on black + curses.init_pair(3, curses.COLOR_GREEN, -1) + curses.init_pair(4, curses.COLOR_RED, -1) + + height, width = stdscr.getmaxyx() + state = AppState(filename, width, height, show_diff, show_add, show_del, mouse) + + if state.enable_mouse: + curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) + # Enable mouse motion events for better drag tracking + # This escape code is not supported by all terminals (e.g., nsterm) + if os.environ.get("TERM") != "nsterm": + try: + print("\033[?1003h", end="", flush=True) + except Exception: + pass + + state.load_commit_content() + + # Initial draw before the main loop starts + draw_ui(stdscr, state) + + while not state.should_exit: + try: + key = stdscr.getch() + + # Process input and update the application state + if key == -1: # No input available (timeout) + pass # Just redraw the UI and continue + elif key == curses.KEY_RESIZE: + h, w = stdscr.getmaxyx() + state.update_dimensions(h, w) + elif state.enable_mouse and key == curses.KEY_MOUSE: + handle_mouse_input(stdscr, state) + else: + handle_keyboard_input(key, state) + + # After every action, redraw the UI to reflect changes immediately. + # This is crucial for real-time feedback during mouse drags. + draw_ui(stdscr, state) + except Exception as e: + # Prevent crashes by catching exceptions + # Uncomment for debugging: + # with open("/tmp/gtm_error.log", "a") as f: + # f.write(f"{str(e)}\n") + pass + + # Disable mouse motion events when exiting + if state.enable_mouse: + # This escape code is not supported by all terminals (e.g., nsterm) + if os.environ.get("TERM") != "nsterm": + try: + print("\033[?1003l", end="", flush=True) + except Exception: + pass if __name__ == "__main__": parser = argparse.ArgumentParser(description="A \"Git Time Machine\" for viewing file history") @@ -464,10 +715,8 @@ if __name__ == "__main__": filename = args.filename - # Check if the file exists if not os.path.isfile(filename): print(f"Error: File '{filename}' does not exist") sys.exit(1) curses.wrapper(main, filename, args.diff, args.diff_additions, args.diff_deletions, not args.no_mouse) - diff --git a/gtm2.py b/gtm2.py deleted file mode 100755 index abeaba4..0000000 --- a/gtm2.py +++ /dev/null @@ -1,722 +0,0 @@ -#!/usr/bin/env python3 - -import curses -import os -import subprocess -import sys -import argparse - -VERSION = "2025-06-07.3" - -# --- Data Fetching & Utility Functions (Pure) --- - -def get_commits(filename): - cmd = ['git', 'log', '--pretty=format:%h %ad %s', '--date=format:%Y-%m-%d %H:%M', '--', filename] - result = subprocess.run(cmd, capture_output=True, text=True) - return result.stdout.splitlines() - -def get_file_at_commit(commit_hash, filename): - cmd = ['git', 'show', f'{commit_hash}:{filename}'] - result = subprocess.run(cmd, capture_output=True, text=True) - return result.stdout.splitlines() - -def find_best_matching_line(reference_line, file_lines, max_lines=None): - """Find the best matching line in file_lines that matches reference_line. - Returns the line index or None if no good match is found.""" - if not reference_line or not file_lines: - return None - - # First try exact match - for i, line in enumerate(file_lines): - if line == reference_line: - return i - - # If no exact match, try to find the most similar line - # Only search through a reasonable number of lines for performance - search_lines = file_lines[:max_lines] if max_lines else file_lines - - best_match = None - best_score = 0 - - for i, line in enumerate(search_lines): - # Simple similarity score: count of common characters - score = sum(1 for a, b in zip(reference_line, line) if a == b) - - # Adjust score based on length difference - length_diff = abs(len(reference_line) - len(line)) - adjusted_score = score - (length_diff * 0.5) - - if adjusted_score > best_score: - best_score = adjusted_score - best_match = i - - # Only return a match if it's reasonably good - # (at least 60% of the shorter string length) - min_length = min(len(reference_line), 1) # Avoid division by zero - if best_score > (min_length * 0.6): - return best_match - - return None - -def get_diff_info(current_commit, prev_commit, filename): - """Get diff information between two commits for a file""" - if not prev_commit: - return [], [] - - cmd = ['git', 'diff', prev_commit, current_commit, '--', filename] - result = subprocess.run(cmd, capture_output=True, text=True) - - lines = result.stdout.splitlines() - added_lines = [] - deleted_lines = [] - current_line_num = 0 - i = 0 - while i < len(lines): - line = lines[i] - if line.startswith('@@'): - parts = line.split() - if len(parts) >= 3: - add_part = parts[2][1:] - if ',' in add_part: - current_line_num, _ = map(int, add_part.split(',')) - else: - current_line_num = int(add_part) - elif line.startswith('+') and not line.startswith('+++'): - added_lines.append((current_line_num, line[1:])) - current_line_num += 1 - elif line.startswith('-') and not line.startswith('---'): - deleted_lines.append((current_line_num, line[1:])) - elif not line.startswith('+') and not line.startswith('-') and not line.startswith('@@'): - current_line_num += 1 - i += 1 - return added_lines, deleted_lines - -# --- Application State Class --- - -class AppState: - def __init__(self, filename, width, height, show_diff, show_add, show_del, mouse): - self.filename = filename - self.width = width - self.height = height - self.show_whole_diff = show_diff - self.show_additions = show_add - self.show_deletions = show_del - self.enable_mouse = mouse - self.show_sidebar = True - - self.commits = get_commits(filename) - self.file_lines = [] - self.added_lines = [] - self.deleted_lines = [] - - self.focus = "left" - self.divider_col = 40 - - self.selected_commit_idx = 0 - self.left_scroll_offset = 0 - self.right_scroll_offset = 0 - - self.dragging_divider = False - self.should_exit = False - - self.is_selecting = False - self.selection_start_coord = None - self.selection_end_coord = None - self.click_position = None # Store click position to detect clicks vs. drags - self.last_bstate = 0 - self.mouse_x = -1 - self.mouse_y = -1 - - def update_dimensions(self, height, width): - self.height = height - self.width = width - - def toggle_mouse(self): - self.enable_mouse = not self.enable_mouse - if self.enable_mouse: - curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) - else: - curses.mousemask(0) - - def toggle_sidebar(self): - self.show_sidebar = not self.show_sidebar - # When hiding sidebar, focus should be on right pane - if not self.show_sidebar: - self.focus = "right" - - def move_commit_selection(self, delta): - new_idx = self.selected_commit_idx + delta - if 0 <= new_idx < len(self.commits): - self.selected_commit_idx = new_idx - self.load_commit_content() - - def page_commit_selection(self, direction): - page_size = self.height - 1 - delta = page_size * direction - self.move_commit_selection(delta) - - def scroll_right_pane(self, delta): - max_scroll = max(0, len(self.file_lines) - (self.height - 1)) - new_offset = self.right_scroll_offset + delta - self.right_scroll_offset = max(0, min(new_offset, max_scroll)) - - def page_right_pane(self, direction): - page_size = self.height - 1 - delta = page_size * direction - self.scroll_right_pane(delta) - - def update_divider(self, mx): - min_col = 10 - max_col = self.width - 20 - self.divider_col = max(min_col, min(mx, max_col)) - - def load_commit_content(self): - if not self.commits: - return - - reference_line = None - scroll_percentage = 0 - if len(self.file_lines) > 0: - max_scroll_old = max(0, len(self.file_lines) - (self.height - 1)) - if max_scroll_old > 0: - scroll_percentage = self.right_scroll_offset / max_scroll_old - if self.right_scroll_offset < len(self.file_lines): - reference_line = self.file_lines[self.right_scroll_offset] - - commit_hash = self.commits[self.selected_commit_idx].split()[0] - self.file_lines = get_file_at_commit(commit_hash, self.filename) - - prev_commit_hash = None - if self.selected_commit_idx < len(self.commits) - 1: - prev_commit_hash = self.commits[self.selected_commit_idx + 1].split()[0] - - if self.show_whole_diff or self.show_additions or self.show_deletions: - self.added_lines, self.deleted_lines = get_diff_info(commit_hash, prev_commit_hash, self.filename) - else: - self.added_lines, self.deleted_lines = [], [] - - max_scroll_new = max(0, len(self.file_lines) - (self.height - 1)) - if reference_line: - matching_line_idx = find_best_matching_line(reference_line, self.file_lines, 1000) - if matching_line_idx is not None: - self.right_scroll_offset = matching_line_idx - else: - self.right_scroll_offset = int(scroll_percentage * max_scroll_new) - else: - self.right_scroll_offset = int(scroll_percentage * max_scroll_new) - - self.right_scroll_offset = max(0, min(self.right_scroll_offset, max_scroll_new)) - -# --- Rendering Functions (View) --- - -def draw_left_pane(stdscr, state): - if not state.show_sidebar: - return - - if state.selected_commit_idx < state.left_scroll_offset: - state.left_scroll_offset = state.selected_commit_idx - elif state.selected_commit_idx >= state.left_scroll_offset + state.height - 1: - state.left_scroll_offset = state.selected_commit_idx - (state.height - 2) - - visible_commits = state.commits[state.left_scroll_offset:state.left_scroll_offset + state.height - 1] - for i, line in enumerate(visible_commits): - display_index = i + state.left_scroll_offset - if display_index == state.selected_commit_idx: - stdscr.attron(curses.A_REVERSE) - stdscr.addnstr(i, 0, line, state.divider_col - 1) - if display_index == state.selected_commit_idx: - stdscr.attroff(curses.A_REVERSE) - -def draw_right_pane(stdscr, state): - # If sidebar is hidden, right pane starts at column 0 - right_start = 0 if not state.show_sidebar else state.divider_col + 2 - right_width = state.width - right_start - 1 - - max_scroll = max(0, len(state.file_lines) - (state.height - 1)) - state.right_scroll_offset = min(state.right_scroll_offset, max_scroll) - - visible_lines = state.file_lines[state.right_scroll_offset:state.right_scroll_offset + state.height - 1] - - display_lines = [] - deleted_line_map = {} - if state.show_whole_diff or state.show_deletions: - for del_line_num, del_content in state.deleted_lines: - if del_line_num not in deleted_line_map: - deleted_line_map[del_line_num] = [] - deleted_line_map[del_line_num].append(del_content) - - for i, line in enumerate(visible_lines): - line_num = i + state.right_scroll_offset + 1 - if (state.show_whole_diff or state.show_deletions) and line_num in deleted_line_map: - for del_content in deleted_line_map[line_num]: - display_lines.append({'type': 'deleted', 'content': del_content}) - - is_added = False - if state.show_whole_diff or state.show_additions: - for added_line_num, _ in state.added_lines: - if added_line_num == line_num: - is_added = True - break - - display_lines.append({'type': 'added' if is_added else 'regular', 'content': line}) - - display_row = 0 - for line_info in display_lines: - if display_row >= state.height - 2: - break - - line_type = line_info['type'] - content = line_info['content'] - - if line_type == 'added': - stdscr.addstr(display_row, right_start, "+ ", curses.color_pair(3)) - stdscr.addnstr(display_row, right_start + 2, content, right_width - 2, curses.color_pair(3)) - elif line_type == 'deleted': - stdscr.addstr(display_row, right_start, "- ", curses.color_pair(4)) - stdscr.addnstr(display_row, right_start + 2, content, right_width - 2, curses.color_pair(4)) - else: - if state.show_whole_diff or state.show_additions or state.show_deletions: - stdscr.addstr(display_row, right_start, " ") - stdscr.addnstr(display_row, right_start + 2, content, right_width - 2) - else: - # No diff mode, so don't add the margin padding - stdscr.addnstr(display_row, right_start, content, right_width) - - display_row += 1 - -def draw_divider(stdscr, state): - if not state.show_sidebar: - return - - divider_char = "║" if state.dragging_divider else "│" - for y in range(state.height - 1): # Don't draw through the status bar - try: - stdscr.addch(y, state.divider_col, divider_char) - except curses.error: - pass - -def draw_selection(stdscr, state): - if not state.is_selecting or not state.selection_start_coord: - return - - start_x, start_y = state.selection_start_coord - end_x, end_y = state.selection_end_coord - - # Determine pane boundaries based on sidebar visibility - if state.show_sidebar: - # Determine pane from where selection started - pane = 'left' if start_x < state.divider_col else 'right' - if pane == 'left': - pane_x1, pane_x2 = 0, state.divider_col - 1 - else: # right - pane_x1, pane_x2 = state.divider_col + 2, state.width - 1 - else: - # When sidebar is hidden, there's only the right pane - pane = 'right' - pane_x1, pane_x2 = 0, state.width - 1 - - # Determine drag direction to handle multi-line selection correctly - if start_y < end_y or (start_y == end_y and start_x <= end_x): - drag_start_x, drag_start_y = start_x, start_y - drag_end_x, drag_end_y = end_x, end_y - else: # upward drag or right-to-left on same line - drag_start_x, drag_start_y = end_x, end_y - drag_end_x, drag_end_y = start_x, start_y - - for y in range(drag_start_y, drag_end_y + 1): - x1, x2 = -1, -1 - if drag_start_y == drag_end_y: # single line selection - x1, x2 = drag_start_x, drag_end_x - elif y == drag_start_y: # first line of multi-line selection - x1, x2 = drag_start_x, pane_x2 - elif y == drag_end_y: # last line of multi-line selection - x1, x2 = pane_x1, drag_end_x - else: # middle line of multi-line selection - x1, x2 = pane_x1, pane_x2 - - # Clamp selection to pane boundaries - x1 = max(x1, pane_x1) - x2 = min(x2, pane_x2) - - try: - if x1 < state.width and x1 <= x2: - length = x2 - x1 + 1 - if length > 0: - stdscr.chgat(y, x1, length, curses.A_REVERSE) - except curses.error: - pass - -def draw_status_bars(stdscr, state): - # We'll use height-1 for the single status bar - visible_height = state.height - 1 - - # Get commit message for the selected commit - commit_message = "" - if state.commits and state.selected_commit_idx < len(state.commits): - # Format is now: hash date time message - # We need to split on more than just the first two spaces - commit_line = state.commits[state.selected_commit_idx] - parts = commit_line.split(' ', 3) # Split into hash, date, time, message - if len(parts) >= 4: - commit_message = parts[3] - - # Status bar percentages - if len(state.file_lines) > 0: - last_visible_line = state.right_scroll_offset + visible_height - right_percent = int((last_visible_line / len(state.file_lines)) * 100) - right_percent = 100 if last_visible_line >= len(state.file_lines) else right_percent - else: - right_percent = 0 - right_status = f"{right_percent}%" - - if len(state.commits) > 0 and state.show_sidebar: - last_visible_commit = state.left_scroll_offset + visible_height - left_percent = int((last_visible_commit / len(state.commits)) * 100) - left_percent = 100 if last_visible_commit >= len(state.commits) else left_percent - else: - left_percent = 0 - left_status = f"{left_percent}%" - - # Use terminal's default colors for the status bar - status_attr = curses.A_NORMAL # Use terminal's default colors - - # Fill the status bar with spaces using reverse video - for x in range(state.width - 1): - try: - stdscr.addch(state.height - 1, x, ' ', curses.A_REVERSE) - except curses.error: - pass - - # Add left percentage indicator (only if sidebar is visible) - if state.show_sidebar: - try: - # Use normal video if left pane is active (since status bar is already reverse) - left_attr = status_attr if state.focus == "left" else curses.A_REVERSE - # Add a space before and after the percentage with the same highlighting - stdscr.addstr(state.height - 1, 0, f" {left_status} ", left_attr) - except curses.error: - pass - - # Add commit message in the middle - if commit_message: - # Calculate available space - left_margin = len(left_status) + 5 if state.show_sidebar else 1 # +5 for the spaces around percentage - right_margin = len(right_status) + 5 # +5 for the spaces around percentage - available_width = state.width - left_margin - right_margin - - # Truncate message if needed - if len(commit_message) > available_width: - commit_message = commit_message[:available_width-3] + "..." - - # Center the message in the available space - message_x = left_margin - - try: - stdscr.addstr(state.height - 1, message_x, commit_message, curses.A_REVERSE) - except curses.error: - pass - - # Add right percentage indicator with highlighting for active pane - try: - # Include spaces in the highlighted area - padded_right_status = f" {right_status} " - right_x = state.width - len(padded_right_status) - if right_x >= 0: - # Use normal video if right pane is active (since status bar is already reverse) - right_attr = status_attr if state.focus == "right" else curses.A_REVERSE - stdscr.addstr(state.height - 1, right_x, padded_right_status, right_attr) - except curses.error: - pass - -def draw_ui(stdscr, state): - stdscr.erase() - draw_left_pane(stdscr, state) - draw_right_pane(stdscr, state) - draw_divider(stdscr, state) - draw_status_bars(stdscr, state) - draw_selection(stdscr, state) - stdscr.refresh() - -# --- Input Handling Functions (Controller) --- - -def copy_selection_to_clipboard(stdscr, state): - if not state.selection_start_coord or not state.selection_end_coord: - return - - start_x, start_y = state.selection_start_coord - end_x, end_y = state.selection_end_coord - - # Determine pane boundaries based on sidebar visibility - if state.show_sidebar: - # Determine pane from where selection started - pane = 'left' if start_x < state.divider_col else 'right' - if pane == 'left': - pane_x1, pane_x2 = 0, state.divider_col - 1 - else: # right - pane_x1, pane_x2 = state.divider_col + 2, state.width - 1 - else: - # When sidebar is hidden, there's only the right pane - pane = 'right' - pane_x1, pane_x2 = 0, state.width - 1 - - # Determine drag direction to handle multi-line selection correctly - if start_y < end_y or (start_y == end_y and start_x <= end_x): - drag_start_x, drag_start_y = start_x, start_y - drag_end_x, drag_end_y = end_x, end_y - else: # upward drag or right-to-left on same line - drag_start_x, drag_start_y = end_x, end_y - drag_end_x, drag_end_y = start_x, start_y - - height, width = stdscr.getmaxyx() - selected_text_parts = [] - - for y in range(drag_start_y, drag_end_y + 1): - if not (0 <= y < height): - continue - - x1, x2 = -1, -1 - if drag_start_y == drag_end_y: # single line selection - x1, x2 = drag_start_x, drag_end_x - elif y == drag_start_y: # first line of multi-line selection - x1, x2 = drag_start_x, pane_x2 - elif y == drag_end_y: # last line of multi-line selection - x1, x2 = pane_x1, drag_end_x - else: # middle line of multi-line selection - x1, x2 = pane_x1, pane_x2 - - # Clamp selection to pane boundaries and screen width - x1 = max(x1, pane_x1) - x2 = min(x2, pane_x2, width - 1) - - line_str = "" - if x1 <= x2: - for x in range(x1, x2 + 1): - try: - char_and_attr = stdscr.inch(y, x) - char = char_and_attr & 0xFF - line_str += chr(char) - except curses.error: - line_str += " " - selected_text_parts.append(line_str) - - if selected_text_parts: - text_to_copy = "\n".join(selected_text_parts) - if text_to_copy.strip(): - try: - subprocess.run(['pbcopy'], input=text_to_copy, text=True, check=True) - except (FileNotFoundError, subprocess.CalledProcessError): - pass - -def handle_mouse_input(stdscr, state): - try: - _, mx, my, _, bstate = curses.getmouse() - state.last_bstate = bstate - state.mouse_x, state.mouse_y = mx, my - - # Handle mouse button press - if bstate & curses.BUTTON1_PRESSED: - # Check if clicking near divider (only if sidebar is visible) - if state.show_sidebar and abs(mx - state.divider_col) <= 1: - state.dragging_divider = True - else: - # Switch panes immediately on click (only if sidebar is visible) - if state.show_sidebar: - state.focus = "left" if mx < state.divider_col else "right" - else: - state.focus = "right" # When sidebar is hidden, focus is always right - - # Also start a potential selection (in case this becomes a drag) - state.is_selecting = True - state.selection_start_coord = (mx, my) - state.selection_end_coord = (mx, my) - state.click_position = (mx, my) - - # Redraw immediately to show selection highlight and focus change - draw_ui(stdscr, state) - return - - # Handle mouse button release - if bstate & curses.BUTTON1_RELEASED: - # End of divider drag - if state.dragging_divider: - state.dragging_divider = False - return - - # End of selection - if state.is_selecting: - # Check if this was a click (press and release at same position) - # or very close to the same position (within 1-2 pixels) - if (state.click_position and - abs(state.click_position[0] - mx) <= 2 and - abs(state.click_position[1] - my) <= 2): - # This was a click, so switch panes (only if sidebar is visible) - if state.show_sidebar: - state.focus = "left" if mx < state.divider_col else "right" - else: - state.focus = "right" # When sidebar is hidden, focus is always right - - # If clicking in the left pane on a commit entry, select that commit - if state.show_sidebar and mx < state.divider_col and my < min(state.height - 1, len(state.commits) - state.left_scroll_offset): - new_commit_idx = my + state.left_scroll_offset - if 0 <= new_commit_idx < len(state.commits): - state.selected_commit_idx = new_commit_idx - state.load_commit_content() - elif state.selection_start_coord and state.selection_end_coord: - # This was a drag selection, copy the text - copy_selection_to_clipboard(stdscr, state) - - # Reset selection state - state.is_selecting = False - state.selection_start_coord = None - state.selection_end_coord = None - state.click_position = None - return - - # Handle mouse movement during drag operations - if state.dragging_divider: - # Update divider position during drag - state.update_divider(mx) - elif state.is_selecting: - # Update selection end coordinates during drag - state.selection_end_coord = (mx, my) - # Redraw immediately to show selection highlight during drag - draw_ui(stdscr, state) - - except curses.error: - pass - -def handle_keyboard_input(key, state): - if key in [ord('q')]: - state.should_exit = True - elif key == 27: # Escape key - if state.is_selecting: - state.is_selecting = False - state.selection_start_coord = None - state.selection_end_coord = None - else: - state.should_exit = True - elif key == ord('m'): - state.toggle_mouse() - elif key == ord('s'): - state.toggle_sidebar() - elif key in [curses.KEY_LEFT, ord('h')]: - if state.show_sidebar: - state.focus = "left" - elif key in [curses.KEY_RIGHT, ord('l')]: - state.focus = "right" - - elif state.focus == "left": - if key in [curses.KEY_DOWN, ord('j')]: - state.move_commit_selection(1) - elif key in [curses.KEY_UP, ord('k')]: - state.move_commit_selection(-1) - elif key in [curses.KEY_NPAGE, ord(' ')]: - state.page_commit_selection(1) - elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: - state.page_commit_selection(-1) - - elif state.focus == "right": - if key in [curses.KEY_DOWN, ord('j')]: - state.scroll_right_pane(1) - elif key in [curses.KEY_UP, ord('k')]: - state.scroll_right_pane(-1) - elif key in [curses.KEY_NPAGE, ord(' ')]: - state.page_right_pane(1) - elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: - state.page_right_pane(-1) - -# --- Main Application --- - -def main(stdscr, filename, show_diff, show_add, show_del, mouse): - try: - curses.curs_set(0) - except: - pass - stdscr.keypad(True) - stdscr.timeout(50) # Less aggressive timeout (50ms instead of 10ms) - - if curses.has_colors(): - curses.use_default_colors() - # Use a safe background color (7 or less) to avoid "Color number is greater than COLORS-1" error - curses.init_pair(1, curses.COLOR_BLACK, 7) # Active pane: black on white - curses.init_pair(2, curses.COLOR_WHITE, 0) # Inactive pane: white on black - curses.init_pair(3, curses.COLOR_GREEN, -1) - curses.init_pair(4, curses.COLOR_RED, -1) - - height, width = stdscr.getmaxyx() - state = AppState(filename, width, height, show_diff, show_add, show_del, mouse) - - if state.enable_mouse: - curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) - # Enable mouse motion events for better drag tracking - # This escape code is not supported by all terminals (e.g., nsterm) - if os.environ.get("TERM") != "nsterm": - try: - print("\033[?1003h", end="", flush=True) - except Exception: - pass - - state.load_commit_content() - - # Initial draw before the main loop starts - draw_ui(stdscr, state) - - while not state.should_exit: - try: - key = stdscr.getch() - - # Process input and update the application state - if key == -1: # No input available (timeout) - pass # Just redraw the UI and continue - elif key == curses.KEY_RESIZE: - h, w = stdscr.getmaxyx() - state.update_dimensions(h, w) - elif state.enable_mouse and key == curses.KEY_MOUSE: - handle_mouse_input(stdscr, state) - else: - handle_keyboard_input(key, state) - - # After every action, redraw the UI to reflect changes immediately. - # This is crucial for real-time feedback during mouse drags. - draw_ui(stdscr, state) - except Exception as e: - # Prevent crashes by catching exceptions - # Uncomment for debugging: - # with open("/tmp/gtm_error.log", "a") as f: - # f.write(f"{str(e)}\n") - pass - - # Disable mouse motion events when exiting - if state.enable_mouse: - # This escape code is not supported by all terminals (e.g., nsterm) - if os.environ.get("TERM") != "nsterm": - try: - print("\033[?1003l", end="", flush=True) - except Exception: - pass - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="A \"Git Time Machine\" for viewing file history") - parser.add_argument("-d", "--diff", action="store_true", help="Highlight newly added and deleted lines") - parser.add_argument("--diff-additions", action="store_true", help="Highlight newly added lines in green") - parser.add_argument("--diff-deletions", action="store_true", help="Show deleted lines in red") - parser.add_argument("--no-mouse", action="store_true", help="Disable mouse support") - parser.add_argument("-v", "--version", action="store_true", help="Show version number") - parser.add_argument("filename", nargs="?", help="File to view history for") - - args = parser.parse_args() - - if args.version: - print(f"gtm version {VERSION}") - sys.exit(0) - elif not args.filename: - parser.print_help() - sys.exit(1) - - filename = args.filename - - if not os.path.isfile(filename): - print(f"Error: File '{filename}' does not exist") - sys.exit(1) - - curses.wrapper(main, filename, args.diff, args.diff_additions, args.diff_deletions, not args.no_mouse)