Files
community-plugins/file-search/panel.luau
T

1391 lines
54 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 active roots are 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 roots come from the search scope, cycled with the panel's disk button
-- and persisted in the plugin data directory so the launcher entry follows the
-- same choice: the search folder alone (default), the search folder plus every
-- mounted USB/removable volume, or those volumes alone. Mounts are detected
-- with `lsblk` (see probeMounts), only when the scope needs them.
--
-- The index is rebuilt when the panel opens with changed settings, a different
-- scope or a different set of disks, and 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 = "…"
-- Bumped whenever the record format or the pruned-name list below changes: it
-- is part of the cache fingerprint, so an index built by an older version of
-- this plugin is rebuilt instead of being read with the wrong rules.
local INDEX_FORMAT = "2"
-- Volume metadata that other operating systems leave on removable media, plus
-- the filesystem-level ones. None of it is a user file and some of it is huge
-- (a Time Machine sparsebundle or a Spotlight store is tens of thousands of
-- entries), so it is pruned at every root, not only on external disks — the
-- names are vendor-fixed and never collide with real content. Matched with
-- -iname because Windows has shipped both "$RECYCLE.BIN" and "$Recycle.Bin".
-- Most of the macOS ones start with a dot and are already covered when hidden
-- entries are off; they are listed so that turning hidden entries ON does not
-- flood the index.
local VOLUME_NOISE = {
"lost+found",
-- Windows / NTFS
"$RECYCLE.BIN", "RECYCLER", "System Volume Information",
-- Linux XDG trash on removable media (.Trash-<uid>)
".Trash-*",
-- macOS volume services
".Spotlight-V100", ".fseventsd", ".Trashes", ".TemporaryItems",
".DocumentRevisions-V100", ".PKInstallSandboxManager",
-- macOS Time Machine
"Backups.backupdb", "*.sparsebundle", "*.backupbundle",
-- AppleDouble sidecars and AFP/netatalk leftovers
"._*", ".DS_Store", ".AppleDouble", ".AppleDB", ".AppleDesktop",
"Network Trash Folder", "Temporary Items", "TheVolumeSettingsFolder",
}
-- Search scope: which roots the index covers. Persisted as a one-word file in
-- the plugin data directory because a plugin cannot write its own settings —
-- there is no setConfig in the host API — and the launcher entry has to read
-- the same choice.
local SCOPE_GLYPHS = { folder = "folder", all = "folders", external = "usb" }
local NEXT_SCOPE = { folder = "all", all = "external", external = "folder" }
local DEFAULT_SCOPE = "folder"
-- What a right click on a result does, switched by the second header button
-- and persisted next to the scope. Two actions, one gesture: a panel row can
-- only ever receive left and right — the declarative UI wires BTN_LEFT, plus
-- BTN_RIGHT once an onRightClick is attached, and nothing else. Middle click
-- exists for bar widgets only (onMiddleClick), never inside a panel, so the
-- second action lives on a toggle instead of a third button.
local ROW_ACTION_GLYPHS = { copy = "copy", reveal = "folder-open" }
local NEXT_ROW_ACTION = { copy = "reveal", reveal = "copy" }
local DEFAULT_ROW_ACTION = "copy"
-- How fzf scores a match, switched by the third header button and persisted
-- like the others. "path" is fzf's --scheme=path: it treats / as a strong
-- boundary, so a match that starts a file or folder name beats the same letters
-- buried in a long directory. "default" is fzf's generic scoring, which mostly
-- rewards the shortest path. Path wins on most queries here — "config" finds
-- .ssh/config instead of a Steam directory five levels down — but not on all of
-- them, which is exactly why it is a toggle and not a constant.
local RANKING_GLYPHS = { default = "arrows-sort", path = "sitemap" }
local NEXT_RANKING = { default = "path", path = "default" }
local DEFAULT_RANKING = "path"
-- A walk over a USB spinning disk is seek-bound and can run for tens of
-- minutes on a multi-terabyte drive, where the search folder alone is seconds;
-- the timeout follows the scope. Nothing at that price is ever started on its
-- own — see autoIndexAllowed.
local BUILD_TIMEOUT_LOCAL = 180000
local BUILD_TIMEOUT_DISKS = 1800000
-- Seconds a mount scan stays good for, so that mashing the scope button spawns
-- one lsblk instead of one per press.
local MOUNT_TTL = 30
-- Read out of the plugin's own manifest (readFile resolves a relative path
-- against the plugin directory), so the header cannot drift from the version
-- the store shows. Empty when unreadable — a missing version is not worth an
-- error line in the panel.
local pluginVersion = (function()
local text = noctalia.readFile("plugin.toml")
if type(text) ~= "string" then
return ""
end
return ("\n" .. text):match('\nversion%s*=%s*"([^"]+)"') or ""
end)()
-- Whether the installed fzf understands --scheme, decided once per script load
-- (see probeFzfScheme). False until it answers, and on any fzf too old for it.
local schemeSupported = false
local query = ""
local results = {} -- index records: relative to the root, or absolute
local total = nil -- entries in the index, shown in the footer
local buildMs = nil -- how long the walk behind that index took
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 scope = DEFAULT_SCOPE
local rowAction = DEFAULT_ROW_ACTION
local ranking = DEFAULT_RANKING
local mounts = {} -- mount points of the detected removable volumes
local mountsAt = 0 -- unix seconds of the last mount scan (0 = never)
local probing = false
local roots = {} -- what an index built now would cover, ancestors first
local indexRoots = {} -- what the index ON DISK was built with (see indexRootsOf)
local absoluteRecords = false -- records are absolute (set when #indexRoots > 1)
local indexState = "fresh" -- "fresh" | "stale" (usable, out of date) | "missing"
local reloading = false -- a refresh pass is in flight
local reloadQueued = false
local queuedForce = false
local queuedBuild = false
local totals = {} -- scope → { count, size, mtime }: see readTotal
local render
local runSearch
local buildIndex
local applyState
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
local function startsWith(value, prefix)
return value:sub(1, #prefix) == prefix
end
local function now()
return tonumber(noctalia.formatTime("%s")) or 0
end
-- --scheme=path arrived in fzf 0.36; an older build exits with "unknown option"
-- and the panel would show an empty list forever. So the flag is only ever used
-- after the installed version says it is understood — one spawn per script
-- load, generic ranking until it answers.
local function probeFzfScheme()
noctalia.runAsync("fzf --version 2>/dev/null", function(result)
local major, minor = (result.stdout or ""):match("(%d+)%.(%d+)")
major, minor = tonumber(major), tonumber(minor)
schemeSupported = major ~= nil and minor ~= nil and (major > 0 or minor >= 36)
end, 5000)
end
-- The scoring flag for the next search: what the toggle asks for, if fzf can.
local function rankingFlag()
return (ranking == "path" and schemeSupported) and " --scheme=path" or ""
end
-- Whether the plugin may walk the tree by itself. It may for the search folder,
-- which is local and takes seconds; it may NOT once an external disk is in
-- scope. A 5 TB mechanical USB drive takes minutes per walk and would otherwise
-- be re-walked on every scope change, settings change, disk plug and panel
-- open — the refresh button (and the launcher's rebuild row) is the only way.
local function autoIndexAllowed()
return scope == "folder"
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. One cache pair per scope, so
-- switching to the disks and back does not re-walk the search folder; 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. The scope is a
-- word from SCOPE_GLYPHS, never free text, so it is safe inside the path.
local function cacheSh(dir)
return "CACHE_DIR=" .. shellQuote(dir)
.. '\nCACHE="$CACHE_DIR/list-' .. scope .. '"'
.. '\nMETA="$CACHE_DIR/meta-' .. scope .. '"'
.. '\nCOUNTF="$CACHE_DIR/count-' .. scope .. '"'
end
local function readScope(dir)
local raw = noctalia.readFile(dir .. "/scope")
if type(raw) == "string" then
local value = trim(raw)
if SCOPE_GLYPHS[value] ~= nil then
return value
end
end
return DEFAULT_SCOPE
end
local function writeScope(dir, value)
local ok, err = noctalia.writeFile(dir .. "/scope", value)
if not ok then
noctalia.log("file-search: could not persist the scope: " .. tostring(err))
end
end
local function readRowAction(dir)
local raw = noctalia.readFile(dir .. "/row-action")
if type(raw) == "string" then
local value = trim(raw)
if ROW_ACTION_GLYPHS[value] ~= nil then
return value
end
end
return DEFAULT_ROW_ACTION
end
local function writeRowAction(dir, value)
local ok, err = noctalia.writeFile(dir .. "/row-action", value)
if not ok then
noctalia.log("file-search: could not persist the row action: " .. tostring(err))
end
end
local function readRanking(dir)
local raw = noctalia.readFile(dir .. "/ranking")
if type(raw) == "string" then
local value = trim(raw)
if RANKING_GLYPHS[value] ~= nil then
return value
end
end
return DEFAULT_RANKING
end
local function writeRanking(dir, value)
local ok, err = noctalia.writeFile(dir .. "/ranking", value)
if not ok then
noctalia.log("file-search: could not persist the ranking: " .. tostring(err))
end
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
-- ── removable volume detection ───────────────────────────────────────────────
-- A mount point worth indexing: absolute, not the system root, no control
-- character (it becomes a shell word and a cache record), and not the boot
-- partition, which is a mount of firmware files even when it sits on a stick.
local function usableMount(path)
if path == nil or path == "" or path:sub(1, 1) ~= "/" then
return false
end
if path == "/" or path:find("%c") ~= nil then
return false
end
return path ~= "/boot" and not startsWith(path, "/boot/")
end
-- lsblk --pairs quotes every value; depending on the util-linux version a
-- space or a quote inside one can come back as a \xNN escape. nil for an
-- escaped control byte: the whole mount point is then dropped by the caller,
-- rather than kept with a literal "\x0a" in it that no path would match.
local function unescapeHex(value)
local rejected = false
local out = value:gsub("\\x(%x%x)", function(hex)
local code = tonumber(hex, 16)
if code == nil or code < 32 or code == 127 then
rejected = true
return ""
end
return string.char(code)
end)
if rejected then
return nil
end
return out
end
local function pairValue(line, key)
return (" " .. line):match(" " .. key .. '="(.-)"')
end
-- Mount points of the volumes that came from a USB port or report themselves
-- removable (sticks, SD cards, optical media).
--
-- The interesting attribute lives on the DISK, not on the partition that is
-- actually mounted: a USB partition row reports TRAN="" and, for a bus-powered
-- SSD, RM="0" as well — only its parent disk says TRAN="usb". So the PKNAME
-- chain is walked upwards (one more level for an encrypted stick: crypt →
-- part → disk). HOTPLUG is deliberately not part of the test: some NVMe and
-- hot-swap SATA controllers report HOTPLUG="1" for internal drives.
local function parseLsblk(out)
local rows, byName = {}, {}
for line in out:gmatch("[^\n]+") do
local name = pairValue(line, "NAME")
if name ~= nil and name ~= "" then
local row = {
parent = pairValue(line, "PKNAME") or "",
removable = pairValue(line, "RM") == "1",
transport = pairValue(line, "TRAN") or "",
mount = unescapeHex(pairValue(line, "MOUNTPOINT") or ""),
}
byName[name] = row
table.insert(rows, row)
end
end
local found = {}
for _, row in ipairs(rows) do
if usableMount(row.mount) then
local node, depth = row, 0
-- Capped: PKNAME comes from outside and a cycle would hang here.
while node ~= nil and depth < 4 do
if node.transport == "usb" or node.removable then
table.insert(found, row.mount)
break
end
node = (node.parent ~= "") and byName[node.parent] or nil
depth += 1
end
end
end
return found
end
-- Fallback for a system without util-linux: the mount table still exposes the
-- udisks2 convention every mainstream desktop mounts removable media with
-- (/run/media/<user>/<label>), plus the older /media/<user>|<label> layout of
-- udisks1 and pmount. Paths in /proc/mounts spell space and tab as an octal
-- escape. Internal disks mounted by hand under /mnt are NOT picked up here —
-- same as with lsblk, where they are neither USB nor removable.
local function fallbackMounts()
local text = noctalia.readFile("/proc/mounts")
if type(text) ~= "string" then
return {}
end
local found = {}
for line in text:gmatch("[^\n]+") do
local raw = line:match("^%S+%s+(%S+)%s")
if raw ~= nil then
local rejected = false
local path = raw:gsub("\\(%d%d%d)", function(octal)
local code = tonumber(octal, 8)
if code == nil or code < 32 or code == 127 then
rejected = true
return ""
end
return string.char(code)
end)
if rejected then
path = ""
end
local rest = path:match("^/run/media/[^/]+/(.+)$") or path:match("^/media/(.+)$")
if rest ~= nil and rest ~= "" and usableMount(path) then
table.insert(found, path)
end
end
end
return found
end
-- Refresh `mounts`, then continue. Async because lsblk is a process: every
-- caller has to be written as a continuation. Skipped entirely — no spawn at
-- all — while the scope is the search folder, so the default costs nothing,
-- and a scan younger than MOUNT_TTL is reused unless `force` says otherwise
-- (the refresh button forces one, since noticing a disk is the point of it).
-- Callers must not call this concurrently: requestReload is the single caller
-- that serialises them.
local function probeMounts(force, done)
if scope == "folder" then
mounts = {}
done()
return
end
if not force and mountsAt > 0 and (now() - mountsAt) < MOUNT_TTL then
done()
return
end
local function finish()
mountsAt = now()
probing = false
done()
end
if probing then
-- requestReload serialises callers, so this is defensive only: never
-- start a second scan, and keep the list we already have rather than
-- replacing it with a narrower one.
done()
return
end
if not noctalia.commandExists("lsblk") then
mounts = fallbackMounts()
finish()
return
end
probing = true
local ok = noctalia.runAsync("lsblk -P -o NAME,PKNAME,RM,TRAN,MOUNTPOINT 2>/dev/null", function(result)
if result.exitCode == 0 and not result.timedOut then
mounts = parseLsblk(result.stdout or "")
else
mounts = fallbackMounts()
end
finish()
end, 10000)
if not ok then
mounts = fallbackMounts()
finish()
end
end
-- The roots the index covers, ancestors first and without overlaps: a volume
-- mounted inside the search folder (or inside another volume) would otherwise
-- be walked twice and produce duplicate rows. Sorting puts a parent before its
-- children, so one pass is enough to drop the nested ones.
local function computeRoots()
local list = {}
if scope ~= "external" then
table.insert(list, searchRoot())
end
if scope ~= "folder" then
for _, mount in ipairs(mounts) do
table.insert(list, (mount:gsub("/+$", "")))
end
end
table.sort(list)
local kept = {}
for _, path in ipairs(list) do
local nested = false
for _, parent in ipairs(kept) do
if path == parent or startsWith(path, parent == "/" and "/" or parent .. "/") then
nested = true
break
end
end
if path ~= "" and not nested then
table.insert(kept, path)
end
end
return kept
end
-- Everything that decides how the cache was built. Its two lists are joined
-- with a separator no root and no exclusion name can contain, so a name can
-- never be read back as a root.
local function indexKey()
return INDEX_FORMAT .. "\n" .. scope
.. "\n--\n" .. table.concat(roots, "\n")
.. "\n--\n" .. table.concat(excludeNames(), "\n")
end
-- The cache on disk is current when its fingerprint matches, whether the panel
-- or the launcher built it.
local function cacheFresh(dir)
return noctalia.readFile(dir .. "/meta-" .. scope) == indexKey()
end
-- The roots the index on disk was built with, read back out of its fingerprint,
-- or nil when there is no index. Records must be read the way they were
-- WRITTEN, not the way a fresh walk would write them now: unplug one of two
-- disks and the file still holds absolute records, so deciding the format from
-- the current mount list would make every row unopenable — including the rows
-- of the disk still attached — until the next rebuild.
local function indexRootsOf(dir)
local meta = noctalia.readFile(dir .. "/meta-" .. scope)
if type(meta) ~= "string" then
return nil
end
local section = meta:match("\n%-%-\n(.-)\n%-%-\n")
if section == nil then
return nil
end
local list = {}
for line in section:gmatch("[^\n]+") do
table.insert(list, line)
end
return #list > 0 and list or nil
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
-- 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.
--
-- A build records its own count in a `count-<scope>` sidecar, so the usual path
-- is a 7-byte read and no process at all. The rest is the fallback for an index
-- built before that existed: memoized per scope against the file's size and
-- mtime, because wc has to read the whole thing — an index of a large disk is
-- >100 MB, and paying that read on every scope change is what made the button
-- feel like it wedged the shell.
local function readTotal(dir)
local target = scope
buildMs = nil -- belongs to the index of this scope, if that one recorded it
local recorded = noctalia.readFile(dir .. "/count-" .. target)
if recorded ~= nil then
local count, elapsed = trim(recorded):match("^(%d+)%s+(%d+)$")
total = tonumber(count or trim(recorded)) or 0
buildMs = tonumber(elapsed)
return
end
local info = noctalia.fileInfo(dir .. "/list-" .. target)
if info == nil then
total = 0
return
end
local memo = totals[target]
if memo ~= nil and memo.size == info.size and memo.mtime == info.mtime then
total = memo.count
return
end
local cmd = cacheSh(dir) .. '\nwc -l < "$CACHE" 2>/dev/null'
noctalia.runAsync(cmd, function(result)
if result.exitCode == 0 and not result.timedOut then
local count = tonumber(trim(result.stdout or "")) or 0
totals[target] = { count = count, size = info.size, mtime = info.mtime }
if scope == target then
total = count
render()
end
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
if #roots == 0 then
-- Scope "external" with nothing plugged in. Nothing to walk, and no
-- cache to write either: render() says so, and the next call is one
-- cheap early return rather than a spawn per keystroke.
haveIndex = false
results = {}
total = 0
render()
return
end
indexing = true
errMsg = nil
local key = indexKey()
-- The format this walk WRITES follows the roots it is about to visit, not
-- the format of the index being replaced.
local builtRoots = roots
local absolute = #builtRoots > 1
local builtScope = scope
-- 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 volume noise and the user's
-- own exclusions.
local names = { "-name " .. shellQuote("*\n*") }
for _, pattern in ipairs(VOLUME_NOISE) do
table.insert(names, "-iname " .. shellQuote(pattern))
end
for _, name in ipairs(excludeNames()) do
table.insert(names, "-name " .. shellQuote(name))
end
local prune = "\\( " .. table.concat(names, " -o ") .. " \\) -prune -o "
local args = {}
for _, root in ipairs(roots) do
table.insert(args, shellQuote(root))
end
-- %P prints paths relative to the root, %p the whole path. Relative is
-- what a single root gets: it keeps the rows short and, more to the point,
-- keeps the common "/home/<user>/" prefix out of every line, where it
-- would be fuzzy-matched like any other text. With several roots a
-- relative record would be ambiguous, so those are absolute.
local printSpec = absolute
and "-type d -printf '%p/\\n' -o -printf '%p\\n'"
or "-type d -printf '%P/\\n' -o -printf '%P\\n'"
local walk = "find " .. table.concat(args, " ") .. " -mindepth 1 " .. prune
.. printSpec .. ' > "$TMP" 2>/dev/null'
-- 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).
-- The two cleanup lines drop the pre-0.0.20 single-scope cache and any
-- temp file a killed build left behind — only ones older than 90 minutes,
-- comfortably past the 30-minute build ceiling, so a walk running in the
-- other entry is never touched.
local cmd = cacheSh(dir)
.. '\nrm -f "$CACHE_DIR/index.list" "$CACHE_DIR/index.meta"\n'
.. 'find "$CACHE_DIR" -maxdepth 1 \\( -name \'list-*.*\' -o -name \'meta-*.*\''
.. " -o -name 'count-*.*' \\) -mmin +90 -delete 2>/dev/null\n"
-- Timed here rather than around runAsync: this is the walk itself,
-- without the queueing and callback hops the shell knows nothing about.
.. 'START=$(date +%s%3N)\n'
.. 'TMP=$(mktemp "$CACHE_DIR/list-' .. scope .. '.XXXXXX") || exit 1\n'
.. walk .. "\n"
.. 'mv -f "$TMP" "$CACHE" || exit 1\n'
.. 'TMPM=$(mktemp "$CACHE_DIR/meta-' .. scope .. '.XXXXXX") || exit 1\n'
.. "printf '%s' " .. shellQuote(key) .. ' > "$TMPM"\n'
.. 'mv -f "$TMPM" "$META" || exit 1\n'
-- The count is taken here, while the file is already hot, and left in a
-- sidecar together with the elapsed milliseconds: every later open
-- reads a dozen bytes instead of the whole index, and the footer can
-- still say how long the walk behind it took.
.. 'COUNT=$(wc -l < "$CACHE") || exit 1\n'
.. 'ELAPSED=$(( $(date +%s%3N) - START ))\n'
.. 'TMPN=$(mktemp "$CACHE_DIR/count-' .. scope .. '.XXXXXX") || exit 1\n'
.. 'printf "%s %s" "$COUNT" "$ELAPSED" > "$TMPN"\n'
.. 'mv -f "$TMPN" "$COUNTF" || exit 1\n'
.. 'printf "%s %s" "$COUNT" "$ELAPSED"'
render()
local timeout = (scope == "folder") and BUILD_TIMEOUT_LOCAL or BUILD_TIMEOUT_DISKS
local ok = noctalia.runAsync(cmd, function(result)
indexing = false
if result.exitCode == 0 and not result.timedOut then
totals[builtScope] = nil -- size/mtime moved; recount lazily
if builtScope ~= scope then
-- The scope was cycled while the walk ran, so what just landed
-- on disk is not what the panel is showing: none of it — the
-- count, the freshness — describes the current scope. Derive
-- the state again instead of inheriting it.
applyState()
else
-- The file on disk is the one this walk just wrote: read it
-- the way it was written.
indexRoots = builtRoots
absoluteRecords = absolute
haveIndex = true
local count, elapsed = trim(result.stdout or ""):match("^(%d+)%s+(%d+)$")
total = tonumber(count) or 0
buildMs = tonumber(elapsed)
if indexKey() == key then
indexState = "fresh"
runSearch()
elseif autoIndexAllowed() then
-- Roots or exclusions moved during the walk (a disk came or
-- went). Chasing that is fine for the search folder.
buildIndex()
else
-- For a disk it would be a second multi-minute walk nobody
-- asked for, so the index is just marked out of date.
indexState = "stale"
runSearch()
end
end
else
haveIndex = false
errMsg = tr("err_index")
end
render()
end, timeout)
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" .. rankingFlag() .. " --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 turned into a path, or nil when it could resolve outside the
-- roots it was supposed to come from. The cache is a plain user-editable file,
-- so records are untrusted. Only ever called from the two callbacks that act
-- on a row — never from render: it 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(record)
if record == "" or record:find("%c") ~= nil then
return nil
end
-- A directory keeps its trailing "/" in the index but not in a path meant
-- for xdg-open or for pasting into a shell.
local path = record:gsub("/+$", "")
for part in path:gmatch("[^/]+") do
if part == ".." then
return nil
end
end
-- Validated against the roots of the index the record came from, so a
-- record can still never escape a root, but one written for a disk that has
-- since been unplugged stays openable (xdg-open just fails if it is gone).
local from = #indexRoots > 0 and indexRoots or roots
if absoluteRecords then
for _, root in ipairs(from) do
if startsWith(path, root == "/" and "/" or root .. "/") then
return path
end
end
return nil
end
local root = from[1]
if root == nil or path == "" or path:sub(1, 1) == "/" then
return nil
end
if root == "/" then
return root .. path
end
return root .. "/" .. path
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
-- Copy leaves the panel up so several rows can be picked off in a row.
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
-- file:// URI for a local path. Every byte outside the unreserved set is
-- percent-encoded one byte at a time, which is also the correct encoding for
-- the UTF-8 sequences of a non-ASCII name — and it leaves the result free of
-- quotes and spaces, so it can be dropped into a GVariant literal as-is.
local function fileUri(path)
return "file://" .. (path:gsub("[^%w%-%._~/]", function(byte)
return string.format("%%%02X", string.byte(byte))
end))
end
-- Show the entry in the system file manager: its containing folder opens with
-- the entry itself selected, for a directory too (its parent opens, with the
-- directory highlighted) — left click is what enters a directory.
--
-- ShowItems on the org.freedesktop.FileManager1 interface is the portable way
-- to say "reveal this": Thunar, Nautilus, Dolphin, Nemo, Caja and PCManFM-Qt
-- all implement it, and the session bus activates the handler on demand, so
-- the file manager does not need to be running. When there is no such handler
-- (or no gdbus at all: the `||` also catches a 127 from the shell), the
-- fallback opens the containing folder through the MIME association, which is
-- the same window minus the selection.
local function revealEntry(rel)
local path = absolutePath(rel)
if path == nil then
rejectRecord(rel)
return
end
local parent = path:match("^(.*)/[^/]+$")
if parent == nil or parent == "" then
parent = "/"
end
local cmd = "gdbus call --session --dest org.freedesktop.FileManager1"
.. " --object-path /org/freedesktop/FileManager1"
.. " --method org.freedesktop.FileManager1.ShowItems "
.. shellQuote('["' .. fileUri(path) .. '"]') .. " ''"
.. " >/dev/null 2>&1 || xdg-open " .. shellQuote(parent) .. " >/dev/null 2>&1"
noctalia.runAsync(cmd)
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,
-- The secondary action, whichever the header toggle currently names.
-- 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 — that one is a bar-widget gesture.
-- Read at click time, not captured, so flipping the toggle changes
-- what the rows already on screen do.
onRightClick = function()
if rowAction == "reveal" then
revealEntry(rel)
else
copyEntry(rel)
end
end,
})
end
-- Milliseconds as something a person reads at a glance: "820 ms", "12.4 s",
-- "7m 41s". A disk walk is minutes, a warm home folder is under a second, and
-- the same string has to serve both.
local function formatDuration(ms)
if ms < 1000 then
return string.format("%d ms", ms)
end
local seconds = ms / 1000
if seconds < 60 then
return string.format("%.1f s", seconds)
end
return string.format("%dm %02ds", math.floor(seconds / 60), math.floor(seconds % 60))
end
-- One dim line of fzf's extended-search operators, between the results and the
-- status bar. They are the difference between "many matches" and "the one", and
-- nothing else in the panel hints that the query field understands more than
-- letters. One label, not a row of chips: the cheapest node count for something
-- that renders on every keystroke.
-- Two labels rather than one string: the caption says what the row is, dimmed
-- so it reads as a label and not as part of the syntax, and the operators
-- themselves stay in the brighter colour because they are the content.
local function syntaxLegend()
return ui.row({ key = "legend", gap = 6, align = "center" }, {
ui.label({
key = "legend-caption",
text = tr("syntax_hint_label"),
fontSize = 10,
color = "on_surface_variant",
}),
ui.label({ key = "legend-ops", text = tr("syntax_hint"), fontSize = 10, color = "on_surface" }),
})
end
-- Counts on the left, how long the walk behind this index took on the right.
-- The duration is the one recorded in the count sidecar, so it survives a
-- restart and follows the scope: switching to the disks shows the disks' own
-- walk, not the last one that happened to run. It stays on screen during a
-- rebuild too — "the last one took 7m 41s" is exactly what you want to know
-- while waiting for this one.
local function statusFooter()
local left, tone
if indexing then
-- One root gets named; several would wrap the footer onto a second
-- line, so they are counted instead.
left = #roots > 1
and tr("status_indexing_multi", { count = #roots })
or tr("status_indexing", { path = roots[1] or searchRoot() })
tone = "secondary"
else
left = tr("counts", { shown = #results, total = total or 0 })
if scope ~= "folder" then
left = left .. tr("counts_disks", { disks = #mounts })
end
if indexState == "stale" then
-- Searchable, but built for a different scope or disk set. Saying
-- so is the honest alternative to silently re-walking a 5 TB drive.
left = left .. tr("counts_stale")
tone = "secondary"
else
tone = "on_surface_variant"
end
end
local children = {
ui.label({ key = "counts", text = left, fontSize = 11, color = tone, flexGrow = 1 }),
}
if buildMs ~= nil then
table.insert(children, ui.label({
key = "elapsed",
text = tr("build_time", { duration = formatDuration(buildMs) }),
fontSize = 11,
color = "on_surface_variant",
}))
end
return ui.row({ key = "footer", gap = 8, align = "center" }, children)
end
-- The scope button cycles folder → folder+disks → disks only. A cycling button
-- rather than a ui.select: the panel is 480px tall and the header row is the
-- only space that costs nothing, and the tooltip names both the current scope
-- and the next one, which a select's collapsed state would not.
-- Names what a right click does, and switches it. Its glyph is the answer to
-- "what happens if I right-click a result", which is the question a user of a
-- two-gesture list actually asks.
local function rowActionButton()
return ui.button({
key = "rowaction-" .. rowAction,
glyph = ROW_ACTION_GLYPHS[rowAction],
variant = "ghost",
tooltip = tr("tip_row_action", {
current = tr("row_action." .. rowAction),
next = tr("row_action." .. NEXT_ROW_ACTION[rowAction]),
}),
onClick = "onCycleRowAction",
})
end
-- Names the scoring scheme and switches it. The tooltip has to carry the whole
-- explanation: the difference only shows up in the order of the rows, and there
-- is nowhere else to say what changed. When fzf is too old for --scheme the
-- button stays on the generic scheme and says why, rather than disappearing.
local function rankingButton()
local tip = schemeSupported
and tr("tip_ranking", {
current = tr("ranking." .. ranking),
next = tr("ranking." .. NEXT_RANKING[ranking]),
})
or tr("tip_ranking_unsupported")
return ui.button({
key = "ranking-" .. ranking,
glyph = RANKING_GLYPHS[schemeSupported and ranking or "default"],
variant = "ghost",
tooltip = tip,
onClick = "onCycleRanking",
})
end
local function scopeButton()
return ui.button({
key = "scope-" .. scope,
glyph = SCOPE_GLYPHS[scope],
variant = "ghost",
tooltip = tr("tip_scope", {
current = tr("scope." .. scope),
next = tr("scope." .. NEXT_SCOPE[scope]),
}),
onClick = "onCycleScope",
})
end
render = function()
-- Every child of the panel and of the header carries a stable key, so the
-- reconciler matches roles instead of guessing by position and type.
local children = {
ui.row({ key = "header", gap = 6, align = "center" }, {
ui.label({
key = "title",
text = tr("title"),
fontSize = 16,
fontWeight = "bold",
color = "on_surface",
}),
-- Version off the manifest, small and dimmed: it answers "which
-- build am I running" without competing with the title. The spacer
-- rather than a flexGrow title keeps the two together on the left.
ui.label({ key = "version", text = pluginVersion, fontSize = 10, color = "on_surface_variant" }),
ui.spacer({ key = "gap", flexGrow = 1 }),
rankingButton(),
rowActionButton(),
scopeButton(),
ui.button({ key = "refresh", glyph = "refresh", variant = "ghost", tooltip = tr("tip_refresh"), onClick = "onRefreshIndex" }),
ui.button({ key = "settings", glyph = "settings", variant = "ghost", tooltip = tr("tip_settings"), onClick = "onOpenSettings" }),
ui.button({ key = "close", 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",
}),
}
-- Whatever the state, the body is ONE keyed column that grows: the results
-- list and every message live inside it. Swapping node types at the top
-- level is what left the panel short of its own height, with dead space
-- under the status bar: the reconciler matches children by (type, key), so
-- a changed sequence detaches them all and re-matches — unkeyed labels get
-- handed to whichever role asks for a label first, and a prop the new role
-- does not set (flexGrow among them) is never reset, it just carries over
-- from the previous tenant. A constant shape has nothing to re-match.
local body
if fzfMissing then
body = ui.label({ key = "body-msg", text = tr("err_no_fzf"), color = "error", flexGrow = 1 })
elseif errMsg ~= nil then
body = ui.label({ key = "body-msg", text = errMsg, color = "error", flexGrow = 1 })
elseif #roots == 0 and not indexing then
-- Only reachable with scope "external": nothing to search, and
-- "no matches" would blame the query for it.
body = ui.label({ key = "body-msg", text = tr("no_external"), color = "on_surface_variant", flexGrow = 1 })
elseif indexState == "missing" and not indexing then
-- A disk scope that has never been indexed. Walking it is a
-- minutes-long operation on a large drive, so it is offered rather
-- than performed.
body = ui.label({ key = "body-msg", text = tr("index_missing"), color = "on_surface_variant", flexGrow = 1 })
elseif #results == 0 and not indexing and trim(query) ~= "" then
body = ui.label({
key = "body-msg",
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
body = ui.scroll({ key = "hits", flexGrow = 1, gap = 2 }, rows)
end
table.insert(children, ui.column({ key = "body", flexGrow = 1, align = "stretch" }, { body }))
table.insert(children, syntaxLegend())
table.insert(children, statusFooter())
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children))
end
-- Recompute the roots from the current scope and mounts, then search the cache
-- that belongs to them. A stale fingerprint only triggers a walk when
-- autoIndexAllowed() says so; otherwise the existing index is searched as-is
-- and flagged, or the panel asks for a rebuild when there is none at all.
applyState = function()
roots = computeRoots()
local dir = dataDir()
if dir == nil then
errMsg = tr("err_index")
render()
return
end
-- How to read what is on disk; a walk started below overwrites both.
indexRoots = indexRootsOf(dir) or roots
absoluteRecords = #indexRoots > 1
if cacheFresh(dir) then
haveIndex = true
indexState = "fresh"
-- buildIndex would have set the total; reusing a cache has to go and
-- count it (memoized).
readTotal(dir)
runSearch()
elseif autoIndexAllowed() then
results = {}
total = nil
buildIndex()
else
-- Out of date, and not ours to fix on our own: an index built for
-- another disk set still answers most queries, so it is searched and
-- labelled rather than thrown away.
haveIndex = noctalia.fileExists(dir .. "/list-" .. scope)
indexState = haveIndex and "stale" or "missing"
if haveIndex then
readTotal(dir)
runSearch()
else
results = {}
total = 0
end
end
render()
end
-- The one way to refresh what the panel shows. Mashing the scope button must
-- not stack work: while a pass is in flight the next one is a single flag, and
-- it then runs once against whatever the state has become. Without this, every
-- press queued its own mount scan, its own count and its own search.
--
-- force: rescan the disks even if the last scan is still young.
-- build: walk the tree when the probe comes back, whatever the fingerprint
-- says. Only the refresh button passes it — it is the explicit command that
-- autoIndexAllowed() defers to.
local function requestReload(force, build)
if reloading then
reloadQueued = true
queuedForce = queuedForce or force
queuedBuild = queuedBuild or build
return
end
reloading = true
probeMounts(force, function()
reloading = false
if build then
roots = computeRoots()
-- The index being replaced stays readable until the walk lands, so
-- a row clicked meanwhile still resolves.
local dir = dataDir()
if dir ~= nil then
indexRoots = indexRootsOf(dir) or roots
absoluteRecords = #indexRoots > 1
end
results = {}
total = nil
errMsg = nil
haveIndex = false
buildIndex()
render()
else
applyState()
end
if reloadQueued then
reloadQueued = false
local againForce, againBuild = queuedForce, queuedBuild
queuedForce, queuedBuild = false, false
requestReload(againForce, againBuild)
end
end)
end
function onOpen(_context)
fzfMissing = not noctalia.commandExists("fzf")
query = ""
results = {}
errMsg = nil
-- Not trusted across opens: the scope may have moved under us (the
-- launcher can cycle it), so nothing is searched until reload() has
-- checked the fingerprint of the cache this scope actually uses.
haveIndex = false
inputRev += 1
noctalia.state.set("file_search_open", true)
local dir = dataDir()
if dir ~= nil then
-- Re-read on every open: the launcher may have cycled the scope, and
-- a script reload drops both of these from memory.
scope = readScope(dir)
rowAction = readRowAction(dir)
ranking = readRanking(dir)
end
if fzfMissing then
render()
return
end
render() -- the panel is up while lsblk answers
requestReload(false, false)
end
function onClose()
noctalia.state.set("file_search_open", false)
end
function onConfigChanged()
if fzfMissing or indexing then
return
end
-- Scope and mounts are re-read rather than reused: this fires while the
-- panel is closed too, when the launcher may have cycled the scope and the
-- in-memory mount list can predate it. Rebuilding a disk index from a stale
-- (empty) list would throw away a good index and take minutes to redo.
local dir = dataDir()
if dir ~= nil then
scope = readScope(dir)
end
requestReload(false, false)
end
function onQueryChanged(value)
query = value
runSearch()
end
function onOpenFirst(value)
query = value
if results[1] ~= nil then
openEntry(results[1])
end
end
-- The explicit command. This is the ONLY thing that walks an external disk:
-- opening the panel, changing the scope, changing a setting or plugging a disk
-- in all leave the existing index alone and just mark it out of date.
function onRefreshIndex()
if indexing then
return
end
-- A refresh is also how a disk plugged in after the panel opened gets
-- noticed, so the mount scan is forced rather than reused.
requestReload(true, true)
end
-- Allowed mid-build on purpose: a walk over a slow disk can run for minutes and
-- there is no way to cancel it, so blocking the button would trap the user in
-- the scope they are trying to leave. The in-flight build finishes against the
-- cache of the scope it started in and never starts another one by itself.
--
-- The press itself is deliberately cheap — a 6-byte write and a render. Every
-- process it might need (mount scan, count, search) goes through
-- requestReload, which collapses a burst of presses into one pass.
-- The index is untouched; only the order of the rows changes, so the current
-- query is simply run again through the other scheme.
function onCycleRanking()
if not schemeSupported then
return
end
ranking = NEXT_RANKING[ranking] or DEFAULT_RANKING
local dir = dataDir()
if dir ~= nil then
writeRanking(dir, ranking)
end
render()
runSearch()
end
-- Nothing to reload: this only changes what a right click will do next.
function onCycleRowAction()
rowAction = NEXT_ROW_ACTION[rowAction] or DEFAULT_ROW_ACTION
local dir = dataDir()
if dir ~= nil then
writeRowAction(dir, rowAction)
end
render()
end
function onCycleScope()
scope = NEXT_SCOPE[scope] or DEFAULT_SCOPE
local dir = dataDir()
if dir ~= nil then
writeScope(dir, scope)
end
results = {}
total = nil
errMsg = nil
render()
requestReload(false, false)
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
probeFzfScheme()