diff --git a/gtm b/gtm index b284e8b..892ce8d 100755 --- a/gtm +++ b/gtm @@ -24,6 +24,36 @@ def get_commits(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 + author_cmd = ['git', 'show', '-s', '--format=%an <%ae>', 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) @@ -147,6 +177,16 @@ class AppState: # 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) --- @@ -202,7 +242,7 @@ def load_commit_content(state: AppState) -> AppState: reference_line = None scroll_percentage = 0 if len(state.file_lines) > 0: - max_scroll_old = max(0, len(state.file_lines) - (state.height - 1)) + 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): @@ -220,7 +260,15 @@ def load_commit_content(state: AppState) -> AppState: else: added_lines, deleted_lines = [], [] - max_scroll_new = max(0, len(file_lines) - (state.height - 1)) + # 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 hash, date, time, message + short_message = parts[3] if len(parts) >= 4 else "" + + 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) @@ -237,7 +285,11 @@ def load_commit_content(state: AppState) -> AppState: file_lines=file_lines, added_lines=added_lines, deleted_lines=deleted_lines, - right_scroll_offset=right_scroll_offset + 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 @@ -291,10 +343,10 @@ 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) + 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 - 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: @@ -429,10 +481,10 @@ def draw_right_pane(stdscr, state): right_width = state.width - right_start - 1 - max_scroll = max(0, len(state.file_lines) - (state.height - 1)) + max_scroll = max(0, len(state.file_lines) - (state.height - state.status_bar_height)) 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] + visible_lines = state.file_lines[state.right_scroll_offset:state.right_scroll_offset + state.height - state.status_bar_height] display_lines = [] deleted_line_map = {} @@ -591,7 +643,7 @@ def draw_divider(stdscr, state): return divider_char = "║" if state.dragging_divider else "│" - for y in range(state.height - 1): # Don't draw through the status bar + 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: @@ -755,53 +807,51 @@ def draw_help_popup(stdscr, state): pass def draw_status_bars(stdscr, state): - # We'll use height-1 for the single status bar - visible_height = state.height - 1 + # Calculate the position of the status bars + status_bar_start = state.height - state.status_bar_height + visible_height = status_bar_start - # If in search mode, draw the search input field instead of the normal status bar + # If in search mode, draw the search input field instead of the normal status bars if state.search_mode: - # Fill the status bar with spaces + # Fill the top status bar with spaces for x in range(state.width - 1): try: - stdscr.addch(state.height - 1, x, ' ') + stdscr.addch(status_bar_start, x, ' ') except curses.error: pass # Draw search prompt search_prompt = "Search: " try: - stdscr.addstr(state.height - 1, 0, search_prompt) + stdscr.addstr(status_bar_start, 0, search_prompt) # Draw the search query - stdscr.addstr(state.height - 1, len(search_prompt), state.search_query) + stdscr.addstr(status_bar_start, len(search_prompt), state.search_query) # Draw cursor at the end of the query try: - stdscr.move(state.height - 1, len(search_prompt) + len(state.search_query)) + stdscr.move(status_bar_start, len(search_prompt) + len(state.search_query)) except curses.error: pass # If we have search results, show count if state.search_matches: match_info = f" [{state.current_match_idx + 1}/{len(state.search_matches)}]" - stdscr.addstr(state.height - 1, state.width - len(match_info) - 1, match_info) + stdscr.addstr(status_bar_start, state.width - len(match_info) - 1, match_info) except curses.error: pass + # Draw a blank second status bar + for x in range(state.width - 1): + try: + stdscr.addch(state.height - 1, x, ' ', curses.A_REVERSE) + except curses.error: + pass + return - # Normal status bar (not in search mode) - # Get commit message for the selected commit - commit_message = "" - if state.commits and state.selected_commit_idx < len(state.commits): - # Format is now: hash date time message - # We need to split on more than just the first two spaces - commit_line = state.commits[state.selected_commit_idx] - parts = commit_line.split(' ', 3) # Split into hash, date, time, message - if len(parts) >= 4: - commit_message = parts[3] - - # Add indicators to commit message + # --- First status bar (top) --- + # Add indicators to status bar wrap_indicator = "" if state.wrap_lines else "[NW] " change_indicator = "" if state.change_blocks and state.current_change_idx != -1: @@ -811,7 +861,7 @@ def draw_status_bars(stdscr, state): if state.search_matches: search_indicator = f"[Search {state.current_match_idx + 1}/{len(state.search_matches)}] " - commit_message = wrap_indicator + change_indicator + search_indicator + commit_message + status_indicators = wrap_indicator + change_indicator + search_indicator # Status bar percentages if len(state.file_lines) > 0: @@ -833,10 +883,10 @@ def draw_status_bars(stdscr, state): # 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 + # Fill the top status bar with spaces using reverse video for x in range(state.width - 1): try: - stdscr.addch(state.height - 1, x, ' ', curses.A_REVERSE) + stdscr.addch(status_bar_start, x, ' ', curses.A_REVERSE) except curses.error: pass @@ -846,26 +896,23 @@ def draw_status_bars(stdscr, state): # 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) + stdscr.addstr(status_bar_start, 0, f" {left_status} ", left_attr) except curses.error: pass - # Add commit message in the middle - if commit_message: + # 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 = 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 + # Truncate if needed + if len(status_indicators) > available_width: + status_indicators = status_indicators[:available_width-3] + "..." try: - stdscr.addstr(state.height - 1, message_x, commit_message, curses.A_REVERSE) + stdscr.addstr(status_bar_start, left_margin, status_indicators, curses.A_REVERSE) except curses.error: pass @@ -877,7 +924,61 @@ def draw_status_bars(stdscr, state): 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) + stdscr.addstr(status_bar_start, right_x, padded_right_status, right_attr) + except curses.error: + pass + + # --- Second status bar (bottom) --- + # Draw the commit details in the second status bar + + # Fill the bottom 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 + + # Draw the commit hash and message on the left + if state.commit_hash: + commit_info = f" {state.commit_hash} " + + # Calculate available space for the commit message + author_branch_info = f" {state.commit_author} [{state.commit_branch}] " + available_width = state.width - len(commit_info) - len(author_branch_info) - 1 + + # Get the commit message (potentially multi-line) + commit_message = state.commit_message + + # For multi-line messages, we'll show as many lines as we can fit in the status bar height + message_lines = commit_message.splitlines() + if not message_lines: + message_lines = [""] + + # Show the first line in the status bar + display_message = message_lines[0] + + # Truncate message if needed + if len(display_message) > available_width: + display_message = display_message[:available_width-3] + "..." + + try: + # Draw the commit hash with bold + stdscr.addstr(state.height - 1, 0, commit_info, curses.A_REVERSE | curses.A_BOLD) + + # Draw the commit message + stdscr.addstr(state.height - 1, len(commit_info), display_message, curses.A_REVERSE) + + # Draw the author and branch on the right + right_x = state.width - len(author_branch_info) + if right_x >= 0: + stdscr.addstr(state.height - 1, right_x, author_branch_info, curses.A_REVERSE) + except curses.error: + pass + + # Draw a handle for resizing the status bar + try: + handle_char = "≡" if state.dragging_status_bar else "=" + stdscr.addstr(status_bar_start, state.width // 2 - 1, handle_char * 3, curses.A_REVERSE | curses.A_BOLD) except curses.error: pass @@ -1014,15 +1115,37 @@ def copy_selection_to_clipboard(stdscr, state): except (FileNotFoundError, subprocess.CalledProcessError): pass # Silently fail if no clipboard command is available +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 how far the user has dragged + status_bar_start = state.height - state.status_bar_height + drag_distance = status_bar_start - my + new_height = state.status_bar_height + drag_distance + + # Clamp to allowed range + new_height = max(min_height, min(new_height, max_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: if state.show_sidebar and abs(mx - state.divider_col) <= 1: return replace(state, dragging_divider=True) + elif my == status_bar_start and abs(mx - (state.width // 2)) <= 2: + # Clicked on the status bar handle + return replace(state, dragging_status_bar=True) else: focus = "right" if state.show_sidebar: @@ -1039,6 +1162,8 @@ def handle_mouse_input(stdscr, state: AppState) -> AppState: if bstate & curses.BUTTON1_RELEASED: 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) @@ -1054,7 +1179,7 @@ def handle_mouse_input(stdscr, state: AppState) -> AppState: new_state = replace(state, focus=focus) # If clicking in the left pane on a commit entry, select that commit - if state.show_sidebar and mx < state.divider_col and my < min(state.height - 1, len(state.commits) - state.left_scroll_offset): + 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) @@ -1073,6 +1198,8 @@ def handle_mouse_input(stdscr, state: AppState) -> AppState: # 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))