471 lines
17 KiB
Odin
471 lines
17 KiB
Odin
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
|
|
}
|