From d4c4ed8468ab975db1f22ff862fcb2eea475ae6e Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Tue, 6 Oct 2026 11:27:20 -0400 Subject: [PATCH] feat: Added `view` command for opening files. --- cli.odin | 19 +++++ cmd_list.odin | 6 +- cmd_list_test.odin | 23 +++++- cmd_view.odin | 157 +++++++++++++++++++++++++++++++++++ cmd_view_test.odin | 158 ++++++++++++++++++++++++++++++++++++ db.odin | 2 +- docs/cli/envr.md | 1 + docs/cli/envr_backup.md | 2 +- docs/cli/envr_check.md | 2 +- docs/cli/envr_completion.md | 4 +- docs/cli/envr_init.md | 2 - docs/cli/envr_list.md | 2 + docs/cli/envr_remove.md | 2 +- docs/cli/envr_restore.md | 2 +- docs/cli/envr_uninstall.md | 2 - docs/cli/envr_view.md | 26 ++++++ gendocs.odin | 4 +- main.odin | 2 + prompt.odin | 67 ++++++++++++++- 19 files changed, 461 insertions(+), 22 deletions(-) create mode 100644 cmd_view.odin create mode 100644 cmd_view_test.odin create mode 100644 docs/cli/envr_view.md diff --git a/cli.odin b/cli.odin index 7c10d14..3506281 100644 --- a/cli.odin +++ b/cli.odin @@ -25,6 +25,7 @@ Flags :: struct { output: Output_Format `args:"short=o" usage:"the format of output data" default:"table" completion:"output"`, color: Color_Mode `usage:"Whether or not to colorize output" default:"auto" completion:"color"`, force: bool `args:"short=f" usage:"Overwrite existing config"`, + stdout: bool `args:"short=p" usage:"Print file contents directly to stdout instead of using a pager"`, } Flag_Type :: enum { @@ -33,6 +34,7 @@ Flag_Type :: enum { Output, Color, Force, + Stdout, } Output_Format :: enum { @@ -114,6 +116,7 @@ key somewhere, otherwise your data could be lost forever.`, name = "list", usage = "envr list", short = "View your tracked files", + long = "Table output is sorted by stored path and numbered from 1. Use 'envr view ' to view the contents of a backup.", flags = GLOBAL_FLAGS + {.Output}, }, { @@ -138,6 +141,21 @@ key somewhere, otherwise your data could be lost forever.`, }, }, }, + { + name = "view", + usage = "envr view [path]", + short = "View a backed-up .env file", + long = "If no path is provided, the user will be asked to select one from a list. You may also pass the number from the \"#\" column of `envr list` instead of the full path. If a partial path is passed, the closest match will be opened.", + flags = GLOBAL_FLAGS + {.Stdout}, + args = { + { + name = "path", + completion = "tracked-paths", + optional = true, + desc = "Path or substring, or number of a backed-up file.", + }, + }, + }, {name = "version", usage = "envr version", short = "Show envr's version", flags = {.Help}}, { name = "edit-config", @@ -485,3 +503,4 @@ delete_command :: proc(cmd: ^Command) { bufio.writer_destroy(cmd.out_buf) free(cmd.out_buf) } + diff --git a/cmd_list.odin b/cmd_list.odin index 2bb9965..f3e62af 100644 --- a/cmd_list.odin +++ b/cmd_list.odin @@ -32,18 +32,19 @@ cmd_list :: proc(cmd: ^Command) { table.aligned_header_of_values( &t, .Center, + colorize(.Table_Heading, "#"), colorize(.Table_Heading, "Directory"), colorize(.Table_Heading, "Path"), ) - for row in rows { + for row, i in rows { dir_str := strings.concatenate( {row.dir, os.Path_Separator_String}, context.temp_allocator, ) filename := filepath.base(row.path) - table.row(&t, dir_str, filename) + table.row(&t, fmt.tprintf("%d", i + 1), dir_str, filename) } table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width) @@ -73,4 +74,3 @@ cmd_list :: proc(cmd: ^Command) { fmt.wprintln(cmd.out, string(data), flush = false) } } - diff --git a/cmd_list_test.odin b/cmd_list_test.odin index f309b56..fdfa7c5 100644 --- a/cmd_list_test.odin +++ b/cmd_list_test.odin @@ -80,9 +80,12 @@ test_cmd_list_output_table :: proc(t: ^testing.T) { db, db_ok := db_open(cfg_path) testing.expect(t, db_ok, "db should open") if !db_ok do return - f := make_test_env_file("/project/.env", "abc123", "SECRET=value") - defer delete(f.remotes) - testing.expect(t, db_insert(&db, f), "insert should succeed") + paths := []string{"/zeta/.env", "/alpha/.env", "/middle/.env"} + for path in paths { + f := make_test_env_file(path, "abc123", "SECRET=value") + defer delete(f.remotes) + testing.expect(t, db_insert(&db, f), "insert should succeed") + } db_close(&db) out_b: strings.Builder @@ -106,6 +109,20 @@ test_cmd_list_output_table :: proc(t: ^testing.T) { output := strings.to_string(out_b) testing.expect(t, strings.contains(output, "│"), "table output should contain border chars") + testing.expect(t, strings.contains(output, "#"), "table output should show a number header") + alpha := strings.index(output, "/alpha/") + middle := strings.index(output, "/middle/") + zeta := strings.index(output, "/zeta/") + testing.expect( + t, + alpha >= 0 && alpha < middle && middle < zeta, + "table should be sorted by full stored path", + ) + testing.expect( + t, + strings.contains(output, "│ 1 │ /alpha/"), + "first path should be numbered 1", + ) testing.expect( t, strings.contains(output, "Directory"), diff --git a/cmd_view.odin b/cmd_view.odin new file mode 100644 index 0000000..3905bcf --- /dev/null +++ b/cmd_view.odin @@ -0,0 +1,157 @@ +package main + +import "core:fmt" +import "core:io" +import "core:os" +import "core:path/filepath" +import "core:strconv" +import "core:strings" +import "core:terminal" + +cmd_view :: proc(cmd: ^Command) -> bool { + if len(cmd.args) > 1 { + print_command_help(cmd) + return false + } + + path := len(cmd.args) == 1 ? cmd.args[0] : "" + + db := db_open(cmd.flags.config_file) or_return + defer db_close(&db) + + files := db_list(&db) or_return + paths := make([]string, len(files), context.temp_allocator) + for file, i in files do paths[i] = file.path + resolved_path := choose_view_path(path, paths, cmd.err) or_return + + file := db_fetch(&db, resolved_path) or_return + + if cmd.flags.stdout || !terminal.is_terminal(os.stdout) { + fmt.wprintf(cmd.out, "%s", file.contents, flush = false) + return true + } + + page_file_contents(file.contents, cmd.err) + return true +} + +choose_view_path :: proc(path: string, paths: []string, err_out: io.Writer) -> (string, bool) { + if len(strings.trim_space(path)) > 0 { + resolved, _, _, is_remote, path_ok := normalize_remote_identity(path) + if is_remote && !path_ok do return "", false + if !is_remote { + abs_path, abs_err := filepath.abs(path, context.temp_allocator) + if abs_err == nil do resolved = abs_path + } + for stored in paths { + if stored == resolved do return stored, true + } + if number, number_ok := strconv.parse_int(path, 10); + number_ok && number > 0 && number <= len(paths) { + return paths[number - 1], true + } + } + + matches := matching_view_paths(path, paths) + if len(matches) == 0 { + fmt.wprintf(err_out, "No backed-up file matches: %s\n", path, flush = false) + return "", false + } + if len(path) > 0 && len(matches) == 1 { + return matches[0], true + } + if !terminal.is_terminal(os.stdin) || !terminal.is_terminal(os.stderr) { + fmt.wprintln( + err_out, + "An interactive terminal is required to choose a backup; pass a more specific path.", + flush = false, + ) + return "", false + } + selected := single_select("Select a backed-up file:", matches[:]) + if selected < 0 { + return "", false + } + return matches[selected], true +} + +matching_view_paths :: proc(path: string, paths: []string) -> [dynamic]string { + matches := make([dynamic]string, 0, len(paths), context.temp_allocator) + needle := strings.to_lower(path, context.temp_allocator) + for stored in paths { + if strings.contains(strings.to_lower(stored, context.temp_allocator), needle) { + append(&matches, stored) + } + } + return matches +} + +page_file_contents :: proc(contents: string, err_out: io.Writer) { + pager := os.get_env("PAGER", context.temp_allocator) + if len(strings.trim_space(pager)) == 0 { + when ODIN_OS == .Windows { + pager = "more" + } else { + pager = "less" + } + } + + parts := strings.split(strings.trim_space(pager), " ", context.temp_allocator) + command := make([dynamic]string, 0, len(parts) + 1, context.temp_allocator) + for part in parts { + if len(part) > 0 { + append(&command, part) + } + } + if len(command) == 0 { + append(&command, "less") + } + when ODIN_OS == .Windows { + if command[0] != "more" do append(&command, "-") + } else { + append(&command, "-") + } + + read_end, write_end, pipe_err := os.pipe() + if pipe_err != nil { + fmt.wprintf(err_out, "Error creating pager pipe: %v\n", pipe_err, flush = false) + return + } + defer os.close(write_end) + + desc := os.Process_Desc { + command = command[:], + stdin = read_end, + stdout = os.stdout, + stderr = os.stderr, + } + process, start_err := os.process_start(desc) + os.close(read_end) + if start_err != nil { + fmt.wprintf(err_out, "Error starting pager %q: %v\n", command[0], start_err, flush = false) + return + } + + remaining := transmute([]u8)contents + write_err: os.Error + for len(remaining) > 0 { + written, err := os.write(write_end, remaining) + if err != nil { + write_err = err + break + } + if written == 0 do break + remaining = remaining[written:] + } + os.close(write_end) + state, wait_err := os.process_wait(process) + if write_err != nil { + fmt.wprintf(err_out, "Error writing to pager: %v\n", write_err, flush = false) + } + if wait_err != nil { + fmt.wprintf(err_out, "Error waiting for pager: %v\n", wait_err, flush = false) + } else if state.exit_code != 0 { + fmt.wprintf(err_out, "Pager exited with status %d\n", state.exit_code, flush = false) + } +} + diff --git a/cmd_view_test.odin b/cmd_view_test.odin new file mode 100644 index 0000000..cf2c115 --- /dev/null +++ b/cmd_view_test.odin @@ -0,0 +1,158 @@ +#+test +package main + +import "core:bufio" +import "core:os" +import "core:path/filepath" +import "core:strings" +import "core:terminal" +import "core:testing" + +@(test) +test_cmd_view_writes_decrypted_backup_to_stdout :: proc(t: ^testing.T) { + base := test_temp_dir(t, "envr-test-view-*") + defer os.remove_all(base) + + cfg_path, _ := filepath.join({base, "config.json"}, context.temp_allocator) + file_path, _ := filepath.join({base, ".env"}, context.temp_allocator) + { + cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path) + defer delete_config(&cfg) + testing.expect(t, save_config(cfg, force = true), "config should save") + } + + { + db, db_ok := db_open(cfg_path) + defer db_close(&db) + testing.expect(t, db_ok, "db should open") + if !db_ok do return + file := make_test_env_file(file_path, "abc123", "SECRET=value\n") + testing.expect(t, db_insert(&db, file), "backup should insert") + } + + out_b: strings.Builder + strings.builder_init(&out_b) + defer strings.builder_destroy(&out_b) + err_b: strings.Builder + strings.builder_init(&err_b) + defer strings.builder_destroy(&err_b) + + cmd, ok := parse_args( + []string{"envr", "view", file_path, "--stdout", "--config-file", cfg_path}, + strings.to_stream(&out_b), + strings.to_stream(&err_b), + ) + testing.expect(t, ok, "view command should parse") + if !ok do return + defer delete_command(&cmd) + + testing.expect(t, cmd_view(&cmd), "stored backup should be viewable without local file") + bufio.writer_flush(cmd.out_buf) + testing.expect_value(t, strings.to_string(out_b), "SECRET=value\n") + testing.expect_value(t, len(strings.to_string(err_b)), 0) +} + +@(test) +test_cmd_view_number_follows_list_order :: proc(t: ^testing.T) { + base := test_temp_dir(t, "envr-test-view-number-*") + defer os.remove_all(base) + cfg_path, _ := filepath.join({base, "config.json"}, context.temp_allocator) + { + cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path) + defer delete_config(&cfg) + testing.expect(t, save_config(cfg, force = true), "config should save") + } + + { + db, db_ok := db_open(cfg_path) + defer db_close(&db) + testing.expect(t, db_ok, "db should open") + if !db_ok do return + paths := []string{"/zeta/.env", "/alpha/.env"} + for path, i in paths { + contents := i == 0 ? "ZETA\n" : "ALPHA\n" + file := make_test_env_file(path, "abc123", contents) + testing.expect(t, db_insert(&db, file), "backup should insert") + delete(file.remotes) + } + } + + out_b: strings.Builder + strings.builder_init(&out_b) + defer strings.builder_destroy(&out_b) + err_b: strings.Builder + strings.builder_init(&err_b) + defer strings.builder_destroy(&err_b) + cmd, ok := parse_args( + []string{"envr", "view", "1", "--stdout", "--config-file", cfg_path}, + strings.to_stream(&out_b), + strings.to_stream(&err_b), + ) + testing.expect(t, ok, "view command should parse") + if !ok do return + defer delete_command(&cmd) + + testing.expect(t, cmd_view(&cmd), "first list position should resolve") + bufio.writer_flush(cmd.out_buf) + testing.expect_value(t, strings.to_string(out_b), "ALPHA\n") +} + +@(test) +test_view_path_matches :: proc(t: ^testing.T) { + paths := []string{"/projects/api/.env", "/projects/web/.env", "user@host:/etc/service.env"} + matches := matching_view_paths("WEB", paths) + testing.expect_value(t, len(matches), 1) + if len(matches) > 0 do testing.expect_value(t, matches[0], paths[1]) + remote := matching_view_paths("host:/etc", paths) + testing.expect_value(t, len(remote), 1) + if len(remote) > 0 do testing.expect_value(t, remote[0], paths[2]) +} + +@(test) +test_view_path_unique_and_missing :: proc(t: ^testing.T) { + paths := []string{"/projects/api/.env", "/projects/web/.env", "/projects/2"} + err_b: strings.Builder + strings.builder_init(&err_b) + defer strings.builder_destroy(&err_b) + w := strings.to_writer(&err_b) + path, ok := choose_view_path("api", paths, w) + testing.expect(t, ok, "unique substring should resolve") + testing.expect_value(t, path, paths[0]) + path, ok = choose_view_path("/projects/web/.env", paths, w) + testing.expect(t, ok, "exact stored path should resolve") + testing.expect_value(t, path, paths[1]) + path, ok = choose_view_path("2", paths, w) + testing.expect(t, ok, "list position should resolve") + testing.expect_value(t, path, paths[1]) + testing.expect( + t, + path != paths[2], + "list position should take priority over a partial path match", + ) + path, ok = choose_view_path("3", paths, w) + testing.expect(t, ok, "third list position should resolve") + testing.expect_value(t, path, paths[2]) + _, ok = choose_view_path("0", paths, w) + testing.expect(t, !ok, "zero is not a valid list position") + _, ok = choose_view_path("missing", paths, w) + testing.expect(t, !ok, "missing backup should fail") + testing.expect( + t, + strings.contains(strings.to_string(err_b), "No backed-up file matches"), + "missing backup should explain failure", + ) +} + +@(test) +test_view_picker_requires_terminal :: proc(t: ^testing.T) { + if terminal.is_terminal(os.stdin) && terminal.is_terminal(os.stderr) do return + paths := []string{"/projects/api/.env", "/projects/web/.env"} + err_b: strings.Builder + strings.builder_init(&err_b) + defer strings.builder_destroy(&err_b) + _, no_path_ok := choose_view_path("", paths, strings.to_writer(&err_b)) + testing.expect(t, !no_path_ok, "no path should require an interactive picker") + _, ambiguous_ok := choose_view_path("projects", paths, strings.to_writer(&err_b)) + testing.expect(t, !ambiguous_ok, "ambiguous path should require an interactive picker") +} + diff --git a/db.odin b/db.odin index 781db25..ec839ad 100644 --- a/db.odin +++ b/db.odin @@ -213,7 +213,7 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) { stmt: sqlite.Stmt rc := sqlite.prepare_v2( db.conn, - "SELECT path, remotes, sha256, contents FROM envr_env_files", + "SELECT path, remotes, sha256, contents FROM envr_env_files ORDER BY path COLLATE BINARY", -1, &stmt, nil, diff --git a/docs/cli/envr.md b/docs/cli/envr.md index af7212b..ea69407 100644 --- a/docs/cli/envr.md +++ b/docs/cli/envr.md @@ -51,6 +51,7 @@ at before, restore your backup with: * [envr list](envr_list.md) - View your tracked files * [envr remove](envr_remove.md) - Remove a .env file from your database * [envr check](envr_check.md) - Check if files are backed up +* [envr view](envr_view.md) - View a backed-up .env file * [envr version](envr_version.md) - Show envr's version * [envr edit-config](envr_edit-config.md) - Edit your config with your default editor * [envr completion](envr_completion.md) - Generate shell completion scripts diff --git a/docs/cli/envr_backup.md b/docs/cli/envr_backup.md index 1230486..b8258c6 100644 --- a/docs/cli/envr_backup.md +++ b/docs/cli/envr_backup.md @@ -6,7 +6,7 @@ Import a .env file into envr envr backup [flags] ``` -**Arguments:** +### Arguments * `path` — Path to .env file to backup diff --git a/docs/cli/envr_check.md b/docs/cli/envr_check.md index 2cb46bf..3325ebc 100644 --- a/docs/cli/envr_check.md +++ b/docs/cli/envr_check.md @@ -6,7 +6,7 @@ Check if files are backed up envr check [path] [flags] ``` -**Arguments:** +### Arguments * `path` — Path to check (defaults to current directory) (optional) diff --git a/docs/cli/envr_completion.md b/docs/cli/envr_completion.md index 2aae995..a04df18 100644 --- a/docs/cli/envr_completion.md +++ b/docs/cli/envr_completion.md @@ -2,8 +2,6 @@ Generate shell completion scripts -### Synopsis - Supported shells: nushell @@ -13,7 +11,7 @@ Supported shells: envr completion [flags] ``` -**Arguments:** +### Arguments * `shell` — Shell to generate completions for diff --git a/docs/cli/envr_init.md b/docs/cli/envr_init.md index e3c1cb6..73459ba 100644 --- a/docs/cli/envr_init.md +++ b/docs/cli/envr_init.md @@ -2,8 +2,6 @@ Set up envr -### Synopsis - The init command generates your initial config and saves it to ~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to encrypt your databse. **Make 100% sure** that you have **a remote copy** of this diff --git a/docs/cli/envr_list.md b/docs/cli/envr_list.md index fc89258..0bad90a 100644 --- a/docs/cli/envr_list.md +++ b/docs/cli/envr_list.md @@ -2,6 +2,8 @@ View your tracked files +Table output is sorted by stored path and numbered from 1. Use 'envr view ' to view the contents of a backup. + ``` envr list [flags] ``` diff --git a/docs/cli/envr_remove.md b/docs/cli/envr_remove.md index a04ff95..f51ed2f 100644 --- a/docs/cli/envr_remove.md +++ b/docs/cli/envr_remove.md @@ -6,7 +6,7 @@ Remove a .env file from your database envr remove [flags] ``` -**Arguments:** +### Arguments * `path` — Path to .env file to remove diff --git a/docs/cli/envr_restore.md b/docs/cli/envr_restore.md index 77c2b83..c113717 100644 --- a/docs/cli/envr_restore.md +++ b/docs/cli/envr_restore.md @@ -6,7 +6,7 @@ Restore a .env file from the database envr restore [flags] ``` -**Arguments:** +### Arguments * `path` — Path to .env file to restore diff --git a/docs/cli/envr_uninstall.md b/docs/cli/envr_uninstall.md index 7a525d2..29a6a16 100644 --- a/docs/cli/envr_uninstall.md +++ b/docs/cli/envr_uninstall.md @@ -2,8 +2,6 @@ Remove envr configuration and database -### Synopsis - This will remove all files generated by envr from your system. ``` diff --git a/docs/cli/envr_view.md b/docs/cli/envr_view.md new file mode 100644 index 0000000..5dae9b8 --- /dev/null +++ b/docs/cli/envr_view.md @@ -0,0 +1,26 @@ +## envr view + +View a backed-up .env file + +If no path is provided, the user will be asked to select one from a list. You may also pass the number from the "#" column of `envr list` instead of the full path. If a partial path is passed, the closest match will be opened. + +``` +envr view [path] [flags] +``` + +### Arguments + +* `path` — Path or substring, or number of a backed-up file. (optional) + +### Options + +``` + -h, --help show this documentation + -c, --config-file config file (default "~/.envr/config.json") + --color Whether or not to colorize output (default 'auto') + -p, --stdout Print file contents directly to stdout instead of using a pager +``` + +### SEE ALSO + +* [envr](envr.md) - Manage your .env files. diff --git a/gendocs.odin b/gendocs.odin index e5b03b2..ce81e17 100644 --- a/gendocs.odin +++ b/gendocs.odin @@ -201,7 +201,6 @@ generate_md_command :: proc(c: ^CommandInfo) { fmt.sbprintf(&sb, "%s\n\n", c.short) if len(c.long) > 0 { - fmt.sbprintf(&sb, "### Synopsis\n\n") fmt.sbprint(&sb, c.long) fmt.sbprintf(&sb, "\n\n") } @@ -209,7 +208,7 @@ generate_md_command :: proc(c: ^CommandInfo) { fmt.sbprintf(&sb, "```\n%s [flags]\n```\n\n", c.usage) if len(c.args) > 0 { - fmt.sbprintf(&sb, "**Arguments:**\n\n") + fmt.sbprintf(&sb, "### Arguments\n\n") for arg in c.args { optional := arg.optional ? " (optional)" : "" fmt.sbprintf(&sb, "* `%s` — %s%s\n", arg.name, arg.desc, optional) @@ -286,4 +285,3 @@ write_file :: proc(path: string, content: string) { fmt.eprintf("Error writing %s: %v\n", path, err) } } - diff --git a/main.odin b/main.odin index 2aff0fc..898f42e 100644 --- a/main.odin +++ b/main.odin @@ -74,6 +74,8 @@ when !#config(GENDOCS, false) { cmd_edit_config(&cmd) case "check": cmd_check(&cmd) + case "view": + if !cmd_view(&cmd) do os.exit(1) case "scan": cmd_scan(&cmd) case "sync": diff --git a/prompt.odin b/prompt.odin index fe3dbf3..d80da3d 100644 --- a/prompt.odin +++ b/prompt.odin @@ -1,6 +1,7 @@ package main import "core:fmt" +import "core:os" import "core:strings" import "core:sys/posix" import "core:terminal/ansi" @@ -28,6 +29,71 @@ Raw_State :: struct { MAX_VISIBLE :: 7 +// Returns the index of the chosen option, or -1 on cancellation or terminal error. +single_select :: proc(prompt: string, options: []string) -> int { + if len(options) == 0 do return -1 + + filter: [dynamic]u8 + defer delete(filter) + filtered: [dynamic]int + defer delete(filtered) + rebuild_filtered(options, filter[:], &filtered) + + raw, ok := enable_raw_mode(posix.STDIN_FILENO) + if !ok do return -1 + defer disable_raw_mode(&raw) + + cursor, scroll_offset := 0, 0 + fmt.fprintf(os.stderr, ansi.CSI + ansi.DECTCEM_HIDE) + visible := render_single_options(prompt, options, filtered[:], "", cursor, scroll_offset) + defer fmt.fprintf(os.stderr, ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1) + + for { + key, ch := read_key() + switch key { + case .Char: + append(&filter, u8(ch)) + rebuild_filtered(options, filter[:], &filtered) + cursor = clamp(cursor, 0, max(0, len(filtered) - 1)) + case .Backspace: + if len(filter) > 0 { + pop(&filter) + rebuild_filtered(options, filter[:], &filtered) + cursor = clamp(cursor, 0, max(0, len(filtered) - 1)) + } + case .Up: + if cursor > 0 do cursor -= 1 + case .Down: + if cursor < len(filtered) - 1 do cursor += 1 + case .Enter: + if len(filtered) > 0 do return filtered[cursor] + case .Escape: + return -1 + case .Unknown: + return -1 + case .Space: + } + + scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(filtered) - MAX_VISIBLE)) + fmt.fprintf(os.stderr, ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.RESET + ansi.ED, visible + 1) + visible = render_single_options(prompt, options, filtered[:], string(filter[:]), cursor, scroll_offset) + } +} + +render_single_options :: proc(prompt: string, options: []string, filtered: []int, filter_text: string, cursor, scroll_offset: int) -> int { + fmt.fprintf(os.stderr, "%s (type to filter, ↑/↓ move, enter select, esc cancel)\r\n", prompt) + fmt.fprintf(os.stderr, "filter: %s\r\n", filter_text) + if len(filtered) == 0 { + fmt.fprintf(os.stderr, " No matches\r\n") + return 2 + } + end := min(scroll_offset + MAX_VISIBLE, len(filtered)) + for i in scroll_offset ..< end { + fmt.fprintf(os.stderr, "%s %s\r\n", i == cursor ? ">" : " ", options[filtered[i]]) + } + return 1 + end - scroll_offset +} + // Caller is responsible for deleting the responses. multi_select :: proc( prompt: string, @@ -263,4 +329,3 @@ read_key :: proc() -> (key: Key, ch: rune) { return .Unknown, 0 } } -