package cli import "base:runtime" import "core:io" import "core:os" import "core:log" import "core:fmt" import "core:mem" import "core:slice" import "core:bytes" import "core:strings" import "core:unicode" import "core:sys/posix" import "core:path/filepath" import "core:unicode/utf8" import "../config" History :: struct { data: [dynamic][]rune, file: string, maybe_idx: Maybe(i32), maybe_cached_input: Maybe([]rune), } Input :: struct { buffer: [dynamic]rune, prompt_size: i32, cursor_offset: i32, } Term :: struct { orig_mode: posix.termios, input: Input, history: History, } term := Term{} fill_history_cache :: proc () { USER_PERMISSIONS :: 0o600 if !os.exists(term.history.file) { handle, ferr := os.open(term.history.file, os.O_CREATE | os.O_RDWR, USER_PERMISSIONS) log.assertf(ferr == nil, "Failed to create history file") os.close(handle) } history_content, err := os.read_entire_file_from_filename_or_err(term.history.file, context.allocator) log.assertf(err == nil, "Failed to read history file") defer delete(history_content) history_content_it := string(history_content) for line in strings.split_lines_iterator(&history_content_it) { append(&term.history.data, utf8.string_to_runes(line)) } } init_cli :: proc () { mem_err: mem.Allocator_Error term.input.buffer, mem_err = make([dynamic]rune, context.allocator) log.assertf(mem_err == nil, "Memory allocation failed") term.history.data, mem_err = make([dynamic][]rune, context.allocator) log.assertf(mem_err == nil, "Memory allocation failed") home_path := string(posix.getenv("HOME")) log.assertf(home_path != "", "Home path not found") term.history.file = filepath.join({home_path, config.HISTORY_FILE}, context.allocator) log.assertf(mem_err == nil, "Memory allocation failed") fill_history_cache() } deinit_cli :: proc () { delete(term.input.buffer) delete(term.history.data) delete(term.history.file) } disable_raw_mode :: proc "c" () { posix.tcsetattr(posix.STDIN_FILENO, .TCSANOW, &term.orig_mode) } enable_raw_mode :: proc() { status := posix.tcgetattr(posix.STDIN_FILENO, &term.orig_mode) log.assertf(status == .OK, "Couldn't enable terminal in raw mode") posix.atexit(disable_raw_mode) raw := term.orig_mode raw.c_lflag -= {.ECHO, .ICANON} status = posix.tcsetattr(posix.STDIN_FILENO, .TCSANOW, &raw) log.assertf(status == .OK, "Couldn't enable terminal in raw mode") } get_maybe_git_prompt :: proc(dir: string) -> Maybe(string) { GIT_FOLDER :: ".git" git_path := filepath.join({dir, GIT_FOLDER}, context.temp_allocator) uses_git := os.exists(git_path) if !uses_git do return nil HEAD_FILE_NAME :: "HEAD" head_file_path := filepath.join({git_path, HEAD_FILE_NAME}, context.temp_allocator) head_data, ferr := os.read_entire_file_or_err(head_file_path, context.temp_allocator) if ferr != nil do return nil // e.g. ref: refs/heads/main, last part indicates branch ref_parts, merr := strings.split(string(head_data), "/", context.temp_allocator) log.assertf(merr == nil, "Memory allocation failed") branch: string ok: bool is_raw_hash := len(ref_parts) == 1 if is_raw_hash { HASH_MAX_LEN :: 9 branch, ok = strings.substring(string(head_data), 0, HASH_MAX_LEN) if !ok do return nil } else { branch = strings.trim_right_space(ref_parts[len(ref_parts) - 1]) } // todo: add if branch has changes or not prompt_part: string prompt_part, merr = strings.concatenate({"‹", branch, "›"}, context.temp_allocator) log.assertf(merr == nil, "Memory allocation failed") return prompt_part } @(private) get_prompt_prefix :: proc() -> string { dir := os.get_current_directory(context.temp_allocator) home := string(posix.getenv("HOME")) prompt_dir: string if strings.contains(dir, home) { // Bug: might replace if later directories mirror the home path prompt_dir, _ = strings.replace(dir, home, "~", 1, context.temp_allocator) } else { prompt_dir = dir } uid := posix.getuid() pw := posix.getpwuid(uid) DEFAULT_USERNAME :: "skal" user: string if pw == nil { user = DEFAULT_USERNAME } else { user = string(pw.pw_name) } git_prompt := get_maybe_git_prompt(dir).? or_else "" FIRST_DETAIL :: " :: " SPACE :: " " SECOND_DETAIL :: " » " prompt_parts := []string { config.USER_COLOR, user, config.DETAILS_COLOR, FIRST_DETAIL, config.DIR_COLOR, prompt_dir, SPACE, config.GIT_COLOR, git_prompt, config.DETAILS_COLOR, SECOND_DETAIL, config.BASE_COLOR } prompt, err := strings.concatenate(prompt_parts[:], context.temp_allocator) log.assertf(err == nil, "Memory allocation failed") raw_prompt := []string { user, FIRST_DETAIL, prompt_dir, SPACE, git_prompt, SECOND_DETAIL, } raw_prompt_str, _ := strings.concatenate(raw_prompt[:], context.temp_allocator) term.input.prompt_size = i32(len(utf8.string_to_runes(raw_prompt_str, context.temp_allocator))) return prompt } @(private) handle_esc_seq :: proc(in_stream: ^io.Stream) { NAV_SEQ_START :: '[' next_rn, _, err := io.read_rune(in_stream^) log.assertf(err == nil, "Couldn't read from stdin") if next_rn == NAV_SEQ_START do handle_nav(in_stream) } @(private) handle_nav :: proc(in_stream: ^io.Stream) { UP :: 'A' DOWN :: 'B' RIGHT :: 'C' LEFT :: 'D' rn, _, err := io.read_rune(in_stream^) log.assertf(err == nil, "Couldn't read from stdin") switch rn { case UP: cur_history_idx, scrolling_history := term.history.maybe_idx.? if !scrolling_history { term.history.maybe_cached_input = slice.clone(term.input.buffer[:], context.temp_allocator) } else if cur_history_idx == 0 { return } history_idx := term.history.maybe_idx.? or_else i32(len(term.history.data)) history_idx -= 1 term.history.maybe_idx = history_idx new_input := term.history.data[history_idx] replace_input(new_input) case DOWN: history_idx, scrolling_history := term.history.maybe_idx.? if !scrolling_history do return history_idx += 1 history_len := i32(len(term.history.data)) new_input: []rune if history_idx >= history_len { term.history.maybe_idx = nil history_idx = history_len ok: bool new_input, ok = term.history.maybe_cached_input.?; assert(ok) term.history.maybe_cached_input = nil } else { term.history.maybe_idx = history_idx new_input = term.history.data[history_idx] } replace_input(new_input) case RIGHT: if term.input.cursor_offset > 0 { fmt.print("\x1b[C") term.input.cursor_offset -= 1 } case LEFT: if term.input.cursor_offset < i32(len(term.input.buffer)) { fmt.print("\x1b[D") term.input.cursor_offset += 1 } } } @(private) write_rune :: proc(rn: rune) { idx_to_inject := i32(len(term.input.buffer)) - term.input.cursor_offset inject_at(&term.input.buffer, idx_to_inject, rn) CLEAR_ALL :: "\r\x1b[2K" fmt.print(CLEAR_ALL) print_prompt() fmt.print(utf8.runes_to_string(term.input.buffer[:], context.temp_allocator)) } @(private) replace_input :: proc(rns: []rune) { clear_input() CLEAR_ALL :: "\r\x1b[2K" fmt.print(CLEAR_ALL) print_prompt() append(&term.input.buffer, ..rns[:]) fmt.print(utf8.runes_to_string(term.input.buffer[:], context.temp_allocator)) } @(private) remove_rune_at_cursor :: proc() { buffer_len := i32(len(term.input.buffer)) idx_to_remove := buffer_len - 1 - term.input.cursor_offset if idx_to_remove < 0 do return LEFT :: "\x1b[C" for _ in 0.. Maybe(string) { enable_raw_mode() clear_input() in_stream := os.stream_from_handle(os.stdin) for { rn, _, err := io.read_rune(in_stream) log.assertf(err == nil, "Couldn't read from stdin") switch rn { case '\n': fmt.println() if len(term.input.buffer) == 0 do return "" term.input.cursor_offset = 0 append_history_file(term.input.buffer[:]) input := utf8.runes_to_string(term.input.buffer[:], context.temp_allocator) return input case '\f': return "clear" // This is a terminal history wipe, usually isn't case '\t': case '\u007f': remove_rune_at_cursor() case '\x1b': handle_esc_seq(&in_stream) case utf8.RUNE_EOF: fallthrough case '\x04': return nil case: // Bug: if user moved to earlier character write_rune(rn) } } }