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:
co-authored by
nightwatch75
Claude Opus 5
parent
549245d54b
commit
404765a456
+25
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user