Update nightwatch75/file-search to 0.0.25 (#162)
Co-authored-by: nightwatch75 <nightwatch75@users.noreply.github.com>
This commit is contained in:
co-authored by
nightwatch75
parent
cea8a8b1b7
commit
0cb921baff
+125
-20
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user