-- pickers: fuzzy file finder, live ripgrep, build errors, yazi file manager, gitu. local M = {} -- height of the drop-down fzf panel at the top ('100%' = full screen) M.height = '100%' -- --------------------------------------------------------------------------- -- shared constants -- --------------------------------------------------------------------------- local BAT = 'bat --theme=custom_zen --color=always --style=numbers' local PREVIEW_FILE = BAT .. ' --line-range=:500 {}' -- {} = path local PREVIEW_AT = BAT .. ' --highlight-line {2} {1}' -- {1}=path {2}=line local KEYS_NAV = 'ctrl-n:down,ctrl-p:up' local KEYS_PREVIEW = 'ctrl-/:toggle-preview,shift-up:preview-up,shift-down:preview-down' -- an Odin error line (-error-pos-style:unix): path.odin:line:col: message local ERROR_PATTERN = '^(.-%.odin):(%d+):(%d+):' -- --------------------------------------------------------------------------- -- helpers -- --------------------------------------------------------------------------- -- single-quote a string for the shell local function q(s) return "'" .. s:gsub("'", "'\\''") .. "'" end -- write a raw escape sequence straight to the terminal local function tty_write(seq) local tty = io.open('/dev/tty', 'w') if tty then tty:write(seq) tty:close() end end -- hand the terminal back to vis: re-enter the alt screen and repaint local function restore_screen() tty_write('\27[?1049h') vis:redraw() end -- fzf command line: common flags + a list of extra options local function fzf(prompt, opts) return 'fzf --height=' .. M.height .. ' --layout=reverse --border' .. ' --prompt ' .. q(prompt) .. ' ' .. table.concat(opts, ' ') end -- "--flag 'value'" local function opt(flag, value) return flag .. ' ' .. q(value) end -- run an fzf pipeline; returns its output lines and exit code local function run_fzf(cmd) -- --height mode draws from the cursor down, so start at the top-left tty_write('\27[H') local handle = io.popen(cmd) if not handle then vis:info('fzf: failed to start') return nil end local lines = {} for line in handle:lines() do table.insert(lines, line) end local _, _, code = handle:close() restore_screen() return lines, code end -- open a path in the current window, unless it's already the current file. -- returns false if the buffer has unsaved changes and nothing was opened local function open_path(path) local win = vis.win local current = win and win.file and win.file.path if current and (current == path or current:sub(-#path - 1) == '/' .. path) then return true end if win and win.file and win.file.modified then vis:info('Buffer has unsaved changes — :w first (or use :e! manually)') return false end vis:command(string.format("e '%s'", path)) return true end -- --------------------------------------------------------------------------- -- files: fuzzy-find a file, or create one from the query -- enter open in the current window -- ctrl-v open in a vertical split -- alt-enter create a file named after the query -- ctrl-n/p move down/up -- --------------------------------------------------------------------------- function M.files() local cmd = 'fd --type f --hidden --exclude .git | ' .. fzf('files> ', { '--print-query --expect=alt-enter,ctrl-v', -- output: query, key, choice opt('--header', 'enter: open · alt-enter: new file · ctrl-v: vsplit'), opt('--preview', PREVIEW_FILE), opt('--preview-window', 'down:75%:border-top'), opt('--bind', KEYS_NAV), opt('--bind', KEYS_PREVIEW), }) local out, code = run_fzf(cmd) if not out or code == 130 then return end -- failed, or Esc / Ctrl-C local query, key, choice = out[1], out[2], out[3] local is_new = key == 'alt-enter' or not choice or choice == '' local path = is_new and query or choice if not path or path == '' then return end if key == 'ctrl-v' then vis:command(string.format("vsplit '%s'", path)) elseif not open_path(path) then return end if is_new then vis:info('New file: ' .. path .. ' (written to disk on :w)') end end -- --------------------------------------------------------------------------- -- grep: live ripgrep search across the project, jump to the match -- M.grep() start empty, results appear as you type -- M.grep_selection() visual mode: start with the selected text -- --------------------------------------------------------------------------- local function grep_with(query) -- don't run rg on an empty query (it would match every line) local reload = '[ -n {q} ] && rg --column --line-number --no-heading' .. ' --color=always --smart-case --hidden --glob "!.git" -- {q} || true' local opts = { '--ansi --disabled', -- let rg do the matching, not fzf '--delimiter :', -- fields: path:line:col:text opt('--bind', 'change:reload:' .. reload), opt('--preview', PREVIEW_AT), opt('--preview-window', 'down:90%:border-top:+{2}/2'), opt('--bind', KEYS_PREVIEW), } if query then table.insert(opts, opt('--query', query)) table.insert(opts, opt('--bind', 'start:reload:' .. reload)) end -- empty stdin: results only appear once there is a query local out = run_fzf(': | ' .. fzf('rg> ', opts)) local choice = out and out[1] if not choice or choice == '' then return end choice = choice:gsub('\27%[[%d;]*m', '') -- strip colour codes local path, line, col = choice:match('^(.-):(%d+):(%d+):') if not path or not open_path(path) then return end vis.win.selection:to(tonumber(line), tonumber(col)) end function M.grep() grep_with(nil) end function M.grep_selection() local win = vis.win local text if win and win.file and win.selection.range then text = win.file:content(win.selection.range) end -- leave visual mode before fzf takes over the screen vis.mode = vis.modes.NORMAL -- first line of the selection only; nothing usable -> plain grep text = text and text:match('^[^\n]*') if not text or text:match('^%s*$') then grep_with(nil) else grep_with(text) end end -- --------------------------------------------------------------------------- -- errors: browse the errors from the last build (the log build.lua writes) -- enter jump to the error -- --------------------------------------------------------------------------- function M.errors() local f = io.open(require('build').log, 'r') if not f then vis:info('No build log yet — run a build first') return end -- keep only the path.odin:line:col: lines; the log comes from a pty, so -- strip colour escapes and carriage returns first local errors = {} for line in f:lines() do local clean = line:gsub('\27%[[%d;?]*%a', ''):gsub('\r', '') if clean:match(ERROR_PATTERN) then table.insert(errors, clean) end end f:close() if #errors == 0 then vis:info('No errors in the last build') return end local tmp = os.tmpname() f = io.open(tmp, 'w') if not f then vis:info('errors: cannot write temp file') return end f:write(table.concat(errors, '\n'), '\n') f:close() local cmd = fzf('errors> ', { '--no-sort', -- keep build order: the first error is the one to fix first '--delimiter :', opt('--preview', PREVIEW_AT), opt('--preview-window', 'down:75%:border-top:+{2}/2'), opt('--bind', KEYS_NAV), opt('--bind', KEYS_PREVIEW), }) .. ' < ' .. q(tmp) local out = run_fzf(cmd) os.remove(tmp) local choice = out and out[1] if not choice or choice == '' then return end local path, line, col = choice:match(ERROR_PATTERN) if not path or not open_path(path) then return end vis.win.selection:to(tonumber(line), tonumber(col)) end -- --------------------------------------------------------------------------- -- fm: browse/create/rename/delete in yazi, then open the file you pick -- --------------------------------------------------------------------------- function M.fm() local tmp = os.tmpname() os.execute(string.format("yazi --chooser-file='%s' /dev/tty 2>&1", tmp)) restore_screen() local f = io.open(tmp, 'r') local path = f and f:read('*l') if f then f:close() end os.remove(tmp) if path and path ~= '' then open_path(path) end end -- --------------------------------------------------------------------------- -- git: run gitu, then hand the screen back to vis -- --------------------------------------------------------------------------- function M.git() tty_write('\27[?1049l') os.execute('gitu /dev/tty 2>&1') restore_screen() -- gitu may have changed files on disk (checkout, discard, stash, ...): -- reload the current buffer if there's nothing unsaved to lose local win = vis.win if win and win.file and win.file.path and not win.file.modified then vis:command('e!') end end -- --------------------------------------------------------------------------- -- events -- --------------------------------------------------------------------------- -- create missing parent directories on save, so a new file like -- src/util/new.odin can be written even if src/util/ doesn't exist yet vis.events.subscribe(vis.events.FILE_SAVE_PRE, function(file, path) -- luacheck: no unused args local dir = path and path:match('^(.*)/[^/]+$') if dir and dir ~= '' then os.execute('mkdir -p ' .. q(dir)) end return true end) return M