378 lines
11 KiB
Luau
378 lines
11 KiB
Luau
--!nonstrict
|
|
-- file-search — fuzzy search panel, fzf as the matching subsystem.
|
|
--
|
|
-- On open the search folder is walked once with `find` into a cache file
|
|
-- (excluded directory names are pruned, hidden entries too unless enabled);
|
|
-- after that every keystroke runs `fzf --filter=<query>` over the cache, so
|
|
-- typing stays responsive even on large trees. Results update live; picking
|
|
-- one opens it with the system MIME association (xdg-open) — directories
|
|
-- open in the file manager. Enter opens the top match.
|
|
--
|
|
-- The index is rebuilt when the panel opens with changed settings, or on
|
|
-- demand via the refresh button. The bar widget mirrors the panel's open
|
|
-- state through the shared "file_search_open" state key.
|
|
|
|
local query = ""
|
|
local results = {} -- relative paths; directories keep a trailing "/"
|
|
local total = nil -- entries in the index, shown in the footer
|
|
local indexing = false
|
|
local searching = false
|
|
local errMsg = nil
|
|
local fzfMissing = false
|
|
local inputRev = 0 -- bumped to reseed the query input on open
|
|
local haveIndex = false -- the cache on disk matches the current settings
|
|
|
|
local render
|
|
local runSearch
|
|
local buildIndex
|
|
|
|
local function tr(key, args)
|
|
return noctalia.tr(key, args)
|
|
end
|
|
|
|
local function trim(value)
|
|
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
|
|
end
|
|
|
|
local function shellQuote(value)
|
|
return "'" .. value:gsub("'", "'\\''") .. "'"
|
|
end
|
|
|
|
-- Private per-plugin storage (XDG state); the host creates the directory on
|
|
-- every call. nil (with a log line) when no state directory resolves.
|
|
local function dataDir()
|
|
local dir, err = noctalia.pluginDataDir()
|
|
if dir == nil then
|
|
noctalia.log("file-search: pluginDataDir failed: " .. tostring(err))
|
|
return nil
|
|
end
|
|
return dir
|
|
end
|
|
|
|
-- Shell header shared by every cache command. The .meta sidecar holds the
|
|
-- settings fingerprint the cache was built with, so the panel and the
|
|
-- launcher can both tell a stale index apart.
|
|
local function cacheSh(dir)
|
|
return "CACHE_DIR=" .. shellQuote(dir)
|
|
.. '\nCACHE="$CACHE_DIR/index.list"\nMETA="$CACHE_DIR/index.meta"'
|
|
end
|
|
|
|
local function searchRoot()
|
|
local dir = noctalia.getConfig("search_folder")
|
|
if dir == nil or dir == "" then
|
|
dir = noctalia.getenv("HOME") or "/tmp"
|
|
else
|
|
dir = noctalia.expandPath(dir)
|
|
end
|
|
dir = dir:gsub("/+$", "")
|
|
if dir == "" then
|
|
dir = "/"
|
|
end
|
|
return dir
|
|
end
|
|
|
|
local function maxResults()
|
|
return math.max(10, math.min(200, tonumber(noctalia.getConfig("max_results")) or 50))
|
|
end
|
|
|
|
-- Excluded directory names from the setting, split on ',' or ';'. Matching
|
|
-- is by basename (find -name), so entries containing '/' are skipped and
|
|
-- logged. Hidden entries are folded in as an extra '.*' pattern.
|
|
local function excludeNames()
|
|
local raw = noctalia.getConfig("exclude_dirs")
|
|
if type(raw) ~= "string" then
|
|
raw = ""
|
|
end
|
|
local names = {}
|
|
for entry in raw:gmatch("[^,;]+") do
|
|
local name = trim(entry)
|
|
if name:find("/") then
|
|
noctalia.log("file-search: ignoring exclude entry with '/': '" .. name .. "'")
|
|
elseif name ~= "" then
|
|
table.insert(names, name)
|
|
end
|
|
end
|
|
if noctalia.getConfig("show_hidden") ~= true then
|
|
table.insert(names, ".*")
|
|
end
|
|
return names
|
|
end
|
|
|
|
local function indexKey()
|
|
return searchRoot() .. "\n" .. table.concat(excludeNames(), "\n")
|
|
end
|
|
|
|
-- The cache on disk is current when its fingerprint matches the settings,
|
|
-- whether the panel or the launcher built it.
|
|
local function cacheFresh(dir)
|
|
return noctalia.readFile(dir .. "/index.meta") == indexKey()
|
|
end
|
|
|
|
-- One cache record, about to be joined to the search root. The cache is a
|
|
-- plain user-editable file, so records are untrusted: reject anything that
|
|
-- could resolve outside the root.
|
|
local function safeRel(rel)
|
|
if rel == "" or rel:sub(1, 1) == "/" or rel:find("\n", 1, true) then
|
|
return false
|
|
end
|
|
for part in rel:gmatch("[^/]+") do
|
|
if part == ".." then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
buildIndex = function()
|
|
if fzfMissing or indexing then
|
|
return
|
|
end
|
|
local dir = dataDir()
|
|
if dir == nil then
|
|
errMsg = tr("err_index")
|
|
render()
|
|
return
|
|
end
|
|
indexing = true
|
|
errMsg = nil
|
|
local key = indexKey()
|
|
|
|
-- Names containing a newline would each forge extra one-per-fragment
|
|
-- records (a crafted name can smuggle '..' lines into the index), so
|
|
-- they are pruned unconditionally, before the user's exclusions.
|
|
local names = { "-name " .. shellQuote("*\n*") }
|
|
for _, name in ipairs(excludeNames()) do
|
|
table.insert(names, "-name " .. shellQuote(name))
|
|
end
|
|
local prune = "\\( " .. table.concat(names, " -o ") .. " \\) -prune -o "
|
|
-- %P prints paths relative to the root; the trailing '/' marks
|
|
-- directories so rows get the right glyph. find's own exit status is
|
|
-- ignored, so permission errors inside the tree don't fail the build.
|
|
-- Cache and fingerprint are written to mktemp-created private files and
|
|
-- renamed into place: rename replaces a planted symlink at the
|
|
-- destination instead of following it. The host guarantees $CACHE_DIR
|
|
-- exists (created by pluginDataDir above).
|
|
local cmd = cacheSh(dir)
|
|
.. '\nTMP=$(mktemp "$CACHE_DIR/index.list.XXXXXX") || exit 1\n'
|
|
.. "find " .. shellQuote(searchRoot()) .. " -mindepth 1 " .. prune
|
|
.. "-type d -printf '%P/\\n' -o -printf '%P\\n' > \"$TMP\" 2>/dev/null\n"
|
|
.. 'mv -f "$TMP" "$CACHE" || exit 1\n'
|
|
.. 'TMPM=$(mktemp "$CACHE_DIR/index.meta.XXXXXX") || exit 1\n'
|
|
.. "printf '%s' " .. shellQuote(key) .. ' > "$TMPM"\n'
|
|
.. 'mv -f "$TMPM" "$META" || exit 1\n'
|
|
.. 'wc -l < "$CACHE"'
|
|
|
|
render()
|
|
local ok = noctalia.runAsync(cmd, function(result)
|
|
indexing = false
|
|
if result.exitCode == 0 and not result.timedOut then
|
|
haveIndex = true
|
|
total = tonumber(trim(result.stdout or "")) or 0
|
|
if indexKey() ~= key then
|
|
buildIndex() -- settings changed while the walk was running
|
|
else
|
|
runSearch()
|
|
end
|
|
else
|
|
haveIndex = false
|
|
errMsg = tr("err_index")
|
|
end
|
|
render()
|
|
end, 180000)
|
|
if not ok then
|
|
indexing = false
|
|
errMsg = tr("err_spawn")
|
|
render()
|
|
end
|
|
end
|
|
|
|
runSearch = function()
|
|
if fzfMissing or indexing or searching or not haveIndex then
|
|
return
|
|
end
|
|
local dir = dataDir()
|
|
if dir == nil then
|
|
return
|
|
end
|
|
searching = true
|
|
local q = query
|
|
local limit = maxResults()
|
|
local cmd
|
|
if trim(q) == "" then
|
|
cmd = cacheSh(dir) .. '\nhead -n ' .. limit .. ' "$CACHE" 2>/dev/null'
|
|
else
|
|
-- head closes the pipe early; the pipeline status stays head's (0).
|
|
cmd = cacheSh(dir) .. "\nfzf --filter=" .. shellQuote(q)
|
|
.. ' < "$CACHE" 2>/dev/null | head -n ' .. limit
|
|
end
|
|
local ok = noctalia.runAsync(cmd, function(result)
|
|
searching = false
|
|
if not result.timedOut then
|
|
results = {}
|
|
for line in (result.stdout or ""):gmatch("[^\n]+") do
|
|
table.insert(results, line)
|
|
end
|
|
end
|
|
-- The query moved on while this search ran: chase it. The stale
|
|
-- rows rendered below are overwritten as soon as it completes.
|
|
if query ~= q then
|
|
runSearch()
|
|
end
|
|
render()
|
|
end, 15000)
|
|
if not ok then
|
|
searching = false
|
|
end
|
|
end
|
|
|
|
local function openEntry(rel)
|
|
if not safeRel(rel) then
|
|
noctalia.log("file-search: refusing unsafe index record: " .. rel)
|
|
noctalia.notify(tr("title"), tr("err_bad_record"))
|
|
return
|
|
end
|
|
local path = searchRoot()
|
|
if path ~= "/" then
|
|
path = path .. "/"
|
|
end
|
|
path = path .. rel:gsub("/+$", "")
|
|
noctalia.runAsync("xdg-open " .. shellQuote(path) .. " >/dev/null 2>&1")
|
|
panel.close()
|
|
end
|
|
|
|
local function resultRow(rel, index)
|
|
local isDir = rel:sub(-1) == "/"
|
|
return ui.button({
|
|
key = "hit-" .. index,
|
|
glyph = isDir and "folder" or "file",
|
|
text = rel,
|
|
variant = "ghost",
|
|
contentAlign = "start",
|
|
onClick = function()
|
|
openEntry(rel)
|
|
end,
|
|
})
|
|
end
|
|
|
|
local function statusFooter()
|
|
if indexing then
|
|
return ui.label({ text = tr("status_indexing", { path = searchRoot() }), fontSize = 11, color = "secondary" })
|
|
end
|
|
local shown = #results
|
|
return ui.label({
|
|
text = tr("counts", { shown = shown, total = total or 0 }),
|
|
fontSize = 11,
|
|
color = "on_surface_variant",
|
|
})
|
|
end
|
|
|
|
render = function()
|
|
local children = {
|
|
ui.row({ gap = 6, align = "center" }, {
|
|
ui.label({
|
|
text = tr("title"),
|
|
fontSize = 16,
|
|
fontWeight = "bold",
|
|
color = "on_surface",
|
|
flexGrow = 1,
|
|
}),
|
|
ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("tip_refresh"), onClick = "onRefreshIndex" }),
|
|
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("tip_close"), onClick = "onClosePanel" }),
|
|
}),
|
|
ui.input({
|
|
key = "query-" .. inputRev,
|
|
value = query,
|
|
focus = true,
|
|
placeholder = tr("search_placeholder"),
|
|
onChange = "onQueryChanged",
|
|
onSubmit = "onOpenFirst",
|
|
}),
|
|
}
|
|
|
|
if fzfMissing then
|
|
table.insert(children, ui.label({ text = tr("err_no_fzf"), color = "error" }))
|
|
elseif errMsg ~= nil then
|
|
table.insert(children, ui.label({ text = errMsg, color = "error" }))
|
|
else
|
|
if #results == 0 and not indexing and trim(query) ~= "" then
|
|
table.insert(children, ui.label({
|
|
text = tr("no_results", { query = query }),
|
|
color = "on_surface_variant",
|
|
flexGrow = 1,
|
|
}))
|
|
else
|
|
local rows = {}
|
|
for index, rel in ipairs(results) do
|
|
table.insert(rows, resultRow(rel, index))
|
|
end
|
|
table.insert(children, ui.scroll({ flexGrow = 1, gap = 2 }, rows))
|
|
end
|
|
table.insert(children, statusFooter())
|
|
end
|
|
|
|
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children))
|
|
end
|
|
|
|
function onOpen(_context)
|
|
fzfMissing = not noctalia.commandExists("fzf")
|
|
query = ""
|
|
results = {}
|
|
inputRev += 1
|
|
noctalia.state.set("file_search_open", true)
|
|
if not fzfMissing then
|
|
local dir = dataDir()
|
|
if dir == nil then
|
|
errMsg = tr("err_index")
|
|
else
|
|
haveIndex = cacheFresh(dir)
|
|
if not haveIndex then
|
|
buildIndex()
|
|
else
|
|
runSearch()
|
|
end
|
|
end
|
|
end
|
|
render()
|
|
end
|
|
|
|
function onClose()
|
|
noctalia.state.set("file_search_open", false)
|
|
end
|
|
|
|
function onConfigChanged()
|
|
if fzfMissing or indexing then
|
|
return
|
|
end
|
|
local dir = dataDir()
|
|
if dir == nil then
|
|
return
|
|
end
|
|
haveIndex = cacheFresh(dir)
|
|
if not haveIndex then
|
|
buildIndex()
|
|
else
|
|
runSearch()
|
|
end
|
|
end
|
|
|
|
function onQueryChanged(value)
|
|
query = value
|
|
runSearch()
|
|
end
|
|
|
|
function onOpenFirst(value)
|
|
query = value
|
|
if results[1] ~= nil then
|
|
openEntry(results[1])
|
|
end
|
|
end
|
|
|
|
function onRefreshIndex()
|
|
haveIndex = false
|
|
buildIndex()
|
|
end
|
|
|
|
function onClosePanel()
|
|
panel.close()
|
|
end
|