/** * ncurses_diff_viewer.c * NCurses-based interactive diff viewer implementation */ #include "ncurses_diff_viewer.h" #include "git_integration.h" #include #include #include #include #include #include #include #include #include #include #include static volatile int terminal_resized = 0; void handle_sigwinch(int sig) { (void)sig; terminal_resized = 1; } /** * Handle terminal resize by recreating all windows */ void handle_terminal_resize(NCursesDiffViewer *viewer) { if (!viewer) return; // Clean up old windows if (viewer->file_list_win) delwin(viewer->file_list_win); if (viewer->branch_list_win) delwin(viewer->branch_list_win); if (viewer->commit_list_win) delwin(viewer->commit_list_win); if (viewer->stash_list_win) delwin(viewer->stash_list_win); if (viewer->file_content_win) delwin(viewer->file_content_win); if (viewer->status_bar_win) delwin(viewer->status_bar_win); // Clean up search windows during resize cleanup_fuzzy_search(viewer); cleanup_grep_search(viewer); // Reinitialize ncurses with new terminal size endwin(); refresh(); clear(); // Get new terminal dimensions getmaxyx(stdscr, viewer->terminal_height, viewer->terminal_width); viewer->file_panel_width = viewer->terminal_width * 0.4; viewer->status_bar_height = viewer->terminal_height * 0.05; if (viewer->status_bar_height < 1) viewer->status_bar_height = 1; int available_height = viewer->terminal_height - 1 - viewer->status_bar_height; viewer->file_panel_height = available_height * 0.3; viewer->commit_panel_height = available_height * 0.3; viewer->branch_panel_height = available_height * 0.2; viewer->stash_panel_height = available_height - viewer->file_panel_height - viewer->commit_panel_height - viewer->branch_panel_height - 3; int status_bar_y = 1 + available_height; // Recreate all windows with new dimensions viewer->file_list_win = newwin(viewer->file_panel_height, viewer->file_panel_width, 1, 0); viewer->branch_list_win = newwin(viewer->branch_panel_height, viewer->file_panel_width, 1 + viewer->file_panel_height + 1, 0); viewer->commit_list_win = newwin( viewer->commit_panel_height, viewer->file_panel_width, 1 + viewer->file_panel_height + 1 + viewer->branch_panel_height + 1, 0); viewer->stash_list_win = newwin(viewer->stash_panel_height, viewer->file_panel_width, 1 + viewer->file_panel_height + 1 + viewer->branch_panel_height + 1 + viewer->commit_panel_height + 1, 0); viewer->file_content_win = newwin( available_height, viewer->terminal_width - viewer->file_panel_width - 1, 1, viewer->file_panel_width + 1); viewer->status_bar_win = newwin(viewer->status_bar_height, viewer->terminal_width, status_bar_y, 0); // Force complete redraw terminal_resized = 0; } /** * Initialize the ncurses diff viewer */ int init_ncurses_diff_viewer(NCursesDiffViewer *viewer) { if (!viewer) return 0; memset(viewer, 0, sizeof(NCursesDiffViewer)); // Initialize fuzzy search init_fuzzy_search(viewer); // Initialize grep search init_grep_search(viewer); viewer->commit_scroll_offset = 0; viewer->selected_file = 0; viewer->file_scroll_offset = 0; viewer->file_cursor_line = 0; viewer->selected_stash = 0; viewer->stash_scroll_offset = 0; viewer->selected_branch = 0; viewer->current_mode = NCURSES_MODE_FILE_LIST; viewer->sync_status = SYNC_STATUS_IDLE; viewer->spinner_frame = 0; viewer->last_sync_time = time(NULL); viewer->animation_frame = 0; viewer->text_char_count = 0; viewer->pushing_branch_index = -1; viewer->pulling_branch_index = -1; viewer->branch_push_status = SYNC_STATUS_IDLE; viewer->branch_pull_status = SYNC_STATUS_IDLE; viewer->branch_animation_frame = 0; viewer->branch_text_char_count = 0; viewer->critical_operation_in_progress = 0; viewer->staged_cursor_line = 0; viewer->split_view_mode = 0; viewer->staged_scroll_offset = 0; viewer->active_pane = 0; viewer->total_hunks = 0; viewer->staged_line_count = 0; memset(viewer->current_file_path, 0, sizeof(viewer->current_file_path)); // Initialize branch commits viewer->branch_commit_count = 0; viewer->branch_commits_scroll_offset = 0; viewer->branch_commits_cursor_line = 0; viewer->fetch_pid = -1; viewer->fetch_in_progress = 0; memset(viewer->current_branch_for_commits, 0, sizeof(viewer->current_branch_for_commits)); // Set locale for Unicode support setlocale(LC_ALL, ""); // Initialize ncurses initscr(); cbreak(); noecho(); keypad(stdscr, TRUE); nodelay(stdscr, TRUE); // Make getch() non-blocking curs_set(0); // Hide cursor to prevent flickering // Set fast escape key detection set_escdelay(25); // Enable colors if (has_colors()) { start_color(); use_default_colors(); // Enable transparent background init_pair(1, COLOR_GREEN, -1); // Additions init_pair(2, COLOR_RED, -1); // Deletions init_pair(3, COLOR_CYAN, -1); // Headers init_pair(4, COLOR_YELLOW, -1); // Selected init_pair(5, COLOR_BLACK, COLOR_WHITE); // Highlighted selection - keep white background init_pair(6, COLOR_MAGENTA, -1); // Orange-ish color for commit hash // Gruvbox theme colors for commit info init_pair(7, COLOR_CYAN, -1); // HEAD (blue-green) init_pair(8, COLOR_GREEN, -1); // main branch (nice green) init_pair(9, COLOR_RED, -1); // origin/* (red) init_pair(10, COLOR_YELLOW, -1); // commit hash and arrows } getmaxyx(stdscr, viewer->terminal_height, viewer->terminal_width); viewer->file_panel_width = viewer->terminal_width * 0.4; // 40% of screen width viewer->status_bar_height = viewer->terminal_height * 0.05; // 5% of screen height if (viewer->status_bar_height < 1) viewer->status_bar_height = 1; // Minimum 1 line int available_height = viewer->terminal_height - 1 - viewer->status_bar_height; // Subtract top bar and status bar // Distribute height among 4 panels: file, commit, branch, stash viewer->file_panel_height = available_height * 0.3; // 30% viewer->commit_panel_height = available_height * 0.3; // 30% viewer->branch_panel_height = available_height * 0.2; // 20% viewer->stash_panel_height = available_height - viewer->file_panel_height - viewer->commit_panel_height - viewer->branch_panel_height - 3; // Rest minus separators // Position status bar right after the main content int status_bar_y = 1 + available_height; // Create six windows: file_list, branch_list, commit_list, stash_list, // file_content, status_bar viewer->file_list_win = newwin(viewer->file_panel_height, viewer->file_panel_width, 1, 0); viewer->branch_list_win = newwin(viewer->branch_panel_height, viewer->file_panel_width, 1 + viewer->file_panel_height + 1, 0); viewer->commit_list_win = newwin( viewer->commit_panel_height, viewer->file_panel_width, 1 + viewer->file_panel_height + 1 + viewer->branch_panel_height + 1, 0); viewer->stash_list_win = newwin(viewer->stash_panel_height, viewer->file_panel_width, 1 + viewer->file_panel_height + 1 + viewer->branch_panel_height + 1 + viewer->commit_panel_height + 1, 0); viewer->file_content_win = newwin( available_height, viewer->terminal_width - viewer->file_panel_width - 1, 1, viewer->file_panel_width + 1); viewer->status_bar_win = newwin(viewer->status_bar_height, viewer->terminal_width, status_bar_y, 0); if (!viewer->file_list_win || !viewer->file_content_win || !viewer->commit_list_win || !viewer->stash_list_win || !viewer->branch_list_win || !viewer->status_bar_win) { cleanup_ncurses_diff_viewer(viewer); return 0; } return 1; } /** * Get list of changed files from git */ int get_ncurses_changed_files(NCursesDiffViewer *viewer) { if (!viewer) return 0; FILE *fp = popen("git status --porcelain 2>/dev/null", "r"); if (!fp) return 0; viewer->file_count = 0; char line[512]; while (fgets(line, sizeof(line), fp) != NULL && viewer->file_count < MAX_FILES) { char *newline = strchr(line, '\n'); if (newline) *newline = '\0'; if (strlen(line) < 3) continue; char staged_status = line[0]; char unstaged_status = line[1]; char *filename = line + 3; viewer->files[viewer->file_count].status = (unstaged_status != ' ' ? unstaged_status : staged_status); viewer->files[viewer->file_count].marked_for_commit = 0; viewer->files[viewer->file_count].has_staged_changes = (staged_status != ' ' && staged_status != '?') ? 1 : 0; strncpy(viewer->files[viewer->file_count].filename, filename, MAX_FILENAME_LEN - 1); viewer->files[viewer->file_count].filename[MAX_FILENAME_LEN - 1] = '\0'; viewer->file_count++; } pclose(fp); return viewer->file_count; } /** * Create temporary file with current working version */ int create_temp_file_with_changes(const char *filename, char *temp_path) { snprintf(temp_path, 256, "/tmp/shell_diff_current_%d", getpid()); char cmd[1024]; snprintf(cmd, sizeof(cmd), "cp \"%s\" \"%s\" 2>/dev/null", filename, temp_path); return (system(cmd) == 0); } /** * Create temporary file with git HEAD version */ int create_temp_file_git_version(const char *filename, char *temp_path) { snprintf(temp_path, 256, "/tmp/shell_diff_git_%d", getpid()); char cmd[1024]; snprintf(cmd, sizeof(cmd), "git show HEAD:\"%s\" > \"%s\" 2>/dev/null", filename, temp_path); return (system(cmd) == 0); } /** * Check if a file is a new untracked file */ int is_ncurses_new_file(const char *filename) { char cmd[1024]; snprintf(cmd, sizeof(cmd), "git ls-files --error-unmatch \"%s\" 2>/dev/null", filename); FILE *fp = popen(cmd, "r"); if (!fp) return 1; // Assume new if we can't check char output[256]; int is_tracked = (fgets(output, sizeof(output), fp) != NULL); pclose(fp); return !is_tracked; // Return 1 if not tracked (new file) } /** * Load file with staging information */ int load_file_with_staging_info(NCursesDiffViewer *viewer, const char *filename) { if (!viewer || !filename) return 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; viewer->file_cursor_line = 0; viewer->total_hunks = 0; viewer->staged_line_count = 0; viewer->staged_cursor_line = 0; // Store current file path strncpy(viewer->current_file_path, filename, sizeof(viewer->current_file_path) - 1); viewer->current_file_path[sizeof(viewer->current_file_path) - 1] = '\0'; // Check if this is a new file if (is_ncurses_new_file(filename)) { // For new files, show first 50 lines as additions FILE *fp = fopen(filename, "r"); if (!fp) return 0; char line[1024]; int line_count = 0; int current_hunk = 0; // Add fake hunk header for new files NCursesFileLine *hunk_line = &viewer->file_lines[viewer->file_line_count]; snprintf(hunk_line->line, sizeof(hunk_line->line), "@@ -0,0 +1,%d @@", 50); hunk_line->type = '@'; hunk_line->is_diff_line = 0; hunk_line->hunk_id = current_hunk; hunk_line->is_staged = 0; hunk_line->line_number_old = 0; hunk_line->line_number_new = 1; hunk_line->is_context = 0; viewer->file_line_count++; while (fgets(line, sizeof(line), fp) != NULL && viewer->file_line_count < MAX_FULL_FILE_LINES && line_count < 50) { char *newline_pos = strchr(line, '\n'); if (newline_pos) *newline_pos = '\0'; NCursesFileLine *file_line = &viewer->file_lines[viewer->file_line_count]; snprintf(file_line->line, sizeof(file_line->line), "+%s", line); file_line->type = '+'; file_line->is_diff_line = 1; file_line->hunk_id = current_hunk; file_line->is_staged = 0; file_line->line_number_old = -1; file_line->line_number_new = line_count + 1; file_line->is_context = 0; viewer->file_line_count++; line_count++; } viewer->total_hunks = current_hunk + 1; fclose(fp); // For new files, also build staged view from what's actually staged rebuild_staged_view_from_git(viewer); return viewer->file_line_count; } // Use git diff to get unstaged changes (staging area vs working directory) char cmd[1024]; snprintf(cmd, sizeof(cmd), "git diff -U5 \"%s\" 2>/dev/null", filename); FILE *diff_fp = popen(cmd, "r"); if (!diff_fp) return 0; char diff_line[1024]; int current_hunk = -1; int old_line_num = 0, new_line_num = 0; while (fgets(diff_line, sizeof(diff_line), diff_fp) != NULL && viewer->file_line_count < MAX_FULL_FILE_LINES) { char *newline_pos = strchr(diff_line, '\n'); if (newline_pos) *newline_pos = '\0'; // Skip file headers if (strncmp(diff_line, "diff --git", 10) == 0 || strncmp(diff_line, "index ", 6) == 0 || strncmp(diff_line, "--- ", 4) == 0 || strncmp(diff_line, "+++ ", 4) == 0) { continue; } NCursesFileLine *file_line = &viewer->file_lines[viewer->file_line_count]; strncpy(file_line->line, diff_line, sizeof(file_line->line) - 1); file_line->line[sizeof(file_line->line) - 1] = '\0'; file_line->is_staged = 0; // Process hunk headers and content if (diff_line[0] == '@' && diff_line[1] == '@') { // Parse hunk header: @@ -old_start,old_count +new_start,new_count @@ current_hunk++; sscanf(diff_line, "@@ -%d,%*d +%d,%*d @@", &old_line_num, &new_line_num); file_line->type = '@'; file_line->is_diff_line = 0; file_line->hunk_id = current_hunk; file_line->line_number_old = old_line_num; file_line->line_number_new = new_line_num; file_line->is_context = 0; } else if (diff_line[0] == '+') { file_line->type = '+'; file_line->is_diff_line = 1; file_line->hunk_id = current_hunk; file_line->line_number_old = -1; file_line->line_number_new = new_line_num++; file_line->is_context = 0; } else if (diff_line[0] == '-') { file_line->type = '-'; file_line->is_diff_line = 1; file_line->hunk_id = current_hunk; file_line->line_number_old = old_line_num++; file_line->line_number_new = -1; file_line->is_context = 0; } else if (diff_line[0] == ' ') { file_line->type = ' '; file_line->is_diff_line = 0; file_line->hunk_id = current_hunk; file_line->line_number_old = old_line_num++; file_line->line_number_new = new_line_num++; file_line->is_context = 1; } else { continue; } viewer->file_line_count++; } viewer->total_hunks = current_hunk + 1; pclose(diff_fp); // Build staged view from what's actually in git's staging area rebuild_staged_view_from_git(viewer); return viewer->file_line_count; } /** * Stage/unstage a single line by line index */ // this is a change // this is another change int stage_hunk_by_line(NCursesDiffViewer *viewer, int line_index) { if (!viewer) return 0; if (viewer->active_pane == 0) { // Unstaged pane - use line_index directly from file_lines if (line_index < 0 || line_index >= viewer->file_line_count) return 0; NCursesFileLine *selected_line = &viewer->file_lines[line_index]; // Don't stage/unstage hunk headers or context lines if (selected_line->type == '@' || selected_line->type == ' ') return 0; // Only stage actual diff lines (+ or -) if (selected_line->type != '+' && selected_line->type != '-') return 0; // Toggle the staging state selected_line->is_staged = !selected_line->is_staged; } else { // Staged pane - need to find corresponding line in file_lines if (line_index < 0 || line_index >= viewer->staged_line_count) return 0; NCursesFileLine *staged_line = &viewer->staged_lines[line_index]; // Skip headers and context lines if (staged_line->type == '@' || staged_line->type == ' ') return 0; // Only unstage actual diff lines (+ or -) if (staged_line->type != '+' && staged_line->type != '-') return 0; // Find the corresponding line in file_lines and unstage it for (int i = 0; i < viewer->file_line_count; i++) { NCursesFileLine *orig_line = &viewer->file_lines[i]; // Match by content and type if (orig_line->type == staged_line->type && strcmp(orig_line->line, staged_line->line) == 0 && orig_line->is_staged) { orig_line->is_staged = 0; // Unstage it break; } } } // Rebuild staged view to reflect changes rebuild_staged_view(viewer); return 1; } /** * Rebuild staged view with proper git patch format for individual lines */ void rebuild_staged_view(NCursesDiffViewer *viewer) { if (!viewer) return; viewer->staged_line_count = 0; // Check if we have any staged changes int has_staged_changes = 0; for (int i = 0; i < viewer->file_line_count; i++) { if (viewer->file_lines[i].is_staged) { has_staged_changes = 1; break; } } if (!has_staged_changes) { return; } // Add git diff header NCursesFileLine *header = &viewer->staged_lines[viewer->staged_line_count++]; snprintf(header->line, sizeof(header->line), "diff --git a/%s b/%s", viewer->current_file_path, viewer->current_file_path); header->type = '@'; header->is_diff_line = 0; header->is_staged = 1; header->is_context = 0; // Add index line header = &viewer->staged_lines[viewer->staged_line_count++]; snprintf(header->line, sizeof(header->line), "index 13bdd0a..9abd450 100644"); header->type = '@'; header->is_diff_line = 0; header->is_staged = 1; header->is_context = 0; // Add file headers header = &viewer->staged_lines[viewer->staged_line_count++]; snprintf(header->line, sizeof(header->line), "--- a/%s", viewer->current_file_path); header->type = '@'; header->is_diff_line = 0; header->is_staged = 1; header->is_context = 0; header = &viewer->staged_lines[viewer->staged_line_count++]; snprintf(header->line, sizeof(header->line), "+++ b/%s", viewer->current_file_path); header->type = '@'; header->is_diff_line = 0; header->is_staged = 1; header->is_context = 0; // Process each hunk that has staged changes for (int hunk = 0; hunk < viewer->total_hunks; hunk++) { // Check if this hunk has staged changes int hunk_has_staged = 0; for (int i = 0; i < viewer->file_line_count; i++) { if (viewer->file_lines[i].hunk_id == hunk && viewer->file_lines[i].is_staged) { hunk_has_staged = 1; break; } } if (!hunk_has_staged) continue; // Find the hunk boundaries int hunk_start = -1, hunk_end = -1; for (int i = 0; i < viewer->file_line_count; i++) { if (viewer->file_lines[i].hunk_id == hunk) { if (hunk_start == -1) hunk_start = i; hunk_end = i; } } if (hunk_start == -1) continue; // Calculate new hunk header with adjusted line counts int old_start = -1, new_start = -1; int old_count = 0, new_count = 0; // Find first line numbers for (int i = hunk_start; i <= hunk_end; i++) { NCursesFileLine *line = &viewer->file_lines[i]; if (line->type == '@') { old_start = line->line_number_old; new_start = line->line_number_new; } else if (line->is_staged || line->is_context) { if (line->type != '+') old_count++; if (line->type != '-') new_count++; } } // Add hunk header NCursesFileLine *staged_header = &viewer->staged_lines[viewer->staged_line_count++]; snprintf(staged_header->line, sizeof(staged_header->line), "@@ -%d,%d +%d,%d @@", old_start, old_count, new_start, new_count); staged_header->type = '@'; staged_header->is_diff_line = 0; staged_header->is_staged = 1; staged_header->is_context = 0; // Add context lines before staged changes for (int i = hunk_start; i <= hunk_end; i++) { NCursesFileLine *line = &viewer->file_lines[i]; if (line->type == '@') continue; // Always include context lines and staged diff lines if (line->is_context || line->is_staged) { NCursesFileLine *staged_line = &viewer->staged_lines[viewer->staged_line_count++]; *staged_line = *line; staged_line->is_staged = 1; if (viewer->staged_line_count >= MAX_FULL_FILE_LINES) break; } } if (viewer->staged_line_count >= MAX_FULL_FILE_LINES) break; } } /** * Rebuild staged view from what's actually staged in git */ void rebuild_staged_view_from_git(NCursesDiffViewer *viewer) { if (!viewer) return; viewer->staged_line_count = 0; // Get staged changes from git (HEAD vs staging area) char cmd[1024]; snprintf(cmd, sizeof(cmd), "git diff --cached -U5 \"%s\" 2>/dev/null", viewer->current_file_path); FILE *diff_fp = popen(cmd, "r"); if (!diff_fp) return; char diff_line[1024]; int has_any_staged = 0; // First pass: check if there are any staged changes while (fgets(diff_line, sizeof(diff_line), diff_fp) != NULL) { char *newline_pos = strchr(diff_line, '\n'); if (newline_pos) *newline_pos = '\0'; // Skip file headers if (strncmp(diff_line, "diff --git", 10) == 0 || strncmp(diff_line, "index ", 6) == 0 || strncmp(diff_line, "--- ", 4) == 0 || strncmp(diff_line, "+++ ", 4) == 0) { continue; } if (strlen(diff_line) > 0) { has_any_staged = 1; break; } } pclose(diff_fp); if (!has_any_staged) { return; } // Second pass: actually build the staged view diff_fp = popen(cmd, "r"); if (!diff_fp) return; while (fgets(diff_line, sizeof(diff_line), diff_fp) != NULL && viewer->staged_line_count < MAX_FULL_FILE_LINES) { char *newline_pos = strchr(diff_line, '\n'); if (newline_pos) *newline_pos = '\0'; // Skip file headers for the first few lines, but include them for patch // format if (strncmp(diff_line, "diff --git", 10) == 0 || strncmp(diff_line, "index ", 6) == 0 || strncmp(diff_line, "--- ", 4) == 0 || strncmp(diff_line, "+++ ", 4) == 0) { NCursesFileLine *staged_line = &viewer->staged_lines[viewer->staged_line_count]; strncpy(staged_line->line, diff_line, sizeof(staged_line->line) - 1); staged_line->line[sizeof(staged_line->line) - 1] = '\0'; staged_line->type = '@'; staged_line->is_diff_line = 0; staged_line->is_staged = 1; staged_line->is_context = 0; viewer->staged_line_count++; continue; } NCursesFileLine *staged_line = &viewer->staged_lines[viewer->staged_line_count]; strncpy(staged_line->line, diff_line, sizeof(staged_line->line) - 1); staged_line->line[sizeof(staged_line->line) - 1] = '\0'; staged_line->is_staged = 1; // Set line type for proper coloring if (diff_line[0] == '@' && diff_line[1] == '@') { staged_line->type = '@'; staged_line->is_diff_line = 0; staged_line->is_context = 0; } else if (diff_line[0] == '+') { staged_line->type = '+'; staged_line->is_diff_line = 1; staged_line->is_context = 0; } else if (diff_line[0] == '-') { staged_line->type = '-'; staged_line->is_diff_line = 1; staged_line->is_context = 0; } else if (diff_line[0] == ' ') { staged_line->type = ' '; staged_line->is_diff_line = 0; staged_line->is_context = 1; } else { staged_line->type = ' '; staged_line->is_diff_line = 0; staged_line->is_context = 0; } viewer->staged_line_count++; } pclose(diff_fp); } /** * Apply staged changes using git */ int apply_staged_changes(NCursesDiffViewer *viewer) { if (!viewer || viewer->staged_line_count == 0) return 0; char *patch_content = malloc(50000); if (!patch_content) return 0; strcpy(patch_content, ""); for (int i = 0; i < viewer->staged_line_count; i++) { strcat(patch_content, viewer->staged_lines[i].line); strcat(patch_content, "\n"); } char patch_filename[256]; snprintf(patch_filename, sizeof(patch_filename), "/tmp/lazygit-%d-%ld.patch", getpid(), time(NULL)); FILE *patch_file = fopen(patch_filename, "w"); if (!patch_file) { free(patch_content); return 0; } fprintf(patch_file, "%s", patch_content); fclose(patch_file); free(patch_content); char cmd[512]; snprintf(cmd, sizeof(cmd), "git apply --cached \"%s\" >/dev/null 2>&1", patch_filename); int result = system(cmd); unlink(patch_filename); if (result == 0) { // Don't clear staging state - reload from git instead get_ncurses_changed_files(viewer); if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } } return (result == 0); } /** * Unstage a specific line from the staged pane */ int unstage_line_from_git(NCursesDiffViewer *viewer, int staged_line_index) { if (!viewer || staged_line_index < 0 || staged_line_index >= viewer->staged_line_count) return 0; NCursesFileLine *line = &viewer->staged_lines[staged_line_index]; // Skip headers and context lines if (line->type == '@' || line->type == ' ') return 0; // Only unstage actual diff lines (+ or -) if (line->type != '+' && line->type != '-') return 0; // For now, let's just reset the entire file and let user re-stage what they // want This is simpler and more reliable than trying to create reverse // patches char reset_cmd[512]; snprintf(reset_cmd, sizeof(reset_cmd), "git reset HEAD \"%s\" >/dev/null 2>&1", viewer->current_file_path); int result = system(reset_cmd); if (result == 0) { // Reload after unstaging get_ncurses_changed_files(viewer); if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } return 1; } return 0; } /** * Reset staged changes */ int reset_staged_changes(NCursesDiffViewer *viewer) { if (!viewer) return 0; for (int i = 0; i < viewer->file_line_count; i++) { viewer->file_lines[i].is_staged = 0; } viewer->staged_line_count = 0; rebuild_staged_view(viewer); return 1; } /** * Draw a box with rounded corners */ void draw_rounded_box(WINDOW *win) { if (!win) return; int height, width; getmaxyx(win, height, width); // Draw horizontal lines for (int x = 1; x < width - 1; x++) { mvwaddch(win, 0, x, ACS_HLINE); mvwaddch(win, height - 1, x, ACS_HLINE); } // Draw vertical lines for (int y = 1; y < height - 1; y++) { mvwaddch(win, y, 0, ACS_VLINE); mvwaddch(win, y, width - 1, ACS_VLINE); } // Draw rounded corners mvwaddch(win, 0, 0, ACS_ULCORNER); mvwaddch(win, 0, width - 1, ACS_URCORNER); mvwaddch(win, height - 1, 0, ACS_LLCORNER); mvwaddch(win, height - 1, width - 1, ACS_LRCORNER); } /** * Get commit history */ int get_commit_history(NCursesDiffViewer *viewer) { if (!viewer) return 0; FILE *fp = popen("git log --oneline -20 --format=\"%h|%an|%s\" 2>/dev/null", "r"); if (!fp) return 0; viewer->commit_count = 0; char line[512]; // Get list of unpushed commits first char unpushed_hashes[MAX_COMMITS][16]; int unpushed_count = 0; FILE *unpushed_fp = popen("git log origin/HEAD..HEAD --format=\"%h\" 2>/dev/null", "r"); if (unpushed_fp) { while (fgets(line, sizeof(line), unpushed_fp) != NULL && unpushed_count < MAX_COMMITS) { char *newline = strchr(line, '\n'); if (newline) *newline = '\0'; strncpy(unpushed_hashes[unpushed_count], line, sizeof(unpushed_hashes[unpushed_count]) - 1); unpushed_hashes[unpushed_count] [sizeof(unpushed_hashes[unpushed_count]) - 1] = '\0'; unpushed_count++; } pclose(unpushed_fp); } // If origin/HEAD doesn't exist, try origin/main and origin/master if (unpushed_count == 0) { unpushed_fp = popen("git log origin/main..HEAD --format=\"%h\" 2>/dev/null", "r"); if (unpushed_fp) { while (fgets(line, sizeof(line), unpushed_fp) != NULL && unpushed_count < MAX_COMMITS) { char *newline = strchr(line, '\n'); if (newline) *newline = '\0'; strncpy(unpushed_hashes[unpushed_count], line, sizeof(unpushed_hashes[unpushed_count]) - 1); unpushed_hashes[unpushed_count] [sizeof(unpushed_hashes[unpushed_count]) - 1] = '\0'; unpushed_count++; } pclose(unpushed_fp); } } if (unpushed_count == 0) { unpushed_fp = popen("git log origin/master..HEAD --format=\"%h\" 2>/dev/null", "r"); if (unpushed_fp) { while (fgets(line, sizeof(line), unpushed_fp) != NULL && unpushed_count < MAX_COMMITS) { char *newline = strchr(line, '\n'); if (newline) *newline = '\0'; strncpy(unpushed_hashes[unpushed_count], line, sizeof(unpushed_hashes[unpushed_count]) - 1); unpushed_hashes[unpushed_count] [sizeof(unpushed_hashes[unpushed_count]) - 1] = '\0'; unpushed_count++; } pclose(unpushed_fp); } } while (fgets(line, sizeof(line), fp) != NULL && viewer->commit_count < MAX_COMMITS) { // Remove newline char *newline = strchr(line, '\n'); if (newline) *newline = '\0'; // Parse format: hash|author|title char *hash = strtok(line, "|"); char *author = strtok(NULL, "|"); char *title = strtok(NULL, "|"); if (hash && author && title) { // Store commit hash (first 7 chars) strncpy(viewer->commits[viewer->commit_count].hash, hash, sizeof(viewer->commits[viewer->commit_count].hash) - 1); viewer->commits[viewer->commit_count] .hash[sizeof(viewer->commits[viewer->commit_count].hash) - 1] = '\0'; // Store first two letters of author name viewer->commits[viewer->commit_count].author_initials[0] = author[0] ? author[0] : '?'; viewer->commits[viewer->commit_count].author_initials[1] = author[1] ? author[1] : '?'; viewer->commits[viewer->commit_count].author_initials[2] = '\0'; // Store title strncpy(viewer->commits[viewer->commit_count].title, title, MAX_COMMIT_TITLE_LEN - 1); viewer->commits[viewer->commit_count].title[MAX_COMMIT_TITLE_LEN - 1] = '\0'; // Check if this commit is in the unpushed list viewer->commits[viewer->commit_count].is_pushed = 1; // Default to pushed for (int i = 0; i < unpushed_count; i++) { if (strcmp(viewer->commits[viewer->commit_count].hash, unpushed_hashes[i]) == 0) { viewer->commits[viewer->commit_count].is_pushed = 0; break; } } viewer->commit_count++; } } pclose(fp); return viewer->commit_count; } /** * Toggle file marking for commit */ void toggle_file_mark(NCursesDiffViewer *viewer, int file_index) { if (!viewer || file_index < 0 || file_index >= viewer->file_count) return; viewer->files[file_index].marked_for_commit = !viewer->files[file_index].marked_for_commit; } /** * Mark all files for commit (or unmark if all are already marked) */ void mark_all_files(NCursesDiffViewer *viewer) { if (!viewer) return; // Check if all files are already marked int all_marked = 1; for (int i = 0; i < viewer->file_count; i++) { if (!viewer->files[i].marked_for_commit) { all_marked = 0; break; } } // If all are marked, unmark all. Otherwise, mark all for (int i = 0; i < viewer->file_count; i++) { viewer->files[i].marked_for_commit = all_marked ? 0 : 1; } } /** * Show confirmation dialog for diverged branch * Returns 1 for force push, 0 for cancel */ int show_diverged_branch_dialog(int commits_ahead, int commits_behind) { // Save current screen WINDOW *saved_screen = dupwin(stdscr); // Calculate window dimensions int dialog_width = 60; int dialog_height = 8; int start_x = COLS / 2 - dialog_width / 2; int start_y = LINES / 2 - dialog_height / 2; // Create dialog window WINDOW *dialog_win = newwin(dialog_height, dialog_width, start_y, start_x); if (!dialog_win) { if (saved_screen) delwin(saved_screen); return 0; } // Draw dialog wattron(dialog_win, COLOR_PAIR(3)); // Red for warning box(dialog_win, 0, 0); // Title mvwprintw(dialog_win, 1, 2, "Branch has diverged!"); // Message mvwprintw(dialog_win, 3, 2, "Local: %d commit(s) ahead", commits_ahead); mvwprintw(dialog_win, 4, 2, "Remote: %d commit(s) ahead", commits_behind); // Instructions mvwprintw(dialog_win, 6, 2, "Force push anyway? (y/N):"); wattroff(dialog_win, COLOR_PAIR(3)); wrefresh(dialog_win); // Get user input int ch; int result = 0; while ((ch = wgetch(dialog_win)) != ERR) { if (ch == 'y' || ch == 'Y') { result = 1; break; } else if (ch == 'n' || ch == 'N' || ch == 27 || ch == 'q') { // ESC or q or n result = 0; break; } else if (ch == '\n' || ch == '\r') { result = 0; // Default to no on Enter break; } } // Cleanup delwin(dialog_win); if (saved_screen) { touchwin(saved_screen); wrefresh(saved_screen); delwin(saved_screen); } return result; } /** * Show confirmation dialog requiring user to type "yes" * Returns 1 if confirmed, 0 if cancelled */ int show_reset_confirmation_dialog(void) { // Save current screen WINDOW *saved_screen = dupwin(stdscr); // Calculate window dimensions int dialog_width = 60; int dialog_height = 10; int start_x = COLS / 2 - dialog_width / 2; int start_y = LINES / 2 - dialog_height / 2; // Create dialog window WINDOW *dialog_win = newwin(dialog_height, dialog_width, start_y, start_x); if (!dialog_win) { if (saved_screen) delwin(saved_screen); return 0; } char input_buffer[10] = ""; int input_pos = 0; while (1) { // Clear and redraw dialog werase(dialog_win); wattron(dialog_win, COLOR_PAIR(3)); // Red for warning box(dialog_win, 0, 0); // Title mvwprintw(dialog_win, 1, 2, "HARD RESET WARNING!"); // Warning message mvwprintw(dialog_win, 3, 2, "This will permanently delete the most recent"); mvwprintw(dialog_win, 4, 2, "commit and ALL uncommitted changes!"); // Instructions mvwprintw(dialog_win, 6, 2, "Type 'yes' to confirm or ESC to cancel:"); // Input field mvwprintw(dialog_win, 7, 2, "> %s", input_buffer); wattroff(dialog_win, COLOR_PAIR(3)); wrefresh(dialog_win); // Position cursor wmove(dialog_win, 7, 4 + strlen(input_buffer)); // Get user input int ch = wgetch(dialog_win); if (ch == 27 || ch == 'q') { // ESC or q break; } else if (ch == '\n' || ch == '\r') { // Check if input is "yes" (case insensitive) if (strcasecmp(input_buffer, "yes") == 0) { // Cleanup and return confirmed delwin(dialog_win); if (saved_screen) { touchwin(saved_screen); wrefresh(saved_screen); delwin(saved_screen); } return 1; } else { // Wrong input, clear buffer input_buffer[0] = '\0'; input_pos = 0; } } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { // Backspace if (input_pos > 0) { input_pos--; input_buffer[input_pos] = '\0'; } } else if (ch >= 32 && ch <= 126 && input_pos < 8) { // Regular character input_buffer[input_pos] = ch; input_pos++; input_buffer[input_pos] = '\0'; } } // Cleanup and return cancelled delwin(dialog_win); if (saved_screen) { touchwin(saved_screen); wrefresh(saved_screen); delwin(saved_screen); } return 0; } /** * Get commit title and message input from user */ int get_commit_title_input(char *title, int max_len, char *message, int max_message_len) { if (!title) return 0; // Calculate window dimensions int input_width = COLS * 0.8; // 80% of screen width int title_height = 3; int message_height = 15; // Much taller message box int start_x = COLS / 2 - input_width / 2; int title_start_y = LINES / 2 - (title_height + message_height + 2) / 2; int message_start_y = title_start_y + title_height + 1; // Create title and message windows directly (no outer dialog) WINDOW *title_win = newwin(title_height, input_width, title_start_y, start_x); WINDOW *message_win = newwin(message_height, input_width, message_start_y, start_x); if (!title_win || !message_win) { if (title_win) delwin(title_win); if (message_win) delwin(message_win); return 0; } // Initialize message buffer if (message) { message[0] = '\0'; } // Variables for input handling char local_message[2048] = ""; int current_field = 0; // 0 = title, 1 = message int title_scroll_offset = 0; // For horizontal scrolling int message_scroll_offset = 0; // For vertical scrolling int ch; // Function to redraw title window void redraw_title() { werase(title_win); box(title_win, 0, 0); int visible_width = input_width - 4; int title_len = strlen(title); // CRITICAL: Clear the content area with spaces FIRST for (int x = 1; x <= visible_width; x++) { mvwaddch(title_win, 1, x, ' '); } // Calculate what part of the title to show int display_start = title_scroll_offset; int display_end = display_start + visible_width; if (display_end > title_len) display_end = title_len; // Show the visible portion of the title ON TOP of cleared spaces for (int i = display_start; i < display_end; i++) { mvwaddch(title_win, 1, 1 + (i - display_start), title[i]); } // Highlight header if active if (current_field == 0) { wattron(title_win, COLOR_PAIR(4)); mvwprintw(title_win, 0, 2, " Title (Tab to switch, Enter to commit) "); wattroff(title_win, COLOR_PAIR(4)); } else { mvwprintw(title_win, 0, 2, " Title (Tab to switch, Enter to commit) "); } wrefresh(title_win); } // Function to redraw message window - SIMPLE like title field void redraw_message() { werase(message_win); box(message_win, 0, 0); int visible_height = message_height - 2; int message_visible_width = input_width - 3; // Clear content area for (int y = 1; y <= visible_height; y++) { for (int x = 1; x <= message_visible_width; x++) { mvwaddch(message_win, y, x, ' '); } } // SIMPLE: Just display the raw string, character by character int y = 1, x = 1; for (int i = 0; local_message[i] && y <= visible_height; i++) { if (local_message[i] == '\n') { y++; x = 1; } else { if (x <= message_visible_width) { mvwaddch(message_win, y, x, local_message[i]); x++; if (x > message_visible_width) { y++; x = 1; } } } } // Highlight header if active if (current_field == 1) { wattron(message_win, COLOR_PAIR(4)); mvwprintw(message_win, 0, 2, " Message (Tab to switch, Enter for newline) "); wattroff(message_win, COLOR_PAIR(4)); } else { mvwprintw(message_win, 0, 2, " Message (Tab to switch, Enter for newline) "); } wrefresh(message_win); } // Initial draw redraw_title(); redraw_message(); // Position initial cursor if (current_field == 0) { int cursor_pos = strlen(title) - title_scroll_offset; int visible_width = input_width - 4; if (cursor_pos > visible_width - 1) cursor_pos = visible_width - 1; if (cursor_pos < 0) cursor_pos = 0; wmove(title_win, 1, 1 + cursor_pos); wrefresh(title_win); } curs_set(1); noecho(); // Main input loop while (1) { ch = getch(); int redraw_needed = 0; if (ch == 27) { // ESC to cancel - don't commit if (message && max_message_len > 0) { message[0] = '\0'; // Clear message } title[0] = '\0'; // Clear title break; } if (ch == '\t') { // Switch between fields current_field = !current_field; redraw_needed = 1; } else if (ch == '\n' || ch == '\r') { if (current_field == 0) { // Enter in title field - commit if title has content if (strlen(title) > 0) { break; } } else { // Enter in message field - add newline int len = strlen(local_message); if (len < 2047) { local_message[len] = '\n'; local_message[len + 1] = '\0'; // Auto-scroll down if needed int visible_height = message_height - 2; int current_lines = 1; for (int i = 0; local_message[i]; i++) { if (local_message[i] == '\n') current_lines++; } if (current_lines > visible_height + message_scroll_offset) { message_scroll_offset += 2; } redraw_message(); } } } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { // Handle backspace if (current_field == 0) { int len = strlen(title); if (len > 0) { title[len - 1] = '\0'; // Adjust scroll if needed int visible_width = input_width - 4; if (len - 1 <= title_scroll_offset) { title_scroll_offset = (len - 1) - (visible_width - 5); if (title_scroll_offset < 0) title_scroll_offset = 0; } redraw_title(); } } else { int len = strlen(local_message); if (len > 0) { local_message[len - 1] = '\0'; redraw_message(); } } } else if (ch >= 32 && ch <= 126) { // Regular character input if (current_field == 0) { int len = strlen(title); if (len < max_len - 1) { title[len] = ch; title[len + 1] = '\0'; // Auto-scroll horizontally if needed int visible_width = input_width - 4; if (len + 1 > title_scroll_offset + visible_width - 5) { title_scroll_offset = (len + 1) - (visible_width - 5); } redraw_title(); } } else { int len = strlen(local_message); if (len < 2047) { local_message[len] = ch; local_message[len + 1] = '\0'; redraw_message(); } } } // Redraw if field switched if (redraw_needed) { redraw_title(); redraw_message(); } // Position cursor in active field if (current_field == 0) { int cursor_pos = strlen(title) - title_scroll_offset; int visible_width = input_width - 4; if (cursor_pos > visible_width - 1) cursor_pos = visible_width - 1; if (cursor_pos < 0) cursor_pos = 0; wmove(title_win, 1, 1 + cursor_pos); wrefresh(title_win); } else { // Position cursor in message field - EXACTLY like title field int message_len = strlen(local_message); int message_visible_width = input_width - 3; // Simple cursor positioning - just count where we are int y = 1, x = 1; for (int i = 0; i < message_len; i++) { if (local_message[i] == '\n') { y++; x = 1; } else { x++; if (x > message_visible_width) { y++; x = 1; } } } // Position cursor at end of text wmove(message_win, y, x); wrefresh(message_win); } } // Copy local message to output parameter only if not canceled if (message && max_message_len > 0 && strlen(title) > 0) { strncpy(message, local_message, max_message_len - 1); message[max_message_len - 1] = '\0'; } // Restore settings curs_set(0); // Hide cursor for (int y = title_start_y; y < title_start_y + title_height; y++) { move(y, start_x); for (int x = 0; x < input_width; x++) { addch(' '); } } // Clear message window area for (int y = message_start_y; y < message_start_y + message_height; y++) { move(y, start_x); for (int x = 0; x < input_width; x++) { addch(' '); } } // Clean up windows delwin(title_win); delwin(message_win); return strlen(title) > 0 ? 1 : 0; } /** * Commit marked files with title and message */ int commit_marked_files(NCursesDiffViewer *viewer, const char *commit_title, const char *commit_message) { if (!viewer || !commit_title || strlen(commit_title) == 0) return 0; // First, add marked files to git for (int i = 0; i < viewer->file_count; i++) { if (viewer->files[i].marked_for_commit) { char cmd[1024]; snprintf(cmd, sizeof(cmd), "git add \"%s\" 2>/dev/null >/dev/null", viewer->files[i].filename); system(cmd); } } // Write commit message to temp file char temp_msg_file[256]; snprintf(temp_msg_file, sizeof(temp_msg_file), "/tmp/commit_msg_%d", getpid()); FILE *msg_file = fopen(temp_msg_file, "w"); if (!msg_file) return 0; if (commit_message && strlen(commit_message) > 0) { fprintf(msg_file, "%s\n\n%s", commit_title, commit_message); } else { fprintf(msg_file, "%s", commit_title); } fclose(msg_file); char commit_cmd[512]; snprintf(commit_cmd, sizeof(commit_cmd), "git commit -F \"%s\" 2>/dev/null >/dev/null", temp_msg_file); int result = system(commit_cmd); unlink(temp_msg_file); if (result == 0) { // Small delay to ensure git has processed the commit usleep(100000); // 100ms delay // Refresh file list and commit history get_ncurses_changed_files(viewer); get_commit_history(viewer); get_ncurses_git_branches(viewer); // Reset selection if no files remain if (viewer->file_count == 0) { viewer->selected_file = 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; } else if (viewer->selected_file >= viewer->file_count) { viewer->selected_file = viewer->file_count - 1; } // Reload the currently selected file's diff if files still exist if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } werase(viewer->branch_list_win); render_branch_list_window(viewer); wrefresh(viewer->branch_list_win); return 1; } return 0; } /** * Reset commit (soft) - undo commit but keep changes */ int reset_commit_soft(NCursesDiffViewer *viewer, int commit_index) { if (!viewer || commit_index < 0 || commit_index >= viewer->commit_count) return 0; // Only allow reset of the most recent commit (index 0) if (commit_index != 0) return 0; // Do soft reset of HEAD~1 int result = system("git reset --soft HEAD~1 2>/dev/null >/dev/null"); if (result == 0) { // Small delay to ensure git has processed the reset usleep(100000); // 100ms delay // Refresh everything get_ncurses_changed_files(viewer); get_commit_history(viewer); // Reload current file if any files exist if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } return 1; } return 0; } /** * Reset commit (hard) - undo commit and discard changes */ int reset_commit_hard(NCursesDiffViewer *viewer, int commit_index) { if (!viewer || commit_index < 0 || commit_index >= viewer->commit_count) return 0; // Only allow reset of the most recent commit (index 0) if (commit_index != 0) return 0; // Show confirmation dialog if (!show_reset_confirmation_dialog()) { return 0; // User cancelled } // Do hard reset of HEAD~1 int result = system("git reset --hard HEAD~1 2>/dev/null >/dev/null"); if (result == 0) { // Small delay to ensure git has processed the reset usleep(100000); // 100ms delay // Refresh everything get_ncurses_changed_files(viewer); get_commit_history(viewer); // Reset file selection since changes are discarded viewer->selected_file = 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; // Reload current file if any files exist if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } return 1; } return 0; } /** * Amend the most recent commit */ int amend_commit(NCursesDiffViewer *viewer) { if (!viewer || viewer->commit_count == 0) return 0; // Get current commit message char current_title[MAX_COMMIT_TITLE_LEN] = ""; char current_message[2048] = ""; // Get the current commit message FILE *fp = popen("git log -1 --pretty=format:%s 2>/dev/null", "r"); if (fp) { fgets(current_title, sizeof(current_title), fp); pclose(fp); } // Get the current commit body (if any) fp = popen("git log -1 --pretty=format:%b 2>/dev/null", "r"); if (fp) { fgets(current_message, sizeof(current_message), fp); pclose(fp); } // Get new commit message from user (pre-filled with current message) char new_title[MAX_COMMIT_TITLE_LEN]; char new_message[2048]; strncpy(new_title, current_title, sizeof(new_title) - 1); new_title[sizeof(new_title) - 1] = '\0'; strncpy(new_message, current_message, sizeof(new_message) - 1); new_message[sizeof(new_message) - 1] = '\0'; if (get_commit_title_input(new_title, MAX_COMMIT_TITLE_LEN, new_message, sizeof(new_message))) { // Add any marked files first for (int i = 0; i < viewer->file_count; i++) { if (viewer->files[i].marked_for_commit) { char cmd[1024]; snprintf(cmd, sizeof(cmd), "git add \"%s\" 2>/dev/null >/dev/null", viewer->files[i].filename); system(cmd); } } // Write amended commit message to temp file char temp_msg_file[256]; snprintf(temp_msg_file, sizeof(temp_msg_file), "/tmp/amend_msg_%d", getpid()); FILE *msg_file = fopen(temp_msg_file, "w"); if (!msg_file) return 0; if (strlen(new_message) > 0) { fprintf(msg_file, "%s\n\n%s", new_title, new_message); } else { fprintf(msg_file, "%s", new_title); } fclose(msg_file); char amend_cmd[512]; snprintf(amend_cmd, sizeof(amend_cmd), "git commit --amend -F \"%s\" 2>/dev/null >/dev/null", temp_msg_file); int result = system(amend_cmd); unlink(temp_msg_file); // this is a change i want // this is a change i dont want if (result == 0) { // Small delay to ensure git has processed the amend usleep(100000); // 100ms delay // Refresh everything after ammending get_ncurses_changed_files(viewer); get_commit_history(viewer); get_ncurses_git_branches(viewer); // Reset selection if no files remain if (viewer->file_count == 0) { viewer->selected_file = 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; } else if (viewer->selected_file >= viewer->file_count) { viewer->selected_file = viewer->file_count - 1; } // Reload current file if any files exist if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } return 1; } } return 0; } // Helper function to check if a character is safe for PAT/username input static int is_safe_input_char(int ch) { // Allow most printable ASCII but exclude problematic characters if (ch < 32 || ch > 126) return 0; // Non-printable if (ch == '?' || ch == 127) return 0; // Question mark and DEL if (ch == '\x1b') return 0; // ESC return 1; // Allow everything else } int get_single_input(const char *title, const char *prompt, char *input, int input_len, int is_password) { if (!input) return 0; // Debug logging FILE *debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "\n=== Starting input for: %s ===\n", prompt); fclose(debug_file); } // Make dialog much wider for PAT input - 80% of screen width, minimum 80 // characters int dialog_width = (COLS * 8) / 10; if (dialog_width < 80) dialog_width = 80; if (dialog_width > 120) dialog_width = 120; int dialog_height = 4; // Keep it simple and compact int start_x = (COLS - dialog_width) / 2; int start_y = (LINES - dialog_height) / 2; // Ensure we're properly set up for ncurses if (!stdscr) { return 0; } WINDOW *input_win = newwin(dialog_height, dialog_width, start_y, start_x); if (!input_win) return 0; char temp_input[512] = ""; // Larger buffer for long PATs int input_pos = 0; // Enable keypad for special keys and disable timeout for paste keypad(input_win, TRUE); nodelay(input_win, FALSE); notimeout(input_win, TRUE); curs_set(1); while (1) { werase(input_win); box(input_win, 0, 0); // Title with helpful instructions for PAT if (is_password) { mvwprintw(input_win, 1, 2, "%s (Ctrl+V to paste):", prompt); } else { mvwprintw(input_win, 1, 2, "%s:", prompt); } // Clear the input area for (int x = 2; x < dialog_width - 2; x++) { mvwaddch(input_win, 2, x, ' '); } // Input area - much simpler approach int max_display = dialog_width - 6; // Leave room for borders and padding if (is_password) { // For passwords, show simple asterisks without complex scrolling mvwprintw(input_win, 2, 2, ""); int display_count = (input_pos > max_display) ? max_display : input_pos; for (int i = 0; i < display_count; i++) { waddch(input_win, '*'); } // Show length if PAT is long if (input_pos > max_display) { wprintw(input_win, " (%d total)", input_pos); } } else { // Regular text input mvwprintw(input_win, 2, 2, "%s", temp_input); } // Simple cursor positioning int cursor_x = 2 + ((input_pos > max_display) ? max_display : input_pos); if (cursor_x >= dialog_width - 1) cursor_x = dialog_width - 2; wmove(input_win, 2, cursor_x); wrefresh(input_win); int ch = wgetch(input_win); // Debug log key presses debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "Key pressed: %d (0x%x) '%c'\n", ch, ch, (ch >= 32 && ch <= 126) ? ch : '?'); fclose(debug_file); } switch (ch) { case 27: // Escape debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "User cancelled with Escape\n"); fclose(debug_file); } delwin(input_win); curs_set(0); return 0; case '\n': case '\r': case KEY_ENTER: debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "Enter key detected (key: %d), input length: %d\n", ch, input_pos); fclose(debug_file); } if (input_pos > 0) { debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { if (is_password) { fprintf(debug_file, "Input completed, length: %d characters\n", input_pos); } else { fprintf(debug_file, "Input completed: '%s'\n", temp_input); } fclose(debug_file); } strncpy(input, temp_input, input_len - 1); input[input_len - 1] = '\0'; delwin(input_win); curs_set(0); return 1; } break; case KEY_BACKSPACE: case 127: case 8: if (input_pos > 0) { input_pos--; temp_input[input_pos] = '\0'; } break; // Handle Ctrl+V paste (ASCII 22) and other paste-like sequences case 22: case 200: // Bracketed paste start debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "Paste detected (key: %d), starting paste mode...\n", ch); fclose(debug_file); } // Clear current input and start fresh for paste input_pos = 0; temp_input[0] = '\0'; // Set non-blocking and read all available characters nodelay(input_win, TRUE); usleep(50000); // Longer delay to let entire paste buffer fill int paste_count = 0; char paste_buffer[512] = {0}; int paste_pos = 0; // Read all available characters while (paste_pos < 511) { int paste_ch = wgetch(input_win); if (paste_ch == ERR) break; // No more characters if (paste_ch == 201) break; // Bracketed paste end // Only collect printable ASCII characters (exclude backspaces!) if (paste_ch >= 32 && paste_ch <= 126) { paste_buffer[paste_pos] = paste_ch; paste_pos++; paste_count++; } // Skip backspaces (263), control chars, etc. } nodelay(input_win, FALSE); // Back to blocking mode paste_buffer[paste_pos] = '\0'; // Clean up the pasted content - remove spaces and special chars for (int i = 0; i < paste_pos && input_pos < 511; i++) { char c = paste_buffer[i]; // Only keep alphanumeric and safe special characters for PAT if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || c == '-') { temp_input[input_pos] = c; input_pos++; } } temp_input[input_pos] = '\0'; debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "Paste cleanup: raw chars=%d, final chars=%d\n", paste_count, input_pos); if (is_password) { fprintf(debug_file, "Final PAT length: %d\n", input_pos); } else { fprintf(debug_file, "Final input: '%s'\n", temp_input); } fclose(debug_file); } break; default: // Accept basic ASCII characters only (no spaces for passwords) if (ch >= 32 && ch <= 126 && input_pos < 511) { if (is_password && ch == ' ') { // Skip spaces in password input break; } temp_input[input_pos] = ch; input_pos++; temp_input[input_pos] = '\0'; } break; } } } int get_github_credentials(char *username, int username_len, char *token, int token_len) { if (!username || !token) return 0; FILE *debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "\n=== Getting GitHub credentials ===\n"); fclose(debug_file); } // Get username first if (!get_single_input("GitHub Authentication", "Username", username, username_len, 0)) { debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "Username input cancelled\n"); fclose(debug_file); } return 0; } debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "Username entered: '%s'\n", username); fclose(debug_file); } // Get token second if (!get_single_input("GitHub Authentication", "PAT", token, token_len, 1)) { debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "PAT input cancelled\n"); fclose(debug_file); } return 0; } debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "PAT entered, length: %zu characters\n", strlen(token)); fclose(debug_file); } return 1; } int execute_git_with_auth(const char *base_cmd, const char *username, const char *token) { if (!base_cmd || !username || !token) return 1; FILE *debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "\n=== Executing git with auth (SAFE VERSION) ===\n"); fprintf(debug_file, "Base command: %s\n", base_cmd); fprintf(debug_file, "Username: %s\n", username); fprintf(debug_file, "Token length: %zu\n", strlen(token)); fclose(debug_file); } // Get remote URL to determine auth format char remote_url[1024] = ""; FILE *fp = popen("git config --get remote.origin.url 2>/dev/null", "r"); if (!fp) { debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "ERROR: Could not get remote URL\n"); fclose(debug_file); } return 1; } if (fgets(remote_url, sizeof(remote_url), fp) == NULL) { pclose(fp); debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "ERROR: No remote URL found\n"); fclose(debug_file); } return 1; } pclose(fp); char *newline = strchr(remote_url, '\n'); if (newline) *newline = '\0'; debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "Remote URL: %s\n", remote_url); fclose(debug_file); } // Create authenticated command using environment variables - SAFE APPROACH char auth_cmd[4096]; char auth_url[2048]; if (strstr(remote_url, "https://github.com/")) { char *repo_part = remote_url + strlen("https://github.com/"); snprintf(auth_url, sizeof(auth_url), "https://%s:%s@github.com/%s", username, token, repo_part); } else if (strstr(remote_url, "git@github.com:")) { char *repo_part = strchr(remote_url, ':') + 1; char repo_clean[512]; strncpy(repo_clean, repo_part, sizeof(repo_clean) - 1); repo_clean[sizeof(repo_clean) - 1] = '\0'; if (strstr(repo_clean, ".git")) { *(strstr(repo_clean, ".git")) = '\0'; } snprintf(auth_url, sizeof(auth_url), "https://%s:%s@github.com/%s", username, token, repo_clean); } else { debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "ERROR: Unsupported remote URL format: %s\n", remote_url); fclose(debug_file); } return 1; } // Create a simple git push command with authenticated URL snprintf(auth_cmd, sizeof(auth_cmd), "git push %s", auth_url); debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "SAFE: Using direct git push with auth URL\n"); fprintf(debug_file, "Executing: git push ***auth_url***\n"); fclose(debug_file); } // Execute with authentication - this does NOT modify the repository int result = system(auth_cmd); debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "SAFE: No repository modification needed - git -c used\n"); fprintf(debug_file, "Git command result: %d\n", result); if (result == 0) { fprintf(debug_file, "SUCCESS: Git push with authentication succeeded\n"); } else { fprintf(debug_file, "FAILED: Git push with authentication failed\n"); } fclose(debug_file); } return result; } /** * Push specific commit */ int push_commit(NCursesDiffViewer *viewer, int commit_index) { if (!viewer || commit_index < 0 || commit_index >= viewer->commit_count) return 0; // Check if current branch has upstream char current_branch[256]; if (!get_current_branch_name(current_branch, sizeof(current_branch))) { show_error_popup("Failed to get current branch name"); viewer->sync_status = SYNC_STATUS_IDLE; return 0; } if (!branch_has_upstream(current_branch)) { // Show upstream selection dialog char upstream_selection[512]; if (show_upstream_selection_dialog(current_branch, upstream_selection, sizeof(upstream_selection))) { // User selected an upstream, set it up char cmd[1024]; snprintf(cmd, sizeof(cmd), "git push --set-upstream %s >/dev/null 2>&1", upstream_selection); int result = system(cmd); if (result == 0) { // Upstream set successfully, show success and refresh viewer->sync_status = SYNC_STATUS_PUSHED_APPEARING; viewer->animation_frame = 0; viewer->text_char_count = 0; get_commit_history(viewer); // Only refresh the commit pane werase(viewer->commit_list_win); render_commit_list_window(viewer); wrefresh(viewer->commit_list_win); return 1; } else { show_error_popup( "Failed to set upstream and push. Check your connection."); } } viewer->sync_status = SYNC_STATUS_IDLE; return 0; } // Check for branch divergence first int commits_ahead = 0; int commits_behind = 0; int is_diverged = check_branch_divergence(&commits_ahead, &commits_behind); // If diverged, show confirmation dialog if (is_diverged) { if (!show_diverged_branch_dialog(commits_ahead, commits_behind)) { // User cancelled viewer->sync_status = SYNC_STATUS_IDLE; return 0; } } // Animation already started by key handler for immediate feedback for (int i = 0; i < viewer->branch_count; i++) { if (viewer->branches[i].status == 1) { // Current branch viewer->pushing_branch_index = i; break; } } // Set branch-specific push status viewer->branch_push_status = SYNC_STATUS_PUSHING_VISIBLE; viewer->branch_animation_frame = 0; viewer->branch_text_char_count = 7; // Show full "Pushing" immediately // Force immediate branch window refresh to show "Pushing" before the blocking // git operation werase(viewer->branch_list_win); render_file_list_window(viewer); render_file_content_window(viewer); render_commit_list_window(viewer); render_branch_list_window(viewer); render_stash_list_window(viewer); render_status_bar(viewer); // Create a simple animated push with spinner updates pid_t push_pid; int result = 0; // Try push first without authentication char push_cmd[256]; if (is_diverged) { strcpy(push_cmd, "git push --force-with-lease origin"); } else { strcpy(push_cmd, "git push origin"); } // Try push without credentials first - force git to fail without prompting // Use multiple methods to ensure git doesn't prompt for credentials result = system(is_diverged ? "GIT_ASKPASS=/bin/false GIT_TERMINAL_PROMPT=0 " "SSH_ASKPASS=/bin/false git push --force-with-lease " "origin /dev/null 2>/dev/null" : "GIT_ASKPASS=/bin/false GIT_TERMINAL_PROMPT=0 " "SSH_ASKPASS=/bin/false git push origin /dev/null 2>/dev/null"); // If push failed, immediately try with authentication if (result != 0) { FILE *debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "\n=== PUSH FAILED - Starting credential flow ===\n"); fprintf(debug_file, "Initial push result: %d\n", result); fclose(debug_file); } char username[256] = ""; char token[512] = ""; // Larger buffer for long PATs // Save current terminal state and fully clear screen endwin(); clear(); refresh(); // Reinitialize ncurses for clean credential dialog initscr(); noecho(); cbreak(); keypad(stdscr, TRUE); start_color(); // Initialize color pairs for the dialog init_pair(1, COLOR_WHITE, COLOR_BLACK); init_pair(2, COLOR_GREEN, COLOR_BLACK); init_pair(3, COLOR_YELLOW, COLOR_BLACK); init_pair(4, COLOR_CYAN, COLOR_BLACK); init_pair(5, COLOR_RED, COLOR_BLACK); init_pair(6, COLOR_MAGENTA, COLOR_BLACK); clear(); refresh(); debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "Ncurses reinitialized, calling credential dialog\n"); fclose(debug_file); } if (get_github_credentials(username, sizeof(username), token, sizeof(token))) { debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "Credentials obtained successfully, attempting " "authenticated push\n"); fclose(debug_file); } // Try authenticated push result = execute_git_with_auth(push_cmd, username, token); // Clear credentials from memory for security memset(username, 0, sizeof(username)); memset(token, 0, sizeof(token)); } else { debug_file = fopen("/tmp/git_debug.log", "a"); if (debug_file) { fprintf(debug_file, "Credential dialog cancelled by user\n"); fclose(debug_file); } result = 1; // User cancelled } // Force complete screen refresh after credential dialog clear(); refresh(); } // Update UI after push attempt get_ncurses_changed_files(viewer); get_commit_history(viewer); get_ncurses_git_branches(viewer); if (result == 0) { // Immediately transition to "Pushed!" animation viewer->sync_status = SYNC_STATUS_PUSHED_APPEARING; viewer->animation_frame = 0; viewer->text_char_count = 0; // Set branch-specific pushed status viewer->branch_push_status = SYNC_STATUS_PUSHED_APPEARING; viewer->branch_animation_frame = 0; viewer->branch_text_char_count = 0; // Refresh commit history to get proper push status get_commit_history(viewer); get_ncurses_git_branches(viewer); // Add this line to refresh branch status // Refresh both commit and branch panes werase(viewer->commit_list_win); render_commit_list_window(viewer); wrefresh(viewer->commit_list_win); werase(viewer->branch_list_win); render_branch_list_window(viewer); wrefresh(viewer->branch_list_win); return 1; } else { // Push failed, show error show_error_popup("Push failed. Check your network, credentials, or get a " "Personal Access Token from github.com/settings/tokens"); viewer->sync_status = SYNC_STATUS_IDLE; viewer->pushing_branch_index = -1; viewer->branch_push_status = SYNC_STATUS_IDLE; } return 0; } /** * Pull commits from remote */ int pull_commits(NCursesDiffViewer *viewer) { if (!viewer) return 0; // Start pulling animation immediately viewer->sync_status = SYNC_STATUS_PULLING_APPEARING; viewer->animation_frame = 0; viewer->text_char_count = 0; // Render immediately to show the animation start render_status_bar(viewer); // Do the actual pull work int result = system("git pull origin 2>/dev/null >/dev/null"); if (result == 0) { // Refresh everything after pull get_ncurses_changed_files(viewer); get_commit_history(viewer); // Reset selection if no files remain if (viewer->file_count == 0) { viewer->selected_file = 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; } else if (viewer->selected_file >= viewer->file_count) { viewer->selected_file = viewer->file_count - 1; } // Reload current file if any if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } return 1; } return 0; } /** * Render the file list window */ void render_file_list_window(NCursesDiffViewer *viewer) { if (!viewer || !viewer->file_list_win) return; // CRITICAL: Clear the entire window first werase(viewer->file_list_win); // Draw rounded border and title draw_rounded_box(viewer->file_list_win); mvwprintw(viewer->file_list_win, 0, 2, " 1. Files "); int max_files_visible = viewer->file_panel_height - 2; // CRITICAL: Clear the content area with spaces FIRST for (int y = 1; y < viewer->file_panel_height - 1; y++) { for (int x = 1; x < viewer->file_panel_width - 1; x++) { mvwaddch(viewer->file_list_win, y, x, ' '); } } for (int i = 0; i < max_files_visible; i++) { int y = i + 1; // Skip if no more files if (i >= viewer->file_count) continue; // Check if this line should be highlighted int is_selected = (i == viewer->selected_file && viewer->current_mode == NCURSES_MODE_FILE_LIST); int is_marked = (i == viewer->selected_file && viewer->current_mode != NCURSES_MODE_FILE_LIST); // Apply line highlight if selected if (is_selected) { wattron(viewer->file_list_win, COLOR_PAIR(5)); } // Show selection indicator if (is_selected) { mvwprintw(viewer->file_list_win, y, 1, ">"); } else if (is_marked) { wattron(viewer->file_list_win, COLOR_PAIR(1)); mvwprintw(viewer->file_list_win, y, 1, "*"); wattroff(viewer->file_list_win, COLOR_PAIR(1)); } else { mvwprintw(viewer->file_list_win, y, 1, " "); } // Status indicator - turn off highlight temporarily for colored status if (is_selected) { wattroff(viewer->file_list_win, COLOR_PAIR(5)); } char status = viewer->files[i].status; if (status == 'M') { wattron(viewer->file_list_win, COLOR_PAIR(4)); mvwprintw(viewer->file_list_win, y, 2, "M"); wattroff(viewer->file_list_win, COLOR_PAIR(4)); } else if (status == 'A') { wattron(viewer->file_list_win, COLOR_PAIR(1)); mvwprintw(viewer->file_list_win, y, 2, "A"); wattroff(viewer->file_list_win, COLOR_PAIR(1)); } else if (status == 'D') { wattron(viewer->file_list_win, COLOR_PAIR(2)); mvwprintw(viewer->file_list_win, y, 2, "D"); wattroff(viewer->file_list_win, COLOR_PAIR(2)); } else { mvwprintw(viewer->file_list_win, y, 2, "%c", status); } // Restore highlight if it was on if (is_selected) { wattron(viewer->file_list_win, COLOR_PAIR(5)); } // Filename (truncated to fit panel with "..") int max_name_len = viewer->file_panel_width - 6; // Leave space for border char truncated_name[256]; if ((int)strlen(viewer->files[i].filename) > max_name_len) { strncpy(truncated_name, viewer->files[i].filename, max_name_len - 2); truncated_name[max_name_len - 2] = '\0'; strcat(truncated_name, ".."); } else { strcpy(truncated_name, viewer->files[i].filename); } // show staged indicator and filename if (viewer->files[i].has_staged_changes) { if (is_selected) { wattroff(viewer->file_list_win, COLOR_PAIR(5)); } wattron(viewer->file_list_win, COLOR_PAIR(1)); // green for staged mvwprintw(viewer->file_list_win, y, 4, "5"); wattroff(viewer->file_list_win, COLOR_PAIR(1)); if (is_selected) { wattron(viewer->file_list_win, COLOR_PAIR(5)); } if (viewer->files[i].marked_for_commit) { if (is_selected) { wattroff(viewer->file_list_win, COLOR_PAIR(5)); } wattron(viewer->file_list_win, COLOR_PAIR(1)); mvwprintw(viewer->file_list_win, y, 5, " %s", truncated_name); wattroff(viewer->file_list_win, COLOR_PAIR(1)); if (is_selected) { wattron(viewer->file_list_win, COLOR_PAIR(5)); } } else { mvwprintw(viewer->file_list_win, y, 5, " %s", truncated_name); } } else { if (viewer->files[i].marked_for_commit) { if (is_selected) { wattroff(viewer->file_list_win, COLOR_PAIR(5)); } wattron(viewer->file_list_win, COLOR_PAIR(1)); mvwprintw(viewer->file_list_win, y, 4, " %s", truncated_name); wattroff(viewer->file_list_win, COLOR_PAIR(1)); if (is_selected) { wattron(viewer->file_list_win, COLOR_PAIR(5)); } } else { mvwprintw(viewer->file_list_win, y, 4, "%s", truncated_name); } } // Turn off line highlight if it was applied if (is_selected) { wattroff(viewer->file_list_win, COLOR_PAIR(5)); } } wrefresh(viewer->file_list_win); } /** * Render the commit list window */ void render_commit_list_window(NCursesDiffViewer *viewer) { if (!viewer || !viewer->commit_list_win) return; // CRITICAL: Clear the entire window first werase(viewer->commit_list_win); // Draw rounded border and title draw_rounded_box(viewer->commit_list_win); mvwprintw(viewer->commit_list_win, 0, 2, " 4. Commits "); int max_commits_visible = viewer->commit_panel_height - 2; // CRITICAL: Clear the content area with spaces FIRST for (int y = 1; y < viewer->commit_panel_height - 1; y++) { for (int x = 1; x < viewer->file_panel_width - 1; x++) { mvwaddch(viewer->commit_list_win, y, x, ' '); } } for (int i = 0; i < max_commits_visible; i++) { int y = i + 1; int commit_index = i + viewer->commit_scroll_offset; // Skip if no more commits if (commit_index >= viewer->commit_count) continue; // Check if this commit line should be highlighted int is_selected_commit = (commit_index == viewer->selected_commit && viewer->current_mode == NCURSES_MODE_COMMIT_LIST); // Check if this commit is currently being viewed int is_being_viewed = (commit_index == viewer->selected_commit && viewer->current_mode == NCURSES_MODE_COMMIT_VIEW); // Apply line highlight if selected if (is_selected_commit) { wattron(viewer->commit_list_win, COLOR_PAIR(5)); } // Show view indicator (currently being viewed) if (is_being_viewed) { wattron(viewer->commit_list_win, COLOR_PAIR(1)); // Green for active view mvwprintw(viewer->commit_list_win, y, 1, "*"); wattroff(viewer->commit_list_win, COLOR_PAIR(1)); } else { mvwprintw(viewer->commit_list_win, y, 1, " "); } // Show selection indicator if (is_selected_commit) { mvwprintw(viewer->commit_list_win, y, 2, ">"); } else { mvwprintw(viewer->commit_list_win, y, 2, " "); } // Show commit hash with color based on push status if (is_selected_commit) { wattroff(viewer->commit_list_win, COLOR_PAIR(5)); } // Color commit hash based on push status: yellow for pushed, red for // unpushed if (viewer->commits[commit_index].is_pushed) { wattron(viewer->commit_list_win, COLOR_PAIR(4)); // Yellow for pushed commits } else { wattron(viewer->commit_list_win, COLOR_PAIR(2)); // Red for unpushed commits } mvwprintw(viewer->commit_list_win, y, 2, "%s", viewer->commits[commit_index].hash); if (viewer->commits[commit_index].is_pushed) { wattroff(viewer->commit_list_win, COLOR_PAIR(4)); } else { wattroff(viewer->commit_list_win, COLOR_PAIR(2)); } // Show author initials - use cyan for a subtle contrast wattron(viewer->commit_list_win, COLOR_PAIR(3)); // Cyan for author initials mvwprintw(viewer->commit_list_win, y, 10, "%s", viewer->commits[commit_index].author_initials); wattroff(viewer->commit_list_win, COLOR_PAIR(3)); if (is_selected_commit) { wattron(viewer->commit_list_win, COLOR_PAIR(5)); } // Show commit title (always white, truncated to fit with "..") int max_title_len = viewer->file_panel_width - 15; // Leave space for border char truncated_title[256]; if ((int)strlen(viewer->commits[commit_index].title) > max_title_len) { strncpy(truncated_title, viewer->commits[commit_index].title, max_title_len - 2); truncated_title[max_title_len - 2] = '\0'; strcat(truncated_title, ".."); } else { strcpy(truncated_title, viewer->commits[commit_index].title); } mvwprintw(viewer->commit_list_win, y, 13, "%s", truncated_title); // Turn off selection highlighting if this was the selected commit if (is_selected_commit) { wattroff(viewer->commit_list_win, COLOR_PAIR(5)); } } wrefresh(viewer->commit_list_win); } /** * Render the file content window with split staging view */ void render_file_content_window(NCursesDiffViewer *viewer) { if (!viewer || !viewer->file_content_win) return; int height, width; getmaxyx(viewer->file_content_win, height, width); // Clear the window werase(viewer->file_content_win); draw_rounded_box(viewer->file_content_win); if (!viewer->split_view_mode) { // Show preview content in list modes and view modes if (viewer->current_mode == NCURSES_MODE_FILE_LIST || viewer->current_mode == NCURSES_MODE_COMMIT_LIST || viewer->current_mode == NCURSES_MODE_COMMIT_VIEW || viewer->current_mode == NCURSES_MODE_BRANCH_LIST || viewer->current_mode == NCURSES_MODE_BRANCH_VIEW || viewer->current_mode == NCURSES_MODE_STASH_LIST || viewer->current_mode == NCURSES_MODE_STASH_VIEW) { // Add title based on current mode const char *title = ""; switch (viewer->current_mode) { case NCURSES_MODE_FILE_LIST: title = " File Diff Preview "; break; case NCURSES_MODE_COMMIT_LIST: title = " Commit Details "; break; case NCURSES_MODE_COMMIT_VIEW: title = " Commit Diff "; break; case NCURSES_MODE_BRANCH_LIST: title = " Branch Commits "; break; case NCURSES_MODE_BRANCH_VIEW: title = " Branch Details "; break; case NCURSES_MODE_STASH_LIST: title = " Stash Details "; break; case NCURSES_MODE_STASH_VIEW: title = " Stash Diff "; break; default: title = " Preview "; break; } mvwprintw(viewer->file_content_win, 0, 2, "%s", title); // Render preview content using the loaded file_lines if (viewer->file_line_count > 0) { int max_lines_visible = height - 2; int display_count = 0; for (int i = viewer->file_scroll_offset; i < viewer->file_line_count && display_count < max_lines_visible; i++) { NCursesFileLine *line = &viewer->file_lines[i]; int is_cursor_line = (i == viewer->file_cursor_line); // Calculate how many display lines this logical line will need int line_height = calculate_wrapped_line_height(line->line, width - 4); // Skip if this line would exceed remaining space if (display_count + line_height > max_lines_visible) { break; } int y = display_count + 1; int color_pair = 0; // Determine color based on line type if (line->type == '@') { color_pair = 3; // Cyan for hunk headers } else if (line->type == '+') { color_pair = 1; // Green for additions } else if (line->type == '-') { color_pair = 2; // Red for deletions } // Render the line with wrapping int rows_used = render_wrapped_line( viewer->file_content_win, line->line, y, 1, width - 2, line_height, color_pair, is_cursor_line); display_count += rows_used; } } else { // No content to show mvwprintw(viewer->file_content_win, height / 2, (width - 15) / 2, "No preview available"); } } wrefresh(viewer->file_content_win); return; } // Split view mode for file staging int split_line = height / 2; int unstaged_height = split_line - 1; int staged_height = height - split_line - 2; // Draw split line for (int x = 1; x < width - 1; x++) { mvwaddch(viewer->file_content_win, split_line, x, ACS_HLINE); } // Render unstaged changes pane if (viewer->active_pane == 0) { wattron(viewer->file_content_win, COLOR_PAIR(4)); } mvwprintw(viewer->file_content_win, 0, 2, " Unstaged changes "); if (viewer->active_pane == 0) { wattroff(viewer->file_content_win, COLOR_PAIR(4)); } // Show unstaged lines with wrapping int unstaged_display_count = 0; for (int i = viewer->file_scroll_offset; i < viewer->file_line_count && unstaged_display_count < unstaged_height - 1; i++) { NCursesFileLine *line = &viewer->file_lines[i]; int is_cursor_line = (i == viewer->file_cursor_line && viewer->active_pane == 0); // Calculate how many display lines this logical line will need int line_height = calculate_wrapped_line_height(line->line, width - 4); // Skip if this line would exceed remaining space if (unstaged_display_count + line_height > unstaged_height - 1) { break; } int y = unstaged_display_count + 1; int color_pair = 0; // Determine color based on line type if (line->type == '@') { color_pair = 3; // Cyan for hunk headers } else if (line->is_staged) { color_pair = 3; // Dimmed for staged content } else if (line->type == '+') { color_pair = 1; // Green for additions } else if (line->type == '-') { color_pair = 2; // Red for deletions } // Handle staged indicator for diff lines if (line->is_staged && (line->type == '+' || line->type == '-')) { // First render the staged indicator if (is_cursor_line) { wattron(viewer->file_content_win, A_REVERSE); } wattron(viewer->file_content_win, COLOR_PAIR(1)); mvwaddch(viewer->file_content_win, y, 1, '*'); wattroff(viewer->file_content_win, COLOR_PAIR(1)); if (is_cursor_line) { wattroff(viewer->file_content_win, A_REVERSE); } // Then render the line content starting from column 2, skipping first // char char line_without_prefix[1024]; strcpy(line_without_prefix, line->line + 1); int rows_used = render_wrapped_line( viewer->file_content_win, line_without_prefix, y, 2, width - 2, line_height, color_pair, is_cursor_line); unstaged_display_count += rows_used; } else { // Regular line rendering int rows_used = render_wrapped_line(viewer->file_content_win, line->line, y, 1, width - 2, line_height, color_pair, is_cursor_line); unstaged_display_count += rows_used; } } // Render staged changes pane if (viewer->active_pane == 1) { wattron(viewer->file_content_win, COLOR_PAIR(1)); } mvwprintw(viewer->file_content_win, split_line, 2, " Staged changes "); if (viewer->active_pane == 1) { wattroff(viewer->file_content_win, COLOR_PAIR(1)); } // Show staged lines with proper git patch format and wrapping int staged_display_count = 0; for (int i = viewer->staged_scroll_offset; i < viewer->staged_line_count && staged_display_count < staged_height - 1; i++) { NCursesFileLine *line = &viewer->staged_lines[i]; int is_cursor_line = (i == viewer->staged_cursor_line && viewer->active_pane == 1); // Calculate how many display lines this logical line will need int line_height = calculate_wrapped_line_height(line->line, width - 4); // Skip if this line would exceed remaining space if (staged_display_count + line_height > staged_height - 1) { break; } int y = split_line + 1 + staged_display_count; int color_pair = 0; // Determine color based on line type if (line->type == '+') { color_pair = 1; // Green for additions } else if (line->type == '-') { color_pair = 2; // Red for deletions } else if (line->type == '@') { color_pair = 3; // Cyan for headers } // Render the line with wrapping int rows_used = render_wrapped_line(viewer->file_content_win, line->line, y, 1, width - 2, line_height, color_pair, is_cursor_line); staged_display_count += rows_used; } wrefresh(viewer->file_content_win); } /** * Render the status bar */ void render_status_bar(NCursesDiffViewer *viewer) { if (!viewer || !viewer->status_bar_win) return; // Clear status bar (no border) werase(viewer->status_bar_win); wbkgd(viewer->status_bar_win, COLOR_PAIR(3)); // Cyan background // Left side: Key bindings based on current mode char keybindings[256] = ""; if (viewer->current_mode == NCURSES_MODE_FILE_LIST) { strcpy(keybindings, "Stage: | Stage All: a | Stash: s | Commit: c"); } else if (viewer->current_mode == NCURSES_MODE_COMMIT_LIST) { strcpy(keybindings, "Push: P | Pull: p | Reset: r/R | Amend: a | Nav: j/k"); } else if (viewer->current_mode == NCURSES_MODE_STASH_LIST) { strcpy(keybindings, "Apply: | Pop: g | Drop: d | Nav: j/k"); } else if (viewer->current_mode == NCURSES_MODE_BRANCH_LIST) { strcpy(keybindings, "View: Enter | Checkout: c | New: n | Rename: r | " "Delete: d | Pull: p | Nav: j/k"); } else if (viewer->current_mode == NCURSES_MODE_FILE_VIEW) { strcpy(keybindings, "Scroll: j/k | Page: Ctrl+U/D | Back: Esc"); } else if (viewer->current_mode == NCURSES_MODE_COMMIT_VIEW) { strcpy(keybindings, "Scroll: j/k | Page: Ctrl+U/D | Back: Esc"); } else if (viewer->current_mode == NCURSES_MODE_STASH_VIEW) { strcpy(keybindings, "Scroll: j/k | Page: Ctrl+U/D | Back: Esc"); } else if (viewer->current_mode == NCURSES_MODE_BRANCH_VIEW) { strcpy(keybindings, "Scroll: j/k | Page: Ctrl+U/D | Back: Esc"); } mvwprintw(viewer->status_bar_win, 0, 1, "%s", keybindings); // Right side: Sync status char sync_text[64] = ""; char *spinner_chars[] = {"|", "/", "-", "\\"}; int spinner_idx = (viewer->spinner_frame / 2) % 4; // Change every frame (~20ms per character) if (viewer->sync_status == SYNC_STATUS_IDLE) { // Show nothing when idle strcpy(sync_text, ""); } else if (viewer->sync_status >= SYNC_STATUS_SYNCING_APPEARING && viewer->sync_status <= SYNC_STATUS_SYNCING_DISAPPEARING) { // Show partial or full "Fetching" text with spinner char full_text[] = "Fetching"; int chars_to_show = viewer->text_char_count; if (chars_to_show > 8) chars_to_show = 8; // Max length of "Fetching" if (chars_to_show < 0) chars_to_show = 0; if (chars_to_show > 0) { char partial_text[16]; strncpy(partial_text, full_text, chars_to_show); partial_text[chars_to_show] = '\0'; if (viewer->sync_status == SYNC_STATUS_SYNCING_VISIBLE) { snprintf(sync_text, sizeof(sync_text), "%s %s", partial_text, spinner_chars[spinner_idx]); } else { strcpy(sync_text, partial_text); } } else { strcpy(sync_text, ""); } } else if (viewer->sync_status >= SYNC_STATUS_PUSHING_APPEARING && viewer->sync_status <= SYNC_STATUS_PUSHING_DISAPPEARING) { // Show partial or full "Pushing" text with spinner char full_text[] = "Pushing"; int chars_to_show = viewer->text_char_count; if (chars_to_show > 7) chars_to_show = 7; // Max length of "Pushing" if (chars_to_show < 0) chars_to_show = 0; if (chars_to_show > 0) { char partial_text[16]; strncpy(partial_text, full_text, chars_to_show); partial_text[chars_to_show] = '\0'; if (viewer->sync_status == SYNC_STATUS_PUSHING_VISIBLE) { snprintf(sync_text, sizeof(sync_text), "%s %s", partial_text, spinner_chars[spinner_idx]); } else { strcpy(sync_text, partial_text); } } else { strcpy(sync_text, ""); } } else if (viewer->sync_status >= SYNC_STATUS_PULLING_APPEARING && viewer->sync_status <= SYNC_STATUS_PULLING_DISAPPEARING) { // Show partial or full "Pulling" text with spinner char full_text[] = "Pulling"; int chars_to_show = viewer->text_char_count; if (chars_to_show > 7) chars_to_show = 7; // Max length of "Pulling" if (chars_to_show < 0) chars_to_show = 0; if (chars_to_show > 0) { char partial_text[16]; strncpy(partial_text, full_text, chars_to_show); partial_text[chars_to_show] = '\0'; if (viewer->sync_status == SYNC_STATUS_PULLING_VISIBLE) { snprintf(sync_text, sizeof(sync_text), "%s %s", partial_text, spinner_chars[spinner_idx]); } else { strcpy(sync_text, partial_text); } } else { strcpy(sync_text, ""); } } else if (viewer->sync_status >= SYNC_STATUS_SYNCED_APPEARING && viewer->sync_status <= SYNC_STATUS_SYNCED_DISAPPEARING) { // Show partial or full "Synced!" text (for fetching) char full_text[] = "Synced!"; int chars_to_show = viewer->text_char_count; if (chars_to_show > 7) chars_to_show = 7; // Max length of "Synced!" if (chars_to_show < 0) chars_to_show = 0; strncpy(sync_text, full_text, chars_to_show); sync_text[chars_to_show] = '\0'; } else if (viewer->sync_status >= SYNC_STATUS_PUSHED_APPEARING && viewer->sync_status <= SYNC_STATUS_PUSHED_DISAPPEARING) { // Show partial or full "Pushed!" text char full_text[] = "Pushed!"; int chars_to_show = viewer->text_char_count; if (chars_to_show > 7) chars_to_show = 7; // Max length of "Pushed!" if (chars_to_show < 0) chars_to_show = 0; strncpy(sync_text, full_text, chars_to_show); sync_text[chars_to_show] = '\0'; } else if (viewer->sync_status >= SYNC_STATUS_PULLED_APPEARING && viewer->sync_status <= SYNC_STATUS_PULLED_DISAPPEARING) { // Show partial or full "Pulled!" text char full_text[] = "Pulled!"; int chars_to_show = viewer->text_char_count; if (chars_to_show > 7) chars_to_show = 7; // Max length of "Pulled!" if (chars_to_show < 0) chars_to_show = 0; strncpy(sync_text, full_text, chars_to_show); sync_text[chars_to_show] = '\0'; } if (strlen(sync_text) > 0) { int sync_text_pos = viewer->terminal_width - strlen(sync_text) - 1; // No border padding needed if (viewer->sync_status >= SYNC_STATUS_SYNCED_APPEARING && viewer->sync_status <= SYNC_STATUS_SYNCED_DISAPPEARING || viewer->sync_status >= SYNC_STATUS_PUSHED_APPEARING && viewer->sync_status <= SYNC_STATUS_PUSHED_DISAPPEARING || viewer->sync_status >= SYNC_STATUS_PULLED_APPEARING && viewer->sync_status <= SYNC_STATUS_PULLED_DISAPPEARING) { wattron(viewer->status_bar_win, COLOR_PAIR(1)); // Green for success messages mvwprintw(viewer->status_bar_win, 0, sync_text_pos, "%s", sync_text); wattroff(viewer->status_bar_win, COLOR_PAIR(1)); } else { wattron(viewer->status_bar_win, COLOR_PAIR(4)); // Yellow for in-progress messages mvwprintw(viewer->status_bar_win, 0, sync_text_pos, "%s", sync_text); wattroff(viewer->status_bar_win, COLOR_PAIR(4)); } } wrefresh(viewer->status_bar_win); // Ensure cursor stays hidden and positioned off-screen move(viewer->terminal_height - 1, viewer->terminal_width - 1); refresh(); } /** * Update sync status and check for new files */ void update_sync_status(NCursesDiffViewer *viewer) { if (!viewer) return; time_t current_time = time(NULL); // Check if it's time to sync (every 30 seconds) if (current_time - viewer->last_sync_time >= 30 && !viewer->critical_operation_in_progress && !viewer->fetch_in_progress) { viewer->last_sync_time = current_time; // Start background fetch instead of blocking start_background_fetch(viewer); return; } // Always check if background fetch is complete check_background_fetch(viewer); // Handle all animation states if (viewer->sync_status != SYNC_STATUS_IDLE) { viewer->animation_frame++; // Handle fetching animation (4 seconds total: appear + visible + disappear) if (viewer->sync_status >= SYNC_STATUS_SYNCING_APPEARING && viewer->sync_status <= SYNC_STATUS_SYNCING_DISAPPEARING) { if (viewer->sync_status == SYNC_STATUS_SYNCING_APPEARING) { // Appearing: one character every 2 frames (0.1s) for "Fetching" (8 // chars) viewer->text_char_count = viewer->animation_frame / 2; if (viewer->text_char_count >= 8) { viewer->text_char_count = 8; viewer->sync_status = SYNC_STATUS_SYNCING_VISIBLE; viewer->animation_frame = 0; } } else if (viewer->sync_status == SYNC_STATUS_SYNCING_VISIBLE) { // Visible with spinner for 2.4 seconds (48 frames) if (viewer->animation_frame >= 48) { viewer->sync_status = SYNC_STATUS_SYNCING_DISAPPEARING; viewer->animation_frame = 0; viewer->text_char_count = 8; } } else if (viewer->sync_status == SYNC_STATUS_SYNCING_DISAPPEARING) { // Disappearing: remove one character every 2 frames (0.1s) int chars_to_remove = viewer->animation_frame / 2; viewer->text_char_count = 8 - chars_to_remove; if (viewer->text_char_count <= 0) { viewer->text_char_count = 0; viewer->sync_status = SYNC_STATUS_SYNCED_APPEARING; viewer->animation_frame = 0; } } } // Handle pushing animation (same 4 second pattern) else if (viewer->sync_status >= SYNC_STATUS_PUSHING_APPEARING && viewer->sync_status <= SYNC_STATUS_PUSHING_DISAPPEARING) { if (viewer->sync_status == SYNC_STATUS_PUSHING_APPEARING) { // Appearing: one character every frame (0.05s) for "Pushing" (7 // chars) viewer->text_char_count = viewer->animation_frame; if (viewer->text_char_count >= 7) { viewer->text_char_count = 7; viewer->sync_status = SYNC_STATUS_PUSHING_VISIBLE; viewer->animation_frame = 0; } } else if (viewer->sync_status == SYNC_STATUS_PUSHING_VISIBLE) { // Visible with spinner - keep spinning until push_commit changes status // Don't auto-transition, let push_commit function handle the transition // This allows the spinner to keep going during the blocking git push } else if (viewer->sync_status == SYNC_STATUS_PUSHING_DISAPPEARING) { // Disappearing: remove one character every frame (0.05s) int chars_to_remove = viewer->animation_frame; viewer->text_char_count = 7 - chars_to_remove; if (viewer->text_char_count <= 0) { viewer->text_char_count = 0; viewer->sync_status = SYNC_STATUS_PUSHED_APPEARING; viewer->animation_frame = 0; } } } // Handle pulling animation (same pattern as pushing) else if (viewer->sync_status >= SYNC_STATUS_PULLING_APPEARING && viewer->sync_status <= SYNC_STATUS_PULLING_DISAPPEARING) { if (viewer->sync_status == SYNC_STATUS_PULLING_APPEARING) { // Appearing: one character every 2 frames (0.1s) for "Pulling" (7 // chars) viewer->text_char_count = viewer->animation_frame / 2; if (viewer->text_char_count >= 7) { viewer->text_char_count = 7; viewer->sync_status = SYNC_STATUS_PULLING_VISIBLE; viewer->animation_frame = 0; } } else if (viewer->sync_status == SYNC_STATUS_PULLING_VISIBLE) { // Visible with spinner for 1.2 seconds (24 frames) - faster if (viewer->animation_frame >= 24) { viewer->sync_status = SYNC_STATUS_PULLING_DISAPPEARING; viewer->animation_frame = 0; viewer->text_char_count = 7; } } else if (viewer->sync_status == SYNC_STATUS_PULLING_DISAPPEARING) { // Disappearing: remove one character every 2 frames (0.1s) int chars_to_remove = viewer->animation_frame / 2; viewer->text_char_count = 7 - chars_to_remove; if (viewer->text_char_count <= 0) { viewer->text_char_count = 0; viewer->sync_status = SYNC_STATUS_PULLED_APPEARING; viewer->animation_frame = 0; } } } // Handle synced animation else if (viewer->sync_status >= SYNC_STATUS_SYNCED_APPEARING && viewer->sync_status <= SYNC_STATUS_SYNCED_DISAPPEARING) { if (viewer->sync_status == SYNC_STATUS_SYNCED_APPEARING) { // Appearing: one character every 2 frames (0.1s) for "Synced!" (7 // chars) viewer->text_char_count = viewer->animation_frame / 2; if (viewer->text_char_count >= 7) { viewer->text_char_count = 7; viewer->sync_status = SYNC_STATUS_SYNCED_VISIBLE; viewer->animation_frame = 0; } } else if (viewer->sync_status == SYNC_STATUS_SYNCED_VISIBLE) { // Visible for 3 seconds (60 frames) if (viewer->animation_frame >= 60) { viewer->sync_status = SYNC_STATUS_SYNCED_DISAPPEARING; viewer->animation_frame = 0; viewer->text_char_count = 7; } } else if (viewer->sync_status == SYNC_STATUS_SYNCED_DISAPPEARING) { // Disappearing: remove one character every 2 frames (0.1s) int chars_to_remove = viewer->animation_frame / 2; viewer->text_char_count = 7 - chars_to_remove; if (viewer->text_char_count <= 0) { viewer->text_char_count = 0; viewer->sync_status = SYNC_STATUS_IDLE; } } } // Handle pushed animation else if (viewer->sync_status >= SYNC_STATUS_PUSHED_APPEARING && viewer->sync_status <= SYNC_STATUS_PUSHED_DISAPPEARING) { if (viewer->sync_status == SYNC_STATUS_PUSHED_APPEARING) { // Appearing: one character every frame (0.05s) for "Pushed!" (7 // chars) viewer->text_char_count = viewer->animation_frame; if (viewer->text_char_count >= 7) { viewer->text_char_count = 7; viewer->sync_status = SYNC_STATUS_PUSHED_VISIBLE; viewer->animation_frame = 0; } } else if (viewer->sync_status == SYNC_STATUS_PUSHED_VISIBLE) { // Visible for 2 seconds (100 frames at 20ms) if (viewer->animation_frame >= 100) { viewer->sync_status = SYNC_STATUS_PUSHED_DISAPPEARING; viewer->animation_frame = 0; viewer->text_char_count = 7; } } else if (viewer->sync_status == SYNC_STATUS_PUSHED_DISAPPEARING) { // Disappearing: remove one character every frame (0.05s) int chars_to_remove = viewer->animation_frame; viewer->text_char_count = 7 - chars_to_remove; if (viewer->text_char_count <= 0) { viewer->text_char_count = 0; viewer->sync_status = SYNC_STATUS_IDLE; } } } // Handle pulled animation else if (viewer->sync_status >= SYNC_STATUS_PULLED_APPEARING && viewer->sync_status <= SYNC_STATUS_PULLED_DISAPPEARING) { if (viewer->sync_status == SYNC_STATUS_PULLED_APPEARING) { // Appearing: one character every 2 frames (0.1s) for "Pulled!" (7 // chars) viewer->text_char_count = viewer->animation_frame / 2; if (viewer->text_char_count >= 7) { viewer->text_char_count = 7; viewer->sync_status = SYNC_STATUS_PULLED_VISIBLE; viewer->animation_frame = 0; } } else if (viewer->sync_status == SYNC_STATUS_PULLED_VISIBLE) { // Visible for 2 seconds (40 frames) if (viewer->animation_frame >= 40) { viewer->sync_status = SYNC_STATUS_PULLED_DISAPPEARING; viewer->animation_frame = 0; viewer->text_char_count = 7; } } else if (viewer->sync_status == SYNC_STATUS_PULLED_DISAPPEARING) { // Disappearing: remove one character every 2 frames (0.1s) int chars_to_remove = viewer->animation_frame / 2; viewer->text_char_count = 7 - chars_to_remove; if (viewer->text_char_count <= 0) { viewer->text_char_count = 0; viewer->sync_status = SYNC_STATUS_IDLE; } } } } // Always update spinner frame for spinner animation viewer->spinner_frame++; if (viewer->spinner_frame > 100) viewer->spinner_frame = 0; // Reset to prevent overflow // Handle branch-specific animations (only for completed states) if (viewer->branch_push_status != SYNC_STATUS_IDLE || viewer->branch_pull_status != SYNC_STATUS_IDLE) { viewer->branch_animation_frame++; // Handle push animations (only "Pushed!" phase) if (viewer->branch_push_status >= SYNC_STATUS_PUSHED_APPEARING && viewer->branch_push_status <= SYNC_STATUS_PUSHED_DISAPPEARING) { if (viewer->branch_push_status == SYNC_STATUS_PUSHED_APPEARING) { viewer->branch_text_char_count = viewer->branch_animation_frame; if (viewer->branch_text_char_count >= 7) { viewer->branch_text_char_count = 7; viewer->branch_push_status = SYNC_STATUS_PUSHED_VISIBLE; viewer->branch_animation_frame = 0; } } else if (viewer->branch_push_status == SYNC_STATUS_PUSHED_VISIBLE) { if (viewer->branch_animation_frame >= 100) { viewer->branch_push_status = SYNC_STATUS_PUSHED_DISAPPEARING; viewer->branch_animation_frame = 0; viewer->branch_text_char_count = 7; } } else if (viewer->branch_push_status == SYNC_STATUS_PUSHED_DISAPPEARING) { int chars_to_remove = viewer->branch_animation_frame; viewer->branch_text_char_count = 7 - chars_to_remove; if (viewer->branch_text_char_count <= 0) { viewer->branch_text_char_count = 0; viewer->branch_push_status = SYNC_STATUS_IDLE; viewer->pushing_branch_index = -1; } } } // Handle pull animations (only "Pulled!" phase) if (viewer->branch_pull_status >= SYNC_STATUS_PULLED_APPEARING && viewer->branch_pull_status <= SYNC_STATUS_PULLED_DISAPPEARING) { if (viewer->branch_pull_status == SYNC_STATUS_PULLED_APPEARING) { viewer->branch_text_char_count = viewer->branch_animation_frame / 2; if (viewer->branch_text_char_count >= 7) { viewer->branch_text_char_count = 7; viewer->branch_pull_status = SYNC_STATUS_PULLED_VISIBLE; viewer->branch_animation_frame = 0; } } else if (viewer->branch_pull_status == SYNC_STATUS_PULLED_VISIBLE) { if (viewer->branch_animation_frame >= 40) { viewer->branch_pull_status = SYNC_STATUS_PULLED_DISAPPEARING; viewer->branch_animation_frame = 0; viewer->branch_text_char_count = 7; } } else if (viewer->branch_pull_status == SYNC_STATUS_PULLED_DISAPPEARING) { int chars_to_remove = viewer->branch_animation_frame / 2; viewer->branch_text_char_count = 7 - chars_to_remove; if (viewer->branch_text_char_count <= 0) { viewer->branch_text_char_count = 0; viewer->branch_pull_status = SYNC_STATUS_IDLE; viewer->pulling_branch_index = -1; } } } } } int commit_staged_changes_only(NCursesDiffViewer *viewer, const char *commit_title, const char *commit_message) { if (!viewer || !commit_title || strlen(commit_title) == 0) return 0; char temp_msg_file[256]; snprintf(temp_msg_file, sizeof(temp_msg_file), "/tmp/commit_msg_%d", getpid()); FILE *msg_file = fopen(temp_msg_file, "w"); if (!msg_file) return 0; if (commit_message && strlen(commit_message) > 0) { fprintf(msg_file, "%s\n\n%s", commit_title, commit_message); } else { fprintf(msg_file, "%s", commit_title); } fclose(msg_file); char commit_cmd[512]; snprintf(commit_cmd, sizeof(commit_cmd), "git commit -F \"%s\" 2>/dev/null >/dev/null", temp_msg_file); int result = system(commit_cmd); unlink(temp_msg_file); if (result == 0) { usleep(100000); get_ncurses_changed_files(viewer); get_commit_history(viewer); get_ncurses_git_branches(viewer); if (viewer->file_count == 0) { viewer->selected_file = 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; } else if (viewer->selected_file >= viewer->file_count) { viewer->selected_file = viewer->file_count - 1; } return 1; } return 0; } /** * Handle keyboard input for navigation */ int handle_ncurses_diff_input(NCursesDiffViewer *viewer, int key) { if (!viewer) return 0; // Handle fuzzy search input first if active if (viewer->fuzzy_search_active) { return handle_fuzzy_search_input(viewer, key); } // Handle grep search input if active if (viewer->grep_search_active) { return handle_grep_search_input(viewer, key); } int max_lines_visible = viewer->terminal_height - 4; // Global quit commands if (key == 'q' || key == 'Q') { return 0; // Exit } // Global number key navigation switch (key) { case '1': viewer->current_mode = NCURSES_MODE_FILE_LIST; viewer->split_view_mode = 0; // Exit split view mode // Load the selected file content when switching to file list mode if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } break; case '2': viewer->split_view_mode = 0; // Exit split view mode // Switch to file view mode and load selected file if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); viewer->current_mode = NCURSES_MODE_FILE_VIEW; } break; case '3': viewer->current_mode = NCURSES_MODE_BRANCH_LIST; viewer->split_view_mode = 0; // Exit split view mode // Load commits for the currently selected branch when entering branch mode if (viewer->branch_count > 0) { load_branch_commits(viewer, viewer->branches[viewer->selected_branch].name); viewer->branch_commits_scroll_offset = 0; } break; case '4': viewer->current_mode = NCURSES_MODE_COMMIT_LIST; viewer->split_view_mode = 0; // Exit split view mode // Auto-preview the selected commit if (viewer->commit_count > 0) { load_commit_for_viewing(viewer, viewer->commits[viewer->selected_commit].hash); } break; case '5': viewer->current_mode = NCURSES_MODE_STASH_LIST; viewer->split_view_mode = 0; // Exit split view mode // Auto-preview the selected stash if (viewer->stash_count > 0) { load_stash_for_viewing(viewer, viewer->selected_stash); } break; } if (viewer->current_mode == NCURSES_MODE_FILE_LIST) { // File list mode navigation switch (key) { case 27: // ESC return 0; // Exit from file list mode case KEY_UP: case 'k': if (viewer->selected_file > 0) { viewer->selected_file--; } break; case KEY_DOWN: case 'j': if (viewer->selected_file < viewer->file_count - 1) { viewer->selected_file++; } break; case ' ': // Space - toggle file marking if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { toggle_file_mark(viewer, viewer->selected_file); } break; case 'a': case 'A': // Mark all files mark_all_files(viewer); break; case 's': case 'S': viewer->critical_operation_in_progress = 1; create_ncurses_git_stash(viewer); viewer->critical_operation_in_progress = 0; break; case 'c': case 'C': // Commit marked files { char commit_title[MAX_COMMIT_TITLE_LEN] = ""; char commit_message[2048] = ""; viewer->critical_operation_in_progress = 2; if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count && viewer->files[viewer->selected_file].has_staged_changes) { if (get_commit_title_input(commit_title, MAX_COMMIT_TITLE_LEN, commit_message, sizeof(commit_message))) { commit_staged_changes_only(viewer, commit_title, commit_message); } } else { if (get_commit_title_input(commit_title, MAX_COMMIT_TITLE_LEN, commit_message, sizeof(commit_message))) { commit_marked_files(viewer, commit_title, commit_message); } } viewer->critical_operation_in_progress = 0; clear(); refresh(); render_file_list_window(viewer); render_file_content_window(viewer); render_commit_list_window(viewer); render_branch_list_window(viewer); render_stash_list_window(viewer); render_status_bar(viewer); } break; case '\t': // Tab - switch to commit list mode viewer->current_mode = NCURSES_MODE_COMMIT_LIST; break; case '/': // Forward slash - enter fuzzy search mode enter_fuzzy_search_mode(viewer); break; case '\n': case '\r': case KEY_ENTER: // Enter file view mode with staging and load the selected file if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); viewer->current_mode = NCURSES_MODE_FILE_VIEW; viewer->split_view_mode = 1; // Enable split view for staging viewer->active_pane = 0; // Start in unstaged pane } break; } } else if (viewer->current_mode == NCURSES_MODE_FILE_VIEW) { // File view mode navigation switch (key) { case 27: // ESC if (viewer->split_view_mode) { viewer->split_view_mode = 0; // Exit split view viewer->current_mode = NCURSES_MODE_FILE_LIST; // Return to file list } else { viewer->current_mode = NCURSES_MODE_FILE_LIST; } break; case ' ': // Space - stage/unstage line if (viewer->split_view_mode) { if (viewer->active_pane == 0) { stage_hunk_by_line(viewer, viewer->file_cursor_line); } else { // Unstage from staged pane unstage_line_from_git(viewer, viewer->staged_cursor_line); } } else { // Existing scroll logic for non-split view if (viewer->file_line_count > max_lines_visible) { viewer->file_scroll_offset += max_lines_visible; if (viewer->file_scroll_offset > viewer->file_line_count - max_lines_visible) { viewer->file_scroll_offset = viewer->file_line_count - max_lines_visible; } } } break; case '\t': // Tab - switch between unstaged and staged panes if (viewer->split_view_mode) { viewer->active_pane = !viewer->active_pane; // Reset cursors when switching panes if (viewer->active_pane == 0) { viewer->file_cursor_line = 0; } else { viewer->staged_cursor_line = 0; } } break; case 'a': // apply staged changes if (viewer->split_view_mode) { apply_staged_changes(viewer); clear(); refresh(); } break; case 'r': // Reset staged changes if (viewer->split_view_mode) { reset_staged_changes(viewer); } break; case KEY_UP: case 'k': if (viewer->split_view_mode) { if (viewer->active_pane == 0) { // Unstaged pane move_cursor_smart_unstaged(viewer, -1); } else { // Staged pane move_cursor_smart_staged(viewer, -1); } } else { move_cursor_smart(viewer, -1); } break; case KEY_DOWN: case 'j': if (viewer->split_view_mode) { if (viewer->active_pane == 0) { // Unstaged pane move_cursor_smart_unstaged(viewer, 1); } else { // Staged pane move_cursor_smart_staged(viewer, 1); } } else { move_cursor_smart(viewer, 1); } break; case KEY_PPAGE: // Page Up // Scroll content up by page viewer->file_scroll_offset -= max_lines_visible; if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } break; case 21: // Ctrl+U { // Move cursor up half page with smart positioning int target_cursor = viewer->file_cursor_line - max_lines_visible / 2; if (target_cursor < 0) { target_cursor = 0; } // Find the actual cursor position (skip empty lines like // move_cursor_smart does) int final_cursor = target_cursor; int attempts = 0; const int max_attempts = viewer->file_line_count; // Look for non-empty line near target position while (attempts < max_attempts && final_cursor < viewer->file_line_count) { NCursesFileLine *line = &viewer->file_lines[final_cursor]; char *trimmed = line->line; // Skip leading whitespace while (*trimmed == ' ' || *trimmed == '\t') { trimmed++; } // If line has content, stop here if (*trimmed != '\0') { break; } // Move down to find content, but don't go too far from target final_cursor++; attempts++; // If we've moved too far down, go back to target and try going up if (final_cursor > target_cursor + 5) { final_cursor = target_cursor; // Try going up from target while (final_cursor > 0 && attempts < max_attempts) { line = &viewer->file_lines[final_cursor]; trimmed = line->line; while (*trimmed == ' ' || *trimmed == '\t') { trimmed++; } if (*trimmed != '\0') { break; } final_cursor--; attempts++; } break; } } // Ensure final cursor is in bounds if (final_cursor < 0) final_cursor = 0; if (final_cursor >= viewer->file_line_count) final_cursor = viewer->file_line_count - 1; viewer->file_cursor_line = final_cursor; // Adjust scroll based on FINAL cursor position if (viewer->file_cursor_line < viewer->file_scroll_offset + 5) { viewer->file_scroll_offset = viewer->file_cursor_line - 5; if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } } break; } case 4: // Ctrl+D { // Move cursor down half page with smart positioning int target_cursor = viewer->file_cursor_line + max_lines_visible / 2; if (target_cursor >= viewer->file_line_count) { target_cursor = viewer->file_line_count - 1; } // Find the actual cursor position (skip empty lines like // move_cursor_smart does) int final_cursor = target_cursor; int attempts = 0; const int max_attempts = viewer->file_line_count; // Look for non-empty line near target position while (attempts < max_attempts && final_cursor >= 0) { NCursesFileLine *line = &viewer->file_lines[final_cursor]; char *trimmed = line->line; // Skip leading whitespace while (*trimmed == ' ' || *trimmed == '\t') { trimmed++; } // If line has content, stop here if (*trimmed != '\0') { break; } // Move up to find content, but don't go too far from target final_cursor--; attempts++; // If we've moved too far up, go back to target and try going down if (final_cursor < target_cursor - 5) { final_cursor = target_cursor; // Try going down from target while (final_cursor < viewer->file_line_count - 1 && attempts < max_attempts) { line = &viewer->file_lines[final_cursor]; trimmed = line->line; while (*trimmed == ' ' || *trimmed == '\t') { trimmed++; } if (*trimmed != '\0') { break; } final_cursor++; attempts++; } break; } } // Ensure final cursor is in bounds if (final_cursor < 0) final_cursor = 0; if (final_cursor >= viewer->file_line_count) final_cursor = viewer->file_line_count - 1; viewer->file_cursor_line = final_cursor; // Adjust scroll based on FINAL cursor position if (viewer->file_cursor_line >= viewer->file_scroll_offset + max_lines_visible - 5) { viewer->file_scroll_offset = viewer->file_cursor_line - max_lines_visible + 5; if (viewer->file_scroll_offset > viewer->file_line_count - max_lines_visible) { viewer->file_scroll_offset = viewer->file_line_count - max_lines_visible; } if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } } break; } case KEY_NPAGE: // Page Down // Scroll content down by page if (viewer->file_line_count > max_lines_visible) { viewer->file_scroll_offset += max_lines_visible; if (viewer->file_scroll_offset > viewer->file_line_count - max_lines_visible) { viewer->file_scroll_offset = viewer->file_line_count - max_lines_visible; } } break; } } else if (viewer->current_mode == NCURSES_MODE_COMMIT_LIST) { // Commit list mode navigation switch (key) { case 27: // ESC case '\t': // Tab - return to file list mode viewer->current_mode = NCURSES_MODE_FILE_LIST; viewer->split_view_mode = 0; // Exit split view mode break; case KEY_UP: case 'k': if (viewer->selected_commit > 0) { viewer->selected_commit--; // Calculate the scroll boundaries int max_commits_visible = viewer->commit_panel_height - 2; int scroll_threshhold = 2; if (viewer->selected_commit < viewer->commit_scroll_offset + scroll_threshhold) { if (viewer->commit_scroll_offset > 0) { viewer->commit_scroll_offset--; } } // Auto-preview the selected commit if (viewer->commit_count > 0) { load_commit_for_viewing( viewer, viewer->commits[viewer->selected_commit].hash); } } break; case KEY_DOWN: case 'j': if (viewer->selected_commit < viewer->commit_count - 1) { viewer->selected_commit++; int max_commits_visible = viewer->commit_panel_height - 2; int scroll_threshhold = 2; if (viewer->selected_commit >= viewer->commit_scroll_offset + max_commits_visible - scroll_threshhold) { if (viewer->commit_scroll_offset < viewer->commit_count - max_commits_visible) { viewer->commit_scroll_offset++; } } // Auto-preview the selected commit if (viewer->commit_count > 0) { load_commit_for_viewing( viewer, viewer->commits[viewer->selected_commit].hash); } } break; case '\n': case '\r': case KEY_ENTER: // Enter commit view mode if (viewer->commit_count > 0 && viewer->selected_commit < viewer->commit_count) { load_commit_for_viewing(viewer, viewer->commits[viewer->selected_commit].hash); viewer->current_mode = NCURSES_MODE_COMMIT_VIEW; } break; case 'P': // Push commit if (viewer->commit_count > 0 && viewer->selected_commit < viewer->commit_count) { viewer->critical_operation_in_progress = 1; // Block fetching during push // Show immediate feedback viewer->sync_status = SYNC_STATUS_PUSHING_VISIBLE; viewer->animation_frame = 0; viewer->text_char_count = 7; // Show full "Pushing" immediately // Force a quick render to show "Pushing!" immediately render_status_bar(viewer); wrefresh(viewer->status_bar_win); // Now do the actual push push_commit(viewer, viewer->selected_commit); viewer->critical_operation_in_progress = 0; // Re-enable fetching } break; case 'r': if (viewer->commit_count > 0 && viewer->selected_commit == 0) { viewer->critical_operation_in_progress = 1; reset_commit_soft(viewer, viewer->selected_commit); viewer->critical_operation_in_progress = 0; } break; case 'R': // Reset (hard) - undo commit and discard changes if (viewer->commit_count > 0 && viewer->selected_commit == 0) { viewer->critical_operation_in_progress = 1; reset_commit_hard(viewer, viewer->selected_commit); viewer->critical_operation_in_progress = 0; } break; case 'a': case 'A': // Amend most recent commit if (viewer->commit_count > 0) { viewer->critical_operation_in_progress = 1; amend_commit(viewer); viewer->critical_operation_in_progress = 0; // Force complete screen refresh after amend dialog clear(); refresh(); // Redraw all windows immediately render_file_list_window(viewer); render_file_content_window(viewer); render_commit_list_window(viewer); render_branch_list_window(viewer); render_stash_list_window(viewer); render_status_bar(viewer); } break; case '/': // Forward slash - enter grep search mode enter_grep_search_mode(viewer); break; } } else if (viewer->current_mode == NCURSES_MODE_STASH_LIST) { // Stash list mode navigation switch (key) { case 27: // ESC case '\t': // Tab - return to file list mode viewer->current_mode = NCURSES_MODE_FILE_LIST; viewer->split_view_mode = 0; // Exit split view mode break; case KEY_UP: case 'k': if (viewer->selected_stash > 0) { viewer->selected_stash--; // Calculate scroll boundaries int max_stashes_visible = viewer->stash_panel_height - 2; int scroll_threshold = 2; // Scroll up if cursor is getting too close to top if (viewer->selected_stash < viewer->stash_scroll_offset + scroll_threshold) { if (viewer->stash_scroll_offset > 0 && viewer->stash_count > max_stashes_visible) { viewer->stash_scroll_offset--; } } // Auto-preview the selected stash if (viewer->stash_count > 0) { load_stash_for_viewing(viewer, viewer->selected_stash); } } break; case KEY_DOWN: case 'j': if (viewer->selected_stash < viewer->stash_count - 1) { viewer->selected_stash++; // Calculate scroll boundaries int max_stashes_visible = viewer->stash_panel_height - 2; int scroll_threshold = 2; // Scroll down if cursor is getting too close to bottom if (viewer->selected_stash >= viewer->stash_scroll_offset + max_stashes_visible - scroll_threshold) { if (viewer->stash_scroll_offset < viewer->stash_count - max_stashes_visible && viewer->stash_count > max_stashes_visible) { viewer->stash_scroll_offset++; } } // Auto-preview the selected stash if (viewer->stash_count > 0) { load_stash_for_viewing(viewer, viewer->selected_stash); } } break; case '\n': case '\r': case KEY_ENTER: // Enter stash view mode if (viewer->stash_count > 0 && viewer->selected_stash < viewer->stash_count) { load_stash_for_viewing(viewer, viewer->selected_stash); viewer->current_mode = NCURSES_MODE_STASH_VIEW; } break; case ' ': // Space - Apply stash (keeps stash in list) if (viewer->stash_count > 0 && viewer->selected_stash < viewer->stash_count) { viewer->critical_operation_in_progress = 1; if (apply_git_stash(viewer->selected_stash)) { // Refresh everything after applying stash get_ncurses_changed_files(viewer); get_commit_history(viewer); // Reset file selection if no files remain if (viewer->file_count == 0) { viewer->selected_file = 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; } else if (viewer->selected_file >= viewer->file_count) { viewer->selected_file = viewer->file_count - 1; } // Reload current file if any files exist if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } } viewer->critical_operation_in_progress = 0; } break; case 'g': case 'G': // Pop stash (applies and removes from list) if (viewer->stash_count > 0 && viewer->selected_stash < viewer->stash_count) { viewer->critical_operation_in_progress = 1; if (pop_git_stash(viewer->selected_stash)) { // Refresh everything after popping stash get_ncurses_changed_files(viewer); get_ncurses_git_stashes(viewer); get_commit_history(viewer); // Adjust selected stash if needed if (viewer->selected_stash >= viewer->stash_count && viewer->stash_count > 0) { viewer->selected_stash = viewer->stash_count - 1; } // Reset file selection if no files remain if (viewer->file_count == 0) { viewer->selected_file = 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; } else if (viewer->selected_file >= viewer->file_count) { viewer->selected_file = viewer->file_count - 1; } // Reload current file if any files exist if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } } viewer->critical_operation_in_progress = 0; } break; case 'd': case 'D': // Drop stash (removes without applying) if (viewer->stash_count > 0 && viewer->selected_stash < viewer->stash_count) { viewer->critical_operation_in_progress = 1; if (drop_git_stash(viewer->selected_stash)) { // Refresh stash list after dropping get_ncurses_git_stashes(viewer); // Adjust selected stash if needed if (viewer->selected_stash >= viewer->stash_count && viewer->stash_count > 0) { viewer->selected_stash = viewer->stash_count - 1; } } viewer->critical_operation_in_progress = 0; } break; case '/': // Forward slash - enter grep search mode enter_grep_search_mode(viewer); break; } } else if (viewer->current_mode == NCURSES_MODE_BRANCH_LIST) { // Branch list mode navigation switch (key) { case 27: // ESC case '\t': // Tab - return to file list mode viewer->current_mode = NCURSES_MODE_FILE_LIST; viewer->split_view_mode = 0; // Exit split view mode break; case KEY_UP: case 'k': if (viewer->selected_branch > 0) { viewer->selected_branch--; // Load commits for the newly selected branch and reset scroll if (viewer->branch_count > 0) { load_branch_commits(viewer, viewer->branches[viewer->selected_branch].name); viewer->branch_commits_scroll_offset = 0; } } break; case KEY_DOWN: case 'j': if (viewer->selected_branch < viewer->branch_count - 1) { viewer->selected_branch++; // Load commits for the newly selected branch and reset scroll if (viewer->branch_count > 0) { load_branch_commits(viewer, viewer->branches[viewer->selected_branch].name); viewer->branch_commits_scroll_offset = 0; } } break; case '\n': case '\r': case KEY_ENTER: // Enter - Switch to branch view mode to scroll commits if (viewer->branch_count > 0 && viewer->selected_branch < viewer->branch_count) { // Load commits and parse them for navigation load_branch_commits(viewer, viewer->branches[viewer->selected_branch].name); parse_branch_commits_to_lines(viewer); viewer->current_mode = NCURSES_MODE_BRANCH_VIEW; } break; case 'c': // c - Checkout selected branch if (viewer->branch_count > 0 && viewer->selected_branch < viewer->branch_count) { viewer->critical_operation_in_progress = 1; char cmd[512]; snprintf(cmd, sizeof(cmd), "git checkout \"%s\" >/dev/null 2>&1", viewer->branches[viewer->selected_branch].name); if (system(cmd) == 0) { // Refresh everything after branch switch get_ncurses_changed_files(viewer); get_commit_history(viewer); get_ncurses_git_branches( viewer); // Refresh branch list to update current branch // Reset file selection if no files remain if (viewer->file_count == 0) { viewer->selected_file = 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; } else if (viewer->selected_file >= viewer->file_count) { viewer->selected_file = viewer->file_count - 1; } // Reload current file if any files exist if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } } viewer->critical_operation_in_progress = 0; // Force complete screen refresh after checkout clear(); refresh(); } break; case 'n': // New branch { viewer->critical_operation_in_progress = 1; char new_branch_name[256]; if (get_branch_name_input(new_branch_name, sizeof(new_branch_name))) { if (create_git_branch(new_branch_name)) { // Refresh everything after creating new branch get_ncurses_changed_files(viewer); get_commit_history(viewer); get_ncurses_git_branches(viewer); // Find and select the new branch (need to look for the cleaned name // with dashes) char clean_branch_name[256]; strncpy(clean_branch_name, new_branch_name, sizeof(clean_branch_name) - 1); clean_branch_name[sizeof(clean_branch_name) - 1] = '\0'; for (int j = 0; clean_branch_name[j] != '\0'; j++) { if (clean_branch_name[j] == ' ') { clean_branch_name[j] = '-'; } } for (int i = 0; i < viewer->branch_count; i++) { if (strcmp(viewer->branches[i].name, clean_branch_name) == 0) { viewer->selected_branch = i; break; } } // Reset file selection if no files remain if (viewer->file_count == 0) { viewer->selected_file = 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; } else if (viewer->selected_file >= viewer->file_count) { viewer->selected_file = viewer->file_count - 1; } // Reload current file if any files exist if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } } } // Force immediate branch window update werase(viewer->branch_list_win); render_branch_list_window(viewer); wrefresh(viewer->branch_list_win); // Clear screen artifacts from dialog clear(); refresh(); viewer->critical_operation_in_progress = 0; } break; case 'd': // Delete branch if (viewer->branch_count > 0 && viewer->selected_branch < viewer->branch_count) { viewer->critical_operation_in_progress = 1; const char *branch_to_delete = viewer->branches[viewer->selected_branch].name; // Don't allow deleting the current branch if (viewer->branches[viewer->selected_branch].status == 1) { show_error_popup("Cannot delete current branch!"); break; } DeleteBranchOption option = show_delete_branch_dialog(branch_to_delete); if (option != DELETE_CANCEL) { if (delete_git_branch(branch_to_delete, option)) { // Refresh branch list after deletion get_ncurses_git_branches(viewer); // Adjust selection if needed if (viewer->selected_branch >= viewer->branch_count && viewer->branch_count > 0) { viewer->selected_branch = viewer->branch_count - 1; } } } // Force immediate branch window update werase(viewer->branch_list_win); render_branch_list_window(viewer); wrefresh(viewer->branch_list_win); // Clear screen artifacts from dialog clear(); refresh(); viewer->critical_operation_in_progress = 0; } break; case 'r': // Rename branch if (viewer->branch_count > 0 && viewer->selected_branch < viewer->branch_count) { viewer->critical_operation_in_progress = 1; const char *current_name = viewer->branches[viewer->selected_branch].name; char new_name[256]; if (get_rename_branch_input(current_name, new_name, sizeof(new_name))) { if (rename_git_branch(current_name, new_name)) { // Refresh branch list after rename get_ncurses_git_branches(viewer); // Find and select the renamed branch for (int i = 0; i < viewer->branch_count; i++) { if (strcmp(viewer->branches[i].name, new_name) == 0) { viewer->selected_branch = i; break; } } } } // Force immediate branch window update werase(viewer->branch_list_win); render_branch_list_window(viewer); wrefresh(viewer->branch_list_win); // Clear screen artifacts from dialog clear(); refresh(); viewer->critical_operation_in_progress = 0; } break; case 'p': if (viewer->branch_count > 0 && viewer->selected_branch < viewer->branch_count) { viewer->critical_operation_in_progress = 1; const char *branch_name = viewer->branches[viewer->selected_branch].name; if (viewer->branches[viewer->selected_branch].commits_behind > 0) { // Start pulling animation immediately viewer->sync_status = SYNC_STATUS_PULLING_APPEARING; viewer->animation_frame = 0; viewer->text_char_count = 0; // Set branch-specific pull status viewer->pulling_branch_index = viewer->selected_branch; viewer->branch_pull_status = SYNC_STATUS_PULLING_VISIBLE; viewer->branch_animation_frame = 0; viewer->branch_text_char_count = 7; // Show full "Pulling" immediately // Force immediate branch window refresh to show "Pulling" before the // blocking git operation werase(viewer->branch_list_win); render_branch_list_window(viewer); wrefresh(viewer->branch_list_win); // Create a simple animated pull with spinner updates pid_t pull_pid = fork(); int result = 0; if (pull_pid == 0) { // Child process: do the actual pull exit(system("git pull 2>/dev/null >/dev/null")); } // Parent process: animate the spinner while pull is happening if (pull_pid > 0) { int status; int spinner_counter = 0; while (waitpid(pull_pid, &status, WNOHANG) == 0) { // Update spinner animation viewer->branch_animation_frame = spinner_counter; spinner_counter = (spinner_counter + 1) % 40; // Cycle every 40 iterations // Refresh the branch window to show spinning animation werase(viewer->branch_list_win); render_branch_list_window(viewer); wrefresh(viewer->branch_list_win); // Small delay for animation timing usleep(100000); // 100ms delay } // Get the exit status of the pull command if (WIFEXITED(status)) { result = WEXITSTATUS(status); } else { result = 1; // Error } } else { result = 1; // Fork failed } if (result == 0) { // Reset branch animation completely viewer->branch_pull_status = SYNC_STATUS_PULLED_APPEARING; viewer->branch_animation_frame = 0; viewer->branch_text_char_count = 0; get_ncurses_changed_files(viewer); get_commit_history(viewer); get_ncurses_git_branches(viewer); if (viewer->file_count == 0) { viewer->selected_file = 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; } else if (viewer->selected_file >= viewer->file_count) { viewer->selected_file = viewer->file_count - 1; } if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } viewer->sync_status = SYNC_STATUS_PULLED_APPEARING; viewer->animation_frame = 0; viewer->text_char_count = 0; } else { show_error_popup("Pull failed. Check your network connection."); viewer->sync_status = SYNC_STATUS_IDLE; viewer->pulling_branch_index = -1; viewer->branch_pull_status = SYNC_STATUS_IDLE; } } else { show_error_popup("No commits to pull from remote"); } viewer->critical_operation_in_progress = 0; } break; case '/': // Forward slash - enter grep search mode enter_grep_search_mode(viewer); break; } } else if (viewer->current_mode == NCURSES_MODE_COMMIT_VIEW) { // Commit view mode navigation (same as file view) switch (key) { case 27: // ESC viewer->current_mode = NCURSES_MODE_COMMIT_LIST; // Return to commit list mode viewer->split_view_mode = 0; // Exit split view mode break; case KEY_UP: case 'k': // Move cursor up while skipping empty lines move_cursor_smart(viewer, -1); break; case KEY_DOWN: case 'j': // Move cursor down while skipping empty lines move_cursor_smart(viewer, 1); break; case 21: // Ctrl+U // Move cursor up half page viewer->file_cursor_line -= max_lines_visible / 2; if (viewer->file_cursor_line < 0) { viewer->file_cursor_line = 0; } // Adjust scroll to keep cursor visible with padding if (viewer->file_cursor_line < viewer->file_scroll_offset + 3) { viewer->file_scroll_offset = viewer->file_cursor_line - 3; if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } } break; case 4: // Ctrl+D // Move cursor down half page viewer->file_cursor_line += max_lines_visible / 2; if (viewer->file_cursor_line >= viewer->file_line_count) { viewer->file_cursor_line = viewer->file_line_count - 1; } // Adjust scroll to keep cursor visible with padding if (viewer->file_cursor_line >= viewer->file_scroll_offset + max_lines_visible - 3) { viewer->file_scroll_offset = viewer->file_cursor_line - max_lines_visible + 4; if (viewer->file_scroll_offset > viewer->file_line_count - max_lines_visible) { viewer->file_scroll_offset = viewer->file_line_count - max_lines_visible; } if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } } break; case KEY_NPAGE: // Page Down case ' ': // Scroll content down by page if (viewer->file_line_count > max_lines_visible) { viewer->file_scroll_offset += max_lines_visible; if (viewer->file_scroll_offset > viewer->file_line_count - max_lines_visible) { viewer->file_scroll_offset = viewer->file_line_count - max_lines_visible; } } break; case KEY_PPAGE: // Page Up // Scroll content up by page viewer->file_scroll_offset -= max_lines_visible; if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } break; } } else if (viewer->current_mode == NCURSES_MODE_STASH_VIEW) { // Stash view mode navigation (same as file view) switch (key) { case 27: // ESC viewer->current_mode = NCURSES_MODE_STASH_LIST; // Return to stash list mode viewer->split_view_mode = 0; // Exit split view mode break; case KEY_UP: case 'k': // Move cursor up while skipping empty lines move_cursor_smart(viewer, -1); break; case KEY_DOWN: case 'j': // Move cursor down while skipping empty lines move_cursor_smart(viewer, 1); break; case 21: // Ctrl+U // Move cursor up half page viewer->file_cursor_line -= max_lines_visible / 2; if (viewer->file_cursor_line < 0) { viewer->file_cursor_line = 0; } // Adjust scroll to keep cursor visible with padding if (viewer->file_cursor_line < viewer->file_scroll_offset + 3) { viewer->file_scroll_offset = viewer->file_cursor_line - 3; if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } } break; case 4: // Ctrl+D // Move cursor down half page viewer->file_cursor_line += max_lines_visible / 2; if (viewer->file_cursor_line >= viewer->file_line_count) { viewer->file_cursor_line = viewer->file_line_count - 1; } // Adjust scroll to keep cursor visible with padding if (viewer->file_cursor_line >= viewer->file_scroll_offset + max_lines_visible - 3) { viewer->file_scroll_offset = viewer->file_cursor_line - max_lines_visible + 4; if (viewer->file_scroll_offset > viewer->file_line_count - max_lines_visible) { viewer->file_scroll_offset = viewer->file_line_count - max_lines_visible; } if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } } break; case KEY_NPAGE: // Page Down case ' ': // Scroll content down by page if (viewer->file_line_count > max_lines_visible) { viewer->file_scroll_offset += max_lines_visible; if (viewer->file_scroll_offset > viewer->file_line_count - max_lines_visible) { viewer->file_scroll_offset = viewer->file_line_count - max_lines_visible; } } break; case KEY_PPAGE: // Page Up // Scroll content up by page viewer->file_scroll_offset -= max_lines_visible; if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } break; } } else if (viewer->current_mode == NCURSES_MODE_BRANCH_VIEW) { // Branch view mode navigation (same as file view) switch (key) { case 27: // ESC viewer->current_mode = NCURSES_MODE_BRANCH_LIST; // Return to branch list mode viewer->split_view_mode = 0; // Exit split view mode break; case KEY_UP: case 'k': // Move cursor up while skipping empty lines move_cursor_smart(viewer, -1); break; case KEY_DOWN: case 'j': // Move cursor down while skipping empty lines move_cursor_smart(viewer, 1); break; case 21: // Ctrl+U // Move cursor up half page viewer->file_cursor_line -= max_lines_visible / 2; if (viewer->file_cursor_line < 0) { viewer->file_cursor_line = 0; } // Adjust scroll to keep cursor visible with padding if (viewer->file_cursor_line < viewer->file_scroll_offset + 3) { viewer->file_scroll_offset = viewer->file_cursor_line - 3; if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } } break; case 4: // Ctrl+D // Move cursor down half page viewer->file_cursor_line += max_lines_visible / 2; if (viewer->file_cursor_line >= viewer->file_line_count) { viewer->file_cursor_line = viewer->file_line_count - 1; } // Adjust scroll to keep cursor visible with padding if (viewer->file_cursor_line >= viewer->file_scroll_offset + max_lines_visible - 3) { viewer->file_scroll_offset = viewer->file_cursor_line - max_lines_visible + 4; if (viewer->file_scroll_offset > viewer->file_line_count - max_lines_visible) { viewer->file_scroll_offset = viewer->file_line_count - max_lines_visible; } if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } } break; case KEY_NPAGE: // Page Down case ' ': // Scroll content down by page if (viewer->file_line_count > max_lines_visible) { viewer->file_scroll_offset += max_lines_visible; if (viewer->file_scroll_offset > viewer->file_line_count - max_lines_visible) { viewer->file_scroll_offset = viewer->file_line_count - max_lines_visible; } } break; case KEY_PPAGE: // Page Up // Scroll content up by page viewer->file_scroll_offset -= max_lines_visible; if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } break; } } return 1; // Continue } /** * Run the ncurses diff viewer */ int run_ncurses_diff_viewer(void) { NCursesDiffViewer viewer; if (!init_ncurses_diff_viewer(&viewer)) { printf("Failed to initialize ncurses diff viewer\n"); return 1; } signal(SIGWINCH, handle_sigwinch); // Get changed files (can be 0, that's okay) get_ncurses_changed_files(&viewer); // get stashes get_ncurses_git_stashes(&viewer); // get branches get_ncurses_git_branches(&viewer); // Load commit history get_commit_history(&viewer); // Initial preview will be handled by update_preview_for_current_selection // Initial display attron(COLOR_PAIR(3)); if (viewer.current_mode == NCURSES_MODE_FILE_LIST) { mvprintw(0, 0, "Git Diff Viewer: 1=files 2=view 3=branches 4=commits 5=stashes | " "j/k=nav " "Space=mark " "A=all S=stash C=commit P=push | q=quit"); } else if (viewer.current_mode == NCURSES_MODE_FILE_VIEW) { mvprintw(0, 0, "Git Diff Viewer: 1=files 2=view 3=branches 4=commits 5=stashes | " "j/k=scroll " "Ctrl+U/D=30lines | q=quit"); } else { mvprintw(0, 0, "Git Diff Viewer: 1=files 2=view 3=branches 4=commits 5=stashes | " "j/k=nav P=push " "r/R=reset a=amend | q=quit"); } attroff(COLOR_PAIR(3)); refresh(); render_file_list_window(&viewer); render_file_content_window(&viewer); render_commit_list_window(&viewer); render_branch_list_window(&viewer); render_stash_list_window(&viewer); render_status_bar(&viewer); render_fuzzy_search(&viewer); render_grep_search(&viewer); // Main display loop int running = 1; NCursesViewMode last_mode = viewer.current_mode; while (running) { if (terminal_resized) { handle_terminal_resize(&viewer); } // Only update title if mode changed if (viewer.current_mode != last_mode) { // Clear just the title line move(0, 0); clrtoeol(); attron(COLOR_PAIR(3)); if (viewer.current_mode == NCURSES_MODE_FILE_LIST) { mvprintw(0, 0, "Git Diff Viewer: 1=files 2=view 3=branches 4=commits " "5=stashes | j/k=nav " "Space=mark A=all S=stash C=commit P=push | q=quit"); } else if (viewer.current_mode == NCURSES_MODE_FILE_VIEW) { mvprintw(0, 0, "Git Diff Viewer: 1=files 2=view 3=branches 4=commits " "5=stashes | j/k=scroll " "Ctrl+U/D=30lines | q=quit"); } else { mvprintw( 0, 0, "Git Diff Viewer: 1=files 2=view 3=branches 4=commits 5=stashes | " "j/k=nav P=push " "r/R=reset a=amend | q=quit"); } attroff(COLOR_PAIR(3)); refresh(); last_mode = viewer.current_mode; } // Update sync status and check for new files update_sync_status(&viewer); // Update preview based on current selection update_preview_for_current_selection(&viewer); // Skip main window rendering if fuzzy or grep search is active to prevent // flickering if (!viewer.fuzzy_search_active && !viewer.grep_search_active) { render_file_list_window(&viewer); render_file_content_window(&viewer); render_commit_list_window(&viewer); render_branch_list_window(&viewer); render_stash_list_window(&viewer); render_status_bar(&viewer); } // Always render search overlays (they handle their own visibility) render_fuzzy_search(&viewer); render_grep_search(&viewer); // Keep cursor hidden curs_set(0); int c = getch(); if (c != ERR) { // Only process if a key was actually pressed running = handle_ncurses_diff_input(&viewer, c); } // Small delay to prevent excessive CPU usage and allow animations usleep(20000); // 20ms delay } cleanup_ncurses_diff_viewer(&viewer); return 0; } /** * Get list of git stashes */ /** * Get list of git branches */ int get_ncurses_git_branches(NCursesDiffViewer *viewer) { if (!viewer) return 0; viewer->branch_count = 0; // Note: We now do fetching in the background to avoid UI freezes // Use git branch (without -a) to only show local branches FILE *fp = popen("git branch 2>/dev/null", "r"); if (!fp) { return 0; } char line[512]; while (fgets(line, sizeof(line), fp) && viewer->branch_count < MAX_BRANCHES) { // Remove newline line[strcspn(line, "\n")] = 0; // Skip empty lines if (strlen(line) == 0) continue; // Check if this is the current branch (starts with *) int is_current = 0; char *branch_name = line; // Skip leading spaces while (*branch_name == ' ') branch_name++; if (*branch_name == '*') { is_current = 1; branch_name++; // Skip the * while (*branch_name == ' ') branch_name++; // Skip spaces after * } // Skip any lines that contain "->" (symbolic references like origin/HEAD -> // origin/main) if (strstr(branch_name, "->") != NULL) { continue; } // Skip remote branches (shouldn't appear with git branch, but just in case) if (strncmp(branch_name, "remotes/", 8) == 0) { continue; } // Copy branch name strncpy(viewer->branches[viewer->branch_count].name, branch_name, MAX_BRANCHNAME_LEN - 1); viewer->branches[viewer->branch_count].name[MAX_BRANCHNAME_LEN - 1] = '\0'; viewer->branches[viewer->branch_count].status = is_current; // Initialize ahead/behind counts viewer->branches[viewer->branch_count].commits_ahead = 0; viewer->branches[viewer->branch_count].commits_behind = 0; // Get ahead/behind status using the exact method lazygit uses // First check if remote branch exists char remote_exists_cmd[512]; snprintf(remote_exists_cmd, sizeof(remote_exists_cmd), "git show-ref --verify --quiet refs/remotes/origin/%s", branch_name); if (system(remote_exists_cmd) == 0) { // Remote branch exists, now get ahead/behind counts // Get commits behind (how many commits remote has that we don't) char behind_cmd[512]; snprintf(behind_cmd, sizeof(behind_cmd), "git rev-list --count %s..origin/%s 2>/dev/null", branch_name, branch_name); FILE *behind_fp = popen(behind_cmd, "r"); if (behind_fp) { char behind_count[32]; if (fgets(behind_count, sizeof(behind_count), behind_fp) != NULL) { viewer->branches[viewer->branch_count].commits_behind = atoi(behind_count); } pclose(behind_fp); } // Get commits ahead (how many commits we have that remote doesn't) char ahead_cmd[512]; snprintf(ahead_cmd, sizeof(ahead_cmd), "git rev-list --count origin/%s..%s 2>/dev/null", branch_name, branch_name); FILE *ahead_fp = popen(ahead_cmd, "r"); if (ahead_fp) { char ahead_count[32]; if (fgets(ahead_count, sizeof(ahead_count), ahead_fp) != NULL) { viewer->branches[viewer->branch_count].commits_ahead = atoi(ahead_count); } pclose(ahead_fp); } } viewer->branch_count++; } pclose(fp); return 1; } /** * Get branch name input from user for new branch creation */ int get_branch_name_input(char *branch_name, int max_len) { if (!branch_name || max_len <= 0) return 0; // Create input window int win_height = 7; int win_width = 60; int start_y = (LINES - win_height) / 2; int start_x = (COLS - win_width) / 2; WINDOW *input_win = newwin(win_height, win_width, start_y, start_x); if (!input_win) return 0; char input[256] = {0}; int input_pos = 0; int result = 0; // Enable echo and cursor for input echo(); curs_set(1); keypad(input_win, TRUE); while (1) { // Redraw window werase(input_win); box(input_win, 0, 0); mvwprintw(input_win, 0, 2, " Create New Branch "); mvwprintw(input_win, 2, 2, "Branch name:"); mvwprintw(input_win, 5, 2, "Enter: create | Esc: cancel"); // Show current input mvwprintw(input_win, 3, 2, "> %s", input); // Position cursor at end of input wmove(input_win, 3, 4 + input_pos); wrefresh(input_win); int ch = wgetch(input_win); switch (ch) { case 27: // ESC result = 0; goto cleanup; case '\n': case '\r': case KEY_ENTER: if (input_pos > 0) { strncpy(branch_name, input, max_len - 1); branch_name[max_len - 1] = '\0'; result = 1; } goto cleanup; case KEY_BACKSPACE: case 127: case '\b': if (input_pos > 0) { input_pos--; input[input_pos] = '\0'; } break; default: // Add printable characters if (ch >= 32 && ch <= 126 && input_pos < max_len - 1) { input[input_pos] = ch; input_pos++; input[input_pos] = '\0'; } break; } } cleanup: // Restore ncurses settings noecho(); curs_set(0); delwin(input_win); return result; } /** * Create a new git branch */ int create_git_branch(const char *branch_name) { if (!branch_name || strlen(branch_name) == 0) return 0; // Clean branch name: replace spaces with dashes char clean_branch_name[256]; strncpy(clean_branch_name, branch_name, sizeof(clean_branch_name) - 1); clean_branch_name[sizeof(clean_branch_name) - 1] = '\0'; // Replace spaces with dashes for (int i = 0; clean_branch_name[i] != '\0'; i++) { if (clean_branch_name[i] == ' ') { clean_branch_name[i] = '-'; } } char cmd[512]; snprintf(cmd, sizeof(cmd), "git checkout -b \"%s\" >/dev/null 2>&1", clean_branch_name); return (system(cmd) == 0); } /** * Get new branch name input from user for branch renaming */ int get_rename_branch_input(const char *current_name, char *new_name, int max_len) { if (!current_name || !new_name || max_len <= 0) return 0; // Create input window int win_height = 8; int win_width = 60; int start_y = (LINES - win_height) / 2; int start_x = (COLS - win_width) / 2; WINDOW *input_win = newwin(win_height, win_width, start_y, start_x); if (!input_win) return 0; char input[256] = {0}; int input_pos = 0; int result = 0; // Pre-fill with current name strncpy(input, current_name, sizeof(input) - 1); input_pos = strlen(input); // Enable echo and cursor for input echo(); curs_set(1); keypad(input_win, TRUE); while (1) { // Redraw window werase(input_win); box(input_win, 0, 0); mvwprintw(input_win, 0, 2, " Rename Branch "); mvwprintw(input_win, 2, 2, "Current: %s", current_name); mvwprintw(input_win, 3, 2, "New name:"); mvwprintw(input_win, 6, 2, "Enter: rename | Esc: cancel"); // Show current input mvwprintw(input_win, 4, 2, "> %s", input); // Position cursor at end of input wmove(input_win, 4, 4 + input_pos); wrefresh(input_win); int ch = wgetch(input_win); switch (ch) { case 27: // ESC result = 0; goto cleanup_rename; case '\n': case '\r': case KEY_ENTER: if (input_pos > 0 && strcmp(input, current_name) != 0) { strncpy(new_name, input, max_len - 1); new_name[max_len - 1] = '\0'; result = 1; } goto cleanup_rename; case KEY_BACKSPACE: case 127: case '\b': if (input_pos > 0) { input_pos--; input[input_pos] = '\0'; } break; default: // Add printable characters if (ch >= 32 && ch <= 126 && input_pos < max_len - 1) { input[input_pos] = ch; input_pos++; input[input_pos] = '\0'; } break; } } cleanup_rename: // Restore ncurses settings noecho(); curs_set(0); delwin(input_win); return result; } /** * Rename a git branch */ int rename_git_branch(const char *old_name, const char *new_name) { if (!old_name || !new_name || strlen(old_name) == 0 || strlen(new_name) == 0) return 0; char cmd[512]; snprintf(cmd, sizeof(cmd), "git branch -m \"%s\" \"%s\" >/dev/null 2>&1", old_name, new_name); return (system(cmd) == 0); } /** * Show delete branch confirmation dialog */ int show_delete_branch_dialog(const char *branch_name) { if (!branch_name) return DELETE_CANCEL; // Create dialog window int win_height = 8; int win_width = 50; int start_y = (LINES - win_height) / 2; int start_x = (COLS - win_width) / 2; WINDOW *dialog_win = newwin(win_height, win_width, start_y, start_x); if (!dialog_win) return DELETE_CANCEL; int selected_option = DELETE_LOCAL; const char *options[] = {"Delete local (l)", "Delete remote (r)", "Delete both (b)"}; while (1) { werase(dialog_win); box(dialog_win, 0, 0); mvwprintw(dialog_win, 0, 2, " Delete Branch "); mvwprintw(dialog_win, 2, 2, "Branch: %s", branch_name); // Draw options for (int i = 0; i < 3; i++) { int y = 3 + i; if (i == selected_option) { wattron(dialog_win, COLOR_PAIR(5)); // Highlight selected mvwprintw(dialog_win, y, 2, "> %s", options[i]); wattroff(dialog_win, COLOR_PAIR(5)); } else { mvwprintw(dialog_win, y, 2, " %s", options[i]); } } mvwprintw(dialog_win, 6, 2, "Enter: select | Esc: cancel"); wrefresh(dialog_win); int key = getch(); switch (key) { case 27: // ESC delwin(dialog_win); return DELETE_CANCEL; case 'l': delwin(dialog_win); return DELETE_LOCAL; case 'r': delwin(dialog_win); return DELETE_REMOTE; case 'b': delwin(dialog_win); return DELETE_BOTH; case KEY_UP: case 'k': if (selected_option > 0) { selected_option--; } break; case KEY_DOWN: case 'j': if (selected_option < 2) { selected_option++; } break; case '\n': case '\r': case KEY_ENTER: delwin(dialog_win); return selected_option; } } } /** * Show universal error popup */ void show_error_popup(const char *error_message) { if (!error_message) return; int max_y, max_x; getmaxyx(stdscr, max_y, max_x); int popup_height = 5; int popup_width = strlen(error_message) + 6; if (popup_width > max_x - 4) { popup_width = max_x - 4; } int start_y = (max_y - popup_height) / 2; int start_x = (max_x - popup_width) / 2; WINDOW *popup_win = newwin(popup_height, popup_width, start_y, start_x); wattron(popup_win, COLOR_PAIR(1)); box(popup_win, 0, 0); mvwprintw(popup_win, 1, 2, "Error:"); mvwprintw(popup_win, 2, 2, "%.*s", popup_width - 4, error_message); mvwprintw(popup_win, 3, 2, "Press any key to continue..."); wattroff(popup_win, COLOR_PAIR(1)); wrefresh(popup_win); // Wait for user input getch(); delwin(popup_win); clear(); refresh(); } /** * Get available git remotes */ int get_git_remotes(char remotes[][256], int max_remotes) { FILE *fp = popen("git remote 2>/dev/null", "r"); if (!fp) return 0; int count = 0; char line[256]; while (fgets(line, sizeof(line), fp) && count < max_remotes) { // Remove trailing newline int len = strlen(line); if (len > 0 && line[len - 1] == '\n') { line[len - 1] = '\0'; } if (strlen(line) > 0) { strncpy(remotes[count], line, 255); remotes[count][255] = '\0'; count++; } } pclose(fp); return count; } /** * Show upstream selection dialog */ int show_upstream_selection_dialog(const char *branch_name, char *upstream_result, int max_len) { if (!branch_name || !upstream_result) return 0; int max_y, max_x; getmaxyx(stdscr, max_y, max_x); int dialog_height = 12; int dialog_width = 60; int start_y = (max_y - dialog_height) / 2; int start_x = (max_x - dialog_width) / 2; WINDOW *dialog_win = newwin(dialog_height, dialog_width, start_y, start_x); // Get available remotes char remotes[10][256]; int remote_count = get_git_remotes(remotes, 10); char input_buffer[256] = ""; int cursor_pos = 0; int selected_suggestion = 0; // Default suggestion if (remote_count > 0) { snprintf(input_buffer, sizeof(input_buffer), "%s %s", remotes[0], branch_name); cursor_pos = strlen(input_buffer); } while (1) { werase(dialog_win); box(dialog_win, 0, 0); // Title mvwprintw(dialog_win, 1, 2, "Set Upstream Branch"); mvwprintw(dialog_win, 2, 2, "Enter upstream as "); // Input field mvwprintw(dialog_win, 4, 2, "Upstream: %s", input_buffer); // Suggestions header mvwprintw(dialog_win, 6, 2, "Suggestions (press to focus):"); // Show suggestions for (int i = 0; i < remote_count && i < 3; i++) { char suggestion[256]; snprintf(suggestion, sizeof(suggestion), "%s %s", remotes[i], branch_name); if (i == selected_suggestion) { wattron(dialog_win, A_REVERSE); } mvwprintw(dialog_win, 7 + i, 4, "%s", suggestion); if (i == selected_suggestion) { wattroff(dialog_win, A_REVERSE); } } // Instructions mvwprintw(dialog_win, dialog_height - 2, 2, "Enter: Set | Esc: Cancel"); wrefresh(dialog_win); int key = getch(); switch (key) { case 27: // ESC delwin(dialog_win); return 0; case '\n': case '\r': case KEY_ENTER: if (strlen(input_buffer) > 0) { strncpy(upstream_result, input_buffer, max_len - 1); upstream_result[max_len - 1] = '\0'; delwin(dialog_win); return 1; } break; case '\t': // Tab to select suggestion if (remote_count > 0 && selected_suggestion < remote_count) { snprintf(input_buffer, sizeof(input_buffer), "%s %s", remotes[selected_suggestion], branch_name); cursor_pos = strlen(input_buffer); } break; case KEY_UP: if (selected_suggestion > 0) { selected_suggestion--; } break; case KEY_DOWN: if (selected_suggestion < remote_count - 1) { selected_suggestion++; } break; case KEY_BACKSPACE: case 127: if (cursor_pos > 0) { cursor_pos--; input_buffer[cursor_pos] = '\0'; } break; default: if (isprint(key) && cursor_pos < (int)sizeof(input_buffer) - 1) { input_buffer[cursor_pos] = key; cursor_pos++; input_buffer[cursor_pos] = '\0'; } break; } } delwin(dialog_win); return 0; } /** * Get current git branch name */ int get_current_branch_name(char *branch_name, int max_len) { if (!branch_name) return 0; FILE *fp = popen("git rev-parse --abbrev-ref HEAD 2>/dev/null", "r"); if (!fp) return 0; if (fgets(branch_name, max_len, fp) != NULL) { // Remove trailing newline int len = strlen(branch_name); if (len > 0 && branch_name[len - 1] == '\n') { branch_name[len - 1] = '\0'; } pclose(fp); return 1; } pclose(fp); return 0; } /** * Check if a branch has an upstream */ int branch_has_upstream(const char *branch_name) { if (!branch_name) return 0; char cmd[512]; snprintf(cmd, sizeof(cmd), "git rev-parse --abbrev-ref \"%s@{upstream}\" >/dev/null 2>&1", branch_name); return (system(cmd) == 0); } /** * Delete git branch based on option */ int delete_git_branch(const char *branch_name, DeleteBranchOption option) { if (!branch_name || option == DELETE_CANCEL) return 0; // Check for upstream before attempting remote deletion if (option == DELETE_REMOTE || option == DELETE_BOTH) { if (!branch_has_upstream(branch_name)) { // Show error message for branches without upstream show_error_popup("The selected branch has no upstream (tip: delete the " "branch locally)"); return 0; // Don't proceed with deletion } } char cmd[512]; int success = 1; switch (option) { case DELETE_LOCAL: snprintf(cmd, sizeof(cmd), "git branch -D \"%s\" >/dev/null 2>&1", branch_name); success = (system(cmd) == 0); break; case DELETE_REMOTE: snprintf(cmd, sizeof(cmd), "git push origin --delete \"%s\" >/dev/null 2>&1", branch_name); success = (system(cmd) == 0); break; case DELETE_BOTH: // Delete local first snprintf(cmd, sizeof(cmd), "git branch -D \"%s\" >/dev/null 2>&1", branch_name); success = (system(cmd) == 0); // Then delete remote if (success) { snprintf(cmd, sizeof(cmd), "git push origin --delete \"%s\" >/dev/null 2>&1", branch_name); success = (system(cmd) == 0); } break; case DELETE_CANCEL: default: return 0; } return success; } // this is a test int get_ncurses_git_stashes(NCursesDiffViewer *viewer) { if (!viewer) return 0; char stash_lines[MAX_STASHES][512]; viewer->stash_count = get_git_stashes(stash_lines, MAX_STASHES); for (int i = 0; i < viewer->stash_count; i++) { strncpy(viewer->stashes[i].stash_info, stash_lines[i], 511); viewer->stashes[i].stash_info[511] = '\0'; } return viewer->stash_count; } /** * Get stash name input from user */ int get_stash_name_input(char *stash_name, int max_len) { if (!stash_name) return 0; // Save current screen WINDOW *saved_screen = dupwin(stdscr); // Calculate window dimensions int input_width = COLS * 0.6; // 60% of screen width int input_height = 3; int start_x = COLS / 2 - input_width / 2; int start_y = LINES / 2 - input_height / 2; // Create input window WINDOW *input_win = newwin(input_height, input_width, start_y, start_x); if (!input_win) { if (saved_screen) delwin(saved_screen); return 0; } // Variables for input handling int input_scroll_offset = 0; // For horizontal scrolling int ch; // Function to redraw input window void redraw_input() { werase(input_win); box(input_win, 0, 0); int visible_width = input_width - 4; int name_len = strlen(stash_name); // Clear the content area with spaces FIRST for (int x = 1; x <= visible_width; x++) { mvwaddch(input_win, 1, x, ' '); } // Calculate what part of the name to show int display_start = input_scroll_offset; int display_end = display_start + visible_width; if (display_end > name_len) display_end = name_len; // Show the visible portion of the name ON TOP of cleared spaces for (int i = display_start; i < display_end; i++) { mvwaddch(input_win, 1, 1 + (i - display_start), stash_name[i]); } // Header wattron(input_win, COLOR_PAIR(4)); mvwprintw(input_win, 0, 2, " Enter stash name (ESC to cancel, Enter to confirm) "); wattroff(input_win, COLOR_PAIR(4)); wrefresh(input_win); } // Initial draw redraw_input(); // Position initial cursor int cursor_pos = strlen(stash_name) - input_scroll_offset; int visible_width = input_width - 4; if (cursor_pos > visible_width - 1) cursor_pos = visible_width - 1; if (cursor_pos < 0) cursor_pos = 0; wmove(input_win, 1, 1 + cursor_pos); wrefresh(input_win); curs_set(1); noecho(); // Main input loop while (1) { ch = getch(); if (ch == 27) { // ESC to cancel stash_name[0] = '\0'; // Clear name break; } if (ch == '\n' || ch == '\r') { // Enter to confirm - accept if name has content if (strlen(stash_name) > 0) { break; } } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { // Handle backspace int len = strlen(stash_name); if (len > 0) { stash_name[len - 1] = '\0'; // Adjust scroll if needed int visible_width = input_width - 4; if (len - 1 <= input_scroll_offset) { input_scroll_offset = (len - 1) - (visible_width - 5); if (input_scroll_offset < 0) input_scroll_offset = 0; } redraw_input(); } } else if (ch >= 32 && ch <= 126) { // Regular character input int len = strlen(stash_name); if (len < max_len - 1) { stash_name[len] = ch; stash_name[len + 1] = '\0'; // Auto-scroll horizontally if needed int visible_width = input_width - 4; if (len + 1 > input_scroll_offset + visible_width - 5) { input_scroll_offset = (len + 1) - (visible_width - 5); } redraw_input(); } } // Position cursor int cursor_pos = strlen(stash_name) - input_scroll_offset; int visible_width = input_width - 4; if (cursor_pos > visible_width - 1) cursor_pos = visible_width - 1; if (cursor_pos < 0) cursor_pos = 0; wmove(input_win, 1, 1 + cursor_pos); wrefresh(input_win); } // Restore settings curs_set(0); // Hide cursor // Clean up window delwin(input_win); // Restore the screen if (saved_screen) { overwrite(saved_screen, stdscr); delwin(saved_screen); } // Force a complete redraw clear(); refresh(); return strlen(stash_name) > 0 ? 1 : 0; } /** * Create a new git stash */ int create_ncurses_git_stash(NCursesDiffViewer *viewer) { if (!viewer) return 0; // Get stash name from user char stash_name[256] = ""; if (!get_stash_name_input(stash_name, sizeof(stash_name))) { return 0; // User cancelled } int result = create_git_stash_with_name(stash_name); if (result) { // Refresh everything after creating stash get_ncurses_changed_files(viewer); get_ncurses_git_stashes(viewer); get_commit_history(viewer); // Reset file selection since changes are stashed viewer->selected_file = 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; // Reload current file if any files still exist if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } } return result; } /** * Render the stash list window */ void render_stash_list_window(NCursesDiffViewer *viewer) { if (!viewer || !viewer->stash_list_win) return; // Clear the entire window first werase(viewer->stash_list_win); // Draw rounded border and title draw_rounded_box(viewer->stash_list_win); mvwprintw(viewer->stash_list_win, 0, 2, " 5. Stashes "); int max_stashes_visible = viewer->stash_panel_height - 2; // Clear the content area with spaces for (int y = 1; y < viewer->stash_panel_height - 1; y++) { for (int x = 1; x < viewer->file_panel_width - 1; x++) { mvwaddch(viewer->stash_list_win, y, x, ' '); } } if (viewer->stash_count == 0) { // Show "No stashes" message mvwprintw(viewer->stash_list_win, 1, 2, "No stashes available"); } else { for (int i = 0; i < max_stashes_visible; i++) { int y = i + 1; int stash_index = i + viewer->stash_scroll_offset; // Skip if no more stashes if (stash_index >= viewer->stash_count) continue; // Check if this stash line should be highlighted int is_selected_stash = (stash_index == viewer->selected_stash && viewer->current_mode == NCURSES_MODE_STASH_LIST); // Check if this stash is currently being viewed int is_being_viewed = (stash_index == viewer->selected_stash && viewer->current_mode == NCURSES_MODE_STASH_VIEW); // Apply line highlight if selected if (is_selected_stash) { wattron(viewer->stash_list_win, COLOR_PAIR(5)); } // Show view indicator (currently being viewed) if (is_being_viewed) { wattron(viewer->stash_list_win, COLOR_PAIR(1)); // Green for active view mvwprintw(viewer->stash_list_win, y, 1, "*"); wattroff(viewer->stash_list_win, COLOR_PAIR(1)); } else { mvwprintw(viewer->stash_list_win, y, 1, " "); } // Show selection indicator if (is_selected_stash) { mvwprintw(viewer->stash_list_win, y, 2, ">"); } else { mvwprintw(viewer->stash_list_win, y, 2, " "); } // Show stash info (truncated to fit panel) int max_stash_len = viewer->file_panel_width - 6; char truncated_stash[256]; if ((int)strlen(viewer->stashes[stash_index].stash_info) > max_stash_len) { strncpy(truncated_stash, viewer->stashes[stash_index].stash_info, max_stash_len - 2); truncated_stash[max_stash_len - 2] = '\0'; strcat(truncated_stash, ".."); } else { strcpy(truncated_stash, viewer->stashes[stash_index].stash_info); } // Turn off selection highlighting before rendering content if (is_selected_stash) { wattroff(viewer->stash_list_win, COLOR_PAIR(5)); } // Find the colon after "On [branch]:" char *colon_pos = strstr(truncated_stash, ": "); if (colon_pos) { // Split at the colon int time_part_len = colon_pos - truncated_stash + 1; // Include the ":" // Find where " On " starts to insert space before it for single digits char *on_pos = strstr(truncated_stash, " On "); if (on_pos) { // Check if time is single digit (e.g., "6h" or "4m") int is_single_digit = 0; int time_len = on_pos - truncated_stash; if (time_len == 2) { // Single digit + letter (e.g., "6h", "4m") char first_char = truncated_stash[0]; char second_char = truncated_stash[1]; if (first_char >= '1' && first_char <= '9' && (second_char == 'h' || second_char == 'm' || second_char == 'd' || second_char == 'w')) { is_single_digit = 1; } } // Print time part in yellow wattron(viewer->stash_list_win, COLOR_PAIR(4)); mvwprintw(viewer->stash_list_win, y, 4, "%.*s", time_len, truncated_stash); // Add extra space for single digit times if (is_single_digit) { mvwprintw(viewer->stash_list_win, y, 4 + time_len, " "); time_len++; } // Print " On [branch]:" part in yellow mvwprintw(viewer->stash_list_win, y, 4 + time_len, "%.*s", (int)(colon_pos - on_pos + 1), on_pos); wattroff(viewer->stash_list_win, COLOR_PAIR(4)); // Print message part in normal color (white) mvwprintw(viewer->stash_list_win, y, 4 + time_part_len + (is_single_digit ? 1 : 0), "%s", colon_pos + 1); } else { // Fallback: print entire yellow part if no " On " found wattron(viewer->stash_list_win, COLOR_PAIR(4)); mvwprintw(viewer->stash_list_win, y, 4, "%.*s", time_part_len, truncated_stash); wattroff(viewer->stash_list_win, COLOR_PAIR(4)); // Print message part in normal color (white) mvwprintw(viewer->stash_list_win, y, 4 + time_part_len, "%s", colon_pos + 1); } } else { // Fallback: print entire string in yellow if no colon found wattron(viewer->stash_list_win, COLOR_PAIR(4)); mvwprintw(viewer->stash_list_win, y, 4, "%s", truncated_stash); wattroff(viewer->stash_list_win, COLOR_PAIR(4)); } // Turn off selection highlighting if this was the selected stash if (is_selected_stash) { wattroff(viewer->stash_list_win, COLOR_PAIR(5)); } } } wrefresh(viewer->stash_list_win); } void render_branch_list_window(NCursesDiffViewer *viewer) { if (!viewer || !viewer->branch_list_win) return; werase(viewer->branch_list_win); draw_rounded_box(viewer->branch_list_win); mvwprintw(viewer->branch_list_win, 0, 2, " 3. Branches "); int max_branches_visible = viewer->branch_panel_height - 2; for (int y = 1; y < viewer->branch_panel_height - 1; y++) { for (int x = 1; x < viewer->file_panel_width - 1; x++) { mvwaddch(viewer->branch_list_win, y, x, ' '); } } if (viewer->branch_count == 0) { mvwprintw(viewer->branch_list_win, 1, 2, "No branches available"); } else { for (int i = 0; i < max_branches_visible && i < viewer->branch_count; i++) { int y = i + 1; int is_selected_branch = (i == viewer->selected_branch && viewer->current_mode == NCURSES_MODE_BRANCH_LIST); int is_current_branch = viewer->branches[i].status; // status = 1 for current branch if (is_selected_branch) { wattron(viewer->branch_list_win, COLOR_PAIR(5)); } // Show selection arrow if (is_selected_branch) { mvwprintw(viewer->branch_list_win, y, 1, ">"); } else { mvwprintw(viewer->branch_list_win, y, 1, " "); } // Prepare branch display with asterisk for current branch and status int max_branch_len = viewer->file_panel_width - 15; // Leave more space for status indicators char display_branch[300]; char status_indicator[50] = ""; // Create status indicator if (viewer->branches[i].commits_ahead > 0 && viewer->branches[i].commits_behind > 0) { snprintf(status_indicator, sizeof(status_indicator), " %d↑%d↓", viewer->branches[i].commits_ahead, viewer->branches[i].commits_behind); } else if (viewer->branches[i].commits_ahead > 0) { snprintf(status_indicator, sizeof(status_indicator), " %d↑", viewer->branches[i].commits_ahead); } else if (viewer->branches[i].commits_behind > 0) { snprintf(status_indicator, sizeof(status_indicator), " %d↓", viewer->branches[i].commits_behind); } if (is_current_branch) { snprintf(display_branch, sizeof(display_branch), "* %s", viewer->branches[i].name); } else { snprintf(display_branch, sizeof(display_branch), " %s", viewer->branches[i].name); } // Truncate branch name if too long if ((int)strlen(display_branch) > max_branch_len) { display_branch[max_branch_len - 2] = '.'; display_branch[max_branch_len - 1] = '.'; display_branch[max_branch_len] = '\0'; } // Color the branch name if (is_current_branch) { wattron(viewer->branch_list_win, COLOR_PAIR(1)); // Green for current branch } else { wattron(viewer->branch_list_win, COLOR_PAIR(4)); // Yellow for other branches } mvwprintw(viewer->branch_list_win, y, 2, "%s", display_branch); if (is_current_branch) { wattroff(viewer->branch_list_win, COLOR_PAIR(1)); } else { wattroff(viewer->branch_list_win, COLOR_PAIR(4)); } // Add status indicator with appropriate colors if (strlen(status_indicator) > 0) { if (is_selected_branch) { wattroff(viewer->branch_list_win, COLOR_PAIR(5)); } if (viewer->branches[i].commits_behind > 0) { wattron(viewer->branch_list_win, COLOR_PAIR(2)); // Red for behind } else { wattron(viewer->branch_list_win, COLOR_PAIR(1)); // Green for ahead } mvwprintw(viewer->branch_list_win, y, 2 + strlen(display_branch), "%s", status_indicator); if (viewer->branches[i].commits_behind > 0) { wattroff(viewer->branch_list_win, COLOR_PAIR(2)); } else { wattroff(viewer->branch_list_win, COLOR_PAIR(1)); } if (is_selected_branch) { wattron(viewer->branch_list_win, COLOR_PAIR(5)); } } // Add push/pull status indicators next to the branch char branch_sync_text[32] = ""; char *spinner_chars[] = {"|", "/", "-", "\\"}; int spinner_idx = (viewer->branch_animation_frame / 1) % 4; // Check if this branch is being pushed if (i == viewer->pushing_branch_index) { if (viewer->branch_push_status >= SYNC_STATUS_PUSHING_APPEARING && viewer->branch_push_status <= SYNC_STATUS_PUSHING_DISAPPEARING) { if (viewer->branch_push_status == SYNC_STATUS_PUSHING_VISIBLE) { snprintf(branch_sync_text, sizeof(branch_sync_text), " Pushing %s", spinner_chars[spinner_idx]); } else { char partial_text[16] = ""; int chars_to_show = viewer->branch_text_char_count; if (chars_to_show > 7) chars_to_show = 7; if (chars_to_show > 0) { strncpy(partial_text, "Pushing", chars_to_show); partial_text[chars_to_show] = '\0'; snprintf(branch_sync_text, sizeof(branch_sync_text), " %s", partial_text); } } } else if (viewer->branch_push_status >= SYNC_STATUS_PUSHED_APPEARING && viewer->branch_push_status <= SYNC_STATUS_PUSHED_DISAPPEARING) { char partial_text[16] = ""; int chars_to_show = viewer->branch_text_char_count; if (chars_to_show > 7) chars_to_show = 7; if (chars_to_show > 0) { strncpy(partial_text, "Pushed!", chars_to_show); partial_text[chars_to_show] = '\0'; snprintf(branch_sync_text, sizeof(branch_sync_text), " %s", partial_text); } } } // Check if this branch is being pulled if (i == viewer->pulling_branch_index) { if (viewer->branch_pull_status >= SYNC_STATUS_PULLING_APPEARING && viewer->branch_pull_status <= SYNC_STATUS_PULLING_DISAPPEARING) { if (viewer->branch_pull_status == SYNC_STATUS_PULLING_VISIBLE) { snprintf(branch_sync_text, sizeof(branch_sync_text), " Pulling %s", spinner_chars[spinner_idx]); } else { char partial_text[16] = ""; int chars_to_show = viewer->branch_text_char_count; if (chars_to_show > 7) chars_to_show = 7; if (chars_to_show > 0) { strncpy(partial_text, "Pulling", chars_to_show); partial_text[chars_to_show] = '\0'; snprintf(branch_sync_text, sizeof(branch_sync_text), " %s", partial_text); } } } else if (viewer->branch_pull_status >= SYNC_STATUS_PULLED_APPEARING && viewer->branch_pull_status <= SYNC_STATUS_PULLED_DISAPPEARING) { char partial_text[16] = ""; int chars_to_show = viewer->branch_text_char_count; if (chars_to_show > 7) chars_to_show = 7; if (chars_to_show > 0) { strncpy(partial_text, "Pulled!", chars_to_show); partial_text[chars_to_show] = '\0'; snprintf(branch_sync_text, sizeof(branch_sync_text), " %s", partial_text); } } } // Display the branch sync status if (strlen(branch_sync_text) > 0) { if (is_selected_branch) { wattroff(viewer->branch_list_win, COLOR_PAIR(5)); } wattron(viewer->branch_list_win, COLOR_PAIR(4)); // Yellow for sync status mvwprintw(viewer->branch_list_win, y, 2 + strlen(display_branch) + strlen(status_indicator), "%s", branch_sync_text); wattroff(viewer->branch_list_win, COLOR_PAIR(4)); if (is_selected_branch) { wattron(viewer->branch_list_win, COLOR_PAIR(5)); } } if (is_selected_branch) { wattroff(viewer->branch_list_win, COLOR_PAIR(5)); } } } wrefresh(viewer->branch_list_win); } /** * Parse and load content lines from text with diff highlighting */ int parse_content_lines(NCursesDiffViewer *viewer, const char *content) { if (!viewer || !content) { return 0; } viewer->file_line_count = 0; viewer->file_scroll_offset = 0; viewer->file_cursor_line = 0; // Parse line by line, preserving empty lines const char *line_start = content; const char *line_end; while (*line_start && viewer->file_line_count < MAX_FULL_FILE_LINES) { NCursesFileLine *file_line = &viewer->file_lines[viewer->file_line_count]; // Find end of current line line_end = strchr(line_start, '\n'); if (!line_end) { line_end = line_start + strlen(line_start); } // Calculate line length size_t line_len = line_end - line_start; // Copy line content (truncate if too long) size_t copy_len = (line_len < sizeof(file_line->line) - 1) ? line_len : sizeof(file_line->line) - 1; strncpy(file_line->line, line_start, copy_len); file_line->line[copy_len] = '\0'; // Determine line type for syntax highlighting if (line_len == 0) { file_line->type = ' '; // Empty line } else if (strncmp(file_line->line, "diff --git", 10) == 0 || strncmp(file_line->line, "index ", 6) == 0 || strncmp(file_line->line, "--- ", 4) == 0 || strncmp(file_line->line, "+++ ", 4) == 0) { file_line->type = '@'; // Use @ for headers } else if (line_len > 1 && file_line->line[0] == '@' && file_line->line[1] == '@') { file_line->type = '@'; // Hunk headers } else if (line_len > 0 && file_line->line[0] == '+') { file_line->type = '+'; // Added lines } else if (line_len > 0 && file_line->line[0] == '-') { file_line->type = '-'; // Removed lines } else if (strstr(file_line->line, " | ") && (strstr(file_line->line, "+") || strstr(file_line->line, "-") || strstr(file_line->line, "Bin"))) { file_line->type = 's'; // File statistics lines (special type) } else if (strstr(file_line->line, " files changed") || strstr(file_line->line, " insertions") || strstr(file_line->line, " deletions")) { file_line->type = 's'; // Summary statistics lines } else if (strncmp(file_line->line, "commit ", 7) == 0) { file_line->type = 'h'; // Commit header } else if (strncmp(file_line->line, "Author: ", 8) == 0 || strncmp(file_line->line, "Date: ", 6) == 0) { file_line->type = 'i'; // Commit info lines } else { file_line->type = ' '; // Normal/context lines } file_line->is_diff_line = (file_line->type != ' ') ? 1 : 0; viewer->file_line_count++; // Move to next line if (*line_end == '\n') { line_start = line_end + 1; } else { break; // End of content } } return viewer->file_line_count; } /** * Load commit details for viewing */ int load_commit_for_viewing(NCursesDiffViewer *viewer, const char *commit_hash) { if (!viewer || !commit_hash) { return 0; } char *commit_content = malloc(50000); // Large buffer for commit details if (!commit_content) { return 0; } if (get_commit_details(commit_hash, commit_content, 50000)) { int result = parse_content_lines(viewer, commit_content); free(commit_content); return result; } free(commit_content); return 0; } /** * Load stash details for viewing */ int load_stash_for_viewing(NCursesDiffViewer *viewer, int stash_index) { if (!viewer || stash_index < 0) { return 0; } char *stash_content = malloc(50000); // Large buffer for stash details if (!stash_content) { return 0; } if (get_stash_diff(stash_index, stash_content, 50000)) { int result = parse_content_lines(viewer, stash_content); free(stash_content); return result; } free(stash_content); return 0; } /** * Load commits for a specific branch for the hover preview */ int load_branch_commits(NCursesDiffViewer *viewer, const char *branch_name) { if (!viewer || !branch_name) { return 0; } // Only load if it's a different branch than currently loaded if (strcmp(viewer->current_branch_for_commits, branch_name) == 0) { return viewer->branch_commit_count; // Already loaded } viewer->branch_commit_count = get_branch_commits(branch_name, viewer->branch_commits, MAX_COMMITS); strncpy(viewer->current_branch_for_commits, branch_name, sizeof(viewer->current_branch_for_commits) - 1); viewer ->current_branch_for_commits[sizeof(viewer->current_branch_for_commits) - 1] = '\0'; return viewer->branch_commit_count; } /** * Parse branch commits into navigable lines for branch view mode */ int parse_branch_commits_to_lines(NCursesDiffViewer *viewer) { if (!viewer || viewer->branch_commit_count == 0) { return 0; } viewer->file_line_count = 0; viewer->file_scroll_offset = 0; viewer->file_cursor_line = 0; // Parse each commit into file_lines for navigation for (int commit_idx = 0; commit_idx < viewer->branch_commit_count && viewer->file_line_count < MAX_FULL_FILE_LINES; commit_idx++) { char *commit_text = viewer->branch_commits[commit_idx]; char *line_start = commit_text; char *line_end; // Parse each line of the commit while ((line_end = strchr(line_start, '\n')) != NULL && viewer->file_line_count < MAX_FULL_FILE_LINES) { *line_end = '\0'; // Temporarily null-terminate NCursesFileLine *file_line = &viewer->file_lines[viewer->file_line_count]; strncpy(file_line->line, line_start, sizeof(file_line->line) - 1); file_line->line[sizeof(file_line->line) - 1] = '\0'; // Set line type for appropriate coloring if (strncmp(line_start, "commit ", 7) == 0) { file_line->type = 'h'; // Commit header } else if (strncmp(line_start, "Author:", 7) == 0 || strncmp(line_start, "Date:", 5) == 0) { file_line->type = 'i'; // Info line } else { file_line->type = ' '; // Regular line } file_line->is_diff_line = 0; viewer->file_line_count++; *line_end = '\n'; // Restore newline line_start = line_end + 1; } // Handle last line if no newline at end if (strlen(line_start) > 0 && viewer->file_line_count < MAX_FULL_FILE_LINES) { NCursesFileLine *file_line = &viewer->file_lines[viewer->file_line_count]; strncpy(file_line->line, line_start, sizeof(file_line->line) - 1); file_line->line[sizeof(file_line->line) - 1] = '\0'; file_line->type = ' '; file_line->is_diff_line = 0; viewer->file_line_count++; } // Add spacing between commits if (viewer->file_line_count < MAX_FULL_FILE_LINES) { NCursesFileLine *empty_line = &viewer->file_lines[viewer->file_line_count]; strcpy(empty_line->line, ""); empty_line->type = ' '; empty_line->is_diff_line = 0; viewer->file_line_count++; } } return viewer->file_line_count; } /** * Start background fetch process */ void start_background_fetch(NCursesDiffViewer *viewer) { if (!viewer || viewer->fetch_in_progress || viewer->critical_operation_in_progress) { return; } viewer->fetch_pid = fork(); if (viewer->fetch_pid == 0) { // Child process: do the fetch system("git fetch --all --quiet >/dev/null 2>&1"); exit(0); } else if (viewer->fetch_pid > 0) { // Parent process: mark fetch as in progress viewer->fetch_in_progress = 1; viewer->sync_status = SYNC_STATUS_SYNCING_APPEARING; viewer->animation_frame = 0; viewer->text_char_count = 0; } } /** * Check if background fetch is complete and update UI accordingly */ void check_background_fetch(NCursesDiffViewer *viewer) { if (!viewer || !viewer->fetch_in_progress) { return; } int status; pid_t result = waitpid(viewer->fetch_pid, &status, WNOHANG); if (result == viewer->fetch_pid) { // Fetch completed viewer->fetch_in_progress = 0; viewer->fetch_pid = -1; // Preserve current positions during refresh int preserved_file_scroll = viewer->file_scroll_offset; int preserved_file_cursor = viewer->file_cursor_line; int preserved_selected_file = viewer->selected_file; // Update only the necessary data without blocking UI get_ncurses_changed_files(viewer); get_commit_history(viewer); get_ncurses_git_branches(viewer); // Restore file selection if still valid if (preserved_selected_file < viewer->file_count) { viewer->selected_file = preserved_selected_file; // Reload current file if in file mode and restore position if ((viewer->current_mode == NCURSES_MODE_FILE_LIST || viewer->current_mode == NCURSES_MODE_FILE_VIEW) && viewer->file_count > 0) { load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); // Restore scroll position if still valid if (preserved_file_cursor < viewer->file_line_count) { viewer->file_cursor_line = preserved_file_cursor; } if (preserved_file_scroll < viewer->file_line_count) { viewer->file_scroll_offset = preserved_file_scroll; } } } // If we're in branch mode and have commits loaded, refresh them if (viewer->current_mode == NCURSES_MODE_BRANCH_LIST || viewer->current_mode == NCURSES_MODE_BRANCH_VIEW) { if (viewer->branch_count > 0 && strlen(viewer->current_branch_for_commits) > 0) { load_branch_commits(viewer, viewer->current_branch_for_commits); if (viewer->current_mode == NCURSES_MODE_BRANCH_VIEW) { // Preserve cursor position in branch view too int prev_cursor = viewer->file_cursor_line; int prev_scroll = viewer->file_scroll_offset; parse_branch_commits_to_lines(viewer); if (prev_cursor < viewer->file_line_count) { viewer->file_cursor_line = prev_cursor; } if (prev_scroll < viewer->file_line_count) { viewer->file_scroll_offset = prev_scroll; } } } } // Show completion status briefly viewer->sync_status = SYNC_STATUS_SYNCED_APPEARING; viewer->animation_frame = 0; viewer->text_char_count = 0; } else if (result == -1) { // Error occurred viewer->fetch_in_progress = 0; viewer->fetch_pid = -1; viewer->sync_status = SYNC_STATUS_IDLE; } } /** * Update preview based on current mode and selection */ void update_preview_for_current_selection(NCursesDiffViewer *viewer) { if (!viewer) return; static NCursesViewMode last_mode = (NCursesViewMode)-1; static int last_file = -1; static int last_commit = -1; static int last_branch = -1; static int last_stash = -1; int needs_update = 0; if (viewer->current_mode == NCURSES_MODE_FILE_LIST) { if (last_mode != viewer->current_mode || last_file != viewer->selected_file) { if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) { // Load diff preview instead of raw file content load_file_with_staging_info( viewer, viewer->files[viewer->selected_file].filename); } last_file = viewer->selected_file; needs_update = 1; } } else if (viewer->current_mode == NCURSES_MODE_COMMIT_LIST) { if (last_mode != viewer->current_mode || last_commit != viewer->selected_commit) { if (viewer->commit_count > 0 && viewer->selected_commit < viewer->commit_count) { load_commit_for_viewing(viewer, viewer->commits[viewer->selected_commit].hash); } last_commit = viewer->selected_commit; needs_update = 1; } } else if (viewer->current_mode == NCURSES_MODE_BRANCH_LIST) { if (last_mode != viewer->current_mode || last_branch != viewer->selected_branch) { if (viewer->branch_count > 0 && viewer->selected_branch < viewer->branch_count) { load_branch_commits(viewer, viewer->branches[viewer->selected_branch].name); parse_branch_commits_to_lines( viewer); // Convert to file_lines for preview display } last_branch = viewer->selected_branch; needs_update = 1; } } else if (viewer->current_mode == NCURSES_MODE_STASH_LIST) { if (last_mode != viewer->current_mode || last_stash != viewer->selected_stash) { if (viewer->stash_count > 0 && viewer->selected_stash < viewer->stash_count) { load_stash_for_viewing(viewer, viewer->selected_stash); } last_stash = viewer->selected_stash; needs_update = 1; } } if (needs_update) { last_mode = viewer->current_mode; } } /** * Load file preview showing the top of the file content */ int load_file_preview(NCursesDiffViewer *viewer, const char *filename) { if (!viewer || !filename) return 0; viewer->file_line_count = 0; viewer->file_scroll_offset = 0; viewer->file_cursor_line = 0; FILE *fp = fopen(filename, "r"); if (!fp) { return 0; } char line[1024]; int line_count = 0; // Load first 50 lines of the file for preview while (fgets(line, sizeof(line), fp) && line_count < 50 && line_count < MAX_FULL_FILE_LINES) { // Remove trailing newline int len = strlen(line); if (len > 0 && line[len - 1] == '\n') { line[len - 1] = '\0'; } // Store the line as a context line (no diff markings) strncpy(viewer->file_lines[line_count].line, line, sizeof(viewer->file_lines[line_count].line) - 1); viewer->file_lines[line_count] .line[sizeof(viewer->file_lines[line_count].line) - 1] = '\0'; viewer->file_lines[line_count].type = ' '; // Context line viewer->file_lines[line_count].is_diff_line = 0; viewer->file_lines[line_count].is_staged = 0; viewer->file_lines[line_count].hunk_id = 0; viewer->file_lines[line_count].line_number_old = line_count + 1; viewer->file_lines[line_count].line_number_new = line_count + 1; viewer->file_lines[line_count].is_context = 1; line_count++; } fclose(fp); viewer->file_line_count = line_count; // Reset staged content since this is just a preview viewer->staged_line_count = 0; viewer->staged_cursor_line = 0; viewer->staged_scroll_offset = 0; return 1; } /** * Helper function to wrap a long line into multiple display lines * Returns the number of wrapped lines created */ int wrap_line_to_width(const char *input_line, char wrapped_lines[][1024], int max_lines, int width) { int input_len = strlen(input_line); int line_count = 0; int input_pos = 0; // If line fits in width, just copy it if (input_len <= width) { strcpy(wrapped_lines[0], input_line); return 1; } while (input_pos < input_len && line_count < max_lines - 1) { int chars_to_copy = width; // Don't exceed remaining input length if (input_pos + chars_to_copy > input_len) { chars_to_copy = input_len - input_pos; } // Copy the segment strncpy(wrapped_lines[line_count], input_line + input_pos, chars_to_copy); wrapped_lines[line_count][chars_to_copy] = '\0'; input_pos += chars_to_copy; line_count++; } return line_count; } /** * Render a single line with proper wrapping and coloring * Returns the number of display rows used */ int render_wrapped_line(WINDOW *win, const char *line, int start_y, int start_x, int width, int max_rows, int color_pair, int reverse) { char wrapped_lines[10] [1024]; // Support up to 10 wrapped lines per original line int wrap_count = wrap_line_to_width(line, wrapped_lines, 10, width - start_x); int rows_used = 0; for (int i = 0; i < wrap_count && rows_used < max_rows; i++) { if (reverse) { wattron(win, A_REVERSE); } if (color_pair > 0) { wattron(win, COLOR_PAIR(color_pair)); } mvwprintw(win, start_y + rows_used, start_x, "%s", wrapped_lines[i]); if (color_pair > 0) { wattroff(win, COLOR_PAIR(color_pair)); } if (reverse) { wattroff(win, A_REVERSE); } rows_used++; } return rows_used; } /** * Calculate how many display lines a wrapped line will take */ int calculate_wrapped_line_height(const char *line, int width) { int line_len = strlen(line); if (line_len <= width) { return 1; } return (line_len + width - 1) / width; // Ceiling division } /** * Move cursor up/down while skipping empty lines * direction: -1 for up, 1 for down */ void move_cursor_smart(NCursesDiffViewer *viewer, int direction) { if (!viewer || viewer->file_line_count == 0) { return; } int original_cursor = viewer->file_cursor_line; int new_cursor = viewer->file_cursor_line; int attempts = 0; const int max_attempts = viewer->file_line_count; // Prevent infinite loops do { new_cursor += direction; attempts++; // Bounds checking if (new_cursor < 0) { new_cursor = 0; break; } if (new_cursor >= viewer->file_line_count) { new_cursor = viewer->file_line_count - 1; break; } // Check if current line is empty or just whitespace NCursesFileLine *line = &viewer->file_lines[new_cursor]; char *trimmed = line->line; // Skip leading whitespace while (*trimmed == ' ' || *trimmed == '\t') { trimmed++; } // If line has content after trimming, or we've tried too many times, stop // here if (*trimmed != '\0' || attempts >= max_attempts) { break; } } while (attempts < max_attempts); int cursor_movement = new_cursor - original_cursor; viewer->file_cursor_line = new_cursor; int height, width; getmaxyx(viewer->file_content_win, height, width); int max_lines_visible = height - 2; if (direction == -1) { int cursor_display_pos = viewer->file_cursor_line - viewer->file_scroll_offset; if (cursor_display_pos < 3) { int scroll_adjustment = 3 - cursor_display_pos; viewer->file_scroll_offset -= scroll_adjustment; if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } } } else { int cursor_display_pos = viewer->file_cursor_line - viewer->file_scroll_offset; if (cursor_display_pos >= max_lines_visible - 3) { int scroll_adjustment = cursor_display_pos - (max_lines_visible - 4); viewer->file_scroll_offset += scroll_adjustment; int max_scroll = viewer->file_line_count - max_lines_visible; if (max_scroll < 0) max_scroll = 0; if (viewer->file_scroll_offset > max_scroll) { viewer->file_scroll_offset = max_scroll; } } } } /** * Move cursor up/down in unstaged pane with proper scrolling * direction: -1 for up, 1 for down */ void move_cursor_smart_unstaged(NCursesDiffViewer *viewer, int direction) { if (!viewer || viewer->file_line_count == 0) { return; } int original_cursor = viewer->file_cursor_line; int new_cursor = viewer->file_cursor_line; int attempts = 0; const int max_attempts = viewer->file_line_count; do { new_cursor += direction; attempts++; if (new_cursor < 0) { new_cursor = 0; break; } if (new_cursor >= viewer->file_line_count) { new_cursor = viewer->file_line_count - 1; break; } NCursesFileLine *line = &viewer->file_lines[new_cursor]; char *trimmed = line->line; while (*trimmed == ' ' || *trimmed == '\t') { trimmed++; } if (*trimmed != '\0' || attempts >= max_attempts) { break; } } while (attempts < max_attempts); viewer->file_cursor_line = new_cursor; int height, width; getmaxyx(viewer->file_content_win, height, width); int split_line = height / 2; int unstaged_height = split_line - 1; // Calculate display positions accounting for wrapped lines int cursor_display_rows = 0; int scroll_display_rows = 0; // Count display rows from scroll offset to cursor for (int i = viewer->file_scroll_offset; i <= viewer->file_cursor_line && i < viewer->file_line_count; i++) { int line_height = calculate_wrapped_line_height(viewer->file_lines[i].line, width - 4); if (i < viewer->file_cursor_line) { cursor_display_rows += line_height; } if (i >= viewer->file_scroll_offset) { scroll_display_rows += line_height; } } if (direction == -1) { // Moving up - ensure cursor stays visible at top if (cursor_display_rows < 2) { // Need to scroll up to make cursor visible int target_rows = 2; int new_scroll_offset = viewer->file_cursor_line; int accumulated_rows = calculate_wrapped_line_height( viewer->file_lines[viewer->file_cursor_line].line, width - 4); while (new_scroll_offset > 0 && accumulated_rows < target_rows) { new_scroll_offset--; accumulated_rows += calculate_wrapped_line_height( viewer->file_lines[new_scroll_offset].line, width - 4); } viewer->file_scroll_offset = new_scroll_offset; if (viewer->file_scroll_offset < 0) { viewer->file_scroll_offset = 0; } } } else { // Moving down - ensure cursor stays visible at bottom if (cursor_display_rows >= unstaged_height - 2) { // Need to scroll down to make cursor visible int target_remaining_rows = unstaged_height - 3; int new_scroll_offset = viewer->file_cursor_line; int accumulated_rows = calculate_wrapped_line_height( viewer->file_lines[viewer->file_cursor_line].line, width - 4); while (new_scroll_offset > viewer->file_scroll_offset && accumulated_rows > target_remaining_rows) { new_scroll_offset--; accumulated_rows -= calculate_wrapped_line_height( viewer->file_lines[new_scroll_offset].line, width - 4); } if (new_scroll_offset > viewer->file_scroll_offset) { viewer->file_scroll_offset = new_scroll_offset; } int max_scroll = viewer->file_line_count - 1; if (viewer->file_scroll_offset > max_scroll) { viewer->file_scroll_offset = max_scroll; } } } } /** * Move cursor up/down in staged pane with proper scrolling * direction: -1 for up, 1 for down */ void move_cursor_smart_staged(NCursesDiffViewer *viewer, int direction) { if (!viewer || viewer->staged_line_count == 0) { return; } int height, width; getmaxyx(viewer->file_content_win, height, width); int split_line = height / 2; int staged_height = height - split_line - 2; if (direction == -1) { if (viewer->staged_cursor_line > 0) { viewer->staged_cursor_line--; // Calculate display positions accounting for wrapped lines int cursor_display_rows = 0; // Count display rows from scroll offset to cursor for (int i = viewer->staged_scroll_offset; i < viewer->staged_cursor_line && i < viewer->staged_line_count; i++) { cursor_display_rows += calculate_wrapped_line_height( viewer->staged_lines[i].line, width - 4); } if (cursor_display_rows < 1) { // Need to scroll up to make cursor visible int target_rows = 1; int new_scroll_offset = viewer->staged_cursor_line; int accumulated_rows = calculate_wrapped_line_height( viewer->staged_lines[viewer->staged_cursor_line].line, width - 4); while (new_scroll_offset > 0 && accumulated_rows < target_rows) { new_scroll_offset--; accumulated_rows += calculate_wrapped_line_height( viewer->staged_lines[new_scroll_offset].line, width - 4); } viewer->staged_scroll_offset = new_scroll_offset; if (viewer->staged_scroll_offset < 0) { viewer->staged_scroll_offset = 0; } } } } else { if (viewer->staged_cursor_line < viewer->staged_line_count - 1) { viewer->staged_cursor_line++; // Calculate display positions accounting for wrapped lines int cursor_display_rows = 0; // Count display rows from scroll offset to cursor for (int i = viewer->staged_scroll_offset; i < viewer->staged_cursor_line && i < viewer->staged_line_count; i++) { cursor_display_rows += calculate_wrapped_line_height( viewer->staged_lines[i].line, width - 4); } if (cursor_display_rows >= staged_height - 1) { // Need to scroll down to make cursor visible int target_remaining_rows = staged_height - 2; int new_scroll_offset = viewer->staged_cursor_line; int accumulated_rows = calculate_wrapped_line_height( viewer->staged_lines[viewer->staged_cursor_line].line, width - 4); while (new_scroll_offset > viewer->staged_scroll_offset && accumulated_rows > target_remaining_rows) { new_scroll_offset--; accumulated_rows -= calculate_wrapped_line_height( viewer->staged_lines[new_scroll_offset].line, width - 4); } if (new_scroll_offset > viewer->staged_scroll_offset) { viewer->staged_scroll_offset = new_scroll_offset; } int max_scroll = viewer->staged_line_count - 1; if (viewer->staged_scroll_offset > max_scroll) { viewer->staged_scroll_offset = max_scroll; } } } } } /** * Clean up ncurses diff viewer resources */ void cleanup_ncurses_diff_viewer(NCursesDiffViewer *viewer) { if (viewer) { // Clean up any background fetch process if (viewer->fetch_in_progress && viewer->fetch_pid > 0) { kill(viewer->fetch_pid, SIGTERM); waitpid(viewer->fetch_pid, NULL, 0); } if (viewer->file_list_win) { delwin(viewer->file_list_win); } if (viewer->file_content_win) { delwin(viewer->file_content_win); } if (viewer->commit_list_win) { delwin(viewer->commit_list_win); } if (viewer->stash_list_win) { delwin(viewer->stash_list_win); } if (viewer->branch_list_win) { delwin(viewer->branch_list_win); } if (viewer->status_bar_win) { delwin(viewer->status_bar_win); } // Clean up fuzzy search windows cleanup_fuzzy_search(viewer); // Clean up grep search windows cleanup_grep_search(viewer); } endwin(); } /** * Initialize fuzzy search state */ void init_fuzzy_search(NCursesDiffViewer *viewer) { if (!viewer) return; viewer->fuzzy_search_active = 0; viewer->fuzzy_search_query[0] = '\0'; viewer->fuzzy_search_query_len = 0; viewer->fuzzy_filtered_count = 0; viewer->fuzzy_selected_index = 0; viewer->fuzzy_scroll_offset = 0; viewer->fuzzy_input_win = NULL; viewer->fuzzy_list_win = NULL; // Initialize state tracking viewer->fuzzy_needs_full_redraw = 0; viewer->fuzzy_needs_input_redraw = 0; viewer->fuzzy_needs_list_redraw = 0; viewer->fuzzy_last_query[0] = '\0'; viewer->fuzzy_last_selected = -1; viewer->fuzzy_last_scroll = -1; viewer->fuzzy_last_filtered_count = -1; } /** * Cleanup fuzzy search windows */ void cleanup_fuzzy_search(NCursesDiffViewer *viewer) { if (!viewer) return; if (viewer->fuzzy_input_win) { delwin(viewer->fuzzy_input_win); viewer->fuzzy_input_win = NULL; } if (viewer->fuzzy_list_win) { delwin(viewer->fuzzy_list_win); viewer->fuzzy_list_win = NULL; } } /** * Calculate fuzzy match score - higher score is better match * Returns 0 if no match, positive score if match */ int calculate_fuzzy_score(const char *pattern, const char *filename) { if (!pattern || !filename) return 0; if (strlen(pattern) == 0) return 1000; // Empty pattern matches everything with high score int pattern_len = strlen(pattern); int filename_len = strlen(filename); if (pattern_len > filename_len) return 0; // Pattern longer than filename int score = 0; int pattern_pos = 0; int consecutive_matches = 0; int first_char_bonus = 0; for (int i = 0; i < filename_len && pattern_pos < pattern_len; i++) { char p_char = tolower(pattern[pattern_pos]); char f_char = tolower(filename[i]); if (p_char == f_char) { // Base score for character match score += 1; // Bonus for consecutive matches (prioritizes contiguous substrings) consecutive_matches++; score += consecutive_matches * 5; // Bonus for matching at word boundaries if (i == 0 || filename[i - 1] == '/' || filename[i - 1] == '_' || filename[i - 1] == '-' || filename[i - 1] == '.') { score += 15; } // Bonus for matching first character if (pattern_pos == 0) { if (i == 0) { first_char_bonus = 50; // Very high bonus for first char match } else { // Find start of filename (after last /) const char *basename = strrchr(filename, '/'); basename = basename ? basename + 1 : filename; if (filename + i == basename) { first_char_bonus = 30; // High bonus for basename first char } } } // Bonus for exact position match (same position in pattern and filename) if (i == pattern_pos) { score += 10; } pattern_pos++; } else { // Reset consecutive matches consecutive_matches = 0; } } // Must match all pattern characters if (pattern_pos < pattern_len) return 0; // Apply first character bonus score += first_char_bonus; // Bonus for shorter filenames (prefer more specific matches) score += (100 - filename_len); // Bonus for fewer unmatched characters int unmatched_chars = filename_len - pattern_len; score += (50 - unmatched_chars); return score; } /** * Compare function for sorting scored files (higher scores first) */ int compare_scored_files(const void *a, const void *b) { const struct { int file_index; int score; } *file_a = a; const struct { int file_index; int score; } *file_b = b; return file_b->score - file_a->score; // Descending order (higher scores first) } /** * Update fuzzy search filter based on current query */ void update_fuzzy_filter(NCursesDiffViewer *viewer) { if (!viewer) return; viewer->fuzzy_filtered_count = 0; viewer->fuzzy_selected_index = 0; viewer->fuzzy_scroll_offset = 0; // Calculate scores for all files and collect matches for (int i = 0; i < viewer->file_count && viewer->fuzzy_filtered_count < MAX_FILES; i++) { int score = calculate_fuzzy_score(viewer->fuzzy_search_query, viewer->files[i].filename); if (score > 0) { viewer->fuzzy_scored_files[viewer->fuzzy_filtered_count].file_index = i; viewer->fuzzy_scored_files[viewer->fuzzy_filtered_count].score = score; viewer->fuzzy_filtered_count++; } } // Sort results by score (highest first) if (viewer->fuzzy_filtered_count > 1) { qsort(viewer->fuzzy_scored_files, viewer->fuzzy_filtered_count, sizeof(viewer->fuzzy_scored_files[0]), compare_scored_files); } } /** * Enter fuzzy search mode */ void enter_fuzzy_search_mode(NCursesDiffViewer *viewer) { if (!viewer || viewer->current_mode != NCURSES_MODE_FILE_LIST) return; viewer->fuzzy_search_active = 1; viewer->fuzzy_search_query[0] = '\0'; viewer->fuzzy_search_query_len = 0; // Create fuzzy search windows int input_height = 3; int list_height = viewer->terminal_height - input_height - 6; int width = viewer->terminal_width * 0.5; int start_y = (viewer->terminal_height - input_height - list_height) / 2; int start_x = (viewer->terminal_width - width) / 2; viewer->fuzzy_input_win = newwin(input_height, width, start_y, start_x); viewer->fuzzy_list_win = newwin(list_height, width, start_y + input_height, start_x); if (viewer->fuzzy_input_win) { box(viewer->fuzzy_input_win, 0, 0); mvwprintw(viewer->fuzzy_input_win, 0, 2, " Fuzzy File Search "); } if (viewer->fuzzy_list_win) { box(viewer->fuzzy_list_win, 0, 0); } // Initialize with all files update_fuzzy_filter(viewer); // Force initial full redraw viewer->fuzzy_needs_full_redraw = 1; } /** * Exit fuzzy search mode */ void exit_fuzzy_search_mode(NCursesDiffViewer *viewer) { if (!viewer) return; viewer->fuzzy_search_active = 0; cleanup_fuzzy_search(viewer); // Force redraw of main windows next time touchwin(stdscr); refresh(); } /** * Render fuzzy search input field only */ void render_fuzzy_input(NCursesDiffViewer *viewer) { if (!viewer->fuzzy_input_win) return; // Clear only the content area, not the border for (int y = 1; y < getmaxy(viewer->fuzzy_input_win) - 1; y++) { for (int x = 1; x < getmaxx(viewer->fuzzy_input_win) - 1; x++) { mvwaddch(viewer->fuzzy_input_win, y, x, ' '); } } // Show current query mvwprintw(viewer->fuzzy_input_win, 1, 2, "> %s", viewer->fuzzy_search_query); // Show cursor int cursor_x = 4 + viewer->fuzzy_search_query_len; if (cursor_x < getmaxx(viewer->fuzzy_input_win) - 1) { mvwaddch(viewer->fuzzy_input_win, 1, cursor_x, '_'); } wrefresh(viewer->fuzzy_input_win); } /** * Render fuzzy search file list content only (no borders) */ void render_fuzzy_list_content(NCursesDiffViewer *viewer) { if (!viewer->fuzzy_list_win) return; int list_height = getmaxy(viewer->fuzzy_list_win) - 2; int list_width = getmaxx(viewer->fuzzy_list_win) - 4; // Clear only the content area, not the border for (int y = 1; y <= list_height; y++) { for (int x = 1; x < getmaxx(viewer->fuzzy_list_win) - 1; x++) { mvwaddch(viewer->fuzzy_list_win, y, x, ' '); } } // Show filtered results for (int i = 0; i < viewer->fuzzy_filtered_count && i < list_height; i++) { int display_index = i + viewer->fuzzy_scroll_offset; if (display_index >= viewer->fuzzy_filtered_count) break; int file_index = viewer->fuzzy_scored_files[display_index].file_index; char *filename = viewer->files[file_index].filename; char status = viewer->files[file_index].status; // Highlight selected item if (display_index == viewer->fuzzy_selected_index) { wattron(viewer->fuzzy_list_win, A_REVERSE); } // Show status character and filename mvwprintw(viewer->fuzzy_list_win, i + 1, 2, "%c %-*.*s", status, list_width - 3, list_width - 3, filename); if (display_index == viewer->fuzzy_selected_index) { wattroff(viewer->fuzzy_list_win, A_REVERSE); } } // Show result count in top border area if (viewer->fuzzy_filtered_count > 0) { mvwprintw(viewer->fuzzy_list_win, 0, getmaxx(viewer->fuzzy_list_win) - 15, " %d/%d ", viewer->fuzzy_selected_index + 1, viewer->fuzzy_filtered_count); } else { mvwprintw(viewer->fuzzy_list_win, 0, getmaxx(viewer->fuzzy_list_win) - 10, " 0/0 "); } wrefresh(viewer->fuzzy_list_win); } /** * Create fuzzy search windows with borders (one-time setup) */ void create_fuzzy_windows_with_borders(NCursesDiffViewer *viewer) { if (!viewer->fuzzy_input_win || !viewer->fuzzy_list_win) return; // Draw input window border and title wclear(viewer->fuzzy_input_win); box(viewer->fuzzy_input_win, 0, 0); mvwprintw(viewer->fuzzy_input_win, 0, 2, " Fuzzy File Search "); wrefresh(viewer->fuzzy_input_win); // Draw list window border wclear(viewer->fuzzy_list_win); box(viewer->fuzzy_list_win, 0, 0); wrefresh(viewer->fuzzy_list_win); } /** * Main fuzzy search render function with granular updates */ void render_fuzzy_search(NCursesDiffViewer *viewer) { if (!viewer || !viewer->fuzzy_search_active) return; if (!viewer->fuzzy_input_win || !viewer->fuzzy_list_win) return; // Check what specifically needs to be redrawn int query_changed = strcmp(viewer->fuzzy_search_query, viewer->fuzzy_last_query) != 0; int selection_changed = viewer->fuzzy_selected_index != viewer->fuzzy_last_selected; int scroll_changed = viewer->fuzzy_scroll_offset != viewer->fuzzy_last_scroll; int results_changed = viewer->fuzzy_filtered_count != viewer->fuzzy_last_filtered_count; // Full redraw needed on first render if (viewer->fuzzy_needs_full_redraw) { create_fuzzy_windows_with_borders(viewer); render_fuzzy_input(viewer); render_fuzzy_list_content(viewer); viewer->fuzzy_needs_full_redraw = 0; } // Redraw input field if query changed else if (query_changed || viewer->fuzzy_needs_input_redraw) { render_fuzzy_input(viewer); viewer->fuzzy_needs_input_redraw = 0; } // Redraw file list if results, selection, or scroll changed if (results_changed || selection_changed || scroll_changed || viewer->fuzzy_needs_list_redraw) { render_fuzzy_list_content(viewer); viewer->fuzzy_needs_list_redraw = 0; } // Update last rendered state strcpy(viewer->fuzzy_last_query, viewer->fuzzy_search_query); viewer->fuzzy_last_selected = viewer->fuzzy_selected_index; viewer->fuzzy_last_scroll = viewer->fuzzy_scroll_offset; viewer->fuzzy_last_filtered_count = viewer->fuzzy_filtered_count; } /** * Handle fuzzy search input */ int handle_fuzzy_search_input(NCursesDiffViewer *viewer, int key) { if (!viewer || !viewer->fuzzy_search_active) return 0; switch (key) { case 27: // ESC exit_fuzzy_search_mode(viewer); return 1; case KEY_ENTER: case '\n': case '\r': if (viewer->fuzzy_filtered_count > 0) { select_fuzzy_file(viewer); exit_fuzzy_search_mode(viewer); } return 1; case KEY_UP: if (viewer->fuzzy_selected_index > 0) { viewer->fuzzy_selected_index--; // Adjust scroll if needed if (viewer->fuzzy_selected_index < viewer->fuzzy_scroll_offset) { viewer->fuzzy_scroll_offset = viewer->fuzzy_selected_index; } } return 1; case KEY_DOWN: if (viewer->fuzzy_selected_index < viewer->fuzzy_filtered_count - 1) { viewer->fuzzy_selected_index++; // Adjust scroll if needed int list_height = getmaxy(viewer->fuzzy_list_win) - 2; if (viewer->fuzzy_selected_index >= viewer->fuzzy_scroll_offset + list_height) { viewer->fuzzy_scroll_offset = viewer->fuzzy_selected_index - list_height + 1; } } return 1; case KEY_BACKSPACE: case 127: case '\b': if (viewer->fuzzy_search_query_len > 0) { viewer->fuzzy_search_query_len--; viewer->fuzzy_search_query[viewer->fuzzy_search_query_len] = '\0'; update_fuzzy_filter(viewer); } return 1; default: // Add printable characters to search query if (key >= 32 && key <= 126 && viewer->fuzzy_search_query_len < 255) { viewer->fuzzy_search_query[viewer->fuzzy_search_query_len++] = key; viewer->fuzzy_search_query[viewer->fuzzy_search_query_len] = '\0'; update_fuzzy_filter(viewer); } return 1; } } /** * Select file from fuzzy search results */ void select_fuzzy_file(NCursesDiffViewer *viewer) { if (!viewer || viewer->fuzzy_filtered_count == 0) return; // Get the actual file index from scored results int file_index = viewer->fuzzy_scored_files[viewer->fuzzy_selected_index].file_index; // Update the main file list selection to point to this file viewer->selected_file = file_index; // Scroll the main file list to show the selected file int file_panel_height = getmaxy(viewer->file_list_win) - 2; if (viewer->selected_file < viewer->file_count) { // Ensure the selected file is visible in the main panel if (viewer->selected_file < file_panel_height / 2) { // If near the top, scroll to top // No action needed as the file list starts from 0 } else if (viewer->selected_file >= viewer->file_count - file_panel_height / 2) { // If near the bottom, show as much as possible // No action needed as we'll handle this in rendering } } // Load the selected file with split staging view (same as pressing Enter) if (viewer->selected_file >= 0 && viewer->selected_file < viewer->file_count) { const char *filename = viewer->files[viewer->selected_file].filename; // Load file with staging information load_file_with_staging_info(viewer, filename); // Switch to split view mode viewer->split_view_mode = 1; viewer->current_mode = NCURSES_MODE_FILE_VIEW; viewer->active_pane = 0; // Start with unstaged pane active // Store current file path strncpy(viewer->current_file_path, filename, sizeof(viewer->current_file_path) - 1); viewer->current_file_path[sizeof(viewer->current_file_path) - 1] = '\0'; } } /** * Initialize grep search state */ void init_grep_search(NCursesDiffViewer *viewer) { if (!viewer) return; viewer->grep_search_active = 0; viewer->grep_search_mode = NCURSES_MODE_FILE_LIST; viewer->grep_search_query[0] = '\0'; viewer->grep_search_query_len = 0; viewer->grep_filtered_count = 0; viewer->grep_selected_index = 0; viewer->grep_scroll_offset = 0; viewer->grep_input_win = NULL; viewer->grep_list_win = NULL; // Initialize state tracking viewer->grep_needs_full_redraw = 0; viewer->grep_needs_input_redraw = 0; viewer->grep_needs_list_redraw = 0; viewer->grep_last_query[0] = '\0'; viewer->grep_last_selected = -1; viewer->grep_last_scroll = -1; viewer->grep_last_filtered_count = -1; } /** * Cleanup grep search windows */ void cleanup_grep_search(NCursesDiffViewer *viewer) { if (!viewer) return; if (viewer->grep_input_win) { delwin(viewer->grep_input_win); viewer->grep_input_win = NULL; } if (viewer->grep_list_win) { delwin(viewer->grep_list_win); viewer->grep_list_win = NULL; } } /** * Extract branch name from stash info */ void extract_branch_from_stash(const char *stash_info, char *branch_name, int max_len) { if (!stash_info || !branch_name || max_len <= 0) return; branch_name[0] = '\0'; // Look for "On " pattern in stash info const char *on_pattern = "On "; const char *on_pos = strstr(stash_info, on_pattern); if (on_pos) { const char *branch_start = on_pos + strlen(on_pattern); const char *branch_end = strchr(branch_start, ':'); if (branch_end && branch_end > branch_start) { int branch_len = branch_end - branch_start; if (branch_len < max_len) { strncpy(branch_name, branch_start, branch_len); branch_name[branch_len] = '\0'; // Trim trailing spaces while (branch_len > 0 && branch_name[branch_len - 1] == ' ') { branch_name[--branch_len] = '\0'; } } } } } /** * Calculate grep match score for text content */ int calculate_grep_score(const char *pattern, const char *text) { if (!pattern || !text) return 0; if (strlen(pattern) == 0) return 1000; // Empty pattern matches everything int pattern_len = strlen(pattern); int text_len = strlen(text); if (pattern_len > text_len) return 0; int score = 0; int pattern_pos = 0; int consecutive_matches = 0; int first_char_bonus = 0; for (int i = 0; i < text_len && pattern_pos < pattern_len; i++) { char p_char = tolower(pattern[pattern_pos]); char t_char = tolower(text[i]); if (p_char == t_char) { // Base score for character match score += 1; // Bonus for consecutive matches consecutive_matches++; score += consecutive_matches * 3; // Bonus for matching at word boundaries if (i == 0 || text[i - 1] == ' ' || text[i - 1] == '-' || text[i - 1] == '_') { score += 10; } // Bonus for matching first character if (pattern_pos == 0 && i == 0) { first_char_bonus = 30; } // Bonus for exact position match if (i == pattern_pos) { score += 5; } pattern_pos++; } else { consecutive_matches = 0; } } // Must match all pattern characters if (pattern_pos < pattern_len) return 0; score += first_char_bonus; score += (50 - text_len); // Prefer shorter text score += (30 - (text_len - pattern_len)); // Fewer unmatched chars return score; } /** * Compare function for sorting grep scored items */ int compare_grep_scored_items(const void *a, const void *b) { const struct { int item_index; int score; } *item_a = a; const struct { int item_index; int score; } *item_b = b; return item_b->score - item_a->score; // Descending order } /** * Update grep search filter based on current query and mode */ void update_grep_filter(NCursesDiffViewer *viewer) { if (!viewer) return; viewer->grep_filtered_count = 0; viewer->grep_selected_index = 0; viewer->grep_scroll_offset = 0; // Search different data based on current mode switch (viewer->grep_search_mode) { case NCURSES_MODE_COMMIT_LIST: // Search commit titles and author initials for (int i = 0; i < viewer->commit_count && viewer->grep_filtered_count < MAX_COMMITS; i++) { // Try matching against title first int title_score = calculate_grep_score(viewer->grep_search_query, viewer->commits[i].title); // Try matching against author initials int author_score = calculate_grep_score( viewer->grep_search_query, viewer->commits[i].author_initials); // Use the higher score (prefer title matches over author matches) int score = (title_score > author_score) ? title_score : author_score; if (score > 0) { viewer->grep_scored_items[viewer->grep_filtered_count].item_index = i; viewer->grep_scored_items[viewer->grep_filtered_count].score = score; viewer->grep_filtered_count++; } } break; case NCURSES_MODE_STASH_LIST: // Search stash info and branch names for (int i = 0; i < viewer->stash_count && viewer->grep_filtered_count < MAX_COMMITS; i++) { // Try matching against full stash info first int stash_score = calculate_grep_score(viewer->grep_search_query, viewer->stashes[i].stash_info); // Extract and try matching against branch name char branch_name[64]; extract_branch_from_stash(viewer->stashes[i].stash_info, branch_name, sizeof(branch_name)); int branch_score = 0; if (strlen(branch_name) > 0) { branch_score = calculate_grep_score(viewer->grep_search_query, branch_name); } // Use the higher score (prefer full stash info matches over branch-only // matches) int score = (stash_score > branch_score) ? stash_score : branch_score; if (score > 0) { viewer->grep_scored_items[viewer->grep_filtered_count].item_index = i; viewer->grep_scored_items[viewer->grep_filtered_count].score = score; viewer->grep_filtered_count++; } } break; case NCURSES_MODE_BRANCH_LIST: // Search branch names for (int i = 0; i < viewer->branch_count && viewer->grep_filtered_count < MAX_COMMITS; i++) { int score = calculate_grep_score(viewer->grep_search_query, viewer->branches[i].name); if (score > 0) { viewer->grep_scored_items[viewer->grep_filtered_count].item_index = i; viewer->grep_scored_items[viewer->grep_filtered_count].score = score; viewer->grep_filtered_count++; } } break; default: // No grep search for other modes break; } // Sort results by score if (viewer->grep_filtered_count > 1) { qsort(viewer->grep_scored_items, viewer->grep_filtered_count, sizeof(viewer->grep_scored_items[0]), compare_grep_scored_items); } } /** * Enter grep search mode for current view mode */ void enter_grep_search_mode(NCursesDiffViewer *viewer) { if (!viewer) return; // Only allow grep search in specific modes if (viewer->current_mode != NCURSES_MODE_COMMIT_LIST && viewer->current_mode != NCURSES_MODE_STASH_LIST && viewer->current_mode != NCURSES_MODE_BRANCH_LIST) { return; } viewer->grep_search_active = 1; viewer->grep_search_mode = viewer->current_mode; viewer->grep_search_query[0] = '\0'; viewer->grep_search_query_len = 0; // Create grep search windows int input_height = 3; int list_height = viewer->terminal_height - input_height - 6; int width = viewer->terminal_width * 0.5; int start_y = (viewer->terminal_height - input_height - list_height) / 2; int start_x = (viewer->terminal_width - width) / 2; viewer->grep_input_win = newwin(input_height, width, start_y, start_x); viewer->grep_list_win = newwin(list_height, width, start_y + input_height, start_x); // Initialize with all items update_grep_filter(viewer); // Force initial full redraw viewer->grep_needs_full_redraw = 1; } /** * Exit grep search mode */ void exit_grep_search_mode(NCursesDiffViewer *viewer) { if (!viewer) return; viewer->grep_search_active = 0; cleanup_grep_search(viewer); // Force redraw of main windows next time touchwin(stdscr); refresh(); } /** * Render grep search input field only */ void render_grep_input(NCursesDiffViewer *viewer) { if (!viewer->grep_input_win) return; // Clear only the content area, not the border for (int y = 1; y < getmaxy(viewer->grep_input_win) - 1; y++) { for (int x = 1; x < getmaxx(viewer->grep_input_win) - 1; x++) { mvwaddch(viewer->grep_input_win, y, x, ' '); } } // Show current query mvwprintw(viewer->grep_input_win, 1, 2, "> %s", viewer->grep_search_query); // Show cursor int cursor_x = 4 + viewer->grep_search_query_len; if (cursor_x < getmaxx(viewer->grep_input_win) - 1) { mvwaddch(viewer->grep_input_win, 1, cursor_x, '_'); } wrefresh(viewer->grep_input_win); } /** * Render grep search list content only (no borders) */ void render_grep_list_content(NCursesDiffViewer *viewer) { if (!viewer->grep_list_win) return; int list_height = getmaxy(viewer->grep_list_win) - 2; int list_width = getmaxx(viewer->grep_list_win) - 4; // Clear only the content area, not the border for (int y = 1; y <= list_height; y++) { for (int x = 1; x < getmaxx(viewer->grep_list_win) - 1; x++) { mvwaddch(viewer->grep_list_win, y, x, ' '); } } // Show filtered results for (int i = 0; i < viewer->grep_filtered_count && i < list_height; i++) { int display_index = i + viewer->grep_scroll_offset; if (display_index >= viewer->grep_filtered_count) break; int item_index = viewer->grep_scored_items[display_index].item_index; char display_text[512] = ""; // Get display text based on mode switch (viewer->grep_search_mode) { case NCURSES_MODE_COMMIT_LIST: // For commits, we'll handle colors separately break; case NCURSES_MODE_STASH_LIST: // For stashes, we'll handle special formatting to highlight branch break; case NCURSES_MODE_BRANCH_LIST: strncpy(display_text, viewer->branches[item_index].name, sizeof(display_text) - 1); break; default: strcpy(display_text, "Unknown"); break; } display_text[sizeof(display_text) - 1] = '\0'; // Highlight selected item if (display_index == viewer->grep_selected_index) { wattron(viewer->grep_list_win, A_REVERSE); } // Render based on mode with appropriate colors if (viewer->grep_search_mode == NCURSES_MODE_COMMIT_LIST) { // Render commit with colors: hash (yellow) + author initials (green) + // title int x_pos = 2; // Hash in yellow (COLOR_PAIR(10)) wattron(viewer->grep_list_win, COLOR_PAIR(10)); mvwprintw(viewer->grep_list_win, i + 1, x_pos, "%s", viewer->commits[item_index].hash); wattroff(viewer->grep_list_win, COLOR_PAIR(10)); x_pos += strlen(viewer->commits[item_index].hash) + 1; // Author initials in green (COLOR_PAIR(8)) wattron(viewer->grep_list_win, COLOR_PAIR(8)); mvwprintw(viewer->grep_list_win, i + 1, x_pos, "%s", viewer->commits[item_index].author_initials); wattroff(viewer->grep_list_win, COLOR_PAIR(8)); x_pos += strlen(viewer->commits[item_index].author_initials) + 1; // Title in default color int remaining_width = list_width - (x_pos - 2); if (remaining_width > 0) { mvwprintw(viewer->grep_list_win, i + 1, x_pos, "%-*.*s", remaining_width, remaining_width, viewer->commits[item_index].title); } } else if (viewer->grep_search_mode == NCURSES_MODE_STASH_LIST) { // Render stash with branch name highlighted const char *stash_info = viewer->stashes[item_index].stash_info; char branch_name[64]; extract_branch_from_stash(stash_info, branch_name, sizeof(branch_name)); // Find the "On branch_name:" part to highlight char on_pattern[80]; snprintf(on_pattern, sizeof(on_pattern), "On %s:", branch_name); const char *on_pos = strstr(stash_info, on_pattern); if (on_pos && strlen(branch_name) > 0) { int x_pos = 2; // Render text before "On" int before_len = on_pos - stash_info; if (before_len > 0) { mvwprintw(viewer->grep_list_win, i + 1, x_pos, "%.*s", before_len, stash_info); x_pos += before_len; } // Render "On " in default color mvwprintw(viewer->grep_list_win, i + 1, x_pos, "On "); x_pos += 3; // Render branch name in green (COLOR_PAIR(8)) wattron(viewer->grep_list_win, COLOR_PAIR(8)); mvwprintw(viewer->grep_list_win, i + 1, x_pos, "%s", branch_name); wattroff(viewer->grep_list_win, COLOR_PAIR(8)); x_pos += strlen(branch_name); // Render remaining text after branch name const char *after_pos = on_pos + strlen(on_pattern); int remaining_width = list_width - (x_pos - 2); if (remaining_width > 0 && *after_pos) { mvwprintw(viewer->grep_list_win, i + 1, x_pos, ":%-*.*s", remaining_width - 1, remaining_width - 1, after_pos); } } else { // Fallback: render normally if no branch pattern found mvwprintw(viewer->grep_list_win, i + 1, 2, "%-*.*s", list_width - 2, list_width - 2, stash_info); } } else { // Show item text for other modes (branches) mvwprintw(viewer->grep_list_win, i + 1, 2, "%-*.*s", list_width - 2, list_width - 2, display_text); } if (display_index == viewer->grep_selected_index) { wattroff(viewer->grep_list_win, A_REVERSE); } } // Show result count if (viewer->grep_filtered_count > 0) { mvwprintw(viewer->grep_list_win, 0, getmaxx(viewer->grep_list_win) - 15, " %d/%d ", viewer->grep_selected_index + 1, viewer->grep_filtered_count); } else { mvwprintw(viewer->grep_list_win, 0, getmaxx(viewer->grep_list_win) - 10, " 0/0 "); } wrefresh(viewer->grep_list_win); } /** * Create grep search windows with borders (one-time setup) */ void create_grep_windows_with_borders(NCursesDiffViewer *viewer) { if (!viewer->grep_input_win || !viewer->grep_list_win) return; // Get mode name for title const char *mode_name = "Search"; switch (viewer->grep_search_mode) { case NCURSES_MODE_COMMIT_LIST: mode_name = "Commit Grep"; break; case NCURSES_MODE_STASH_LIST: mode_name = "Stash Grep"; break; case NCURSES_MODE_BRANCH_LIST: mode_name = "Branch Grep"; break; default: break; } // Draw input window border and title wclear(viewer->grep_input_win); box(viewer->grep_input_win, 0, 0); mvwprintw(viewer->grep_input_win, 0, 2, " %s ", mode_name); wrefresh(viewer->grep_input_win); // Draw list window border wclear(viewer->grep_list_win); box(viewer->grep_list_win, 0, 0); wrefresh(viewer->grep_list_win); } /** * Main grep search render function with granular updates */ void render_grep_search(NCursesDiffViewer *viewer) { if (!viewer || !viewer->grep_search_active) return; if (!viewer->grep_input_win || !viewer->grep_list_win) return; // Check what specifically needs to be redrawn int query_changed = strcmp(viewer->grep_search_query, viewer->grep_last_query) != 0; int selection_changed = viewer->grep_selected_index != viewer->grep_last_selected; int scroll_changed = viewer->grep_scroll_offset != viewer->grep_last_scroll; int results_changed = viewer->grep_filtered_count != viewer->grep_last_filtered_count; // Full redraw needed on first render if (viewer->grep_needs_full_redraw) { create_grep_windows_with_borders(viewer); render_grep_input(viewer); render_grep_list_content(viewer); viewer->grep_needs_full_redraw = 0; } // Redraw input field if query changed else if (query_changed || viewer->grep_needs_input_redraw) { render_grep_input(viewer); viewer->grep_needs_input_redraw = 0; } // Redraw list if results, selection, or scroll changed if (results_changed || selection_changed || scroll_changed || viewer->grep_needs_list_redraw) { render_grep_list_content(viewer); viewer->grep_needs_list_redraw = 0; } // Update last rendered state strcpy(viewer->grep_last_query, viewer->grep_search_query); viewer->grep_last_selected = viewer->grep_selected_index; viewer->grep_last_scroll = viewer->grep_scroll_offset; viewer->grep_last_filtered_count = viewer->grep_filtered_count; } /** * Handle grep search input */ int handle_grep_search_input(NCursesDiffViewer *viewer, int key) { if (!viewer || !viewer->grep_search_active) return 0; switch (key) { case 27: // ESC exit_grep_search_mode(viewer); return 1; case KEY_ENTER: case '\n': case '\r': if (viewer->grep_filtered_count > 0) { select_grep_item(viewer); exit_grep_search_mode(viewer); } return 1; case KEY_UP: if (viewer->grep_selected_index > 0) { viewer->grep_selected_index--; // Adjust scroll if needed if (viewer->grep_selected_index < viewer->grep_scroll_offset) { viewer->grep_scroll_offset = viewer->grep_selected_index; } } return 1; case KEY_DOWN: if (viewer->grep_selected_index < viewer->grep_filtered_count - 1) { viewer->grep_selected_index++; // Adjust scroll if needed int list_height = getmaxy(viewer->grep_list_win) - 2; if (viewer->grep_selected_index >= viewer->grep_scroll_offset + list_height) { viewer->grep_scroll_offset = viewer->grep_selected_index - list_height + 1; } } return 1; case KEY_BACKSPACE: case 127: case '\b': if (viewer->grep_search_query_len > 0) { viewer->grep_search_query_len--; viewer->grep_search_query[viewer->grep_search_query_len] = '\0'; update_grep_filter(viewer); } return 1; default: // Add printable characters to search query if (key >= 32 && key <= 126 && viewer->grep_search_query_len < 255) { viewer->grep_search_query[viewer->grep_search_query_len++] = key; viewer->grep_search_query[viewer->grep_search_query_len] = '\0'; update_grep_filter(viewer); } return 1; } } /** * Select item from grep search results */ void select_grep_item(NCursesDiffViewer *viewer) { if (!viewer || viewer->grep_filtered_count == 0) return; // Get the actual item index from scored results int item_index = viewer->grep_scored_items[viewer->grep_selected_index].item_index; // Update the main selection based on mode switch (viewer->grep_search_mode) { case NCURSES_MODE_COMMIT_LIST: viewer->selected_commit = item_index; viewer->current_mode = NCURSES_MODE_COMMIT_VIEW; // Load the selected commit for viewing if (item_index >= 0 && item_index < viewer->commit_count) { load_commit_for_viewing(viewer, viewer->commits[item_index].hash); } break; case NCURSES_MODE_STASH_LIST: viewer->selected_stash = item_index; viewer->current_mode = NCURSES_MODE_STASH_VIEW; // Load the selected stash for viewing if (item_index >= 0 && item_index < viewer->stash_count) { load_stash_for_viewing(viewer, item_index); } break; case NCURSES_MODE_BRANCH_LIST: viewer->selected_branch = item_index; viewer->current_mode = NCURSES_MODE_BRANCH_VIEW; // Load commits for the selected branch if (item_index >= 0 && item_index < viewer->branch_count) { load_branch_commits(viewer, viewer->branches[item_index].name); parse_branch_commits_to_lines(viewer); } break; default: break; } }