diff --git a/file-search/README.md b/file-search/README.md index 3559827..4b64a93 100644 --- a/file-search/README.md +++ b/file-search/README.md @@ -4,7 +4,8 @@ A [noctalia](https://github.com/noctalia-dev/noctalia) v5 bar plugin: fuzzy search files and folders as you type, with [fzf](https://github.com/junegunn/fzf) as the matching subsystem. Click the bar glyph to open a search panel; picking a result opens it with the system MIME association (`xdg-open`) β€” directories -open in your file manager. +open in your file manager. One button widens the search to the USB disks you +have plugged in, or narrows it to those alone. ## Plugin @@ -42,15 +43,79 @@ In the panel: On a result row: -| Action | Effect | -|-------------|------------------------------------------------------------| -| Left click | Open it with the system MIME association | -| Right click | Copy its absolute path to the clipboard (panel stays open) | +| Action | Effect | +|-------------|----------------------------------------------------------| +| Left click | Open it with the system MIME association | +| Right click | Copy its path, *or* reveal it in the file manager | + +The πŸ—/πŸ— button in the panel header picks which of the two, and remembers it. +*Reveal* opens the containing folder with the item selected. Both leave the +panel up, so several rows can be picked off in a row. (Middle click is not an +option: a panel row only ever receives left and right clicks.) A path too long for one row is shortened in the middle rather than at the end, so the file name β€” the part the query matched β€” always stays readable: `.local/share/flatpak/repo/tmp/cache/…dolphin.idx.sig`. +The plugin version sits next to the panel title. The footer counts what is +listed and what is indexed, and on the right how long the walk behind that +index took β€” per scope, kept across restarts, and still visible while a new +walk runs, which is when knowing the last one's cost is most useful. + +### Search syntax + +The query goes to `fzf` as-is, so its extended-search operators work here. The +panel keeps a one-line reminder of them above the status bar. + +| Query | Matches | +|---|---| +| `panel luau` | both terms, in any order (space is AND) | +| `'panel.luau` | **exact**, not fuzzy β€” a single quote, not double quotes | +| `^src` | at the start | +| `.webp$` | at the end | +| `luau !src` | `luau`, excluding anything with `src` | +| `.toml$ \| .json$` | either one (spaces around the `\|` are required) | + +A lowercase query is case-insensitive; one uppercase letter anywhere makes it +case-sensitive. There is no regex: fzf does not have one. + +The πŸ— /πŸ—Ί button in the header switches how matches are scored, and remembers it: + +| Glyph | Ranking | +|---|---| +| πŸ—Ί | **path-aware** *(default)* β€” a match starting a file or folder name wins, so `config` finds `.ssh/config` and not the deepest `…/Steam Controller Configs/` | +| πŸ—  | generic β€” fzf's own scoring, which mostly rewards the shortest path | + +Path-aware costs about a third more CPU per keystroke and needs fzf 0.36 or +newer; on an older build the button stays on generic and says so. + +### Searching external disks + +The πŸ—€ button in the panel header cycles what the search covers: + +| Glyph | Scope | Covers | +|-------|-------|--------| +| πŸ—€ | Search folder only *(default)* | The `search_folder` setting, as before | +| πŸ—€πŸ—€ | Search folder + external disks | Both, in one index | +| ⚿ | External disks only | Only the mounted removable volumes | + +An *external disk* is a mounted volume that came from a USB port or reports +itself removable: sticks and drives (bus-powered SSDs and LUKS-encrypted ones +included), SD cards, optical media. Internal drives never count, not even a +second SATA/NVMe under `/mnt` β€” put that in `search_folder` instead. The plugin +mounts nothing; it only sees what your desktop has already mounted. + +The choice survives restarts and is shared with the `/fs` launcher, which offers +the same switch. Each scope keeps its own index, so switching to the disks and +back does not re-walk your home folder. + +**External disks are only ever indexed on command**, because walking a +multi-terabyte drive takes minutes. Switching scope, opening the panel, changing +a setting or plugging a disk in never start a walk: the index stays in use and +the footer says *out of date*. The ↻ button (or *Rebuild search index* in the +launcher) is what rebuilds it, and a scope never indexed says so and waits. The +search folder alone keeps re-indexing itself, as before β€” it takes seconds. + The panel header also carries a βš™ button that opens this plugin's page in *Settings β†’ Plugins*, and a ↻ button that rebuilds the index. The same settings page opens from the command line, so it can be bound in your compositor too: @@ -67,8 +132,10 @@ In the noctalia launcher (keyboard-first flow, native navigation): | `↑` / `↓` | Move through the results | | `Enter` | Open the selected result (MIME/xdg-open) | -With an empty `/fs` query the list also offers *Rebuild search index*; the -index is shared with the panel and built on demand when missing. +With an empty `/fs` query the list also offers *Rebuild search index* and the +scope switch; the index is shared with the panel. *Rebuild search index* walks +the tree there and then, keeping the launcher open, and is offered next to the +results whenever the index is out of date. ## Features @@ -78,6 +145,8 @@ index is shared with the panel and built on demand when missing. - Configurable bar glyph, search folder (defaults to `~`), excluded folder names (`.git, node_modules, .cache, .venv` by default, matched anywhere in the tree), hidden entries on/off, max results +- One button to search the mounted USB/removable disks as well, or only those: + one index per scope, and disks walked only when you ask - `Enter` opens the top match; every result row opens on click via the system MIME association β€” files in their default app, folders in the file manager @@ -86,9 +155,12 @@ index is shared with the panel and built on demand when missing. `Enter` (plugin panels cannot receive arrow keys in the current Luau API, so the launcher is the keyboard way to browse results) - Folder results are marked with a trailing `/` and a folder glyph -- Index rebuilds automatically when the relevant settings change, and on - demand via the panel's refresh button (external file changes are picked up - on rebuild) +- Right click copies a result's path, or reveals it in the file manager with the + item selected (`org.freedesktop.FileManager1.ShowItems` β€” Thunar, Nautilus, + Dolphin, Nemo, Caja and PCManFM-Qt all implement it) +- The search-folder index rebuilds itself when the relevant settings change, and + on demand via the panel's refresh button; any index covering an external disk + rebuilds on demand only - Panel placement (attached/floating), position and open-near-click are the standard per-panel settings noctalia exposes in Settings β†’ Plugins @@ -106,11 +178,18 @@ index is shared with the panel and built on demand when missing. - noctalia v5.0.0-beta.6 or newer β€” the first tagged release that accepts `plugin_api = 15` (`noctalia.openSettings()`, the panel's βš™ button) -- [`fzf`](https://github.com/junegunn/fzf) β€” the fuzzy matcher -- `find` (GNU findutils) β€” walks the search folder into the index +- [`fzf`](https://github.com/junegunn/fzf) β€” the fuzzy matcher. 0.36 or newer + for the path-aware ranking; older builds work, with fzf's default ranking +- `find` (GNU findutils) β€” walks the roots into the index - `xdg-open` (xdg-utils) β€” opens results with the MIME association -- `mktemp`, `mv`, `wc`, `head`, `rm` β€” GNU coreutils, standard on any Linux - desktop +- `mktemp`, `mv`, `wc`, `head`, `rm`, `date` β€” GNU coreutils, standard on any + Linux desktop (`date` times the index walk for the footer) +- `lsblk` (util-linux) β€” lists the mounted USB/removable volumes; only run when + the scope includes them. Missing, it falls back to `/proc/mounts` and the + udisks2 layout (`/run/media//…`, `/media/…`) +- `gdbus` (glib2) β€” reveals a result in the file manager + (`FileManager1.ShowItems`); only run on that right click. Missing, or with no + file manager implementing it, the click opens the containing folder instead ## Install @@ -131,16 +210,42 @@ noctalia msg plugins enable nightwatch75/file-search - The index lives in the plugin's private data directory (`noctalia.pluginDataDir()`, by default `~/.local/state/noctalia/plugins/data/nightwatch75/file-search/` β€” honors - `NOCTALIA_STATE_HOME`/`XDG_STATE_HOME`): `index.list` is a plain list of - paths relative to the search folder, and `index.meta` records which folder - and exclusions built it, so both the panel and the launcher rebuild - automatically after a settings change. + `NOCTALIA_STATE_HOME`/`XDG_STATE_HOME`): `list-` is a plain list of + paths, `meta-` records the scope, roots and exclusions that built it, + `count-` its line count and the walk's duration in milliseconds, and + `scope`, `row-action` and `ranking` the one word each + header toggle cycles. A fingerprint that no longer matches β€” a settings + change, a scope change, a disk plugged in or removed β€” rebuilds the + search-folder index automatically and marks a disk index out of date. +- Several disks share one index, not one each: a rebuild walks every mounted + volume in a single pass. Records are relative to the root when there is only + one (the common case, and what keeps the rows short) and absolute when there + are several β€” and they are always read the way the index was *written*, so + unplugging one of two disks leaves the rest of the results openable. +- Volume metadata is pruned at every root, since a disk used on Windows or macOS + otherwise contributes tens of thousands of records that are not your files: + `lost+found`, `$RECYCLE.BIN`, `RECYCLER`, `System Volume Information`, + `.Trash-*`, `.Spotlight-V100`, `.fseventsd`, `.Trashes`, `.TemporaryItems`, + `.DocumentRevisions-V100`, `Backups.backupdb`, `*.sparsebundle`, + `*.backupbundle`, `._*`, `.DS_Store`, `.AppleDouble`, `.AppleDB`, + `.AppleDesktop`, `Network Trash Folder`, `Temporary Items`, + `TheVolumeSettingsFolder`. +- `find` is bound by metadata latency, so a spinning USB drive with a million + files runs for minutes β€” hence on-demand only. A walk covering disks gets 30 + minutes against 3 for the search folder, and the panel stays usable + throughout, scope button included: a second walk is never queued. +- Cheap by design elsewhere too: the mount scan is cached 30 seconds and never + concurrent, and the line count comes from a `count-` sidecar instead of + re-reading a >100 MB index. +- Detection reads the transport and removable flags of the parent disk, not of + the mounted partition β€” a USB partition reports neither. NVMe and SATA drives + advertising hot-plug are deliberately not treated as removable. - Both files are written to `mktemp`-created private files and renamed into place, so a rebuild never writes through a symlink planted at the cache path. - Names containing a newline are excluded from the index (they would break - the one-record-per-line format), and every record is validated against - the search root before being opened. + the one-record-per-line format), and every record is validated against the + roots it claims to come from before being opened. - Excluded entries match by folder/file *name* (`find -name`), not by path; entries containing `/` are skipped and logged. - With hidden entries off, anything starting with a dot is pruned β€” both diff --git a/file-search/launcher.luau b/file-search/launcher.luau index f73e88a..2ebf689 100644 --- a/file-search/launcher.luau +++ b/file-search/launcher.luau @@ -7,12 +7,56 @@ -- 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 pendingQuery = nil -- latest query typed while a search/index ran +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 @@ -28,6 +72,49 @@ 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() @@ -39,12 +126,32 @@ local function dataDir() 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. +-- 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/index.list"\nMETA="$CACHE_DIR/index.meta"' + .. '\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() @@ -82,37 +189,228 @@ local function excludeNames() 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 searchRoot() .. "\n" .. table.concat(excludeNames(), "\n") + 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 .. "/index.meta") == indexKey() + return noctalia.readFile(dir .. "/meta-" .. scope) == indexKey() end --- One cache record, about to be joined to the search root. The cache is a --- plain user-editable file, so records are untrusted: reject anything that --- could resolve outside the root. -local function safeRel(rel) - if rel == "" or rel:sub(1, 1) == "/" or rel:find("\n", 1, true) then - return false +-- 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 - for part in rel:gmatch("[^/]+") do + 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 false + return nil end end - return true + -- 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 @@ -123,51 +421,104 @@ local function buildIndex(query) 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 user's exclusions. + -- 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) - .. '\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" + .. '\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/index.meta.XXXXXX") || 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' + .. '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 indexKey() ~= key then - -- Settings changed while the walk was running: the cache - -- holds paths relative to the old root, and activation - -- would join them to the new one. Rebuild instead of - -- searching stale records (mirrors the panel). - buildIndex(queued or query) + 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 - runQuery(queued or query) + -- 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, 180000) + end, timeout) if not ok then indexing = false launcher.setResults(query, { noopRow("err_spawn") }) @@ -188,13 +539,19 @@ runQuery = function(query) launcher.setResults(query, { noopRow("err_index") }) return end - searching = true 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 --filter=" .. shellQuote(text) + cmd = cacheSh(dir) .. "\nfzf" .. rankingFlag() .. " --filter=" .. shellQuote(text) .. ' < "$CACHE" 2>/dev/null | head -n ' .. MAX_RESULTS end local ok = noctalia.runAsync(cmd, function(result) @@ -216,12 +573,12 @@ runQuery = function(query) table.insert(rows, noopRow("launcher.no_results")) end if text == "" then - table.insert(rows, { - id = "reindex", - title = tr("launcher.reindex_title"), - subtitle = searchRoot(), - glyph = "refresh", - }) + 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 @@ -235,6 +592,50 @@ runQuery = function(query) 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") }) @@ -245,42 +646,77 @@ function onQuery(query) launcher.setResults(query, { noopRow("err_index") }) return end - -- A missing or stale index (no meta, or built with a different root or - -- exclusion set) is rebuilt before searching: stale relative paths - -- joined to a new root could resolve to unrelated files. - if not cacheFresh(dir) then - buildIndex(query) - return - end - runQuery(query) + -- 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 == "reindex" then + if id == "scope" then local dir = dataDir() if dir == nil then return end - -- Drop cache and fingerprint so the next keystroke rebuilds against - -- fresh disk state (a surviving .meta would read as a fresh index). - noctalia.runAsync(cacheSh(dir) .. '\nrm -f "$CACHE" "$META"', function(_result) - noctalia.notify(tr("title"), tr("launcher.reindex_done")) - 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 - if not safeRel(rel) then + 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 - local path = searchRoot() - if path ~= "/" then - path = path .. "/" - end - path = path .. rel:gsub("/+$", "") noctalia.runAsync("xdg-open " .. shellQuote(path) .. " >/dev/null 2>&1") end + +probeFzfScheme() diff --git a/file-search/panel.luau b/file-search/panel.luau index 12fe6e2..fef82e0 100644 --- a/file-search/panel.luau +++ b/file-search/panel.luau @@ -1,16 +1,23 @@ --!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 +-- 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 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. +-- 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 @@ -28,19 +35,122 @@ 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 = {} -- relative paths; directories keep a trailing "/" +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) @@ -54,6 +164,40 @@ 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() @@ -65,12 +209,70 @@ local function dataDir() 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. +-- 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/index.list"\nMETA="$CACHE_DIR/index.meta"' + .. '\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() @@ -114,14 +316,238 @@ local function excludeNames() return names end -local function indexKey() - return searchRoot() .. "\n" .. table.concat(excludeNames(), "\n") +-- ── 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 --- The cache on disk is current when its fingerprint matches the settings, --- whether the panel or the launcher built it. +-- 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//