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

View file

@ -111,16 +111,49 @@ daemon_start_process :: proc(workspace: string) -> (Daemon_Process, int, bool) {
return child, port, true
}
// The daemon is its own Gradle project; the workspace is sent later over the
// protocol. Locate the daemon project relative to the environment or the
// editor executable so any folder can be opened as a workspace.
daemon_project_dir :: proc() -> (string, bool) {
env := os.get_env("NATIVE_EDITOR_DAEMON_DIR", context.temp_allocator)
if len(env) > 0 && os.is_dir(env) {
return env, true
}
if os.is_file("daemon/build.gradle.kts") {
return "daemon", true
}
exe, exe_err := os.get_executable_path(context.temp_allocator)
if exe_err == nil {
dir := exe
for _ in 0 ..< 5 {
parent, _ := os.split_path(dir)
if len(parent) == 0 || parent == dir do break
dir = strings.trim_suffix(parent, "/")
if len(dir) == 0 do break
if os.is_file(fmt.tprintf("%s/daemon/build.gradle.kts", dir)) {
return fmt.tprintf("%s/daemon", dir), true
}
}
}
return "", false
}
daemon_start_process_begin :: proc(workspace: string) -> (Daemon_Process, bool) {
child := Daemon_Process{}
project_dir, found := daemon_project_dir()
if !found {
fmt.println("daemon project not found; set NATIVE_EDITOR_DAEMON_DIR to the daemon directory")
return child, false
}
stdout_r, stdout_w, pipe_err := os.pipe()
if pipe_err != nil {
fmt.println("daemon pipe failed:", pipe_err)
return child, false
}
command := []string{"gradle", "-p", workspace, "run", "--quiet"}
command := []string{"gradle", "-p", project_dir, "run", "--quiet"}
process, start_err := os.process_start(os.Process_Desc{
command = command,
stdout = stdout_w,
@ -387,7 +420,7 @@ daemon_send_gradle_run :: proc(client: ^Daemon_Client, task: string) -> int {
daemon_sync_active_buffer :: proc(client: ^Daemon_Client, editor: ^Editor, open: bool) {
active := editor_active_buffer(editor)
if active == nil do return
if active == nil || active.scratch do return
text := editor_active_text(editor)
defer delete(text)

267
client/odin/diff_view.odin Normal file
View file

@ -0,0 +1,267 @@
package main
import "core:strings"
// Side-by-side diff viewer. A unified diff is parsed into aligned rows:
// context lines appear on both sides, removed/added runs are paired up
// line-by-line (with blank filler cells for the unmatched remainder), and
// hunk headers become full-width separator rows. A buffer whose `diff`
// field is set renders this view instead of its text.
Diff_Row :: struct {
header: bool,
header_text: string,
left_number: int, // 0 = no line on this side
right_number: int,
left_text: string,
right_text: string,
left_kind: u8, // '-' removed, ' ' context, 0 filler
right_kind: u8, // '+' added, ' ' context, 0 filler
}
Diff_Document :: struct {
rows: [dynamic]Diff_Row,
scroll: int,
}
diff_document_destroy :: proc(doc: ^Diff_Document) {
for row in doc.rows {
delete(row.header_text)
delete(row.left_text)
delete(row.right_text)
}
delete(doc.rows)
free(doc)
}
diff_parse_leading_int :: proc(s: string) -> int {
value := 0
for b in transmute([]u8)s {
if b < '0' || b > '9' do break
value = value * 10 + int(b - '0')
}
return value
}
// "@@ -12,5 +14,6 @@ ..." -> (12, 14)
diff_parse_hunk_header :: proc(line: string) -> (int, int) {
left, right := 1, 1
if minus := strings.index_byte(line, '-'); minus >= 0 {
left = max_int(diff_parse_leading_int(line[minus + 1:]), 1)
}
if plus := strings.index_byte(line, '+'); plus >= 0 {
right = max_int(diff_parse_leading_int(line[plus + 1:]), 1)
}
return left, right
}
// Pairs the collected removed/added runs into aligned rows.
diff_flush_changes :: proc(doc: ^Diff_Document, removed, added: ^[dynamic]string, left_num, right_num: ^int) {
count := max_int(len(removed), len(added))
for i in 0 ..< count {
row := Diff_Row{}
if i < len(removed) {
row.left_text = strings.clone(removed[i])
row.left_kind = '-'
row.left_number = left_num^
left_num^ += 1
}
if i < len(added) {
row.right_text = strings.clone(added[i])
row.right_kind = '+'
row.right_number = right_num^
right_num^ += 1
}
append(&doc.rows, row)
}
clear(removed)
clear(added)
}
diff_document_make :: proc(text: string) -> ^Diff_Document {
doc := new(Diff_Document)
removed := make([dynamic]string, context.temp_allocator)
added := make([dynamic]string, context.temp_allocator)
left_num, right_num := 1, 1
in_hunk := false
lines, _ := strings.split_lines(text, context.temp_allocator)
for raw_line in lines {
line := strings.trim_right(raw_line, "\r")
if strings.has_prefix(line, "@@") {
diff_flush_changes(doc, &removed, &added, &left_num, &right_num)
left_num, right_num = diff_parse_hunk_header(line)
in_hunk = true
append(&doc.rows, Diff_Row{header = true, header_text = strings.clone(line)})
continue
}
if strings.has_prefix(line, "diff --git") {
diff_flush_changes(doc, &removed, &added, &left_num, &right_num)
in_hunk = false
continue
}
if !in_hunk {
// File headers (index, ---, +++, mode changes, binary notes).
if len(strings.trim_space(line)) > 0 && len(doc.rows) == 0 && !strings.has_prefix(line, "index ") &&
!strings.has_prefix(line, "---") && !strings.has_prefix(line, "+++") &&
!strings.has_prefix(line, "old mode") && !strings.has_prefix(line, "new mode") &&
!strings.has_prefix(line, "new file") && !strings.has_prefix(line, "deleted file") &&
!strings.has_prefix(line, "similarity") && !strings.has_prefix(line, "rename") &&
!strings.has_prefix(line, "copy") {
// Notes like "Binary files differ" get a visible header row.
append(&doc.rows, Diff_Row{header = true, header_text = strings.clone(line)})
}
continue
}
if len(line) == 0 do continue
switch line[0] {
case ' ':
diff_flush_changes(doc, &removed, &added, &left_num, &right_num)
content := line[1:]
append(&doc.rows, Diff_Row{
left_number = left_num,
right_number = right_num,
left_text = strings.clone(content),
right_text = strings.clone(content),
left_kind = ' ',
right_kind = ' ',
})
left_num += 1
right_num += 1
case '-':
append(&removed, line[1:])
case '+':
append(&added, line[1:])
case '\\':
// "\ No newline at end of file"
}
}
diff_flush_changes(doc, &removed, &added, &left_num, &right_num)
return doc
}
// Opens (or refreshes) a diff tab for the given unified diff text.
editor_open_diff :: proc(editor: ^Editor, name, diff_text: string) {
doc := diff_document_make(diff_text)
for &buffer, index in editor.buffers {
if buffer.scratch && buffer.path == name {
editor_buffer_destroy(&buffer)
buffer = diff_editor_buffer(name, doc)
editor.active = index
return
}
}
append(&editor.buffers, diff_editor_buffer(name, doc))
editor.active = len(editor.buffers) - 1
}
diff_editor_buffer :: proc(name: string, doc: ^Diff_Document) -> Editor_Buffer {
return Editor_Buffer{
path = strings.clone(name),
daemon_path = strings.clone(""),
buffer = buffer_make(""),
scratch = true,
diff = doc,
}
}
diff_view_scroll :: proc(active: ^Editor_Buffer, view: ^SDL_View, delta: int) {
doc := active.diff
visible := visible_editor_lines(view)
doc.scroll = clamp_int(doc.scroll + delta, 0, max_int(len(doc.rows) - visible, 0))
}
// ---------------------------------------------------------------------------
// Rendering
DIFF_REMOVED_BG :: [3]u8{58, 33, 36}
DIFF_ADDED_BG :: [3]u8{34, 51, 38}
DIFF_FILLER_BG :: [3]u8{33, 34, 39}
DIFF_HEADER_BG :: [3]u8{39, 41, 49}
render_diff_view_gpu :: proc(gpu: ^GPU_Renderer, view: ^SDL_View, active: ^Editor_Buffer, tasks_panel: ^Gradle_Tasks_Panel) {
doc := active.diff
sidebar_width := left_sidebar_width(view)
right_limit := content_right_edge(gpu.width) - 8
if tasks_panel != nil && tasks_panel.open {
right_limit = content_right_edge(gpu.width) - right_sidebar_width_view(view, gpu.width) - 8
}
area_x := f32(sidebar_width)
area_width := f32(right_limit) - area_x
area_bottom := f32(editor_content_bottom(view))
half := area_width * 0.5
advance := max_f32(gpu.font_advance, 1)
gutter_width := 5 * advance + 10
columns := max_int(int((half - gutter_width - 16) / advance), 4)
visible := visible_editor_lines(view)
doc.scroll = clamp_int(doc.scroll, 0, max_int(len(doc.rows) - visible, 0))
y := f32(SDL_EDITOR_TEXT_Y)
for offset in 0 ..< visible {
index := doc.scroll + offset
if index >= len(doc.rows) do break
row := doc.rows[index]
if row.header {
gpu_rect(gpu, area_x, y - 3, area_width, SDL_LINE_HEIGHT + 1, DIFF_HEADER_BG[0], DIFF_HEADER_BG[1], DIFF_HEADER_BG[2], 255)
gpu_text_limited(gpu, area_x + 12, y, row.header_text, max_int(int((area_width - 24) / advance), 4), 150, 160, 180, 255)
} else {
render_diff_side_gpu(gpu, area_x, y, half, gutter_width, columns, row.left_number, row.left_text, row.left_kind)
render_diff_side_gpu(gpu, area_x + half + 1, y, half - 1, gutter_width, columns, row.right_number, row.right_text, row.right_kind)
}
y += SDL_LINE_HEIGHT
}
// Center divider on top of the row backgrounds.
gpu_rect(gpu, area_x + half, SDL_EDITOR_TOP, 1, area_bottom - SDL_EDITOR_TOP, 52, 54, 62, 255)
}
render_diff_side_gpu :: proc(gpu: ^GPU_Renderer, x, y, width, gutter_width: f32, columns, number: int, text: string, kind: u8) {
background: [3]u8
has_background := true
switch kind {
case '-':
background = DIFF_REMOVED_BG
case '+':
background = DIFF_ADDED_BG
case 0:
background = DIFF_FILLER_BG
case:
has_background = false
}
if has_background {
gpu_rect(gpu, x, y - 3, width, SDL_LINE_HEIGHT + 1, background[0], background[1], background[2], 255)
}
if kind == 0 do return
if number > 0 {
gpu_text(gpu, x + 6, y, fmt_diff_number(number), 116, 120, 128, 255)
}
marker_color := [3]u8{210, 214, 222}
if kind == '-' do marker_color = {235, 140, 145}
if kind == '+' do marker_color = {160, 210, 140}
gpu_text_limited(gpu, x + gutter_width, y, text, columns, marker_color[0], marker_color[1], marker_color[2], 255)
}
fmt_diff_number :: proc(number: int) -> string {
buf: [8]u8
value := number
index := len(buf)
for value > 0 && index > 0 {
index -= 1
buf[index] = '0' + u8(value % 10)
value /= 10
}
out := make([]u8, 4, context.temp_allocator)
for i in 0 ..< 4 {
out[i] = ' '
}
digits := len(buf) - index
copy(out[max_int(4 - digits, 0):], buf[index:])
return string(out)
}

View file

@ -14,6 +14,8 @@ Diagnostic :: struct {
Editor_Buffer :: struct {
path: string,
daemon_path: string,
scratch: bool, // in-memory tab (e.g. a git diff); never saved or synced
diff: ^Diff_Document, // when set, the tab renders a split diff view
original_storage: []u8,
buffer: Buffer,
cursor: Cursor,
@ -60,6 +62,36 @@ editor_open_or_focus_file :: proc(editor: ^Editor, path: string) -> bool {
return editor_open_file(editor, path)
}
// Opens (or replaces) an in-memory tab that is not backed by a file.
editor_open_scratch :: proc(editor: ^Editor, name, contents: string) {
data := make([]u8, len(contents))
copy(data, transmute([]u8)contents)
for &buffer, index in editor.buffers {
if buffer.scratch && buffer.path == name {
editor_buffer_destroy(&buffer)
buffer = Editor_Buffer{
path = strings.clone(name),
daemon_path = strings.clone(""),
original_storage = data,
buffer = buffer_make(string(data)),
scratch = true,
}
editor.active = index
return
}
}
append(&editor.buffers, Editor_Buffer{
path = strings.clone(name),
daemon_path = strings.clone(""),
original_storage = data,
buffer = buffer_make(string(data)),
scratch = true,
})
editor.active = len(editor.buffers) - 1
}
editor_replace_active_file :: proc(editor: ^Editor, path: string) -> bool {
active := editor_active_buffer(editor)
if active == nil do return false
@ -257,7 +289,7 @@ editor_insert_newline_auto_indent :: proc(active: ^Editor_Buffer) {
editor_save_active :: proc(editor: ^Editor) -> bool {
active := editor_active_buffer(editor)
if active == nil do return false
if active == nil || active.scratch do return false
text := buffer_bytes(&active.buffer)
defer delete(text)
@ -276,7 +308,7 @@ editor_save_active :: proc(editor: ^Editor) -> bool {
editor_save_all :: proc(editor: ^Editor) -> bool {
ok := true
for &buffer in editor.buffers {
if !buffer.dirty do continue
if !buffer.dirty || buffer.scratch do continue
text := buffer_bytes(&buffer.buffer)
err := os.write_entire_file(buffer.path, text[:])
@ -432,6 +464,10 @@ editor_destroy :: proc(editor: ^Editor) {
}
editor_buffer_destroy :: proc(editor_buffer: ^Editor_Buffer) {
if editor_buffer.diff != nil {
diff_document_destroy(editor_buffer.diff)
editor_buffer.diff = nil
}
delete(editor_buffer.path)
delete(editor_buffer.daemon_path)
buffer_destroy(&editor_buffer.buffer)

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
}

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
}

View file

@ -37,16 +37,32 @@ GPU_Renderer :: struct {
text_transfer_buffer: ^SDL.GPUTransferBuffer,
font_texture: ^SDL.GPUTexture,
font_sampler: ^SDL.GPUSampler,
font_chars: [95]stbtt.bakedchar,
font_chars: [95]stbtt.packedchar,
font_extra: map[rune]stbtt.packedchar,
font_advance: f32,
vertices: [dynamic]GPU_Vertex,
text_vertices: [dynamic]GPU_Text_Vertex,
batches: [dynamic]GPU_Batch,
max_vertices: int,
max_text_vertices: int,
width: int,
height: int,
}
// Draw batches preserve submission order across the two pipelines, so text
// queued before an opaque rect stays underneath it. Consecutive quads of the
// same kind merge into one draw call.
GPU_Batch_Kind :: enum {
Rect,
Text,
}
GPU_Batch :: struct {
kind: GPU_Batch_Kind,
first: int,
count: int,
}
// Compiled SPIR-V is embedded at build time so the binary never depends on
// finding shader files at runtime. Regenerate with scripts/compile-shaders.sh
// after editing the GLSL sources, then rebuild.
@ -57,7 +73,7 @@ GPU_TEXT_FRAG_SPV :: #load("shaders/compiled/text.frag.spv")
GPU_MAX_VERTICES :: 240000
GPU_MAX_TEXT_VERTICES :: 120000
GPU_FONT_ATLAS_SIZE :: 512
GPU_FONT_ATLAS_SIZE :: 1024
GPU_FONT_PIXEL_HEIGHT :: 15.0
GPU_FONT_BASELINE_OFFSET :: 11.0
GPU_FONT_CELL_PADDING :: 1.0
@ -226,6 +242,8 @@ gpu_renderer_destroy :: proc(gpu: ^GPU_Renderer) {
SDL.DestroyGPUDevice(gpu.device)
}
delete(gpu.vertices)
delete(gpu.batches)
delete(gpu.font_extra)
delete(gpu.text_vertices)
gpu^ = {}
}
@ -249,6 +267,7 @@ gpu_create_shader :: proc(device: ^SDL.GPUDevice, name: string, code: []u8, stag
gpu_begin :: proc(gpu: ^GPU_Renderer) {
clear(&gpu.vertices)
clear(&gpu.text_vertices)
clear(&gpu.batches)
w, h: i32
if SDL.GetWindowSize(gpu.window, &w, &h) {
gpu.width = int(w)
@ -323,6 +342,35 @@ gpu_text_limited :: proc(gpu: ^GPU_Renderer, x, y: f32, text: string, max_chars:
}
}
// Draws a single glyph, returning false when the font has no glyph so the
// caller can substitute an ASCII approximation.
gpu_rune :: proc(gpu: ^GPU_Renderer, x, y: f32, r: rune, red, green, blue, a: u8) -> bool {
if r >= 32 && r < 127 {
buf := [1]u8{u8(r)}
gpu_text(gpu, x, y, string(buf[:]), red, green, blue, a)
return true
}
glyph, ok := gpu.font_extra[r]
if !ok do return false
glyph_w := f32(glyph.x1 - glyph.x0)
glyph_h := f32(glyph.y1 - glyph.y0)
if glyph_w <= 0 || glyph_h <= 0 do return true
xpos := round_f32(x)
ypos := y + GPU_FONT_BASELINE_OFFSET
x0 := round_f32(xpos + glyph.xoff)
y0 := round_f32(ypos + glyph.yoff)
x1 := x0 + glyph_w
y1 := y0 + glyph_h
s0 := f32(glyph.x0) / GPU_FONT_ATLAS_SIZE
t0 := f32(glyph.y0) / GPU_FONT_ATLAS_SIZE
s1 := f32(glyph.x1) / GPU_FONT_ATLAS_SIZE
t1 := f32(glyph.y1) / GPU_FONT_ATLAS_SIZE
gpu_push_text_quad(gpu, x0, y0, x1, y1, s0, t0, s1, t1, color_f32(red, green, blue, a))
return true
}
gpu_text_width :: proc(text: string) -> int {
if len(text) == 0 do return 0
width: f32 = 0
@ -415,21 +463,27 @@ gpu_present :: proc(gpu: ^GPU_Renderer) {
store_op = .STORE,
}
pass := SDL.BeginGPURenderPass(command, &target, 1, nil)
if vertex_count > 0 {
uniforms := GPU_Uniforms{viewport = {f32(gpu.width), f32(gpu.height)}}
SDL.PushGPUVertexUniformData(command, 0, &uniforms, size_of(uniforms))
SDL.BindGPUGraphicsPipeline(pass, gpu.pipeline)
SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.vertex_buffer}), 1)
SDL.DrawGPUPrimitives(pass, u32(vertex_count), 1, 0, 0)
}
if text_vertex_count > 0 {
uniforms := GPU_Uniforms{viewport = {f32(gpu.width), f32(gpu.height)}}
binding := SDL.GPUTextureSamplerBinding{texture = gpu.font_texture, sampler = gpu.font_sampler}
SDL.PushGPUVertexUniformData(command, 0, &uniforms, size_of(uniforms))
SDL.BindGPUGraphicsPipeline(pass, gpu.text_pipeline)
SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.text_vertex_buffer}), 1)
SDL.BindGPUFragmentSamplers(pass, 0, &binding, 1)
SDL.DrawGPUPrimitives(pass, u32(text_vertex_count), 1, 0, 0)
uniforms := GPU_Uniforms{viewport = {f32(gpu.width), f32(gpu.height)}}
bound := false
bound_kind := GPU_Batch_Kind.Rect
for batch in gpu.batches {
if batch.count == 0 do continue
if !bound || bound_kind != batch.kind {
SDL.PushGPUVertexUniformData(command, 0, &uniforms, size_of(uniforms))
switch batch.kind {
case .Rect:
SDL.BindGPUGraphicsPipeline(pass, gpu.pipeline)
SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.vertex_buffer}), 1)
case .Text:
binding := SDL.GPUTextureSamplerBinding{texture = gpu.font_texture, sampler = gpu.font_sampler}
SDL.BindGPUGraphicsPipeline(pass, gpu.text_pipeline)
SDL.BindGPUVertexBuffers(pass, 0, &(SDL.GPUBufferBinding{buffer = gpu.text_vertex_buffer}), 1)
SDL.BindGPUFragmentSamplers(pass, 0, &binding, 1)
}
bound = true
bound_kind = batch.kind
}
SDL.DrawGPUPrimitives(pass, u32(batch.count), 1, u32(batch.first), 0)
}
SDL.EndGPURenderPass(pass)
_ = SDL.SubmitGPUCommandBuffer(command)
@ -443,11 +497,68 @@ gpu_create_font_atlas :: proc(gpu: ^GPU_Renderer) -> bool {
atlas := make([]u8, atlas_size)
defer delete(atlas)
baked := stbtt.BakeFontBitmap(raw_data(font_bytes), 0, GPU_FONT_PIXEL_HEIGHT, raw_data(atlas), GPU_FONT_ATLAS_SIZE, GPU_FONT_ATLAS_SIZE, 32, len(gpu.font_chars), &gpu.font_chars[0])
if baked <= 0 {
fmt.println("font bake failed:", font_path)
// Extra ranges cover what terminal programs commonly draw: Latin-1,
// general punctuation, arrows, box drawing/blocks/geometric shapes,
// check marks and braille (spinners).
Extra_Range :: struct {
first: int,
count: int,
}
extra_ranges := [?]Extra_Range{
{0x00A1, 95},
{0x2010, 24},
{0x2190, 4},
{0x2500, 256},
{0x2713, 6},
{0x2800, 256},
}
total_extra := 0
for r in extra_ranges {
total_extra += r.count
}
extra_chars := make([]stbtt.packedchar, total_extra)
defer delete(extra_chars)
ranges: [1 + len(extra_ranges)]stbtt.pack_range
ranges[0] = stbtt.pack_range{
font_size = GPU_FONT_PIXEL_HEIGHT,
first_unicode_codepoint_in_range = 32,
num_chars = i32(len(gpu.font_chars)),
chardata_for_range = &gpu.font_chars[0],
}
offset := 0
for r, index in extra_ranges {
ranges[index + 1] = stbtt.pack_range{
font_size = GPU_FONT_PIXEL_HEIGHT,
first_unicode_codepoint_in_range = i32(r.first),
num_chars = i32(r.count),
chardata_for_range = &extra_chars[offset],
}
offset += r.count
}
pack: stbtt.pack_context
if stbtt.PackBegin(&pack, raw_data(atlas), GPU_FONT_ATLAS_SIZE, GPU_FONT_ATLAS_SIZE, 0, 1, nil) == 0 {
fmt.println("font pack failed:", font_path)
return false
}
stbtt.PackSetOversampling(&pack, 1, 1)
// Returns 0 when some codepoints are missing from the font; those are
// filtered out below via FindGlyphIndex, so partial packs are fine.
_ = stbtt.PackFontRanges(&pack, raw_data(font_bytes), 0, &ranges[0], i32(len(ranges)))
stbtt.PackEnd(&pack)
font: stbtt.fontinfo
has_font_info := bool(stbtt.InitFont(&font, raw_data(font_bytes), stbtt.GetFontOffsetForIndex(raw_data(font_bytes), 0)))
offset = 0
for r in extra_ranges {
for i in 0 ..< r.count {
code := rune(r.first + i)
if has_font_info && stbtt.FindGlyphIndex(&font, code) == 0 do continue
gpu.font_extra[code] = extra_chars[offset + i]
}
offset += r.count
}
widest_advance: f32 = 0
for char in gpu.font_chars {
glyph_width := char.xoff + f32(char.x1 - char.x0)
@ -518,8 +629,19 @@ gpu_read_font_file :: proc() -> (bytes: []u8, path: string, owned: bool) {
return GPU_EMBEDDED_FONT, "embedded NotoSansMono-Regular.ttf", false
}
gpu_batch_current :: proc(gpu: ^GPU_Renderer, kind: GPU_Batch_Kind, first: int) -> ^GPU_Batch {
if len(gpu.batches) > 0 {
last := &gpu.batches[len(gpu.batches) - 1]
if last.kind == kind do return last
}
append(&gpu.batches, GPU_Batch{kind = kind, first = first})
return &gpu.batches[len(gpu.batches) - 1]
}
gpu_push_quad :: proc(gpu: ^GPU_Renderer, x0, y0, x1, y1: f32, c: [4]f32) {
if len(gpu.vertices) + 6 > gpu.max_vertices do return
batch := gpu_batch_current(gpu, .Rect, len(gpu.vertices))
batch.count += 6
append(&gpu.vertices, GPU_Vertex{{x0, y0}, c})
append(&gpu.vertices, GPU_Vertex{{x1, y0}, c})
append(&gpu.vertices, GPU_Vertex{{x0, y1}, c})
@ -530,6 +652,8 @@ gpu_push_quad :: proc(gpu: ^GPU_Renderer, x0, y0, x1, y1: f32, c: [4]f32) {
gpu_push_text_quad :: proc(gpu: ^GPU_Renderer, x0, y0, x1, y1, s0, t0, s1, t1: f32, c: [4]f32) {
if len(gpu.text_vertices) + 6 > gpu.max_text_vertices do return
batch := gpu_batch_current(gpu, .Text, len(gpu.text_vertices))
batch.count += 6
append(&gpu.text_vertices, GPU_Text_Vertex{{x0, y0}, {s0, t0}, c})
append(&gpu.text_vertices, GPU_Text_Vertex{{x1, y0}, {s1, t0}, c})
append(&gpu.text_vertices, GPU_Text_Vertex{{x0, y1}, {s0, t1}, c})

View file

@ -7,45 +7,69 @@ import "core:strconv"
main :: proc() {
args := os.args
if len(args) < 2 {
fmt.println("usage: odin run client/odin -- <port> [workspace]")
fmt.println(" or: odin run client/odin -- --sdl [workspace] [port] [file]")
if len(args) >= 2 && args[1] == "--terminal-selftest" {
if !terminal_selftest() {
os.exit(1)
}
return
}
if args[1] == "--sdl" {
workspace := "."
if len(args) >= 3 {
workspace = args[2]
if len(args) >= 3 && args[1] == "--git-status-selftest" {
if !git_status_selftest(args[2]) {
os.exit(1)
}
port := 0
file := ""
if len(args) >= 4 {
parsed_port, parsed_ok := strconv.parse_int(args[3])
if parsed_ok {
port = int(parsed_port)
} else {
file = args[3]
return
}
if len(args) >= 2 && args[1] == "--git-selftest" {
workspace := "." if len(args) < 3 else args[2]
if !git_panel_selftest(workspace) {
os.exit(1)
}
return
}
// Legacy smoke-test mode: a bare numeric first argument is a daemon port.
if len(args) >= 2 && args[1] != "--sdl" {
if port, is_port := strconv.parse_int(args[1]); is_port {
workspace := "."
if len(args) >= 3 {
workspace = args[2]
}
run_smoke_client(int(port), workspace)
return
}
if len(args) >= 5 {
file = args[4]
}
// SDL editor mode (default). All arguments are optional and positional in
// any order: a directory is the workspace, a number is a daemon port, and
// anything else is a file to open. `--sdl` is accepted for compatibility.
sdl_args := args[1:]
if len(sdl_args) > 0 && sdl_args[0] == "--sdl" {
sdl_args = sdl_args[1:]
}
workspace := ""
port := 0
file := ""
for arg in sdl_args {
if parsed_port, is_port := strconv.parse_int(arg); is_port && port == 0 {
port = int(parsed_port)
} else if os.is_dir(arg) && len(workspace) == 0 {
workspace = arg
} else if len(file) == 0 {
file = arg
} else {
fmt.println("usage: odin run client/odin -- [workspace] [port] [file]")
fmt.println(" or: odin run client/odin -- <port> [workspace] (smoke test)")
return
}
run_sdl_editor(workspace, port, file)
return
}
port, ok := strconv.parse_int(args[1])
if !ok {
fmt.println("invalid port")
return
}
workspace := "."
if len(args) >= 3 {
workspace = args[2]
}
run_sdl_editor(workspace, port, file)
}
run_smoke_client :: proc(port: int, workspace: string) {
run_buffer_smoke()
editor := run_editor_smoke(workspace)
defer editor_destroy(&editor)

File diff suppressed because it is too large Load diff

1251
client/odin/terminal.odin Normal file

File diff suppressed because it is too large Load diff