From 0e385b1873ecd03b11be00b93458df7e4e8b7f3f Mon Sep 17 00:00:00 2001 From: "n loewen (aider)" Date: Sat, 7 Jun 2025 21:19:34 +0100 Subject: [PATCH] refactor: Restructure gtm into a more modular, class-based architecture --- gtm2.py | 428 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 428 insertions(+) diff --git a/gtm2.py b/gtm2.py index e69de29..558b082 100644 --- a/gtm2.py +++ b/gtm2.py @@ -0,0 +1,428 @@ +#!/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=short', '--', 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.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 + + 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) + else: + curses.mousemask(0) + + 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 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): + right_width = state.width - state.divider_col - 3 + + 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 - 1: + break + + line_type = line_info['type'] + content = line_info['content'] + + if line_type == 'added': + stdscr.addstr(display_row, state.divider_col + 2, "+ ", curses.color_pair(3)) + stdscr.addnstr(display_row, state.divider_col + 4, content, right_width - 2, curses.color_pair(3)) + elif line_type == 'deleted': + stdscr.addstr(display_row, state.divider_col + 2, "- ", curses.color_pair(4)) + stdscr.addnstr(display_row, state.divider_col + 4, content, right_width - 2, curses.color_pair(4)) + else: + stdscr.addstr(display_row, state.divider_col + 2, " ") + stdscr.addnstr(display_row, state.divider_col + 4, content, right_width - 2) + + display_row += 1 + +def draw_divider(stdscr, state): + divider_char = "║" if state.dragging_divider else "│" + for y in range(state.height): + try: + stdscr.addch(y, state.divider_col, divider_char) + except curses.error: + pass + +def draw_status_bars(stdscr, state): + visible_height = state.height - 1 + + 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: + 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}%" + if state.enable_mouse: + left_status += " [M]" + + left_attr = curses.color_pair(1) if state.focus == "left" else curses.color_pair(2) + right_attr = curses.color_pair(1) if state.focus == "right" else curses.color_pair(2) + + for x in range(state.divider_col): + stdscr.addch(state.height - 1, x, ' ', left_attr) + for x in range(state.divider_col + 1, state.width - 1): + stdscr.addch(state.height - 1, x, ' ', right_attr) + + stdscr.addstr(state.height - 1, 1, left_status, left_attr) + stdscr.addstr(state.height - 1, state.width - len(right_status) - 1, right_status, right_attr) + + stdscr.addch(state.height - 1, state.divider_col, "│") + +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) + stdscr.refresh() + +# --- Input Handling Functions (Controller) --- + +def handle_mouse_input(state): + try: + _, mx, my, _, bstate = curses.getmouse() + + if bstate & curses.BUTTON1_PRESSED: + if abs(mx - state.divider_col) <= 1: + state.dragging_divider = True + + if state.dragging_divider: + state.update_divider(mx) + + if bstate & curses.BUTTON1_RELEASED: + if state.dragging_divider: + state.dragging_divider = False + else: + state.focus = "left" if mx < state.divider_col else "right" + except curses.error: + pass + +def handle_keyboard_input(key, state): + if key in [ord('q'), 27]: + state.should_exit = True + elif key == ord('m'): + state.toggle_mouse() + elif key in [curses.KEY_LEFT, ord('h')]: + 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): + curses.curs_set(0) + stdscr.keypad(True) + stdscr.nodelay(True) + stdscr.timeout(50) + + if curses.has_colors(): + curses.use_default_colors() + curses.init_pair(1, 5, 7) + curses.init_pair(2, curses.COLOR_WHITE, 8) + 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) + + state.load_commit_content() + + while not state.should_exit: + draw_ui(stdscr, state) + + key = stdscr.getch() + if key == -1: + continue + + if key == curses.KEY_RESIZE: + h, w = stdscr.getmaxyx() + state.update_dimensions(h, w) + continue + + if state.enable_mouse and key == curses.KEY_MOUSE: + handle_mouse_input(state) + else: + handle_keyboard_input(key, state) + +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)