Files
community-plugins/file-search/panel.luau
T
404765a456 Update nightwatch75/file-search to 0.0.19 (#126)
Settings shortcut in the panel header, middle-elided paths, right-click copy,
and a real indexed-file count.

Co-authored-by: nightwatch75 <nightwatch75@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 09:36:07 -04:00

519 lines
18 KiB
Luau
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--!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.
-- How many characters of a result path fit on one row, from the panel's 520
-- width in plugin.toml: 520 − 2 × Style::panelPadding (14) − the scrollbar
-- gutter (scrollbarWidth 6 + scrollbarGap 8) = 478 usable, less the row
-- Button's 2 × Style::spaceMd (12) horizontal padding, its 14px glyph and the
-- 4px gap between them → 436px of text. Measured against a rendered row, a
-- lowercase path averages 7.2px per character at Style::fontSizeBody, so 61
-- characters is the limit; 56 leaves headroom, because the font is
-- proportional and capitals or digits measure wider than that average.
--
-- A Button cannot do this itself: it has no maxLines, so constraining its
-- width makes the label WRAP rather than ellipsize, and a plain flexGrow never
-- shrinks it below the full text — which is why a long path used to run under
-- the scrollbar and get clipped.
local PATH_MAX_CHARS = 56
local ELLIPSIS = "…"
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
-- Code-point slices. Byte offsets would split a multi-byte character and
-- produce invalid UTF-8, which the text renderer then refuses to measure.
local function headChars(value, count)
local byte = utf8.offset(value, count + 1)
return byte ~= nil and value:sub(1, byte - 1) or value
end
local function tailChars(value, count)
local length = utf8.len(value)
if length == nil or count >= length then
return value
end
local byte = utf8.offset(value, length - count + 1)
return byte ~= nil and value:sub(byte) or value
end
-- Shorten an over-long path by dropping its MIDDLE, keeping both the root it
-- starts from and the name it ends with:
-- .local/share/flatpak/repo/tmp/cache/summaries/dolphin.idx.sig
-- → .local/share/flatpak/repo/tmp/cache/…dolphin.idx.sig
-- End-truncation would cut away the file name, which is the very thing the
-- query matched, so the trailing component is kept whole whenever it fits and
-- the head takes what is left of the budget. utf8.len returns nil on invalid
-- UTF-8 (it never throws, unlike utf8.codes), and such a name is left alone
-- rather than sliced at a guessed offset.
local function elidePath(rel)
-- Byte length is never below the code-point count, so a string short in
-- bytes is short in characters — and the common case skips the O(n)
-- utf8.len entirely. This runs for every visible row on every render.
if #rel <= PATH_MAX_CHARS then
return rel
end
local length = utf8.len(rel)
if length == nil or length <= PATH_MAX_CHARS then
return rel
end
local budget = PATH_MAX_CHARS - 1 -- the ellipsis occupies one column
-- Trailing component, with a directory's own "/" kept as part of it.
local base = rel:match("[^/]+/?$") or ""
local baseLen = utf8.len(base) or 0
if baseLen >= budget then
-- A single component longer than the whole row: no head to show.
return ELLIPSIS .. tailChars(rel, budget)
end
local headPart = headChars(rel, budget - baseLen)
-- Retreat to the last separator so the head ends on a whole directory:
-- ".../tmp/cache/…name" reads as a path, ".../tmp/cache/summ…name" reads
-- as a glitch. Costs a few characters; kept raw when there is no
-- separator to retreat to.
local atSeparator = headPart:match("^(.*/)[^/]*$")
if atSeparator ~= nil and atSeparator ~= "" then
headPart = atSeparator
end
return headPart .. ELLIPSIS .. base
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
-- Count the cache the panel is about to search. buildIndex records the total
-- as a side effect of building, but a panel opening on a cache that is already
-- fresh -- the common case, and the one the launcher leaves behind -- never
-- runs it, so the footer used to report "0 indexed" for a perfectly good
-- index. Counted with wc rather than readFile: the list can hold hundreds of
-- thousands of paths and none of them are needed here, only how many.
local function readTotal(dir)
local cmd = cacheSh(dir) .. '\nwc -l < "$CACHE" 2>/dev/null'
noctalia.runAsync(cmd, function(result)
if result.exitCode == 0 and not result.timedOut then
total = tonumber(trim(result.stdout or "")) or 0
render()
end
end, 15000)
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
-- An index record joined to the search root, or nil when the record could
-- resolve outside it. Only ever called from the two callbacks that act on a
-- row — never from render: searchRoot() costs a getConfig plus an expandPath
-- and safeRel walks every path component, and paying that per row per frame is
-- what gets a script callback killed for exceeding its CPU budget.
local function absolutePath(rel)
if not safeRel(rel) then
return nil
end
local root = searchRoot()
-- A directory keeps its trailing "/" in the index but not in a path meant
-- for xdg-open or for pasting into a shell.
local trimmed = rel:gsub("/+$", "")
if root == "/" then
return root .. trimmed
end
return root .. "/" .. trimmed
end
local function rejectRecord(rel)
noctalia.log("file-search: refusing unsafe index record: " .. rel)
noctalia.notify(tr("title"), tr("err_bad_record"))
end
local function openEntry(rel)
local path = absolutePath(rel)
if path == nil then
rejectRecord(rel)
return
end
noctalia.runAsync("xdg-open " .. shellQuote(path) .. " >/dev/null 2>&1")
panel.close()
end
-- Right click copies the absolute path instead of opening it, and leaves the
-- panel up so several rows can be picked off in a row. Right rather than
-- middle because a ui.button never receives the middle button: it accepts
-- BTN_LEFT, plus BTN_RIGHT only once an onRightClick is attached, and no node
-- in the declarative UI exposes a middle-click callback at all.
local function copyEntry(rel)
local path = absolutePath(rel)
if path == nil then
rejectRecord(rel)
return
end
noctalia.copyToClipboard(path, "text/plain")
noctalia.notify(tr("title"), tr("copied_entry"))
end
local function resultRow(rel, index)
local isDir = rel:sub(-1) == "/"
-- The row carries no tooltip on purpose: one string per row is enough
-- extra weight, over a list this long, to get the render's callback killed
-- for exceeding its CPU budget. Right click yields the full path instead.
local shown = elidePath(rel)
return ui.button({
key = "hit-" .. index,
glyph = isDir and "folder" or "file",
text = shown,
variant = "ghost",
contentAlign = "start",
onClick = function()
openEntry(rel) -- always the full record, never the elided label
end,
onRightClick = function()
copyEntry(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 = "settings", variant = "ghost", tooltip = tr("tip_settings"), onClick = "onOpenSettings" }),
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
-- buildIndex would have set the total; reusing a cache has to
-- go and count it.
readTotal(dir)
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
readTotal(dir)
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
-- Opens the settings window on this plugin's own page (the host supplies the
-- plugin id, so a plugin can only ever open its own). The panel closes on the
-- way; the index survives it, since it lives in the plugin data directory.
function onOpenSettings()
noctalia.openSettings()
end
function onClosePanel()
panel.close()
end