Add nightwatch75/file-search 0.0.9 — fuzzy file search (bar widget + panel + launcher provider) (#26)
* Add nightwatch75/file-search 0.0.7 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * file-search: backtick dependency names in Requirements (CI check) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * file-search 0.0.8: address review — safe index records, atomic cache writes, command dependencies, index fingerprint Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * file-search 0.0.8: min_noctalia → plugin_api = 3 (manifest format change) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * file-search 0.0.9: move index to noctalia.pluginDataDir() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Massimiliano <m.angei@iotron.it> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: nightwatch75 <nightwatch75@users.noreply.github.com>
This commit is contained in:
co-authored by
Massimiliano
Claude Fable 5
nightwatch75
parent
da237fe43b
commit
c71ec00354
@@ -0,0 +1,130 @@
|
|||||||
|
# File Search
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Plugin
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | `nightwatch75/file-search` |
|
||||||
|
| Entries | Bar widget: `file-search`; panel: `panel`; launcher provider: `launcher` |
|
||||||
|
| Launcher Prefix | `/fs` |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Add the `file-search` widget from Noctalia's widget picker and click it to
|
||||||
|
open the search panel. You can also open the panel directly or bind it in
|
||||||
|
your compositor:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
noctalia msg panel-toggle nightwatch75/file-search:panel
|
||||||
|
```
|
||||||
|
|
||||||
|
| Action | Effect |
|
||||||
|
|--------------|-------------------------------------------------|
|
||||||
|
| Left click | Open/close the search panel |
|
||||||
|
| Right click | Open the search folder in the file manager |
|
||||||
|
| Middle click | Copy the search folder path to the clipboard |
|
||||||
|
|
||||||
|
In the panel:
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---------|-------------------------------------|
|
||||||
|
| `Enter` | Open the top match |
|
||||||
|
| `Esc` | Close the panel (noctalia default) |
|
||||||
|
|
||||||
|
In the noctalia launcher (keyboard-first flow, native navigation):
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-------------|-------------------------------------------|
|
||||||
|
| `/fs <text>`| Fuzzy search files and folders |
|
||||||
|
| `↑` / `↓` | 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.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Live results while you type: the search folder is walked once with `find`
|
||||||
|
into a cache, then every keystroke is fuzzy-matched through
|
||||||
|
`fzf --filter`, so typing stays responsive even on large trees
|
||||||
|
- 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
|
||||||
|
- `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
|
||||||
|
- Launcher provider for a keyboard-first flow: type `/fs <text>` in the
|
||||||
|
noctalia launcher and navigate the results with the native arrow keys +
|
||||||
|
`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)
|
||||||
|
- Panel placement (attached/floating), position and open-near-click are the
|
||||||
|
standard per-panel settings noctalia exposes in Settings → Plugins
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
| Setting | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `search_folder` | `folder` | *(empty)* | Root folder the search indexes. Empty = your home folder. |
|
||||||
|
| `exclude_dirs` | `string` | `.git, node_modules, .cache, .venv` | Folder names skipped while indexing, separated by `,` or `;`, matched anywhere in the tree. |
|
||||||
|
| `show_hidden` | `bool` | `false` | Index files and folders whose name starts with a dot. |
|
||||||
|
| `max_results` | `int` | `50` | How many matches the panel lists at most (10–200). |
|
||||||
|
| `glyph` (widget) | `glyph` | `search` | Icon shown on the bar. |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- noctalia ≥ 5.0.0
|
||||||
|
- [`fzf`](https://github.com/junegunn/fzf) — the fuzzy matcher
|
||||||
|
- `find` (GNU findutils) — walks the search folder 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
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install **File Search** from Noctalia's plugin store (*Settings → Plugins*),
|
||||||
|
then add the widget to a bar from *Settings → Bar*. Plugin options live in
|
||||||
|
*Settings → Plugins*.
|
||||||
|
|
||||||
|
For local development, add your working copy as a path source instead
|
||||||
|
(`.luau` edits hot-reload):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
noctalia msg plugins source add dev path /path/to/plugins
|
||||||
|
noctalia msg plugins enable nightwatch75/file-search
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- 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
|
||||||
|
hidden folders (not descended into) and hidden files.
|
||||||
|
- Unreadable subtrees are silently skipped (permission errors don't fail the
|
||||||
|
index).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
--!nonstrict
|
||||||
|
-- file-search — bar widget that toggles the fuzzy search panel.
|
||||||
|
--
|
||||||
|
-- The panel (panel.luau) publishes its open state on the shared
|
||||||
|
-- "file_search_open" state key; the glyph lights up while it is open.
|
||||||
|
--
|
||||||
|
-- Click mapping:
|
||||||
|
-- Left click — open/close the search panel
|
||||||
|
-- Right click — open the search folder in the file manager
|
||||||
|
-- Middle click — copy the search folder path to the clipboard
|
||||||
|
|
||||||
|
local PANEL_ID = "nightwatch75/file-search:panel"
|
||||||
|
|
||||||
|
local open = false
|
||||||
|
|
||||||
|
local function shellQuote(value)
|
||||||
|
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function searchRoot()
|
||||||
|
local dir = noctalia.getConfig("search_folder")
|
||||||
|
if dir == nil or dir == "" then
|
||||||
|
dir = noctalia.getenv("HOME") or "/tmp"
|
||||||
|
else
|
||||||
|
dir = noctalia.expandPath(dir)
|
||||||
|
end
|
||||||
|
dir = dir:gsub("/+$", "")
|
||||||
|
if dir == "" then
|
||||||
|
dir = "/"
|
||||||
|
end
|
||||||
|
return dir
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render()
|
||||||
|
barWidget.setGlyph(noctalia.getConfig("glyph"))
|
||||||
|
local root = searchRoot()
|
||||||
|
if open then
|
||||||
|
barWidget.setGlyphColor("primary")
|
||||||
|
barWidget.setTooltip(noctalia.tr("tooltip_open", { path = root }))
|
||||||
|
else
|
||||||
|
barWidget.setGlyphColor("on_surface")
|
||||||
|
barWidget.setTooltip(noctalia.tr("tooltip_closed", { path = root }))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.state.watch("file_search_open", function(value)
|
||||||
|
open = value == true
|
||||||
|
render()
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Periodic re-render keeps the glyph and tooltip in sync with settings changes.
|
||||||
|
function update()
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onClick()
|
||||||
|
noctalia.togglePanel(PANEL_ID)
|
||||||
|
end
|
||||||
|
|
||||||
|
function onRightClick()
|
||||||
|
noctalia.runAsync("xdg-open " .. shellQuote(searchRoot()) .. " >/dev/null 2>&1")
|
||||||
|
end
|
||||||
|
|
||||||
|
function onMiddleClick()
|
||||||
|
noctalia.copyToClipboard(searchRoot(), "text/plain")
|
||||||
|
noctalia.notify(noctalia.tr("title"), noctalia.tr("copied_path"))
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.setUpdateInterval(1000)
|
||||||
|
render()
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
--!nonstrict
|
||||||
|
-- file-search — launcher provider: the same fuzzy search with the native
|
||||||
|
-- keyboard flow of the noctalia launcher (type, arrows, Enter).
|
||||||
|
--
|
||||||
|
-- `/fs <text>` fuzzy-matches over the index file the panel builds; when the
|
||||||
|
-- index is missing it is built here on demand. Activating a result opens it
|
||||||
|
-- with the system MIME association (xdg-open) — directories open in the
|
||||||
|
-- file manager. Late async results map back through the query echo of
|
||||||
|
-- launcher.setResults, so fzf can answer out of band.
|
||||||
|
|
||||||
|
local MAX_RESULTS = 9
|
||||||
|
|
||||||
|
local searching = false
|
||||||
|
local indexing = false
|
||||||
|
local pendingQuery = nil -- latest query typed while a search/index ran
|
||||||
|
|
||||||
|
local runQuery
|
||||||
|
|
||||||
|
local function tr(key, args)
|
||||||
|
return noctalia.tr(key, args)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function trim(value)
|
||||||
|
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shellQuote(value)
|
||||||
|
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Private per-plugin storage (XDG state); the host creates the directory on
|
||||||
|
-- every call. nil (with a log line) when no state directory resolves.
|
||||||
|
local function dataDir()
|
||||||
|
local dir, err = noctalia.pluginDataDir()
|
||||||
|
if dir == nil then
|
||||||
|
noctalia.log("file-search: pluginDataDir failed: " .. tostring(err))
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return dir
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Shell header shared by every cache command. 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"'
|
||||||
|
end
|
||||||
|
|
||||||
|
local function searchRoot()
|
||||||
|
local dir = noctalia.getConfig("search_folder")
|
||||||
|
if dir == nil or dir == "" then
|
||||||
|
dir = noctalia.getenv("HOME") or "/tmp"
|
||||||
|
else
|
||||||
|
dir = noctalia.expandPath(dir)
|
||||||
|
end
|
||||||
|
dir = dir:gsub("/+$", "")
|
||||||
|
if dir == "" then
|
||||||
|
dir = "/"
|
||||||
|
end
|
||||||
|
return dir
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Same exclusion set as the panel: names from the setting plus hidden
|
||||||
|
-- entries unless enabled.
|
||||||
|
local function excludeNames()
|
||||||
|
local raw = noctalia.getConfig("exclude_dirs")
|
||||||
|
if type(raw) ~= "string" then
|
||||||
|
raw = ""
|
||||||
|
end
|
||||||
|
local names = {}
|
||||||
|
for entry in raw:gmatch("[^,;]+") do
|
||||||
|
local name = trim(entry)
|
||||||
|
if name ~= "" and not name:find("/") then
|
||||||
|
table.insert(names, name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if noctalia.getConfig("show_hidden") ~= true then
|
||||||
|
table.insert(names, ".*")
|
||||||
|
end
|
||||||
|
return names
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 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")
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
end
|
||||||
|
for part in rel:gmatch("[^/]+") do
|
||||||
|
if part == ".." then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function noopRow(titleKey)
|
||||||
|
return { id = "noop", title = tr(titleKey), glyph = "info-circle" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function buildIndex(query)
|
||||||
|
if indexing then
|
||||||
|
pendingQuery = query
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local dir = dataDir()
|
||||||
|
if dir == nil then
|
||||||
|
launcher.setResults(query, { noopRow("err_index") })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
indexing = true
|
||||||
|
pendingQuery = query
|
||||||
|
|
||||||
|
local key = indexKey()
|
||||||
|
-- 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.
|
||||||
|
local names = { "-name " .. shellQuote("*\n*") }
|
||||||
|
for _, name in ipairs(excludeNames()) do
|
||||||
|
table.insert(names, "-name " .. shellQuote(name))
|
||||||
|
end
|
||||||
|
local prune = "\\( " .. table.concat(names, " -o ") .. " \\) -prune -o "
|
||||||
|
-- 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"
|
||||||
|
.. 'mv -f "$TMP" "$CACHE" || exit 1\n'
|
||||||
|
.. 'TMPM=$(mktemp "$CACHE_DIR/index.meta.XXXXXX") || exit 1\n'
|
||||||
|
.. "printf '%s' " .. shellQuote(key) .. ' > "$TMPM"\n'
|
||||||
|
.. 'mv -f "$TMPM" "$META" || exit 1'
|
||||||
|
|
||||||
|
launcher.setResults(query, { noopRow("launcher.indexing") })
|
||||||
|
local ok = noctalia.runAsync(cmd, function(result)
|
||||||
|
indexing = false
|
||||||
|
local queued = pendingQuery
|
||||||
|
pendingQuery = nil
|
||||||
|
if result.exitCode == 0 and not result.timedOut then
|
||||||
|
runQuery(queued or query)
|
||||||
|
else
|
||||||
|
launcher.setResults(queued or query, { noopRow("err_index") })
|
||||||
|
end
|
||||||
|
end, 180000)
|
||||||
|
if not ok then
|
||||||
|
indexing = false
|
||||||
|
launcher.setResults(query, { noopRow("err_spawn") })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
runQuery = function(query)
|
||||||
|
if indexing then
|
||||||
|
pendingQuery = query
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if searching then
|
||||||
|
pendingQuery = query
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local dir = dataDir()
|
||||||
|
if dir == nil then
|
||||||
|
launcher.setResults(query, { noopRow("err_index") })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
searching = true
|
||||||
|
local text = trim(query)
|
||||||
|
local cmd
|
||||||
|
if text == "" then
|
||||||
|
cmd = cacheSh(dir) .. '\nhead -n ' .. MAX_RESULTS .. ' "$CACHE" 2>/dev/null'
|
||||||
|
else
|
||||||
|
cmd = cacheSh(dir) .. "\nfzf --filter=" .. shellQuote(text)
|
||||||
|
.. ' < "$CACHE" 2>/dev/null | head -n ' .. MAX_RESULTS
|
||||||
|
end
|
||||||
|
local ok = noctalia.runAsync(cmd, function(result)
|
||||||
|
searching = false
|
||||||
|
local rows = {}
|
||||||
|
if not result.timedOut then
|
||||||
|
for line in (result.stdout or ""):gmatch("[^\n]+") do
|
||||||
|
local isDir = line:sub(-1) == "/"
|
||||||
|
local rel = line:gsub("/+$", "")
|
||||||
|
table.insert(rows, {
|
||||||
|
id = "open:" .. line,
|
||||||
|
title = rel:match("[^/]+$") or rel,
|
||||||
|
subtitle = line,
|
||||||
|
glyph = isDir and "folder" or "file",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #rows == 0 and text ~= "" then
|
||||||
|
table.insert(rows, noopRow("launcher.no_results"))
|
||||||
|
end
|
||||||
|
if text == "" then
|
||||||
|
table.insert(rows, {
|
||||||
|
id = "reindex",
|
||||||
|
title = tr("launcher.reindex_title"),
|
||||||
|
subtitle = searchRoot(),
|
||||||
|
glyph = "refresh",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
launcher.setResults(query, rows)
|
||||||
|
if pendingQuery ~= nil and pendingQuery ~= query then
|
||||||
|
local nextQuery = pendingQuery
|
||||||
|
pendingQuery = nil
|
||||||
|
runQuery(nextQuery)
|
||||||
|
end
|
||||||
|
end, 15000)
|
||||||
|
if not ok then
|
||||||
|
searching = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function onQuery(query)
|
||||||
|
if not noctalia.commandExists("fzf") then
|
||||||
|
launcher.setResults(query, { noopRow("err_no_fzf") })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local dir = dataDir()
|
||||||
|
if dir == nil then
|
||||||
|
launcher.setResults(query, { noopRow("err_index") })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- 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)
|
||||||
|
end
|
||||||
|
|
||||||
|
function onActivate(id)
|
||||||
|
if id == "reindex" 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)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local rel = id:match("^open:(.+)$")
|
||||||
|
if rel == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not safeRel(rel) 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
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
--!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
|
||||||
|
-- (excluded directory names are pruned, hidden entries too unless enabled);
|
||||||
|
-- after that every keystroke runs `fzf --filter=<query>` 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.
|
||||||
|
|
||||||
|
local query = ""
|
||||||
|
local results = {} -- relative paths; directories keep a trailing "/"
|
||||||
|
local total = nil -- entries in the index, shown in the footer
|
||||||
|
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 render
|
||||||
|
local runSearch
|
||||||
|
local buildIndex
|
||||||
|
|
||||||
|
-- Per-row callbacks need distinct global names; the reconciler dispatches
|
||||||
|
-- callbacks by name only. getfenv() is the script environment lua_getglobal
|
||||||
|
-- reads from, so assigning into it defines the callback the host will find.
|
||||||
|
local env = getfenv()
|
||||||
|
local function rowCallback(prefix, index, fn)
|
||||||
|
local name = prefix .. "_" .. index
|
||||||
|
env[name] = fn
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
|
||||||
|
local function tr(key, args)
|
||||||
|
return noctalia.tr(key, args)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function trim(value)
|
||||||
|
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shellQuote(value)
|
||||||
|
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Private per-plugin storage (XDG state); the host creates the directory on
|
||||||
|
-- every call. nil (with a log line) when no state directory resolves.
|
||||||
|
local function dataDir()
|
||||||
|
local dir, err = noctalia.pluginDataDir()
|
||||||
|
if dir == nil then
|
||||||
|
noctalia.log("file-search: pluginDataDir failed: " .. tostring(err))
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return dir
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Shell header shared by every cache command. 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"'
|
||||||
|
end
|
||||||
|
|
||||||
|
local function searchRoot()
|
||||||
|
local dir = noctalia.getConfig("search_folder")
|
||||||
|
if dir == nil or dir == "" then
|
||||||
|
dir = noctalia.getenv("HOME") or "/tmp"
|
||||||
|
else
|
||||||
|
dir = noctalia.expandPath(dir)
|
||||||
|
end
|
||||||
|
dir = dir:gsub("/+$", "")
|
||||||
|
if dir == "" then
|
||||||
|
dir = "/"
|
||||||
|
end
|
||||||
|
return dir
|
||||||
|
end
|
||||||
|
|
||||||
|
local function maxResults()
|
||||||
|
return math.max(10, math.min(200, tonumber(noctalia.getConfig("max_results")) or 50))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Excluded directory names from the setting, split on ',' or ';'. Matching
|
||||||
|
-- is by basename (find -name), so entries containing '/' are skipped and
|
||||||
|
-- logged. Hidden entries are folded in as an extra '.*' pattern.
|
||||||
|
local function excludeNames()
|
||||||
|
local raw = noctalia.getConfig("exclude_dirs")
|
||||||
|
if type(raw) ~= "string" then
|
||||||
|
raw = ""
|
||||||
|
end
|
||||||
|
local names = {}
|
||||||
|
for entry in raw:gmatch("[^,;]+") do
|
||||||
|
local name = trim(entry)
|
||||||
|
if name:find("/") then
|
||||||
|
noctalia.log("file-search: ignoring exclude entry with '/': '" .. name .. "'")
|
||||||
|
elseif name ~= "" then
|
||||||
|
table.insert(names, name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if noctalia.getConfig("show_hidden") ~= true then
|
||||||
|
table.insert(names, ".*")
|
||||||
|
end
|
||||||
|
return names
|
||||||
|
end
|
||||||
|
|
||||||
|
local function indexKey()
|
||||||
|
return searchRoot() .. "\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()
|
||||||
|
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
|
||||||
|
end
|
||||||
|
for part in rel:gmatch("[^/]+") do
|
||||||
|
if part == ".." then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
buildIndex = function()
|
||||||
|
if fzfMissing or indexing then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local dir = dataDir()
|
||||||
|
if dir == nil then
|
||||||
|
errMsg = tr("err_index")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
indexing = true
|
||||||
|
errMsg = nil
|
||||||
|
local key = indexKey()
|
||||||
|
|
||||||
|
-- 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.
|
||||||
|
local names = { "-name " .. shellQuote("*\n*") }
|
||||||
|
for _, name in ipairs(excludeNames()) do
|
||||||
|
table.insert(names, "-name " .. shellQuote(name))
|
||||||
|
end
|
||||||
|
local prune = "\\( " .. table.concat(names, " -o ") .. " \\) -prune -o "
|
||||||
|
-- %P prints paths relative to the root; the trailing '/' marks
|
||||||
|
-- directories so rows get the right glyph. 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"
|
||||||
|
.. 'mv -f "$TMP" "$CACHE" || exit 1\n'
|
||||||
|
.. 'TMPM=$(mktemp "$CACHE_DIR/index.meta.XXXXXX") || exit 1\n'
|
||||||
|
.. "printf '%s' " .. shellQuote(key) .. ' > "$TMPM"\n'
|
||||||
|
.. 'mv -f "$TMPM" "$META" || exit 1\n'
|
||||||
|
.. 'wc -l < "$CACHE"'
|
||||||
|
|
||||||
|
render()
|
||||||
|
local ok = noctalia.runAsync(cmd, function(result)
|
||||||
|
indexing = false
|
||||||
|
if result.exitCode == 0 and not result.timedOut then
|
||||||
|
haveIndex = true
|
||||||
|
total = tonumber(trim(result.stdout or "")) or 0
|
||||||
|
if indexKey() ~= key then
|
||||||
|
buildIndex() -- settings changed while the walk was running
|
||||||
|
else
|
||||||
|
runSearch()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
haveIndex = false
|
||||||
|
errMsg = tr("err_index")
|
||||||
|
end
|
||||||
|
render()
|
||||||
|
end, 180000)
|
||||||
|
if not ok then
|
||||||
|
indexing = false
|
||||||
|
errMsg = tr("err_spawn")
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
runSearch = function()
|
||||||
|
if fzfMissing or indexing or searching or not haveIndex then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local dir = dataDir()
|
||||||
|
if dir == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
searching = true
|
||||||
|
local q = query
|
||||||
|
local limit = maxResults()
|
||||||
|
local cmd
|
||||||
|
if trim(q) == "" then
|
||||||
|
cmd = cacheSh(dir) .. '\nhead -n ' .. limit .. ' "$CACHE" 2>/dev/null'
|
||||||
|
else
|
||||||
|
-- head closes the pipe early; the pipeline status stays head's (0).
|
||||||
|
cmd = cacheSh(dir) .. "\nfzf --filter=" .. shellQuote(q)
|
||||||
|
.. ' < "$CACHE" 2>/dev/null | head -n ' .. limit
|
||||||
|
end
|
||||||
|
local ok = noctalia.runAsync(cmd, function(result)
|
||||||
|
searching = false
|
||||||
|
if not result.timedOut then
|
||||||
|
results = {}
|
||||||
|
for line in (result.stdout or ""):gmatch("[^\n]+") do
|
||||||
|
table.insert(results, line)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- The query moved on while this search ran: chase it. The stale
|
||||||
|
-- rows rendered below are overwritten as soon as it completes.
|
||||||
|
if query ~= q then
|
||||||
|
runSearch()
|
||||||
|
end
|
||||||
|
render()
|
||||||
|
end, 15000)
|
||||||
|
if not ok then
|
||||||
|
searching = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function openEntry(rel)
|
||||||
|
if not safeRel(rel) 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")
|
||||||
|
panel.close()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resultRow(rel, index)
|
||||||
|
local isDir = rel:sub(-1) == "/"
|
||||||
|
return ui.button({
|
||||||
|
key = "hit-" .. index,
|
||||||
|
glyph = isDir and "folder" or "file",
|
||||||
|
text = rel,
|
||||||
|
variant = "ghost",
|
||||||
|
contentAlign = "start",
|
||||||
|
onClick = rowCallback("openHit", index, function()
|
||||||
|
openEntry(rel)
|
||||||
|
end),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function statusFooter()
|
||||||
|
if indexing then
|
||||||
|
return ui.label({ text = tr("status_indexing", { path = searchRoot() }), fontSize = 11, color = "secondary" })
|
||||||
|
end
|
||||||
|
local shown = #results
|
||||||
|
return ui.label({
|
||||||
|
text = tr("counts", { shown = shown, total = total or 0 }),
|
||||||
|
fontSize = 11,
|
||||||
|
color = "on_surface_variant",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
render = function()
|
||||||
|
local children = {
|
||||||
|
ui.row({ gap = 6, align = "center" }, {
|
||||||
|
ui.label({
|
||||||
|
text = tr("title"),
|
||||||
|
fontSize = 16,
|
||||||
|
fontWeight = "bold",
|
||||||
|
color = "on_surface",
|
||||||
|
flexGrow = 1,
|
||||||
|
}),
|
||||||
|
ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("tip_refresh"), onClick = "onRefreshIndex" }),
|
||||||
|
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("tip_close"), onClick = "onClosePanel" }),
|
||||||
|
}),
|
||||||
|
ui.input({
|
||||||
|
key = "query-" .. inputRev,
|
||||||
|
value = query,
|
||||||
|
focus = true,
|
||||||
|
placeholder = tr("search_placeholder"),
|
||||||
|
onChange = "onQueryChanged",
|
||||||
|
onSubmit = "onOpenFirst",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if fzfMissing then
|
||||||
|
table.insert(children, ui.label({ text = tr("err_no_fzf"), color = "error" }))
|
||||||
|
elseif errMsg ~= nil then
|
||||||
|
table.insert(children, ui.label({ text = errMsg, color = "error" }))
|
||||||
|
else
|
||||||
|
if #results == 0 and not indexing and trim(query) ~= "" then
|
||||||
|
table.insert(children, ui.label({
|
||||||
|
text = tr("no_results", { query = query }),
|
||||||
|
color = "on_surface_variant",
|
||||||
|
flexGrow = 1,
|
||||||
|
}))
|
||||||
|
else
|
||||||
|
local rows = {}
|
||||||
|
for index, rel in ipairs(results) do
|
||||||
|
table.insert(rows, resultRow(rel, index))
|
||||||
|
end
|
||||||
|
table.insert(children, ui.scroll({ flexGrow = 1, gap = 2 }, rows))
|
||||||
|
end
|
||||||
|
table.insert(children, statusFooter())
|
||||||
|
end
|
||||||
|
|
||||||
|
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children))
|
||||||
|
end
|
||||||
|
|
||||||
|
function onOpen(_context)
|
||||||
|
fzfMissing = not noctalia.commandExists("fzf")
|
||||||
|
query = ""
|
||||||
|
results = {}
|
||||||
|
inputRev += 1
|
||||||
|
noctalia.state.set("file_search_open", true)
|
||||||
|
if not fzfMissing then
|
||||||
|
local dir = dataDir()
|
||||||
|
if dir == nil then
|
||||||
|
errMsg = tr("err_index")
|
||||||
|
else
|
||||||
|
haveIndex = cacheFresh(dir)
|
||||||
|
if not haveIndex then
|
||||||
|
buildIndex()
|
||||||
|
else
|
||||||
|
runSearch()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onClose()
|
||||||
|
noctalia.state.set("file_search_open", false)
|
||||||
|
end
|
||||||
|
|
||||||
|
function onConfigChanged()
|
||||||
|
if fzfMissing or indexing then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local dir = dataDir()
|
||||||
|
if dir == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
haveIndex = cacheFresh(dir)
|
||||||
|
if not haveIndex then
|
||||||
|
buildIndex()
|
||||||
|
else
|
||||||
|
runSearch()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function onQueryChanged(value)
|
||||||
|
query = value
|
||||||
|
runSearch()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onOpenFirst(value)
|
||||||
|
query = value
|
||||||
|
if results[1] ~= nil then
|
||||||
|
openEntry(results[1])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function onRefreshIndex()
|
||||||
|
haveIndex = false
|
||||||
|
buildIndex()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onClosePanel()
|
||||||
|
panel.close()
|
||||||
|
end
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# File Search — fuzzy file & folder search from the bar, powered by fzf.
|
||||||
|
# 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).
|
||||||
|
|
||||||
|
id = "nightwatch75/file-search"
|
||||||
|
name = "File Search"
|
||||||
|
version = "0.0.9"
|
||||||
|
plugin_api = 3
|
||||||
|
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"]
|
||||||
|
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."
|
||||||
|
|
||||||
|
# Plugin-level settings: shared by the bar widget (tooltip, folder actions)
|
||||||
|
# and the panel (index + search).
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "search_folder"
|
||||||
|
type = "folder"
|
||||||
|
label_key = "settings.search_folder.label"
|
||||||
|
description_key = "settings.search_folder.description"
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "exclude_dirs"
|
||||||
|
type = "string"
|
||||||
|
label_key = "settings.exclude_dirs.label"
|
||||||
|
description_key = "settings.exclude_dirs.description"
|
||||||
|
default = ".git, node_modules, .cache, .venv"
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "show_hidden"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.show_hidden.label"
|
||||||
|
description_key = "settings.show_hidden.description"
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "max_results"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.max_results.label"
|
||||||
|
description_key = "settings.max_results.description"
|
||||||
|
default = 50
|
||||||
|
min = 10
|
||||||
|
max = 200
|
||||||
|
|
||||||
|
# Keyboard-first flow: the noctalia launcher provides native arrows + Enter
|
||||||
|
# navigation, which plugin panels cannot receive from the current Luau API.
|
||||||
|
[[launcher_provider]]
|
||||||
|
id = "launcher"
|
||||||
|
entry = "launcher.luau"
|
||||||
|
prefix = "fs"
|
||||||
|
glyph = "search"
|
||||||
|
include_in_global_search = false
|
||||||
|
|
||||||
|
[[panel]]
|
||||||
|
id = "panel"
|
||||||
|
entry = "panel.luau"
|
||||||
|
width = 520
|
||||||
|
height = 480
|
||||||
|
placement = "attached"
|
||||||
|
open_near_click = true
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "file-search"
|
||||||
|
entry = "file-search.luau"
|
||||||
|
|
||||||
|
[[widget.setting]]
|
||||||
|
key = "glyph"
|
||||||
|
type = "glyph"
|
||||||
|
label_key = "settings.glyph.label"
|
||||||
|
description_key = "settings.glyph.description"
|
||||||
|
default = "search"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"title": "File Search",
|
||||||
|
"tooltip_open": "File search open — {path}\nClick to close",
|
||||||
|
"tooltip_closed": "File search — {path}\nClick to search · right-click: folder · middle-click: copy path",
|
||||||
|
"copied_path": "Search folder path copied to clipboard",
|
||||||
|
"search_placeholder": "Type to search files and folders…",
|
||||||
|
"status_indexing": "Indexing {path}…",
|
||||||
|
"no_results": "No matches for \"{query}\"",
|
||||||
|
"counts": "{shown} shown · {total} indexed",
|
||||||
|
"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",
|
||||||
|
"err_bad_record": "Ignored an invalid index record — rebuild the index",
|
||||||
|
"tip_refresh": "Rebuild the file index",
|
||||||
|
"tip_close": "Close",
|
||||||
|
"launcher.indexing": "Indexing files…",
|
||||||
|
"launcher.no_results": "No matches",
|
||||||
|
"launcher.reindex_title": "Rebuild search index",
|
||||||
|
"launcher.reindex_done": "Search index dropped — it rebuilds on the next search",
|
||||||
|
"settings.search_folder.label": "Search folder",
|
||||||
|
"settings.search_folder.description": "Root folder the search indexes. Defaults to your home folder when empty.",
|
||||||
|
"settings.exclude_dirs.label": "Excluded folders",
|
||||||
|
"settings.exclude_dirs.description": "Folder names skipped while indexing, separated by ',' or ';' (matched anywhere in the tree), e.g. '.git, node_modules, .cache'.",
|
||||||
|
"settings.show_hidden.label": "Include hidden entries",
|
||||||
|
"settings.show_hidden.description": "Index files and folders whose name starts with a dot.",
|
||||||
|
"settings.max_results.label": "Max results",
|
||||||
|
"settings.max_results.description": "How many matches the panel lists at most.",
|
||||||
|
"settings.glyph.label": "Glyph",
|
||||||
|
"settings.glyph.description": "Icon shown on the bar."
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user