bl
This commit is contained in:
parent
41adeeb47f
commit
5b65c4c0e4
11 changed files with 4237 additions and 297 deletions
471
client/odin/git_status.odin
Normal file
471
client/odin/git_status.odin
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue