package main import "core:fmt" import "core:os" import "core:strings" import "core:time" import SDL "vendor:sdl3" // Left-sidebar Git view: working-tree status with staging, unstaging and // committing. All git commands run through the async Git_Loader; finishing // an action re-runs `git status` so the lists stay current. GIT_STATUS_ROW_HEIGHT :: 17 GIT_STATUS_INPUT_Y :: SDL_TOP_BAR_HEIGHT + 34 GIT_STATUS_INPUT_HEIGHT :: 24 GIT_STATUS_BUTTON_Y :: GIT_STATUS_INPUT_Y + GIT_STATUS_INPUT_HEIGHT + 8 GIT_STATUS_BUTTON_HEIGHT :: 22 GIT_STATUS_LIST_TOP :: GIT_STATUS_BUTTON_Y + GIT_STATUS_BUTTON_HEIGHT + 10 Git_Status_Entry :: struct { staged: bool, status: u8, // M/A/D/R/C/T/? (worktree or index status letter) path: string, } Git_Status_Op :: enum { None, Status, Action, // stage or unstage Commit, } Git_Status_Panel :: struct { entries: [dynamic]Git_Status_Entry, branch: string, message: string, // status line under the header scroll: int, input: [dynamic]u8, // commit message input_focused: bool, loaded_root: string, committed: bool, // a commit just landed; history panels should refresh op: Git_Status_Op, loader: Git_Loader, } git_status_destroy :: proc(panel: ^Git_Status_Panel) { git_loader_destroy(&panel.loader) git_status_clear_entries(panel) delete(panel.entries) delete(panel.branch) delete(panel.message) delete(panel.input) delete(panel.loaded_root) } git_status_clear_entries :: proc(panel: ^Git_Status_Panel) { for entry in panel.entries { delete(entry.path) } clear(&panel.entries) } git_status_set_message :: proc(panel: ^Git_Status_Panel, message: string) { delete(panel.message) panel.message = strings.clone(message) } git_status_request :: proc(panel: ^Git_Status_Panel, workspace: string) { delete(panel.loaded_root) panel.loaded_root = strings.clone(workspace) git_status_set_message(panel, "Loading status...") if git_loader_spawn(&panel.loader, []string{"git", "-C", workspace, "status", "--porcelain=v1", "--branch"}) { panel.op = .Status } else { panel.op = .None git_status_set_message(panel, "git: failed to start") } } git_status_toggle_entry :: proc(panel: ^Git_Status_Panel, workspace: string, index: int) { if panel.op != .None do return if index < 0 || index >= len(panel.entries) do return entry := panel.entries[index] pathspec := fmt.tprintf(":(top)%s", entry.path) command: []string if entry.staged { command = []string{"git", "-C", workspace, "restore", "--staged", "--", pathspec} } else { command = []string{"git", "-C", workspace, "add", "--", pathspec} } if git_loader_spawn(&panel.loader, command) { panel.op = .Action } } git_status_commit :: proc(panel: ^Git_Status_Panel, workspace: string) { if panel.op != .None do return message := strings.trim_space(string(panel.input[:])) if len(message) == 0 { git_status_set_message(panel, "Enter a commit message") return } staged := 0 for entry in panel.entries { if entry.staged do staged += 1 } if staged == 0 { git_status_set_message(panel, "Nothing staged to commit") return } if git_loader_spawn(&panel.loader, []string{"git", "-C", workspace, "commit", "-m", message}) { panel.op = .Commit git_status_set_message(panel, "Committing...") } } git_status_pump :: proc(panel: ^Git_Status_Panel) { if panel.op == .None do return output, done := git_loader_poll(&panel.loader) if !done do return op := panel.op panel.op = .None switch op { case .Status: git_status_parse(panel, output) case .Action: trimmed := strings.trim_space(output) if strings.has_prefix(trimmed, "fatal:") || strings.has_prefix(trimmed, "error:") { git_status_set_message(panel, trimmed) } git_status_request(panel, panel.loaded_root) case .Commit: trimmed := strings.trim_space(output) if strings.has_prefix(trimmed, "fatal:") || strings.has_prefix(trimmed, "error:") { git_status_set_message(panel, trimmed) } else { clear(&panel.input) panel.committed = true } git_status_request(panel, panel.loaded_root) case .None: } } git_status_parse :: proc(panel: ^Git_Status_Panel, output: string) { trimmed := strings.trim_space(output) if strings.has_prefix(trimmed, "fatal:") || strings.has_prefix(trimmed, "error:") { git_status_clear_entries(panel) git_status_set_message(panel, "Not a git repository") return } git_status_clear_entries(panel) delete(panel.branch) panel.branch = strings.clone("") lines, _ := strings.split_lines(output, context.temp_allocator) for line in lines { if len(line) < 3 do continue if strings.has_prefix(line, "## ") { branch := line[3:] if dots := strings.index(branch, "..."); dots >= 0 { branch = branch[:dots] } delete(panel.branch) panel.branch = strings.clone(branch) continue } index_status := line[0] worktree_status := line[1] path := line[3:] // Renames list "old -> new"; show the new name. if arrow := strings.index(path, " -> "); arrow >= 0 { path = path[arrow + 4:] } if index_status != ' ' && index_status != '?' { append(&panel.entries, Git_Status_Entry{staged = true, status = index_status, path = strings.clone(path)}) } if worktree_status != ' ' { status := worktree_status append(&panel.entries, Git_Status_Entry{staged = false, status = status, path = strings.clone(path)}) } } if len(panel.entries) == 0 { git_status_set_message(panel, "No changes") } else { git_status_set_message(panel, "") } } // --------------------------------------------------------------------------- // Flattened rows: section headers + entries, shared by render and clicks. Git_Status_Row :: struct { is_entry: bool, entry_index: int, label: string, // section label for header rows } git_status_rows :: proc(panel: ^Git_Status_Panel) -> []Git_Status_Row { rows := make([dynamic]Git_Status_Row, context.temp_allocator) staged_count := 0 for entry in panel.entries { if entry.staged do staged_count += 1 } unstaged_count := len(panel.entries) - staged_count if staged_count > 0 { append(&rows, Git_Status_Row{label = fmt.tprintf("STAGED (%d)", staged_count)}) for entry, index in panel.entries { if entry.staged do append(&rows, Git_Status_Row{is_entry = true, entry_index = index}) } } if unstaged_count > 0 { append(&rows, Git_Status_Row{label = fmt.tprintf("CHANGES (%d)", unstaged_count)}) for entry, index in panel.entries { if !entry.staged do append(&rows, Git_Status_Row{is_entry = true, entry_index = index}) } } return rows[:] } git_status_visible_rows :: proc(view: ^SDL_View) -> int { return max_int((editor_content_bottom(view) - GIT_STATUS_LIST_TOP - 4) / GIT_STATUS_ROW_HEIGHT, 1) } // --------------------------------------------------------------------------- // Interaction git_status_panel_active :: proc(view: ^SDL_View) -> bool { return view.explorer_visible && view.left_tab == .Git } git_status_input_rect :: proc(view: ^SDL_View) -> (x, y, width, height: f32) { x = f32(content_left_edge() + 10) y = GIT_STATUS_INPUT_Y width = f32(left_sidebar_width(view) - content_left_edge() - 20) height = GIT_STATUS_INPUT_HEIGHT return } git_status_button_rect :: proc(view: ^SDL_View) -> (x, y, width, height: f32) { width = f32(gpu_text_width("Commit")) + 24 x = f32(left_sidebar_width(view)) - width - 10 y = GIT_STATUS_BUTTON_Y height = GIT_STATUS_BUTTON_HEIGHT return } handle_git_status_click :: proc(panel: ^Git_Status_Panel, view: ^SDL_View, workspace: string, x, y: f32) -> bool { if !git_status_panel_active(view) do return false if x < f32(content_left_edge()) || x >= f32(left_sidebar_width(view)) do return false if y < SDL_TOP_BAR_HEIGHT || y >= f32(editor_content_bottom(view)) do return false input_x, input_y, input_w, input_h := git_status_input_rect(view) if x >= input_x && x < input_x + input_w && y >= input_y && y < input_y + input_h { panel.input_focused = true return true } panel.input_focused = false button_x, button_y, button_w, button_h := git_status_button_rect(view) if x >= button_x && x < button_x + button_w && y >= button_y && y < button_y + button_h { git_status_commit(panel, workspace) return true } if y >= GIT_STATUS_LIST_TOP { rows := git_status_rows(panel) row := panel.scroll + int((y - GIT_STATUS_LIST_TOP) / GIT_STATUS_ROW_HEIGHT) if row >= 0 && row < len(rows) && rows[row].is_entry { git_status_toggle_entry(panel, workspace, rows[row].entry_index) } } return true } handle_git_status_wheel :: proc(panel: ^Git_Status_Panel, view: ^SDL_View, x, y: f32, wheel_y: int) -> bool { if !git_status_panel_active(view) do return false if x < f32(content_left_edge()) || x >= f32(left_sidebar_width(view)) do return false rows := git_status_rows(panel) max_scroll := max_int(len(rows) - git_status_visible_rows(view), 0) panel.scroll = clamp_int(panel.scroll - wheel_y * 3, 0, max_scroll) return true } // Commit-message keyboard input. Returns true when the key was consumed. handle_git_status_key :: proc(panel: ^Git_Status_Panel, view: ^SDL_View, workspace: string, key: SDL.Keycode, mod: SDL.Keymod) -> bool { if !git_status_panel_active(view) || !panel.input_focused do return false switch key { case SDL.K_ESCAPE: panel.input_focused = false case SDL.K_BACKSPACE: if len(panel.input) > 0 { resize(&panel.input, len(panel.input) - 1) } case SDL.K_RETURN, SDL.K_KP_ENTER: git_status_commit(panel, workspace) } return true } git_status_input_text :: proc(panel: ^Git_Status_Panel, text: string) { for b in transmute([]u8)text { if b >= 32 && b < 127 { append(&panel.input, b) } } } // --------------------------------------------------------------------------- // Rendering render_git_status_panel_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, panel: ^Git_Status_Panel) { left := f32(content_left_edge()) width := f32(left_sidebar_width(view)) - left advance := max_f32(gpu.font_advance, 1) header := "GIT" if len(panel.branch) > 0 { header = fmt.tprintf("GIT [%s]", panel.branch) } gpu_text_limited(gpu, left + 14, SDL_TREE_HEADER_Y, header, max_int(int((width - 28) / advance), 4), 150, 154, 162, 255) gpu_rect(gpu, left + 14, SDL_TREE_HEADER_Y + 18, width - 28, 1, 52, 54, 60, 255) // Commit message input. input_x, input_y, input_w, input_h := git_status_input_rect(view) gpu_rect(gpu, input_x, input_y, input_w, input_h, 18, 20, 25, 255) if panel.input_focused { gpu_rect_outline(gpu, input_x, input_y, input_w, input_h, 77, 155, 230, 255) } else { gpu_rect_outline(gpu, input_x, input_y, input_w, input_h, 58, 62, 72, 255) } input_columns := max_int(int((input_w - 20) / advance), 4) if len(panel.input) == 0 && !panel.input_focused { gpu_text_limited(gpu, input_x + 8, input_y + 5, "Commit message", input_columns, 110, 114, 124, 255) } else { text := string(panel.input[:]) if len(text) > input_columns && input_columns > 3 { text = fmt.tprintf("...%s", text[len(text) - (input_columns - 3):]) } gpu_text(gpu, input_x + 8, input_y + 5, text, 225, 228, 235, 255) if panel.input_focused { caret_x := input_x + 8 + f32(len(text)) * advance gpu_rect(gpu, caret_x + 1, input_y + 4, 1, input_h - 8, 230, 230, 230, 255) } } // Commit button. button_x, button_y, button_w, button_h := git_status_button_rect(view) gpu_rect(gpu, button_x, button_y, button_w, button_h, 45, 84, 128, 255) gpu_text(gpu, button_x + 12, button_y + 4, "Commit", 226, 236, 248, 255) if len(panel.message) > 0 { gpu_text_limited(gpu, left + 14, GIT_STATUS_BUTTON_Y + 4, panel.message, max_int(int((button_x - left - 22) / advance), 4), 150, 154, 162, 255) } // Entries. rows := git_status_rows(panel) visible := git_status_visible_rows(view) panel.scroll = clamp_int(panel.scroll, 0, max_int(len(rows) - visible, 0)) row_y := f32(GIT_STATUS_LIST_TOP) for offset in 0 ..< visible { index := panel.scroll + offset if index >= len(rows) do break row := rows[index] if !row.is_entry { gpu_text_limited(gpu, left + 14, row_y + 2, row.label, max_int(int((width - 28) / advance), 4), 150, 154, 162, 255) row_y += GIT_STATUS_ROW_HEIGHT continue } entry := panel.entries[row.entry_index] row_hovered := view.mouse_x >= left && view.mouse_x < left + width && view.mouse_y >= row_y && view.mouse_y < row_y + GIT_STATUS_ROW_HEIGHT if row_hovered { gpu_rect(gpu, left + 4, row_y, width - 8, GIT_STATUS_ROW_HEIGHT, 42, 44, 50, 255) } status := entry.status if status == '?' do status = 'A' red, green, blue := git_status_color(status) marker := [1]u8{entry.status} gpu_text(gpu, left + 14, row_y + 2, string(marker[:]), red, green, blue, 255) path_columns := max_int(int((width - 28 - 2 * advance) / advance), 4) gpu_text_limited(gpu, left + 14 + 2 * advance, row_y + 2, entry.path, path_columns, 218, 222, 230, 255) row_y += GIT_STATUS_ROW_HEIGHT } } // --------------------------------------------------------------------------- // Self-test: `editor --git-status-selftest ` exercises the status, // stage, unstage and commit cycle against a scratch repository. git_status_selftest :: proc(workspace: string) -> bool { panel := Git_Status_Panel{} defer git_status_destroy(&panel) wait :: proc(panel: ^Git_Status_Panel) -> bool { for _ in 0 ..< 200 { git_status_pump(panel) if panel.op == .None do return true time.sleep(50 * time.Millisecond) } return false } check :: proc(ok: ^bool, name: string, condition: bool) { if condition { fmt.printf("ok %s\n", name) } else { fmt.printf("FAIL %s\n", name) ok^ = false } } ok := true git_status_request(&panel, workspace) if !wait(&panel) { fmt.println("FAIL status timed out") return false } check(&ok, "status parsed", len(panel.entries) > 0) check(&ok, "untracked entry", len(panel.entries) > 0 && !panel.entries[0].staged && panel.entries[0].status == '?') git_status_toggle_entry(&panel, workspace, 0) if !wait(&panel) { fmt.println("FAIL stage timed out") return false } staged := 0 for entry in panel.entries { if entry.staged do staged += 1 } check(&ok, "file staged", staged == 1) git_status_input_text(&panel, "selftest commit") git_status_commit(&panel, workspace) if !wait(&panel) { fmt.println("FAIL commit timed out") return false } check(&ok, "commit landed", panel.committed && len(panel.entries) == 0 && len(panel.input) == 0) check(&ok, "branch known", len(panel.branch) > 0) // Modify the committed file, stage it, then unstage it again. if len(panel.entries) == 0 { _ = os.write_entire_file(fmt.tprintf("%s/file.txt", workspace), transmute([]u8)string("changed\n")) git_status_request(&panel, workspace) if !wait(&panel) { fmt.println("FAIL modified status timed out") return false } check(&ok, "modification seen", len(panel.entries) == 1 && !panel.entries[0].staged && panel.entries[0].status == 'M') git_status_toggle_entry(&panel, workspace, 0) if !wait(&panel) { fmt.println("FAIL stage timed out") return false } check(&ok, "modification staged", len(panel.entries) == 1 && panel.entries[0].staged) git_status_toggle_entry(&panel, workspace, 0) if !wait(&panel) { fmt.println("FAIL unstage timed out") return false } check(&ok, "modification unstaged", len(panel.entries) == 1 && !panel.entries[0].staged) } fmt.println(ok ? "git status selftest: PASS" : "git status selftest: FAIL") return ok }