This commit is contained in:
pavel 2026-07-08 10:28:32 +02:00
commit 5b65c4c0e4
11 changed files with 4237 additions and 297 deletions

592
client/odin/git_panel.odin Normal file
View file

@ -0,0 +1,592 @@
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 "old<TAB>new"; 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: "<basename> @ <hash>".
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
}