285 lines
9.6 KiB
Lua
285 lines
9.6 KiB
Lua
-- 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 >/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 >/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 |