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 |
|
| Left click | Open/close the search panel |
|
||||||
| Right click | Open the search folder in the file manager |
|
| 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:
|
In the panel:
|
||||||
|
|
||||||
@@ -37,6 +40,25 @@ In the panel:
|
|||||||
| `Enter` | Open the top match |
|
| `Enter` | Open the top match |
|
||||||
| `Esc` | Close the panel (noctalia default) |
|
| `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):
|
In the noctalia launcher (keyboard-first flow, native navigation):
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
@@ -82,7 +104,8 @@ index is shared with the panel and built on demand when missing.
|
|||||||
|
|
||||||
## Requirements
|
## 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
|
- [`fzf`](https://github.com/junegunn/fzf) — the fuzzy matcher
|
||||||
- `find` (GNU findutils) — walks the search folder into the index
|
- `find` (GNU findutils) — walks the search folder into the index
|
||||||
- `xdg-open` (xdg-utils) — opens results with the MIME association
|
- `xdg-open` (xdg-utils) — opens results with the MIME association
|
||||||
|
|||||||
@@ -7,7 +7,12 @@
|
|||||||
-- Click mapping:
|
-- Click mapping:
|
||||||
-- Left click — open/close the search panel
|
-- Left click — open/close the search panel
|
||||||
-- Right click — open the search folder in the file manager
|
-- 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"
|
local PANEL_ID = "nightwatch75/file-search:panel"
|
||||||
|
|
||||||
@@ -61,10 +66,5 @@ function onRightClick()
|
|||||||
noctalia.runAsync("xdg-open " .. shellQuote(searchRoot()) .. " >/dev/null 2>&1")
|
noctalia.runAsync("xdg-open " .. shellQuote(searchRoot()) .. " >/dev/null 2>&1")
|
||||||
end
|
end
|
||||||
|
|
||||||
function onMiddleClick()
|
|
||||||
noctalia.copyToClipboard(searchRoot(), "text/plain")
|
|
||||||
noctalia.notify(noctalia.tr("title"), noctalia.tr("copied_path"))
|
|
||||||
end
|
|
||||||
|
|
||||||
noctalia.setUpdateInterval(1000)
|
noctalia.setUpdateInterval(1000)
|
||||||
render()
|
render()
|
||||||
|
|||||||
+149
-8
@@ -12,6 +12,22 @@
|
|||||||
-- demand via the refresh button. The bar widget mirrors the panel's open
|
-- demand via the refresh button. The bar widget mirrors the panel's open
|
||||||
-- state through the shared "file_search_open" state key.
|
-- 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 query = ""
|
||||||
local results = {} -- relative paths; directories keep a trailing "/"
|
local results = {} -- relative paths; directories keep a trailing "/"
|
||||||
local total = nil -- entries in the index, shown in the footer
|
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()
|
return noctalia.readFile(dir .. "/index.meta") == indexKey()
|
||||||
end
|
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
|
-- 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
|
-- plain user-editable file, so records are untrusted: reject anything that
|
||||||
-- could resolve outside the root.
|
-- could resolve outside the root.
|
||||||
@@ -123,6 +195,22 @@ local function safeRel(rel)
|
|||||||
return true
|
return true
|
||||||
end
|
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()
|
buildIndex = function()
|
||||||
if fzfMissing or indexing then
|
if fzfMissing or indexing then
|
||||||
return
|
return
|
||||||
@@ -225,31 +313,72 @@ runSearch = function()
|
|||||||
end
|
end
|
||||||
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
|
if not safeRel(rel) then
|
||||||
|
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.log("file-search: refusing unsafe index record: " .. rel)
|
||||||
noctalia.notify(tr("title"), tr("err_bad_record"))
|
noctalia.notify(tr("title"), tr("err_bad_record"))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function openEntry(rel)
|
||||||
|
local path = absolutePath(rel)
|
||||||
|
if path == nil then
|
||||||
|
rejectRecord(rel)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local path = searchRoot()
|
|
||||||
if path ~= "/" then
|
|
||||||
path = path .. "/"
|
|
||||||
end
|
|
||||||
path = path .. rel:gsub("/+$", "")
|
|
||||||
noctalia.runAsync("xdg-open " .. shellQuote(path) .. " >/dev/null 2>&1")
|
noctalia.runAsync("xdg-open " .. shellQuote(path) .. " >/dev/null 2>&1")
|
||||||
panel.close()
|
panel.close()
|
||||||
end
|
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 function resultRow(rel, index)
|
||||||
local isDir = rel:sub(-1) == "/"
|
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({
|
return ui.button({
|
||||||
key = "hit-" .. index,
|
key = "hit-" .. index,
|
||||||
glyph = isDir and "folder" or "file",
|
glyph = isDir and "folder" or "file",
|
||||||
text = rel,
|
text = shown,
|
||||||
variant = "ghost",
|
variant = "ghost",
|
||||||
contentAlign = "start",
|
contentAlign = "start",
|
||||||
onClick = function()
|
onClick = function()
|
||||||
openEntry(rel)
|
openEntry(rel) -- always the full record, never the elided label
|
||||||
|
end,
|
||||||
|
onRightClick = function()
|
||||||
|
copyEntry(rel)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -277,6 +406,7 @@ render = function()
|
|||||||
flexGrow = 1,
|
flexGrow = 1,
|
||||||
}),
|
}),
|
||||||
ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("tip_refresh"), onClick = "onRefreshIndex" }),
|
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.button({ glyph = "close", variant = "ghost", tooltip = tr("tip_close"), onClick = "onClosePanel" }),
|
||||||
}),
|
}),
|
||||||
ui.input({
|
ui.input({
|
||||||
@@ -328,6 +458,9 @@ function onOpen(_context)
|
|||||||
if not haveIndex then
|
if not haveIndex then
|
||||||
buildIndex()
|
buildIndex()
|
||||||
else
|
else
|
||||||
|
-- buildIndex would have set the total; reusing a cache has to
|
||||||
|
-- go and count it.
|
||||||
|
readTotal(dir)
|
||||||
runSearch()
|
runSearch()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -351,6 +484,7 @@ function onConfigChanged()
|
|||||||
if not haveIndex then
|
if not haveIndex then
|
||||||
buildIndex()
|
buildIndex()
|
||||||
else
|
else
|
||||||
|
readTotal(dir)
|
||||||
runSearch()
|
runSearch()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -372,6 +506,13 @@ function onRefreshIndex()
|
|||||||
buildIndex()
|
buildIndex()
|
||||||
end
|
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()
|
function onClosePanel()
|
||||||
panel.close()
|
panel.close()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
id = "nightwatch75/file-search"
|
id = "nightwatch75/file-search"
|
||||||
name = "File Search"
|
name = "File Search"
|
||||||
version = "0.0.11"
|
version = "0.0.19"
|
||||||
plugin_api = 9
|
plugin_api = 15
|
||||||
author = "nightwatch75"
|
author = "nightwatch75"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
# The exact commands the plugin spawns (fzf aside, findutils + coreutils +
|
# 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",
|
"counts": "{shown} shown · {total} indexed",
|
||||||
"err_bad_record": "Ignored an invalid index record — rebuild the index",
|
"err_bad_record": "Ignored an invalid index record — rebuild the index",
|
||||||
"err_index": "Failed to index the search folder",
|
"err_index": "Failed to index the search folder",
|
||||||
@@ -38,7 +38,8 @@
|
|||||||
"status_indexing": "Indexing {path}…",
|
"status_indexing": "Indexing {path}…",
|
||||||
"tip_close": "Close",
|
"tip_close": "Close",
|
||||||
"tip_refresh": "Rebuild the file index",
|
"tip_refresh": "Rebuild the file index",
|
||||||
|
"tip_settings": "Plugin settings",
|
||||||
"title": "File Search",
|
"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"
|
"tooltip_open": "File search open — {path}\nClick to close"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user