package main import "core:fmt" import "core:os" import "core:strings" import "core:sync" import "core:thread" import "core:time" // Git history browser for the bottom panel's Git tab. History and commit // details come from spawned `git` processes whose output is read on a // background thread and parsed in the per-frame pump, so the UI never blocks. GIT_PANEL_LOG_LIMIT :: 200 GIT_PANEL_DETAIL_LIMIT :: 2000 GIT_PANEL_ROW_HEIGHT :: 16 Git_Commit :: struct { hash: string, date: string, author: string, subject: string, } Git_Load_Kind :: enum { None, Log, Show, Diff, } Git_File :: struct { status: u8, // M/A/D/R/C/T path: string, } // Reusable async runner for git commands: output is read on a background // thread; poll it each frame until the full output is available. Git_Loader :: struct { process: os.Process, process_active: bool, stdout: ^os.File, reader: ^thread.Thread, reader_done: bool, mutex: sync.Mutex, pending: [dynamic]u8, raw: [dynamic]u8, active: bool, } git_loader_destroy :: proc(loader: ^Git_Loader) { git_loader_cancel(loader) delete(loader.pending) delete(loader.raw) } git_loader_cancel :: proc(loader: ^Git_Loader) { if !loader.active do return if loader.process_active { _ = os.process_terminate(loader.process) _, _ = os.process_wait(loader.process) loader.process_active = false } if loader.reader != nil { // The child is dead, so the reader sees EOF and exits. thread.join(loader.reader) thread.destroy(loader.reader) loader.reader = nil } if loader.stdout != nil { _ = os.close(loader.stdout) loader.stdout = nil } clear(&loader.pending) clear(&loader.raw) loader.active = false loader.reader_done = false } git_loader_reader_thread :: proc(data: rawptr) { loader := (^Git_Loader)(data) buf: [4096]u8 for { n, err := os.read(loader.stdout, buf[:]) if err != nil || n <= 0 do break if sync.mutex_guard(&loader.mutex) { for b in buf[:n] { append(&loader.pending, b) } } } loader.reader_done = true } // stderr shares the pipe so git errors are visible in the output. git_loader_spawn :: proc(loader: ^Git_Loader, command: []string) -> bool { git_loader_cancel(loader) stdout_r, stdout_w, pipe_err := os.pipe() if pipe_err != nil do return false process, start_err := os.process_start(os.Process_Desc{ command = command, stdout = stdout_w, stderr = stdout_w, }) _ = os.close(stdout_w) if start_err != nil { _ = os.close(stdout_r) return false } loader.process = process loader.process_active = true loader.stdout = stdout_r loader.active = true loader.reader_done = false loader.reader = thread.create_and_start_with_data(rawptr(loader), git_loader_reader_thread) return true } // Drains output; returns (temp-allocated output, true) once the command has // finished, cleaning the loader up. git_loader_poll :: proc(loader: ^Git_Loader) -> (string, bool) { if !loader.active do return "", false if sync.mutex_guard(&loader.mutex) { for b in loader.pending { append(&loader.raw, b) } clear(&loader.pending) } if !loader.reader_done do return "", false // One more drain: the reader's final chunk may land just before done. if sync.mutex_guard(&loader.mutex) { for b in loader.pending { append(&loader.raw, b) } clear(&loader.pending) } output := strings.clone(string(loader.raw[:]), context.temp_allocator) git_loader_cancel(loader) return output, true } Git_Panel :: struct { commits: [dynamic]Git_Commit, selected: int, list_scroll: int, files: [dynamic]Git_File, file_selected: int, file_scroll: int, diff_text: string, // finished diff, handed to an editor tab diff_ready: bool, diff_path: string, // path the pending diff is for diff_hash: string, detail_hash: string, // hash the detail pane shows (or is loading) message: string, loaded_root: string, // workspace the commits were loaded from load_kind: Git_Load_Kind, loader: Git_Loader, } git_panel_destroy :: proc(panel: ^Git_Panel) { git_loader_destroy(&panel.loader) panel.load_kind = .None git_panel_clear_commits(panel) delete(panel.commits) git_panel_clear_detail(panel) delete(panel.files) delete(panel.diff_text) delete(panel.diff_path) delete(panel.diff_hash) delete(panel.detail_hash) delete(panel.message) delete(panel.loaded_root) } git_panel_clear_commits :: proc(panel: ^Git_Panel) { for commit in panel.commits { delete(commit.hash) delete(commit.date) delete(commit.author) delete(commit.subject) } clear(&panel.commits) } git_panel_clear_detail :: proc(panel: ^Git_Panel) { for file in panel.files { delete(file.path) } clear(&panel.files) panel.file_selected = 0 panel.file_scroll = 0 git_panel_clear_diff(panel) } git_panel_clear_diff :: proc(panel: ^Git_Panel) { delete(panel.diff_text) panel.diff_text = "" panel.diff_ready = false delete(panel.diff_path) panel.diff_path = "" delete(panel.diff_hash) panel.diff_hash = "" } git_panel_set_message :: proc(panel: ^Git_Panel, message: string) { delete(panel.message) panel.message = strings.clone(message) } git_panel_spawn :: proc(panel: ^Git_Panel, kind: Git_Load_Kind, command: []string) { if git_loader_spawn(&panel.loader, command) { panel.load_kind = kind } else { panel.load_kind = .None git_panel_set_message(panel, "git: failed to start (is git installed?)") } } git_panel_request_log :: proc(panel: ^Git_Panel, workspace: string) { git_panel_clear_commits(panel) panel.selected = 0 panel.list_scroll = 0 git_panel_clear_detail(panel) delete(panel.detail_hash) panel.detail_hash = "" delete(panel.loaded_root) panel.loaded_root = strings.clone(workspace) git_panel_set_message(panel, "Loading git history...") limit := fmt.tprintf("-n%d", GIT_PANEL_LOG_LIMIT) git_panel_spawn(panel, .Log, []string{"git", "-C", workspace, "log", limit, "--date=short", "--pretty=format:%h\t%ad\t%an\t%s"}) } git_panel_request_show :: proc(panel: ^Git_Panel, workspace, hash: string) { if panel.detail_hash == hash do return git_panel_clear_detail(panel) delete(panel.detail_hash) panel.detail_hash = strings.clone(hash) git_panel_spawn(panel, .Show, []string{"git", "-C", workspace, "show", "--name-status", "--format=", hash}) } git_panel_request_diff :: proc(panel: ^Git_Panel, workspace, hash, path: string) { git_panel_clear_diff(panel) panel.diff_path = strings.clone(path) panel.diff_hash = strings.clone(hash) // --name-status paths are repo-root relative, but a plain pathspec is // resolved against the workspace directory — which may be a subdirectory // of the repository. :(top) anchors the pathspec to the repo root. pathspec := fmt.tprintf(":(top)%s", path) git_panel_spawn(panel, .Diff, []string{"git", "-C", workspace, "show", "--format=", "--patch", hash, "--", pathspec}) } // Loads history once per workspace; explicit refreshes go through // git_panel_request_log (panel reopen). git_panel_activate :: proc(panel: ^Git_Panel, workspace: string) { if panel.loaded_root == workspace do return git_panel_request_log(panel, workspace) } // Polls the loader; parses the output once the command finished. Per frame. git_panel_pump :: proc(panel: ^Git_Panel) { if panel.load_kind == .None do return output, done := git_loader_poll(&panel.loader) if !done do return kind := panel.load_kind panel.load_kind = .None switch kind { case .Log: git_panel_parse_log(panel, output) case .Show: git_panel_parse_files(panel, output) case .Diff: git_panel_parse_diff(panel, output) case .None: } } git_panel_parse_log :: proc(panel: ^Git_Panel, output: string) { trimmed := strings.trim_space(output) if strings.has_prefix(trimmed, "fatal:") || strings.has_prefix(trimmed, "error:") { git_panel_set_message(panel, "Not a git repository") return } git_panel_clear_commits(panel) lines, _ := strings.split_lines(output, context.temp_allocator) for line in lines { if len(strings.trim_space(line)) == 0 do continue fields := strings.split_n(line, "\t", 4, context.temp_allocator) if len(fields) < 4 do continue append(&panel.commits, Git_Commit{ hash = strings.clone(fields[0]), date = strings.clone(fields[1]), author = strings.clone(fields[2]), subject = strings.clone(fields[3]), }) } if len(panel.commits) == 0 { git_panel_set_message(panel, "No commits") } else { git_panel_set_message(panel, "") panel.selected = clamp_int(panel.selected, 0, len(panel.commits) - 1) } } git_panel_parse_files :: proc(panel: ^Git_Panel, output: string) { git_panel_clear_detail(panel) lines, _ := strings.split_lines(output, context.temp_allocator) for line in lines { trimmed := strings.trim_right(line, "\r") if len(trimmed) == 0 do continue fields := strings.split(trimmed, "\t", context.temp_allocator) if len(fields) < 2 do continue status := fields[0][0] // Renames/copies list "oldnew"; show the new path. path := fields[len(fields) - 1] append(&panel.files, Git_File{status = status, path = strings.clone(path)}) } panel.file_selected = -1 } git_panel_parse_diff :: proc(panel: ^Git_Panel, output: string) { delete(panel.diff_text) text := output if len(strings.trim_space(text)) == 0 { text = "(no changes for this file in this commit)\n" } panel.diff_text = strings.clone(text) panel.diff_ready = true } // The editor tab name for a finished diff: " @ ". git_panel_diff_tab_name :: proc(panel: ^Git_Panel, allocator := context.temp_allocator) -> string { base := panel.diff_path if slash := strings.last_index_byte(base, '/'); slash >= 0 { base = base[slash + 1:] } return fmt.aprintf("%s @ %s", base, panel.diff_hash, allocator = allocator) } // --------------------------------------------------------------------------- // Layout inside the bottom panel body // Left column: commit list; right column: diff of the selected commit. git_panel_list_width :: proc(panel_width: f32) -> f32 { return max_f32(min_f32(panel_width * 0.4, 520), 240) } git_panel_visible_rows :: proc(body_height: f32) -> int { return max_int(int((body_height - 8) / GIT_PANEL_ROW_HEIGHT), 1) } // Vertical layout of the detail pane: commit header, then the changed-file // list. Shared by render, click and wheel handling. Git_Detail_Layout :: struct { files_y: f32, file_rows: int, } git_detail_layout :: proc(panel: ^Git_Panel, body_y, body_height: f32) -> Git_Detail_Layout { layout: Git_Detail_Layout header_height := f32(2 * GIT_PANEL_ROW_HEIGHT + 6) layout.files_y = body_y + header_height layout.file_rows = max_int(int((body_y + body_height - layout.files_y - 4) / GIT_PANEL_ROW_HEIGHT), 1) return layout } git_status_color :: proc(status: u8) -> (u8, u8, u8) { switch status { case 'A': return 152, 195, 121 case 'D': return 224, 108, 117 case 'R', 'C': return 97, 175, 239 case 'M', 'T': return 229, 192, 123 } return 150, 154, 162 } render_git_panel_body :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, panel: ^Git_Panel, x, y, width, height: f32) { if len(panel.message) > 0 && len(panel.commits) == 0 { gpu_text_limited(gpu, x + 14, y + 8, panel.message, int((width - 28) / max_f32(gpu.font_advance, 1)), 150, 154, 162, 255) return } advance := max_f32(gpu.font_advance, 1) list_width := git_panel_list_width(width) visible := git_panel_visible_rows(height) panel.list_scroll = clamp_int(panel.list_scroll, 0, max_int(len(panel.commits) - visible, 0)) // Commit list. list_columns := max_int(int((list_width - 24) / advance), 8) row_y := y + 4 for offset in 0 ..< visible { index := panel.list_scroll + offset if index >= len(panel.commits) do break commit := panel.commits[index] row_hovered := view.mouse_x >= x && view.mouse_x < x + list_width && view.mouse_y >= row_y && view.mouse_y < row_y + GIT_PANEL_ROW_HEIGHT if index == panel.selected { gpu_rect(gpu, x + 4, row_y, list_width - 8, GIT_PANEL_ROW_HEIGHT, 49, 56, 70, 255) gpu_rect(gpu, x + 4, row_y, 2, GIT_PANEL_ROW_HEIGHT, 77, 155, 230, 255) } else if row_hovered { gpu_rect(gpu, x + 4, row_y, list_width - 8, GIT_PANEL_ROW_HEIGHT, 42, 44, 50, 255) } hash_width := f32(len(commit.hash)) * advance date_width := f32(len(commit.date)) * advance gpu_text(gpu, x + 12, row_y + 2, commit.hash, 120, 190, 250, 255) gpu_text(gpu, x + 12 + hash_width + advance, row_y + 2, commit.date, 150, 154, 162, 255) subject_x := x + 12 + hash_width + date_width + 2 * advance subject_columns := max_int(int((x + list_width - 12 - subject_x) / advance), 4) gpu_text_limited(gpu, subject_x, row_y + 2, commit.subject, subject_columns, 218, 222, 230, 255) row_y += GIT_PANEL_ROW_HEIGHT } // Divider + detail pane. detail_x := x + list_width gpu_rect(gpu, detail_x, y, 1, height, 48, 50, 56, 255) detail_columns := max_int(int((width - list_width - 28) / advance), 8) if panel.selected < 0 || panel.selected >= len(panel.commits) do return if len(panel.detail_hash) == 0 { gpu_text_limited(gpu, detail_x + 14, y + 8, "Select a commit to see its changes", detail_columns, 150, 154, 162, 255) return } // Commit header from the already-parsed commit entry. commit := panel.commits[panel.selected] gpu_text_limited(gpu, detail_x + 14, y + 4, commit.subject, detail_columns, 220, 223, 228, 255) meta := fmt.tprintf("%s %s %s", commit.hash, commit.date, commit.author) gpu_text_limited(gpu, detail_x + 14, y + 4 + GIT_PANEL_ROW_HEIGHT, meta, detail_columns, 150, 154, 162, 255) layout := git_detail_layout(panel, y, height) // Changed files; clicking one opens its diff in an editor tab. if len(panel.files) == 0 { hint := "Loading commit..." if panel.load_kind == .Show else "No changed files" gpu_text_limited(gpu, detail_x + 14, layout.files_y, hint, detail_columns, 150, 154, 162, 255) return } panel.file_scroll = clamp_int(panel.file_scroll, 0, max_int(len(panel.files) - layout.file_rows, 0)) file_y := layout.files_y for offset in 0 ..< layout.file_rows { index := panel.file_scroll + offset if index >= len(panel.files) do break file := panel.files[index] row_hovered := view.mouse_x >= detail_x && view.mouse_x < x + width && view.mouse_y >= file_y && view.mouse_y < file_y + GIT_PANEL_ROW_HEIGHT if index == panel.file_selected { gpu_rect(gpu, detail_x + 4, file_y, width - list_width - 8, GIT_PANEL_ROW_HEIGHT, 49, 56, 70, 255) gpu_rect(gpu, detail_x + 4, file_y, 2, GIT_PANEL_ROW_HEIGHT, 77, 155, 230, 255) } else if row_hovered { gpu_rect(gpu, detail_x + 4, file_y, width - list_width - 8, GIT_PANEL_ROW_HEIGHT, 42, 44, 50, 255) } status := [1]u8{file.status} red, green, blue := git_status_color(file.status) gpu_text(gpu, detail_x + 14, file_y + 2, string(status[:]), red, green, blue, 255) gpu_text_limited(gpu, detail_x + 14 + 2 * advance, file_y + 2, file.path, max_int(detail_columns - 2, 4), 218, 222, 230, 255) file_y += GIT_PANEL_ROW_HEIGHT } } // --------------------------------------------------------------------------- // Self-test: `editor --git-selftest [dir]` loads history + a commit diff from // a real repository through the async loader and reports what it parsed. git_panel_selftest :: proc(workspace: string) -> bool { panel := Git_Panel{} defer git_panel_destroy(&panel) wait_for_load :: proc(panel: ^Git_Panel) -> bool { for _ in 0 ..< 200 { git_panel_pump(panel) if panel.load_kind == .None do return true time.sleep(50 * time.Millisecond) } return false } git_panel_request_log(&panel, workspace) if !wait_for_load(&panel) { fmt.println("FAIL git log timed out") return false } if len(panel.commits) == 0 { fmt.printf("FAIL no commits parsed (%s)\n", panel.message) return false } first := panel.commits[0] fmt.printf("ok %d commits; first: %s %s %s %q\n", len(panel.commits), first.hash, first.date, first.author, first.subject) git_panel_request_show(&panel, workspace, first.hash) if !wait_for_load(&panel) { fmt.println("FAIL git show timed out") return false } if len(panel.files) == 0 { fmt.println("FAIL changed files not parsed") return false } fmt.printf("ok %d changed files; first: %c %s\n", len(panel.files), rune(panel.files[0].status), panel.files[0].path) // Simulate clicking every file of every commit; each diff must load // with real contents. checked := 0 for commit_index in 0 ..< len(panel.commits) { hash := strings.clone(panel.commits[commit_index].hash, context.temp_allocator) delete(panel.detail_hash) panel.detail_hash = "" git_panel_request_show(&panel, workspace, hash) if !wait_for_load(&panel) { fmt.printf("FAIL git show timed out for %s\n", hash) return false } for file_index in 0 ..< len(panel.files) { git_panel_request_diff(&panel, workspace, panel.detail_hash, panel.files[file_index].path) if !wait_for_load(&panel) { fmt.printf("FAIL diff timed out: %s %s\n", hash, panel.files[file_index].path) return false } if !panel.diff_ready || !strings.has_prefix(panel.diff_text, "diff --git") { fmt.printf("FAIL empty diff: %s %s -> %q\n", hash, panel.files[file_index].path, panel.diff_text[:min_int(len(panel.diff_text), 60)]) return false } checked += 1 } } fmt.printf("ok %d file diffs loaded across %d commits\n", checked, len(panel.commits)) // The split-view parser must produce aligned rows; use a modification // diff (HEAD commit) so context, removed and added rows all appear. git_panel_request_show(&panel, workspace, panel.commits[0].hash) if !wait_for_load(&panel) || len(panel.files) == 0 { fmt.println("FAIL reloading HEAD commit") return false } git_panel_request_diff(&panel, workspace, panel.detail_hash, panel.files[0].path) if !wait_for_load(&panel) { fmt.println("FAIL reloading HEAD diff") return false } doc := diff_document_make(panel.diff_text) defer diff_document_destroy(doc) headers, contexts, removed, added, fillers, misaligned := 0, 0, 0, 0, 0, 0 for row in doc.rows { if row.header { headers += 1 continue } if row.left_kind == ' ' && row.right_kind == ' ' { contexts += 1 if row.left_text != row.right_text do misaligned += 1 } if row.left_kind == '-' do removed += 1 if row.right_kind == '+' do added += 1 if row.left_kind == 0 || row.right_kind == 0 do fillers += 1 } if len(doc.rows) == 0 || headers == 0 || misaligned > 0 { fmt.printf("FAIL diff parse: rows=%d headers=%d misaligned=%d\n", len(doc.rows), headers, misaligned) return false } fmt.printf("ok split view: %d rows (%d hunks, %d ctx, %d del, %d add, %d fill)\n", len(doc.rows), headers, contexts, removed, added, fillers) // A workspace that is a subdirectory of the repository must work too: // that is where root-relative --name-status paths bite. subdir := fmt.tprintf("%s/client", workspace) if os.is_dir(subdir) { git_panel_request_log(&panel, subdir) if !wait_for_load(&panel) || len(panel.commits) == 0 { fmt.println("FAIL subdir workspace: log") return false } git_panel_request_show(&panel, subdir, panel.commits[0].hash) if !wait_for_load(&panel) || len(panel.files) == 0 { fmt.println("FAIL subdir workspace: files") return false } git_panel_request_diff(&panel, subdir, panel.detail_hash, panel.files[0].path) if !wait_for_load(&panel) || !strings.has_prefix(panel.diff_text, "diff --git") { fmt.printf("FAIL subdir workspace: diff -> %q\n", panel.diff_text[:min_int(len(panel.diff_text), 60)]) return false } fmt.printf("ok subdir workspace diff: %d bytes for %s\n", len(panel.diff_text), panel.diff_path) } fmt.println("git selftest: PASS") return true }