Update nightwatch75/file-search to 0.0.19 (#126)

Settings shortcut in the panel header, middle-elided paths, right-click copy,
and a real indexed-file count.

Co-authored-by: nightwatch75 <nightwatch75@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
nightwatch75
2026-07-28 09:36:07 -04:00
committed by GitHub
co-authored by nightwatch75 Claude Opus 5
parent 549245d54b
commit 404765a456
5 changed files with 187 additions and 22 deletions
+25 -2
View File
@@ -28,7 +28,10 @@ noctalia msg panel-toggle nightwatch75/file-search:panel
|--------------|-------------------------------------------------|
| 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 |
Middle click is not used: every bar widget carries a built-in binding for it
that opens the widget's own settings, and a bound gesture never reaches the
plugin. Use the panel's ⚙ button, or the command below, for the settings.
In the panel:
@@ -37,6 +40,25 @@ In the panel:
| `Enter` | Open the top match |
| `Esc` | Close the panel (noctalia default) |
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) |
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 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:
```sh
noctalia msg settings-open-plugin nightwatch75/file-search
```
In the noctalia launcher (keyboard-first flow, native navigation):
| Key | Action |
@@ -82,7 +104,8 @@ index is shared with the panel and built on demand when missing.
## Requirements
- noctalia ≥ 5.0.0
- 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
- `xdg-open` (xdg-utils) — opens results with the MIME association
+6 -6
View File
@@ -7,7 +7,12 @@
-- 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
--
-- Middle click is deliberately not handled: every bar widget carries a built-in
-- `middle = settings-open-widget` binding, and a bound gesture is masked off the
-- widget's own input area, so an onMiddleClick here would never be called. The
-- manifest could reclaim it with `[widget.actions] middle = "none"` (plugin API
-- 14) — this plugin stays at the upstream behaviour instead.
local PANEL_ID = "nightwatch75/file-search:panel"
@@ -61,10 +66,5 @@ 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()
+151 -10
View File
@@ -12,6 +12,22 @@
-- demand via the refresh button. The bar widget mirrors the panel's open
-- state through the shared "file_search_open" state key.
-- How many characters of a result path fit on one row, from the panel's 520
-- width in plugin.toml: 520 − 2 × Style::panelPadding (14) − the scrollbar
-- gutter (scrollbarWidth 6 + scrollbarGap 8) = 478 usable, less the row
-- Button's 2 × Style::spaceMd (12) horizontal padding, its 14px glyph and the
-- 4px gap between them → 436px of text. Measured against a rendered row, a
-- lowercase path averages 7.2px per character at Style::fontSizeBody, so 61
-- characters is the limit; 56 leaves headroom, because the font is
-- proportional and capitals or digits measure wider than that average.
--
-- A Button cannot do this itself: it has no maxLines, so constraining its
-- width makes the label WRAP rather than ellipsize, and a plain flexGrow never
-- shrinks it below the full text — which is why a long path used to run under
-- the scrollbar and get clipped.
local PATH_MAX_CHARS = 56
local ELLIPSIS = "…"
local query = ""
local results = {} -- relative paths; directories keep a trailing "/"
local total = nil -- entries in the index, shown in the footer
@@ -108,6 +124,62 @@ local function cacheFresh(dir)
return noctalia.readFile(dir .. "/index.meta") == indexKey()
end
-- Code-point slices. Byte offsets would split a multi-byte character and
-- produce invalid UTF-8, which the text renderer then refuses to measure.
local function headChars(value, count)
local byte = utf8.offset(value, count + 1)
return byte ~= nil and value:sub(1, byte - 1) or value
end
local function tailChars(value, count)
local length = utf8.len(value)
if length == nil or count >= length then
return value
end
local byte = utf8.offset(value, length - count + 1)
return byte ~= nil and value:sub(byte) or value
end
-- Shorten an over-long path by dropping its MIDDLE, keeping both the root it
-- starts from and the name it ends with:
-- .local/share/flatpak/repo/tmp/cache/summaries/dolphin.idx.sig
-- → .local/share/flatpak/repo/tmp/cache/…dolphin.idx.sig
-- End-truncation would cut away the file name, which is the very thing the
-- query matched, so the trailing component is kept whole whenever it fits and
-- the head takes what is left of the budget. utf8.len returns nil on invalid
-- UTF-8 (it never throws, unlike utf8.codes), and such a name is left alone
-- rather than sliced at a guessed offset.
local function elidePath(rel)
-- Byte length is never below the code-point count, so a string short in
-- bytes is short in characters — and the common case skips the O(n)
-- utf8.len entirely. This runs for every visible row on every render.
if #rel <= PATH_MAX_CHARS then
return rel
end
local length = utf8.len(rel)
if length == nil or length <= PATH_MAX_CHARS then
return rel
end
local budget = PATH_MAX_CHARS - 1 -- the ellipsis occupies one column
-- Trailing component, with a directory's own "/" kept as part of it.
local base = rel:match("[^/]+/?$") or ""
local baseLen = utf8.len(base) or 0
if baseLen >= budget then
-- A single component longer than the whole row: no head to show.
return ELLIPSIS .. tailChars(rel, budget)
end
local headPart = headChars(rel, budget - baseLen)
-- Retreat to the last separator so the head ends on a whole directory:
-- ".../tmp/cache/…name" reads as a path, ".../tmp/cache/summ…name" reads
-- as a glitch. Costs a few characters; kept raw when there is no
-- separator to retreat to.
local atSeparator = headPart:match("^(.*/)[^/]*$")
if atSeparator ~= nil and atSeparator ~= "" then
headPart = atSeparator
end
return headPart .. ELLIPSIS .. base
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.
@@ -123,6 +195,22 @@ local function safeRel(rel)
return true
end
-- Count the cache the panel is about to search. buildIndex records the total
-- as a side effect of building, but a panel opening on a cache that is already
-- fresh -- the common case, and the one the launcher leaves behind -- never
-- runs it, so the footer used to report "0 indexed" for a perfectly good
-- index. Counted with wc rather than readFile: the list can hold hundreds of
-- thousands of paths and none of them are needed here, only how many.
local function readTotal(dir)
local cmd = cacheSh(dir) .. '\nwc -l < "$CACHE" 2>/dev/null'
noctalia.runAsync(cmd, function(result)
if result.exitCode == 0 and not result.timedOut then
total = tonumber(trim(result.stdout or "")) or 0
render()
end
end, 15000)
end
buildIndex = function()
if fzfMissing or indexing then
return
@@ -225,31 +313,72 @@ runSearch = function()
end
end
local function openEntry(rel)
-- An index record joined to the search root, or nil when the record could
-- resolve outside it. Only ever called from the two callbacks that act on a
-- row — never from render: searchRoot() costs a getConfig plus an expandPath
-- and safeRel walks every path component, and paying that per row per frame is
-- what gets a script callback killed for exceeding its CPU budget.
local function absolutePath(rel)
if not safeRel(rel) then
noctalia.log("file-search: refusing unsafe index record: " .. rel)
noctalia.notify(tr("title"), tr("err_bad_record"))
return nil
end
local root = searchRoot()
-- A directory keeps its trailing "/" in the index but not in a path meant
-- for xdg-open or for pasting into a shell.
local trimmed = rel:gsub("/+$", "")
if root == "/" then
return root .. trimmed
end
return root .. "/" .. trimmed
end
local function rejectRecord(rel)
noctalia.log("file-search: refusing unsafe index record: " .. rel)
noctalia.notify(tr("title"), tr("err_bad_record"))
end
local function openEntry(rel)
local path = absolutePath(rel)
if path == nil then
rejectRecord(rel)
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
-- Right click copies the absolute path instead of opening it, and leaves the
-- panel up so several rows can be picked off in a row. Right rather than
-- middle because a ui.button never receives the middle button: it accepts
-- BTN_LEFT, plus BTN_RIGHT only once an onRightClick is attached, and no node
-- in the declarative UI exposes a middle-click callback at all.
local function copyEntry(rel)
local path = absolutePath(rel)
if path == nil then
rejectRecord(rel)
return
end
noctalia.copyToClipboard(path, "text/plain")
noctalia.notify(tr("title"), tr("copied_entry"))
end
local function resultRow(rel, index)
local isDir = rel:sub(-1) == "/"
-- The row carries no tooltip on purpose: one string per row is enough
-- extra weight, over a list this long, to get the render's callback killed
-- for exceeding its CPU budget. Right click yields the full path instead.
local shown = elidePath(rel)
return ui.button({
key = "hit-" .. index,
glyph = isDir and "folder" or "file",
text = rel,
text = shown,
variant = "ghost",
contentAlign = "start",
onClick = function()
openEntry(rel)
openEntry(rel) -- always the full record, never the elided label
end,
onRightClick = function()
copyEntry(rel)
end,
})
end
@@ -277,6 +406,7 @@ render = function()
flexGrow = 1,
}),
ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("tip_refresh"), onClick = "onRefreshIndex" }),
ui.button({ glyph = "settings", variant = "ghost", tooltip = tr("tip_settings"), onClick = "onOpenSettings" }),
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("tip_close"), onClick = "onClosePanel" }),
}),
ui.input({
@@ -328,6 +458,9 @@ function onOpen(_context)
if not haveIndex then
buildIndex()
else
-- buildIndex would have set the total; reusing a cache has to
-- go and count it.
readTotal(dir)
runSearch()
end
end
@@ -351,6 +484,7 @@ function onConfigChanged()
if not haveIndex then
buildIndex()
else
readTotal(dir)
runSearch()
end
end
@@ -372,6 +506,13 @@ function onRefreshIndex()
buildIndex()
end
-- Opens the settings window on this plugin's own page (the host supplies the
-- plugin id, so a plugin can only ever open its own). The panel closes on the
-- way; the index survives it, since it lives in the plugin data directory.
function onOpenSettings()
noctalia.openSettings()
end
function onClosePanel()
panel.close()
end
+2 -2
View File
@@ -6,8 +6,8 @@
id = "nightwatch75/file-search"
name = "File Search"
version = "0.0.11"
plugin_api = 9
version = "0.0.19"
plugin_api = 15
author = "nightwatch75"
license = "MIT"
# The exact commands the plugin spawns (fzf aside, findutils + coreutils +
+3 -2
View File
@@ -1,5 +1,5 @@
{
"copied_path": "Search folder path copied to clipboard",
"copied_entry": "Path copied to clipboard",
"counts": "{shown} shown · {total} indexed",
"err_bad_record": "Ignored an invalid index record — rebuild the index",
"err_index": "Failed to index the search folder",
@@ -38,7 +38,8 @@
"status_indexing": "Indexing {path}…",
"tip_close": "Close",
"tip_refresh": "Rebuild the file index",
"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"
}