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

@ -36,21 +36,27 @@ odin run client/odin -- 49321 /path/to/workspace
The client sends `ping` and `workspace/open` requests and prints daemon responses.
Run the SDL editor window:
Run the SDL editor window (the default mode):
```sh
odin run client/odin -- --sdl /path/to/workspace /path/to/file.kt
odin run client/odin
```
The SDL mode starts the daemon automatically when no port is passed. You can still connect to an already-running daemon by passing its port before the file path:
Auto-started daemons are restarted automatically if the process exits or the TCP connection drops. Explicit-port mode does not restart external daemons.
With no arguments the editor reopens the last workspace, falling back to the current directory on first launch. Workspace, daemon port, and file are all optional and can be passed in any order — a directory is the workspace, a number is a daemon port, anything else is a file to open:
```sh
odin run client/odin -- --sdl /path/to/workspace <port> /path/to/file.kt
odin run client/odin -- /path/to/workspace /path/to/file.kt
```
The file path is optional. If no file is passed, the editor restores the last open files, active tab, and cursor positions for the workspace. If there is no saved state, it opens the current prototype default file under the workspace.
The editor starts the daemon automatically when no port is passed. You can still connect to an already-running daemon by passing its port:
```sh
odin run client/odin -- /path/to/workspace <port> /path/to/file.kt
```
Auto-started daemons are restarted automatically if the process exits or the TCP connection drops. Explicit-port mode does not restart external daemons. The `--sdl` flag is still accepted for compatibility. Note that a bare numeric first argument (`odin run client/odin -- <port>`) selects the smoke-test mode above, so pass a workspace too when connecting the editor to an external daemon.
If no file is passed, the editor restores the last open files, active tab, and cursor positions for the workspace. If there is no saved state, it opens the current prototype default file under the workspace.
SDL mode shows a simple workspace file tree in the left sidebar. Click a file to open or focus it.

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)}}
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)
SDL.DrawGPUPrimitives(pass, u32(vertex_count), 1, 0, 0)
}
if text_vertex_count > 0 {
uniforms := GPU_Uniforms{viewport = {f32(gpu.width), f32(gpu.height)}}
case .Text:
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)
}
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" {
if len(args) >= 3 && args[1] == "--git-status-selftest" {
if !git_status_selftest(args[2]) {
os.exit(1)
}
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
}
}
// 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 := ""
if len(args) >= 4 {
parsed_port, parsed_ok := strconv.parse_int(args[3])
if parsed_ok {
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 {
file = args[3]
fmt.println("usage: odin run client/odin -- [workspace] [port] [file]")
fmt.println(" or: odin run client/odin -- <port> [workspace] (smoke test)")
return
}
}
if len(args) >= 5 {
file = args[4]
}
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_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

View file

@ -102,6 +102,7 @@ private data class GradleTaskInfo(
val path: String,
val name: String,
val description: String?,
val group: String?,
)
private class ClientSession(
@ -601,6 +602,7 @@ private fun collectTasks(project: GradleProject): List<GradleTaskInfo> {
path = task.path,
name = task.name,
description = task.description,
group = runCatching { task.group }.getOrNull(),
)
}
current.children.forEach(::visit)
@ -1107,6 +1109,7 @@ private fun tasksJson(tasks: List<GradleTaskInfo>): JsonElement = buildJsonArray
put("path", task.path)
put("name", task.name)
put("description", task.description)
put("group", task.group)
})
}
}