Files
FERRUM/ncurses_diff_viewer.c
marcusDenslow 5695b574ca fixed incorrect fetching for git branch status
the ahead and behind wasnt working in the branch view, also pulling didnt work so the entire feature was broken, this is now fixed and works as expected atleast for the small test case i gave it
2025-06-08 22:01:56 +02:00

4677 lines
144 KiB
C

/**
* ncurses_diff_viewer.c
* NCurses-based interactive diff viewer implementation
*/
#include "ncurses_diff_viewer.h"
#include "git_integration.h"
#include <locale.h>
#include <ncurses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
/**
* Initialize the ncurses diff viewer
*/
int init_ncurses_diff_viewer(NCursesDiffViewer *viewer) {
if (!viewer)
return 0;
memset(viewer, 0, sizeof(NCursesDiffViewer));
viewer->selected_file = 0;
viewer->file_scroll_offset = 0;
viewer->file_cursor_line = 0;
viewer->selected_stash = 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;
// 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
// Enable colors
if (has_colors()) {
start_color();
init_pair(1, COLOR_GREEN, COLOR_BLACK); // Additions
init_pair(2, COLOR_RED, COLOR_BLACK); // Deletions
init_pair(3, COLOR_CYAN, COLOR_BLACK); // Headers
init_pair(4, COLOR_YELLOW, COLOR_BLACK); // Selected
init_pair(
5, COLOR_BLACK,
COLOR_WHITE); // Highlighted selection - low opacity line highlight
init_pair(6, COLOR_MAGENTA,
COLOR_BLACK); // Orange-ish color for commit hash
}
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) {
// Remove newline
char *newline = strchr(line, '\n');
if (newline)
*newline = '\0';
if (strlen(line) < 3)
continue;
// Parse git status format: "XY filename"
char status = line[0];
if (status == ' ')
status = line[1]; // Check second column if first is space
// Skip the status characters and space
char *filename = line + 3;
// Store file info
viewer->files[viewer->file_count].status = status;
viewer->files[viewer->file_count].marked_for_commit =
0; // Not marked by default
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 diff context with highlighting (like lazygit)
*/
int load_full_file_with_diff(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;
// 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;
while (fgets(line, sizeof(line), fp) != NULL &&
viewer->file_line_count < MAX_FULL_FILE_LINES && line_count < 50) {
// Remove newline
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;
viewer->file_line_count++;
line_count++;
}
fclose(fp);
return viewer->file_line_count;
}
// Use git diff to get only the changed context (like lazygit)
char cmd[1024];
snprintf(cmd, sizeof(cmd), "git diff HEAD \"%s\" 2>/dev/null", filename);
FILE *diff_fp = popen(cmd, "r");
if (!diff_fp)
return 0;
char diff_line[1024];
int found_changes = 0;
while (fgets(diff_line, sizeof(diff_line), diff_fp) != NULL &&
viewer->file_line_count < MAX_FULL_FILE_LINES) {
// Remove newline
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;
}
// Process hunk headers and content
if (diff_line[0] == '@' && diff_line[1] == '@') {
// Hunk header
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->type = '@';
file_line->is_diff_line = 0;
viewer->file_line_count++;
found_changes = 1;
} else if (diff_line[0] == '+') {
// Added line
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->type = '+';
file_line->is_diff_line = 1;
viewer->file_line_count++;
found_changes = 1;
} else if (diff_line[0] == '-') {
// Removed line
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->type = '-';
file_line->is_diff_line = 1;
viewer->file_line_count++;
found_changes = 1;
} else if (diff_line[0] == ' ') {
// Context line (unchanged)
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->type = ' ';
file_line->is_diff_line = 0;
viewer->file_line_count++;
}
}
pclose(diff_fp);
// If no changes were found, show a message
if (!found_changes) {
NCursesFileLine *file_line = &viewer->file_lines[viewer->file_line_count];
strncpy(file_line->line, "No changes in this file",
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++;
}
return viewer->file_line_count;
}
/**
* 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;
// Save current screen
WINDOW *saved_screen = dupwin(stdscr);
// 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 (saved_screen)
delwin(saved_screen);
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
// Clean up windows
delwin(title_win);
delwin(message_win);
// Restore the screen
if (saved_screen) {
overwrite(saved_screen, stdscr);
delwin(saved_screen);
}
// Force a complete redraw
clear();
refresh();
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);
}
}
// Commit with the provided title and message
char commit_cmd[2048];
if (commit_message && strlen(commit_message) > 0) {
snprintf(commit_cmd, sizeof(commit_cmd),
"git commit -m \"%s\" -m \"%s\" 2>/dev/null >/dev/null",
commit_title, commit_message);
} else {
snprintf(commit_cmd, sizeof(commit_cmd),
"git commit -m \"%s\" 2>/dev/null >/dev/null", commit_title);
}
int result = system(commit_cmd);
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);
// 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_full_file_with_diff(viewer,
viewer->files[viewer->selected_file].filename);
}
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_full_file_with_diff(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_full_file_with_diff(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);
}
}
// Amend the commit with new message
char amend_cmd[2048];
if (strlen(new_message) > 0) {
snprintf(amend_cmd, sizeof(amend_cmd),
"git commit --amend -m \"%s\" -m \"%s\" 2>/dev/null >/dev/null",
new_title, new_message);
} else {
snprintf(amend_cmd, sizeof(amend_cmd),
"git commit --amend -m \"%s\" 2>/dev/null >/dev/null",
new_title);
}
int result = system(amend_cmd);
if (result == 0) {
// Small delay to ensure git has processed the amend
usleep(100000); // 100ms delay
// Refresh everything
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 files exist
if (viewer->file_count > 0 &&
viewer->selected_file < viewer->file_count) {
load_full_file_with_diff(viewer,
viewer->files[viewer->selected_file].filename);
}
return 1;
}
}
return 0;
}
/**
* 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
// Do the actual push work
int result;
if (is_diverged) {
// Use force push with lease for safety
result = system("git push --force-with-lease origin >/dev/null 2>&1");
} else {
// Normal push
result = system("git push origin >/dev/null 2>&1");
}
if (result == 0) {
// Immediately transition to "Pushed!" animation
viewer->sync_status = SYNC_STATUS_PUSHED_APPEARING;
viewer->animation_frame = 0;
viewer->text_char_count = 0;
// Refresh commit history to get proper push status
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 {
// Push failed, show error
show_error_popup(
"Push failed. Check your network connection and authentication.");
viewer->sync_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_full_file_with_diff(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);
}
// Check if marked for commit and apply green color
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;
// Skip if no more commits
if (i >= viewer->commit_count)
continue;
// Check if this commit line should be highlighted
int is_selected_commit = (i == viewer->selected_commit &&
viewer->current_mode == NCURSES_MODE_COMMIT_LIST);
// Apply line highlight if selected
if (is_selected_commit) {
wattron(viewer->commit_list_win, COLOR_PAIR(5));
}
// Show selection indicator
if (is_selected_commit) {
mvwprintw(viewer->commit_list_win, y, 1, ">");
} else {
mvwprintw(viewer->commit_list_win, y, 1, " ");
}
// Show commit hash with color based on push status
if (is_selected_commit) {
wattroff(viewer->commit_list_win, COLOR_PAIR(5));
}
if (viewer->commits[i].is_pushed) {
wattron(viewer->commit_list_win, COLOR_PAIR(1)); // Green for pushed
} else {
wattron(viewer->commit_list_win, COLOR_PAIR(2)); // Red for unpushed
}
mvwprintw(viewer->commit_list_win, y, 2, "%s", viewer->commits[i].hash);
if (viewer->commits[i].is_pushed) {
wattroff(viewer->commit_list_win, COLOR_PAIR(1));
} else {
wattroff(viewer->commit_list_win, COLOR_PAIR(2));
}
// Show author initials with same color as hash
if (viewer->commits[i].is_pushed) {
wattron(viewer->commit_list_win, COLOR_PAIR(1)); // Green for pushed
} else {
wattron(viewer->commit_list_win, COLOR_PAIR(2)); // Red for unpushed
}
mvwprintw(viewer->commit_list_win, y, 10, "%s",
viewer->commits[i].author_initials);
if (viewer->commits[i].is_pushed) {
wattroff(viewer->commit_list_win, COLOR_PAIR(1));
} else {
wattroff(viewer->commit_list_win, COLOR_PAIR(2));
}
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[i].title) > max_title_len) {
strncpy(truncated_title, viewer->commits[i].title, max_title_len - 2);
truncated_title[max_title_len - 2] = '\0';
strcat(truncated_title, "..");
} else {
strcpy(truncated_title, viewer->commits[i].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
*/
void render_file_content_window(NCursesDiffViewer *viewer) {
if (!viewer || !viewer->file_content_win)
return;
// Clear the window content area (but preserve borders)
int height, width;
getmaxyx(viewer->file_content_win, height, width);
for (int y = 1; y < height - 1; y++) {
wmove(viewer->file_content_win, y, 1);
for (int x = 1; x < width - 1; x++) {
waddch(viewer->file_content_win, ' ');
}
}
// Draw rounded border
draw_rounded_box(viewer->file_content_win);
// Determine what content to show based on current mode
if (viewer->current_mode == NCURSES_MODE_COMMIT_LIST ||
viewer->current_mode == NCURSES_MODE_COMMIT_VIEW) {
// Show commit info
if (viewer->commit_count > 0 &&
viewer->selected_commit < viewer->commit_count) {
if (viewer->current_mode == NCURSES_MODE_COMMIT_LIST) {
mvwprintw(viewer->file_content_win, 0, 2, " 2. Commit %s (Preview) ",
viewer->commits[viewer->selected_commit].hash);
} else {
mvwprintw(viewer->file_content_win, 0, 2, " 2. Commit %s (Scrollable) ",
viewer->commits[viewer->selected_commit].hash);
}
} else {
mvwprintw(viewer->file_content_win, 0, 2, " 2. Commit View ");
}
} else if (viewer->current_mode == NCURSES_MODE_STASH_LIST ||
viewer->current_mode == NCURSES_MODE_STASH_VIEW) {
// Show stash info
if (viewer->stash_count > 0 &&
viewer->selected_stash < viewer->stash_count) {
if (viewer->current_mode == NCURSES_MODE_STASH_LIST) {
mvwprintw(viewer->file_content_win, 0, 2, " 2. Stash@{%d} (Preview) ",
viewer->selected_stash);
} else {
mvwprintw(viewer->file_content_win, 0, 2,
" 2. Stash@{%d} (Scrollable) ", viewer->selected_stash);
}
} else {
mvwprintw(viewer->file_content_win, 0, 2, " 2. Stash View ");
}
} else if (viewer->file_count > 0 &&
viewer->selected_file < viewer->file_count) {
// Show file content (preview in list mode, scrollable in view mode)
if (viewer->current_mode == NCURSES_MODE_FILE_LIST) {
mvwprintw(viewer->file_content_win, 0, 2, " 2. %s (Preview) ",
viewer->files[viewer->selected_file].filename);
} else {
mvwprintw(viewer->file_content_win, 0, 2, " 2. %s (Scrollable) ",
viewer->files[viewer->selected_file].filename);
}
} else {
mvwprintw(viewer->file_content_win, 0, 2, " 2. Content View ");
}
// Show content if available
if (viewer->file_line_count > 0) {
int height, width;
getmaxyx(viewer->file_content_win, height, width);
int max_lines_visible = height - 2;
int content_width = viewer->terminal_width - viewer->file_panel_width - 3;
for (int i = 0; i < max_lines_visible &&
(i + viewer->file_scroll_offset) < viewer->file_line_count;
i++) {
int line_idx = i + viewer->file_scroll_offset;
NCursesFileLine *line = &viewer->file_lines[line_idx];
int y = i + 1;
int is_cursor_line = (line_idx == viewer->file_cursor_line);
// Apply line highlighting for cursor line
if (is_cursor_line) {
wattron(viewer->file_content_win, A_REVERSE);
}
// Handle commit header coloring
if (line->type == 'h' && viewer->current_mode == NCURSES_MODE_FILE_VIEW) {
// Commit header line with special coloring for hash and branches
char display_line[1024];
if ((int)strlen(line->line) > content_width - 2) {
strncpy(display_line, line->line, content_width - 5);
display_line[content_width - 5] = '\0';
strcat(display_line, "...");
} else {
strcpy(display_line, line->line);
}
// Print character by character with appropriate colors
int x = 1;
for (int j = 0; display_line[j] != '\0' && x < content_width; j++) {
char c = display_line[j];
// Look for specific patterns to color
if (strstr(&display_line[j], "commit ") == &display_line[j]) {
// Color "commit" in cyan
wattron(viewer->file_content_win, COLOR_PAIR(3));
mvwprintw(viewer->file_content_win, y, x, "commit ");
wattroff(viewer->file_content_win, COLOR_PAIR(3));
x += 7;
j += 6; // Skip "commit"
// Now check if the next characters are a commit hash (40 hex chars)
if (j + 1 < (int)strlen(display_line) &&
display_line[j + 1] != '\0') {
int hash_start = j + 1;
int hash_len = 0;
// Count consecutive hex characters
while (hash_start + hash_len < (int)strlen(display_line) &&
hash_len < 40 &&
((display_line[hash_start + hash_len] >= '0' &&
display_line[hash_start + hash_len] <= '9') ||
(display_line[hash_start + hash_len] >= 'a' &&
display_line[hash_start + hash_len] <= 'f') ||
(display_line[hash_start + hash_len] >= 'A' &&
display_line[hash_start + hash_len] <= 'F'))) {
hash_len++;
}
// If we found a hash (typically 40 chars, but could be shorter),
// color it orange
if (hash_len >= 7) { // At least 7 characters for short hash
wattron(viewer->file_content_win, COLOR_PAIR(6));
for (int h = 0; h < hash_len; h++) {
mvwaddch(viewer->file_content_win, y, x,
display_line[hash_start + h]);
x++;
}
wattroff(viewer->file_content_win, COLOR_PAIR(6));
j += hash_len; // Skip the hash characters
}
}
} else if (strstr(&display_line[j], "origin/main") ==
&display_line[j]) {
// Color "origin/main" in red
wattron(viewer->file_content_win, COLOR_PAIR(2));
mvwprintw(viewer->file_content_win, y, x, "origin/main");
wattroff(viewer->file_content_win, COLOR_PAIR(2));
x += 11;
j += 10; // Skip "origin/main"
} else if (strstr(&display_line[j], "origin/HEAD") ==
&display_line[j]) {
// Color "origin/HEAD" in red
wattron(viewer->file_content_win, COLOR_PAIR(2));
mvwprintw(viewer->file_content_win, y, x, "origin/HEAD");
wattroff(viewer->file_content_win, COLOR_PAIR(2));
x += 11;
j += 10; // Skip "origin/HEAD"
} else if (strstr(&display_line[j], "main") == &display_line[j]) {
// Color "main" in green (but only if not part of origin/main)
// Check that it's not preceded by "origin/"
int is_standalone_main = 1;
if (j >= 7 && strncmp(&display_line[j - 7], "origin/", 7) == 0) {
is_standalone_main = 0;
}
if (is_standalone_main) {
wattron(viewer->file_content_win, COLOR_PAIR(1));
mvwprintw(viewer->file_content_win, y, x, "main");
wattroff(viewer->file_content_win, COLOR_PAIR(1));
x += 4;
j += 3; // Skip "main"
} else {
mvwaddch(viewer->file_content_win, y, x, c);
x++;
}
} else if (strstr(&display_line[j], "HEAD") == &display_line[j]) {
// Color "HEAD" in yellow
wattron(viewer->file_content_win, COLOR_PAIR(4));
mvwprintw(viewer->file_content_win, y, x, "HEAD");
wattroff(viewer->file_content_win, COLOR_PAIR(4));
x += 4;
j += 3; // Skip "HEAD"
} else if (c == '(' || c == ')' || c == ',' ||
strstr(&display_line[j], "->") == &display_line[j]) {
// Color branch separators in cyan
wattron(viewer->file_content_win, COLOR_PAIR(3));
if (strstr(&display_line[j], "->") == &display_line[j]) {
mvwprintw(viewer->file_content_win, y, x, "->");
x += 2;
j += 1; // Will be incremented by loop
} else {
mvwaddch(viewer->file_content_win, y, x, c);
x++;
}
wattroff(viewer->file_content_win, COLOR_PAIR(3));
} else {
mvwaddch(viewer->file_content_win, y, x, c);
x++;
}
}
} else if (line->type == 'h') {
// In commit/stash view, show header lines as plain white text
char display_line[1024];
if ((int)strlen(line->line) > content_width - 2) {
strncpy(display_line, line->line, content_width - 5);
display_line[content_width - 5] = '\0';
strcat(display_line, "...");
} else {
strcpy(display_line, line->line);
}
mvwprintw(viewer->file_content_win, y, 1, "%s", display_line);
// For cursor line on blank lines, fill with spaces to show highlighting
if (is_cursor_line && strlen(display_line) == 0) {
for (int x = 1; x < content_width; x++) {
mvwaddch(viewer->file_content_win, y, x, ' ');
}
}
} else if (line->type == 'i' &&
viewer->current_mode == NCURSES_MODE_FILE_VIEW) {
// Commit info lines (Author, Date) in white with colored labels - only
// in file view
char display_line[1024];
if ((int)strlen(line->line) > content_width - 2) {
strncpy(display_line, line->line, content_width - 5);
display_line[content_width - 5] = '\0';
strcat(display_line, "...");
} else {
strcpy(display_line, line->line);
}
// Color the label part
if (strncmp(display_line, "Author: ", 8) == 0) {
wattron(viewer->file_content_win, COLOR_PAIR(3));
mvwprintw(viewer->file_content_win, y, 1, "Author: ");
wattroff(viewer->file_content_win, COLOR_PAIR(3));
mvwprintw(viewer->file_content_win, y, 9, "%s", &display_line[8]);
} else if (strncmp(display_line, "Date: ", 6) == 0) {
wattron(viewer->file_content_win, COLOR_PAIR(3));
mvwprintw(viewer->file_content_win, y, 1, "Date: ");
wattroff(viewer->file_content_win, COLOR_PAIR(3));
mvwprintw(viewer->file_content_win, y, 7, "%s", &display_line[6]);
} else {
mvwprintw(viewer->file_content_win, y, 1, "%s", display_line);
}
} else if (line->type == 'i') {
// In commit/stash view, just show plain white text
char display_line[1024];
if ((int)strlen(line->line) > content_width - 2) {
strncpy(display_line, line->line, content_width - 5);
display_line[content_width - 5] = '\0';
strcat(display_line, "...");
} else {
strcpy(display_line, line->line);
}
mvwprintw(viewer->file_content_win, y, 1, "%s", display_line);
// For cursor line on blank lines, fill with spaces to show highlighting
if (is_cursor_line && strlen(display_line) == 0) {
for (int x = 1; x < content_width; x++) {
mvwaddch(viewer->file_content_win, y, x, ' ');
}
}
} else if (line->type == 's') {
// File statistics line - need to color + and - characters and byte
// changes
char display_line[1024];
if ((int)strlen(line->line) > content_width - 2) {
strncpy(display_line, line->line, content_width - 5);
display_line[content_width - 5] = '\0';
strcat(display_line, "...");
} else {
strcpy(display_line, line->line);
}
// Print the line character by character with appropriate colors
int x = 1;
for (int j = 0; display_line[j] != '\0' && x < content_width; j++) {
char c = display_line[j];
// Color + characters green, - characters red
if (c == '+') {
wattron(viewer->file_content_win, COLOR_PAIR(1)); // Green
mvwaddch(viewer->file_content_win, y, x, c);
wattroff(viewer->file_content_win, COLOR_PAIR(1));
} else if (c == '-') {
wattron(viewer->file_content_win, COLOR_PAIR(2)); // Red
mvwaddch(viewer->file_content_win, y, x, c);
wattroff(viewer->file_content_win, COLOR_PAIR(2));
} else if (strstr(&display_line[j], "Bin ") == &display_line[j]) {
// Handle binary file byte changes
char *arrow = strstr(&display_line[j], " -> ");
if (arrow) {
// Print "Bin " normally
mvwprintw(viewer->file_content_win, y, x, "Bin ");
x += 4;
j += 3; // Skip "Bin"
// Print bytes before arrow in red
wattron(viewer->file_content_win, COLOR_PAIR(2));
while (display_line[j] != '\0' && &display_line[j] < arrow) {
mvwaddch(viewer->file_content_win, y, x, display_line[j]);
x++;
j++;
}
wattroff(viewer->file_content_win, COLOR_PAIR(2));
// Print arrow normally
mvwprintw(viewer->file_content_win, y, x, " -> ");
x += 4;
j += 3; // Skip " -> "
// Print bytes after arrow in green
wattron(viewer->file_content_win, COLOR_PAIR(1));
while (display_line[j] != '\0' && display_line[j] != ' ') {
mvwaddch(viewer->file_content_win, y, x, display_line[j]);
x++;
j++;
}
wattroff(viewer->file_content_win, COLOR_PAIR(1));
j--; // Adjust for loop increment
} else {
mvwaddch(viewer->file_content_win, y, x, c);
}
} else {
mvwaddch(viewer->file_content_win, y, x, c);
}
x++;
}
} else {
// Regular line coloring
if (line->type == '+') {
wattron(viewer->file_content_win,
COLOR_PAIR(1)); // Green for additions
} else if (line->type == '-') {
wattron(viewer->file_content_win, COLOR_PAIR(2)); // Red for deletions
} else if (line->type == '@') {
wattron(viewer->file_content_win,
COLOR_PAIR(3)); // Cyan for hunk headers
}
// Truncate line to fit window
char display_line[1024];
if ((int)strlen(line->line) > content_width - 2) {
strncpy(display_line, line->line, content_width - 5);
display_line[content_width - 5] = '\0';
strcat(display_line, "...");
} else {
strcpy(display_line, line->line);
}
mvwprintw(viewer->file_content_win, y, 1, "%s", display_line);
// For cursor line on blank lines, fill with spaces to show highlighting
if (is_cursor_line && strlen(display_line) == 0) {
for (int x = 1; x < content_width; x++) {
mvwaddch(viewer->file_content_win, y, x, ' ');
}
}
// Reset color
if (line->type == '+') {
wattroff(viewer->file_content_win, COLOR_PAIR(1));
} else if (line->type == '-') {
wattroff(viewer->file_content_win, COLOR_PAIR(2));
} else if (line->type == '@') {
wattroff(viewer->file_content_win, COLOR_PAIR(3));
}
}
// Turn off line highlighting if it was applied
if (is_cursor_line) {
wattroff(viewer->file_content_win, A_REVERSE);
}
}
// Show scroll indicator
if (viewer->file_line_count > max_lines_visible) {
int scroll_pos = (viewer->file_scroll_offset * max_lines_visible) /
viewer->file_line_count;
mvwprintw(viewer->file_content_win, scroll_pos + 1, content_width + 1,
"█");
}
// Show current position info only in file view mode
if (viewer->current_mode == NCURSES_MODE_FILE_VIEW) {
mvwprintw(viewer->file_content_win, max_lines_visible + 1, 1,
"Line %d-%d of %d", viewer->file_scroll_offset + 1,
viewer->file_scroll_offset + max_lines_visible <
viewer->file_line_count
? viewer->file_scroll_offset + max_lines_visible
: viewer->file_line_count,
viewer->file_line_count);
} else {
mvwprintw(viewer->file_content_win, max_lines_visible + 1, 1,
"Press Enter to enable scrolling");
}
}
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: <space> | 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: <space> | Pop: g | Drop: d | Nav: j/k");
} else if (viewer->current_mode == NCURSES_MODE_BRANCH_LIST) {
strcpy(keybindings,
"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");
}
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 / 1) %
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 >= 60) {
viewer->sync_status = SYNC_STATUS_SYNCING_APPEARING;
viewer->last_sync_time = current_time;
viewer->animation_frame = 0;
viewer->text_char_count = 0;
// Do the actual sync work immediately - fetch remote changes quietly
system("git fetch --all --quiet >/dev/null 2>&1");
get_ncurses_changed_files(viewer);
// Only reload file diff if we're actually in a file-related mode
if ((viewer->current_mode == NCURSES_MODE_FILE_LIST ||
viewer->current_mode == NCURSES_MODE_FILE_VIEW) &&
viewer->selected_file < viewer->file_count && viewer->file_count > 0) {
load_full_file_with_diff(viewer,
viewer->files[viewer->selected_file].filename);
}
get_commit_history(viewer);
get_ncurses_git_branches(viewer); // This will now show updated branch status
// If we're in commit view mode, reload the current commit
if (viewer->current_mode == NCURSES_MODE_COMMIT_VIEW &&
viewer->commit_count > 0 &&
viewer->selected_commit < viewer->commit_count) {
load_commit_for_viewing(viewer,
viewer->commits[viewer->selected_commit].hash);
}
// If we're in stash view mode, reload the current stash
if (viewer->current_mode == NCURSES_MODE_STASH_VIEW &&
viewer->stash_count > 0 &&
viewer->selected_stash < viewer->stash_count) {
load_stash_for_viewing(viewer, viewer->selected_stash);
}
}
// 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 keyboard input for navigation
*/
int handle_ncurses_diff_input(NCursesDiffViewer *viewer, int key) {
if (!viewer)
return 0;
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;
// Load the selected file content when switching to file list mode
if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) {
load_full_file_with_diff(viewer,
viewer->files[viewer->selected_file].filename);
}
break;
case '2':
// Switch to file view mode and load selected file
if (viewer->file_count > 0 && viewer->selected_file < viewer->file_count) {
load_full_file_with_diff(viewer,
viewer->files[viewer->selected_file].filename);
viewer->current_mode = NCURSES_MODE_FILE_VIEW;
}
break;
case '3':
viewer->current_mode = NCURSES_MODE_BRANCH_LIST;
break;
case '4':
viewer->current_mode = NCURSES_MODE_COMMIT_LIST;
// 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;
// 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--;
// Auto-preview the selected file
if (viewer->file_count > 0) {
load_full_file_with_diff(
viewer, viewer->files[viewer->selected_file].filename);
}
}
break;
case KEY_DOWN:
case 'j':
if (viewer->selected_file < viewer->file_count - 1) {
viewer->selected_file++;
// Auto-preview the selected file
if (viewer->file_count > 0) {
load_full_file_with_diff(
viewer, viewer->files[viewer->selected_file].filename);
}
}
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':
create_ncurses_git_stash(viewer);
break;
case 'c':
case 'C': // Commit marked files
{
char commit_title[MAX_COMMIT_TITLE_LEN];
char commit_message[2048];
if (get_commit_title_input(commit_title, MAX_COMMIT_TITLE_LEN,
commit_message, sizeof(commit_message))) {
commit_marked_files(viewer, commit_title, commit_message);
}
} break;
case '\t': // Tab - switch to commit list mode
viewer->current_mode = NCURSES_MODE_COMMIT_LIST;
break;
case '\n':
case '\r':
case KEY_ENTER:
// Enter file view mode and load the selected file
if (viewer->file_count > 0 &&
viewer->selected_file < viewer->file_count) {
load_full_file_with_diff(viewer,
viewer->files[viewer->selected_file].filename);
viewer->current_mode = NCURSES_MODE_FILE_VIEW;
}
break;
}
} else if (viewer->current_mode == NCURSES_MODE_FILE_VIEW) {
// File view mode navigation
switch (key) {
case 27: // ESC
viewer->current_mode = NCURSES_MODE_FILE_LIST; // Return to file list mode
break;
case KEY_UP:
case 'k':
// Move cursor up (vim-style)
if (viewer->file_cursor_line > 0) {
viewer->file_cursor_line--;
// Scroll up if cursor gets within 3 lines of top
if (viewer->file_cursor_line < viewer->file_scroll_offset + 3 &&
viewer->file_scroll_offset > 0) {
viewer->file_scroll_offset--;
}
}
break;
case KEY_DOWN:
case 'j':
// Move cursor down (vim-style)
if (viewer->file_cursor_line < viewer->file_line_count - 1) {
viewer->file_cursor_line++;
// Scroll down if cursor gets within 3 lines of bottom
if (viewer->file_cursor_line >=
viewer->file_scroll_offset + max_lines_visible - 3 &&
viewer->file_scroll_offset <
viewer->file_line_count - max_lines_visible) {
viewer->file_scroll_offset++;
}
}
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
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;
}
} 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;
break;
case KEY_UP:
case 'k':
if (viewer->selected_commit > 0) {
viewer->selected_commit--;
// 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++;
// 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) {
// 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);
}
break;
case 'r': // Reset (soft) - undo commit but keep changes
if (viewer->commit_count > 0 && viewer->selected_commit == 0) {
reset_commit_soft(viewer, viewer->selected_commit);
}
break;
case 'R': // Reset (hard) - undo commit and discard changes
if (viewer->commit_count > 0 && viewer->selected_commit == 0) {
reset_commit_hard(viewer, viewer->selected_commit);
}
break;
case 'a':
case 'A': // Amend most recent commit
if (viewer->commit_count > 0) {
amend_commit(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;
break;
case KEY_UP:
case 'k':
if (viewer->selected_stash > 0) {
viewer->selected_stash--;
// 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++;
// 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) {
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_full_file_with_diff(
viewer, viewer->files[viewer->selected_file].filename);
}
}
}
break;
case 'g':
case 'G': // Pop stash (applies and removes from list)
if (viewer->stash_count > 0 &&
viewer->selected_stash < viewer->stash_count) {
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_full_file_with_diff(
viewer, viewer->files[viewer->selected_file].filename);
}
}
}
break;
case 'd':
case 'D': // Drop stash (removes without applying)
if (viewer->stash_count > 0 &&
viewer->selected_stash < viewer->stash_count) {
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;
}
}
}
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;
break;
case KEY_UP:
case 'k':
if (viewer->selected_branch > 0) {
viewer->selected_branch--;
}
break;
case KEY_DOWN:
case 'j':
if (viewer->selected_branch < viewer->branch_count - 1) {
viewer->selected_branch++;
}
break;
case '\n':
case '\r':
case KEY_ENTER:
// Enter - Switch to selected branch (same as space)
if (viewer->branch_count > 0 &&
viewer->selected_branch < viewer->branch_count) {
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_full_file_with_diff(
viewer, viewer->files[viewer->selected_file].filename);
}
}
// Force complete screen refresh after checkout
clear();
refresh();
}
break;
case 'c': // c - Checkout selected branch
if (viewer->branch_count > 0 &&
viewer->selected_branch < viewer->branch_count) {
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_full_file_with_diff(
viewer, viewer->files[viewer->selected_file].filename);
}
}
// Force complete screen refresh after checkout
clear();
refresh();
}
break;
case 'n': // New branch
{
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_full_file_with_diff(
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();
} break;
case 'd': // Delete branch
if (viewer->branch_count > 0 &&
viewer->selected_branch < viewer->branch_count) {
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();
}
break;
case 'r': // Rename branch
if (viewer->branch_count > 0 &&
viewer->selected_branch < viewer->branch_count) {
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();
}
break;
case 'p':
if (viewer->branch_count > 0 &&
viewer->selected_branch < viewer->branch_count) {
const char *branch_name =
viewer->branches[viewer->selected_branch].name;
if (viewer->branches[viewer->selected_branch].commits_behind > 0) {
viewer->sync_status = SYNC_STATUS_PULLING_APPEARING;
viewer->animation_frame = 0;
viewer->text_char_count = 0;
render_status_bar(viewer);
char pull_cmd[512];
snprintf(pull_cmd, sizeof(pull_cmd),
"git pull origin %s 2>/dev/null >/dev/null", branch_name);
int result = system(pull_cmd);
if (result == 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_full_file_with_diff(
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;
}
} else {
show_error_popup("No commits to pull from remote");
}
}
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
break;
case KEY_UP:
case 'k':
// Move cursor up (vim-style)
if (viewer->file_cursor_line > 0) {
viewer->file_cursor_line--;
// Scroll up if cursor gets within 3 lines of top
if (viewer->file_cursor_line < viewer->file_scroll_offset + 3 &&
viewer->file_scroll_offset > 0) {
viewer->file_scroll_offset--;
}
}
break;
case KEY_DOWN:
case 'j':
// Move cursor down (vim-style)
if (viewer->file_cursor_line < viewer->file_line_count - 1) {
viewer->file_cursor_line++;
// Scroll down if cursor gets within 3 lines of bottom
if (viewer->file_cursor_line >=
viewer->file_scroll_offset + max_lines_visible - 3 &&
viewer->file_scroll_offset <
viewer->file_line_count - max_lines_visible) {
viewer->file_scroll_offset++;
}
}
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
break;
case KEY_UP:
case 'k':
// Move cursor up (vim-style)
if (viewer->file_cursor_line > 0) {
viewer->file_cursor_line--;
// Scroll up if cursor gets within 3 lines of top
if (viewer->file_cursor_line < viewer->file_scroll_offset + 3 &&
viewer->file_scroll_offset > 0) {
viewer->file_scroll_offset--;
}
}
break;
case KEY_DOWN:
case 'j':
// Move cursor down (vim-style)
if (viewer->file_cursor_line < viewer->file_line_count - 1) {
viewer->file_cursor_line++;
// Scroll down if cursor gets within 3 lines of bottom
if (viewer->file_cursor_line >=
viewer->file_scroll_offset + max_lines_visible - 3 &&
viewer->file_scroll_offset <
viewer->file_line_count - max_lines_visible) {
viewer->file_scroll_offset++;
}
}
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;
}
// 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);
// Load the first file by default
if (viewer.file_count > 0) {
load_full_file_with_diff(&viewer, viewer.files[0].filename);
}
// Load initial commit preview if available
if (viewer.commit_count > 0) {
load_commit_for_viewing(&viewer, viewer.commits[0].hash);
}
// Load initial stash preview if available
if (viewer.stash_count > 0) {
load_stash_for_viewing(&viewer, 0);
}
// 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);
// Main display loop
int running = 1;
NCursesViewMode last_mode = viewer.current_mode;
while (running) {
// 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);
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);
// 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;
// First, fetch from all remotes to get latest status (like lazygit does)
system("git fetch --all --quiet >/dev/null 2>&1");
// 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 <remote> <branchname>");
// Input field
mvwprintw(dialog_win, 4, 2, "Upstream: %s", input_buffer);
// Suggestions header
mvwprintw(dialog_win, 6, 2, "Suggestions (press <tab> 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;
}
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_full_file_with_diff(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 < viewer->stash_count; i++) {
int y = i + 1;
// Check if this stash line should be highlighted
int is_selected_stash = (i == viewer->selected_stash &&
viewer->current_mode == NCURSES_MODE_STASH_LIST);
// Apply line highlight if selected
if (is_selected_stash) {
wattron(viewer->stash_list_win, COLOR_PAIR(5));
}
// Show selection indicator
if (is_selected_stash) {
mvwprintw(viewer->stash_list_win, y, 1, ">");
} else {
mvwprintw(viewer->stash_list_win, y, 1, " ");
}
// Show stash info (truncated to fit panel)
int max_stash_len = viewer->file_panel_width - 4;
char truncated_stash[256];
if ((int)strlen(viewer->stashes[i].stash_info) > max_stash_len) {
strncpy(truncated_stash, viewer->stashes[i].stash_info,
max_stash_len - 2);
truncated_stash[max_stash_len - 2] = '\0';
strcat(truncated_stash, "..");
} else {
strcpy(truncated_stash, viewer->stashes[i].stash_info);
}
// Show stash info with yellow color
if (is_selected_stash) {
wattroff(viewer->stash_list_win, COLOR_PAIR(5));
}
wattron(viewer->stash_list_win, COLOR_PAIR(4));
mvwprintw(viewer->stash_list_win, y, 2, "%s", truncated_stash);
wattroff(viewer->stash_list_win, COLOR_PAIR(4));
if (is_selected_stash) {
wattron(viewer->stash_list_win, COLOR_PAIR(5));
}
// 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));
}
}
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;
}
/**
* Clean up ncurses diff viewer resources
*/
void cleanup_ncurses_diff_viewer(NCursesDiffViewer *viewer) {
if (viewer) {
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);
}
}
endwin();
}