Update nightwatch75/file-search to 0.0.25 (#162)

Co-authored-by: nightwatch75 <nightwatch75@users.noreply.github.com>
This commit is contained in:
nightwatch75
2026-07-30 21:27:23 -04:00
committed by GitHub
co-authored by nightwatch75
parent cea8a8b1b7
commit 0cb921baff
5 changed files with 1653 additions and 211 deletions
+125 -20
View File
@@ -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/<user>/…`, `/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-<scope>` is a plain list of
paths, `meta-<scope>` records the scope, roots and exclusions that built it,
`count-<scope>` 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-<scope>` 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
+494 -58
View File
@@ -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()
+996 -124
View File
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -2,17 +2,20 @@
# The bar glyph toggles a search panel: the search folder is indexed with
# `find` (honoring the excluded directories), every keystroke is matched
# through `fzf --filter`, and picking a result opens it with the system
# MIME association (xdg-open).
# MIME association (xdg-open). The panel's disk button widens the search to
# the mounted USB/removable volumes, detected with `lsblk`.
id = "nightwatch75/file-search"
name = "File Search"
version = "0.0.19"
version = "0.0.25"
plugin_api = 15
author = "nightwatch75"
license = "MIT"
# The exact commands the plugin spawns (fzf aside, findutils + coreutils +
# xdg-utils on any Linux desktop).
dependencies = ["fzf", "find", "mktemp", "mv", "wc", "head", "rm", "xdg-open"]
# The exact commands the plugin spawns (fzf, lsblk and gdbus aside, findutils +
# coreutils + xdg-utils on any Linux desktop). lsblk only runs while the search
# scope includes the external disks, gdbus only on a reveal-in-file-manager
# click, and that one falls back to xdg-open when it is missing.
dependencies = ["fzf", "find", "lsblk", "gdbus", "mktemp", "mv", "wc", "head", "rm", "date", "xdg-open"]
tags = ["bar", "launcher", "panel", "utility", "productivity"]
icon = "search"
description = "Search files and folders as you type, fuzzy-matched with fzf; open results with the system MIME association."
+30 -4
View File
@@ -1,18 +1,37 @@
{
"build_time": "indexed in {duration}",
"copied_entry": "Path copied to clipboard",
"copied_path": "Search folder path copied to clipboard",
"counts": "{shown} shown · {total} indexed",
"counts_disks": " · {disks} external",
"counts_stale": " · out of date",
"err_bad_record": "Ignored an invalid index record — rebuild the index",
"err_index": "Failed to index the search folder",
"err_no_fzf": "fzf not found — install fzf to use this plugin",
"err_spawn": "Could not run the search command",
"index_missing": "External disks are only indexed on demand — press ↻ to index this scope",
"launcher": {
"index_missing": "No index for this scope yet",
"indexing": "Indexing files…",
"no_results": "No matches",
"reindex_done": "Search index dropped — it rebuilds on the next search",
"reindex_title": "Rebuild search index"
"reindex_title": "Rebuild search index",
"scope_next": "Switch to: {next}",
"scope_title": "Searching: {current}"
},
"no_external": "No external disk mounted — plug one in, then rebuild the index",
"no_results": "No matches for \"{query}\"",
"ranking": {
"default": "generic — the shortest path wins",
"path": "path-aware — a match starting a name wins"
},
"row_action": {
"copy": "copies its path",
"reveal": "shows it in the file manager"
},
"scope": {
"all": "search folder + external disks",
"external": "external disks only",
"folder": "search folder only"
},
"search_placeholder": "Type to search files and folders…",
"settings": {
"exclude_dirs": {
@@ -37,10 +56,17 @@
}
},
"status_indexing": "Indexing {path}…",
"syntax_hint": "'exact · ^starts · ends$ · !exclude · a | b",
"syntax_hint_label": "fzf syntax:",
"status_indexing_multi": "Indexing {count} locations…",
"tip_close": "Close",
"tip_ranking": "Ranking: {current}\ne.g. \"config\" → .ssh/config, not …/Steam Controller Configs/\nClick to switch to: {next}",
"tip_ranking_unsupported": "Path-aware ranking needs fzf 0.36 or newer",
"tip_refresh": "Rebuild the file index",
"tip_row_action": "Right click on a result {current}\nClick to switch to: {next}",
"tip_scope": "Searching: {current}\nClick to search {next}",
"tip_settings": "Plugin settings",
"title": "File Search",
"tooltip_closed": "File search — {path}\nClick to search · right-click: folder · middle-click: copy path",
"tooltip_closed": "File search — {path}\nClick to search · right-click: folder",
"tooltip_open": "File search open — {path}\nClick to close"
}