--!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=` 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-) ".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//