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

471
client/odin/git_status.odin Normal file
View file

@ -0,0 +1,471 @@
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 <dir>` 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
}