--!nonstrict -- file-search — launcher provider: the same fuzzy search with the native -- keyboard flow of the noctalia launcher (type, arrows, Enter). -- -- `/fs ` fuzzy-matches over the index file the panel builds; when the -- index is missing it is built here on demand. Activating a result opens it -- with the system MIME association (xdg-open) — directories open in the -- file manager. Late async results map back through the query echo of -- launcher.setResults, so fzf can answer out of band. -- -- The search scope (search folder / + removable disks / disks only) is the one -- the panel persists in the plugin data directory; an empty `/fs` query offers -- a row that cycles it here too, and each scope keeps its own index, so -- switching back and forth never re-walks a tree twice. -- -- Every helper below the tr() line down to computeRoots() is a copy of the -- panel's: entries are separate scripts with no module system, so the two must -- be kept in step by hand — in particular indexKey(), which is the fingerprint -- that lets one entry reuse an index the other built. local MAX_RESULTS = 9 local INDEX_FORMAT = "2" -- Seconds a mount scan stays good for. Long enough that a burst of keystrokes -- costs one lsblk at most, short enough that a disk plugged in mid-session -- shows up without a manual refresh. local MOUNT_TTL = 30 local VOLUME_NOISE = { "lost+found", "$RECYCLE.BIN", "RECYCLER", "System Volume Information", ".Trash-*", ".Spotlight-V100", ".fseventsd", ".Trashes", ".TemporaryItems", ".DocumentRevisions-V100", ".PKInstallSandboxManager", "Backups.backupdb", "*.sparsebundle", "*.backupbundle", "._*", ".DS_Store", ".AppleDouble", ".AppleDB", ".AppleDesktop", "Network Trash Folder", "Temporary Items", "TheVolumeSettingsFolder", } local SCOPE_GLYPHS = { folder = "folder", all = "folders", external = "usb" } local NEXT_SCOPE = { folder = "all", all = "external", external = "folder" } local DEFAULT_SCOPE = "folder" local BUILD_TIMEOUT_LOCAL = 180000 local BUILD_TIMEOUT_DISKS = 1800000 local searching = false local indexing = false local probing = false local forceBuild = false -- set by the rebuild row, consumed by the next onQuery local pendingQuery = nil -- latest query typed while a search/index/probe ran local scope = DEFAULT_SCOPE local mounts = {} local mountsAt = 0 -- unix seconds of the last mount scan (0 = never) local roots = {} local indexRoots = {} -- what the index ON DISK was built with (indexRootsOf) local absoluteRecords = false local staleIndex = false -- searching an index that no longer matches the disks local ranking = "path" -- scoring scheme, as persisted by the panel's toggle local schemeSupported = false -- whether this fzf understands --scheme at all local runQuery 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 -- Same version gate as the panel: fzf learned --scheme in 0.36 and an older one -- would exit on an unknown option, leaving the launcher with an empty list. 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 local function rankingFlag() return (ranking == "path" and schemeSupported) and " --scheme=path" or "" end -- The panel owns the toggle; the launcher just follows what it wrote, re-read -- per query like the scope so the two never rank the same index differently. local function readRanking(dir) local raw = noctalia.readFile(dir .. "/ranking") if type(raw) == "string" then local value = trim(raw) if value == "path" or value == "default" then return value end end return "path" end -- Mirrors the panel: the search folder may be re-walked whenever its -- fingerprint goes stale, an external disk never is. A 5 TB mechanical drive -- takes minutes per walk, and a keystroke is not a mandate to spend them — the -- rebuild row below is. 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; 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/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 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 -- Same exclusion set as the panel: names from the setting plus hidden -- entries unless enabled. 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 ~= "" and not name:find("/") then table.insert(names, name) end end if noctalia.getConfig("show_hidden") ~= true then table.insert(names, ".*") end return names end -- ── removable volume detection (mirrors panel.luau) ────────────────────────── 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 -- nil for an escaped control byte, so the caller drops that mount point -- entirely (see panel.luau). 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 -- USB/removable mount points. The transport lives on the disk, not on the -- mounted partition, so the PKNAME chain is walked upwards; see the long -- comment in panel.luau. 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 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 udisks2 mount convention. 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 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 -- Must build the same string as the panel's indexKey(): the fingerprint in -- the .meta sidecar is how the two entries recognize each other's index. 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 the settings, -- 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 -- (see panel.luau): records must be read the way they were written, or unplug -- one of two disks and every row stops resolving 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 -- One cache record, about to be turned into a path. The cache is a plain -- user-editable file, so records are untrusted: reject anything that could -- resolve outside the roots it claims to come from. local function absolutePath(record) if record == "" or record:find("%c") ~= nil then return nil end local path = record:gsub("/+$", "") for part in path:gmatch("[^/]+") do if part == ".." then return nil end end -- Against the roots of the index the record came from, not of a walk that -- would happen now: a record can still never escape a root. 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 noopRow(titleKey) return { id = "noop", title = tr(titleKey), glyph = "info-circle" } end -- Row that cycles the scope, offered whenever the query is empty (and whenever -- there is nothing to search), so the launcher is never a dead end when the -- scope points at disks that are not plugged in. local function scopeRow() return { id = "scope", title = tr("launcher.scope_title", { current = tr("scope." .. scope) }), subtitle = tr("launcher.scope_next", { next = tr("scope." .. NEXT_SCOPE[scope]) }), glyph = SCOPE_GLYPHS[scope], } end -- Activating this is what authorises a walk. It is always offered on an empty -- query, and pushed to the top when the index for a disk scope is missing or -- out of date, since nothing else will rebuild it. local function reindexRow() return { id = "reindex", title = tr("launcher.reindex_title"), subtitle = #roots > 0 and table.concat(roots, " · ") or tr("scope." .. scope), glyph = "refresh", } end local function buildIndex(query) if indexing then pendingQuery = query return end local dir = dataDir() if dir == nil then launcher.setResults(query, { noopRow("err_index") }) return end if #roots == 0 then -- Scope "external" with nothing plugged in: nothing to walk, and no -- cache to write either. Say so, and keep the row that switches back. launcher.setResults(query, { noopRow("no_external"), scopeRow() }) return end indexing = true pendingQuery = query local key = indexKey() -- The format this walk writes follows the roots it is about to visit. 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 -- Relative records for a single root (short rows, and no common prefix for -- fzf to match on), absolute when several roots share one index. 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). 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" .. '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' -- Same count sidecar the panel's footer reads — count and elapsed -- milliseconds — written while the file is hot so nobody has to re-read -- 100 MB to show a number. .. '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' launcher.setResults(query, { noopRow("launcher.indexing") }) local timeout = (scope == "folder") and BUILD_TIMEOUT_LOCAL or BUILD_TIMEOUT_DISKS local ok = noctalia.runAsync(cmd, function(result) indexing = false local queued = pendingQuery pendingQuery = nil if result.exitCode == 0 and not result.timedOut then if builtScope ~= scope then -- The scope row was activated while the walk ran: this index -- is not the one the launcher is showing. Start the query over -- so roots, records and freshness are derived for the scope -- that is actually current. onQuery(queued or query) else -- The file on disk is the one this walk just wrote: read it -- the way it was written. indexRoots = builtRoots absoluteRecords = absolute if indexKey() == key then staleIndex = false runQuery(queued or query) elseif autoIndexAllowed() then -- Roots or exclusions moved during the walk; chasing that -- is fine for the search folder. buildIndex(queued or query) else -- For a disk it would be a second unasked-for walk, which -- is exactly what this plugin must not do. staleIndex = true runQuery(queued or query) end end else launcher.setResults(queued or query, { noopRow("err_index") }) end end, timeout) if not ok then indexing = false launcher.setResults(query, { noopRow("err_spawn") }) end end runQuery = function(query) if indexing then pendingQuery = query return end if searching then pendingQuery = query return end local dir = dataDir() if dir == nil then launcher.setResults(query, { noopRow("err_index") }) return end local text = trim(query) if #roots == 0 then -- Scope "external" with nothing plugged in: say so, and keep the row -- that switches back within reach. launcher.setResults(query, { noopRow("no_external"), scopeRow() }) return end searching = true local cmd if text == "" then cmd = cacheSh(dir) .. '\nhead -n ' .. MAX_RESULTS .. ' "$CACHE" 2>/dev/null' else cmd = cacheSh(dir) .. "\nfzf" .. rankingFlag() .. " --filter=" .. shellQuote(text) .. ' < "$CACHE" 2>/dev/null | head -n ' .. MAX_RESULTS end local ok = noctalia.runAsync(cmd, function(result) searching = false local rows = {} if not result.timedOut then for line in (result.stdout or ""):gmatch("[^\n]+") do local isDir = line:sub(-1) == "/" local rel = line:gsub("/+$", "") table.insert(rows, { id = "open:" .. line, title = rel:match("[^/]+$") or rel, subtitle = line, glyph = isDir and "folder" or "file", }) end end if #rows == 0 and text ~= "" then table.insert(rows, noopRow("launcher.no_results")) end if text == "" then table.insert(rows, scopeRow()) table.insert(rows, reindexRow()) elseif staleIndex then -- Results came from an index that no longer matches the disks; -- the way to fix that travels with them. table.insert(rows, reindexRow()) end launcher.setResults(query, rows) if pendingQuery ~= nil and pendingQuery ~= query then local nextQuery = pendingQuery pendingQuery = nil runQuery(nextQuery) end end, 15000) if not ok then searching = false end end -- Refresh the mount list if the scope needs it and the last scan has aged out, -- then continue with whatever query is current by then. With the default scope -- no process is spawned at all. local function withMounts(query, done) if scope == "folder" then mounts = {} done(query) return end if mountsAt > 0 and (now() - mountsAt) < MOUNT_TTL then done(query) return end if probing then pendingQuery = query return end probing = true local function finish() probing = false mountsAt = now() local queued = pendingQuery pendingQuery = nil done(queued or query) end if not noctalia.commandExists("lsblk") then mounts = fallbackMounts() finish() return end 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 function onQuery(query) if not noctalia.commandExists("fzf") then launcher.setResults(query, { noopRow("err_no_fzf") }) return end local dir = dataDir() if dir == nil then launcher.setResults(query, { noopRow("err_index") }) return end -- Re-read on every query: the panel may have cycled the scope or the -- ranking since the last keystroke, and both files are a single word. scope = readScope(dir) ranking = readRanking(dir) withMounts(query, function(current) roots = computeRoots() -- How to read what is on disk right now; a walk below overwrites both. indexRoots = indexRootsOf(dir) or roots absoluteRecords = #indexRoots > 1 if cacheFresh(dir) then staleIndex = false runQuery(current) return end -- A missing or stale index (no meta, or built with a different scope, -- root set or exclusions) is rebuilt before searching — but only for -- the search folder, or when the rebuild row asked for it. Records of -- a vanished root are not joined to a new one either way: they are -- validated against the current roots when activated. if forceBuild or autoIndexAllowed() then forceBuild = false staleIndex = false buildIndex(current) return end staleIndex = true if noctalia.fileExists(dir .. "/list-" .. scope) then runQuery(current) else launcher.setResults(current, { noopRow("launcher.index_missing"), reindexRow(), scopeRow() }) end end) end function onActivate(id) if id == "scope" then local dir = dataDir() if dir == nil then return end scope = NEXT_SCOPE[scope] or DEFAULT_SCOPE writeScope(dir, scope) mountsAt = 0 -- the new scope may need a mount list this one never took -- Rewriting the query keeps the launcher open (the host only closes on -- an activation that does not call setQuery) and re-enters onQuery with -- an empty text, so the list comes back with the new scope applied. launcher.setQuery("") return end if id == "reindex" then -- The explicit command to walk the tree. Rewriting the query keeps the -- launcher open and re-enters onQuery, which consumes the flag and -- starts the build with "Indexing files…" on screen. It does not drop -- the current index first: if the walk fails or is aborted, the old one -- is still there to search. forceBuild = true mountsAt = 0 -- rescan: a disk may have been plugged in since launcher.setQuery("") return end local rel = id:match("^open:(.+)$") if rel == nil then return end local path = absolutePath(rel) if path == nil then noctalia.log("file-search: refusing unsafe index record: " .. rel) noctalia.notify(tr("title"), tr("err_bad_record")) return end noctalia.runAsync("xdg-open " .. shellQuote(path) .. " >/dev/null 2>&1") end probeFzfScheme()