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
+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