From 404765a4567601cfb23ca2d55a61432318b4902d Mon Sep 17 00:00:00 2001 From: nightwatch75 <30896720+nightwatch75@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:36:07 +0200 Subject: [PATCH] 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 Co-authored-by: Claude Opus 5 (1M context) --- file-search/README.md | 27 +++++- file-search/file-search.luau | 12 +-- file-search/panel.luau | 161 +++++++++++++++++++++++++++++-- file-search/plugin.toml | 4 +- file-search/translations/en.json | 5 +- 5 files changed, 187 insertions(+), 22 deletions(-) diff --git a/file-search/README.md b/file-search/README.md index 2117ea3..3559827 100644 --- a/file-search/README.md +++ b/file-search/README.md @@ -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 diff --git a/file-search/file-search.luau b/file-search/file-search.luau index 86d1127..cffdb46 100644 --- a/file-search/file-search.luau +++ b/file-search/file-search.luau @@ -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() diff --git a/file-search/panel.luau b/file-search/panel.luau index ae7c21b..12fe6e2 100644 --- a/file-search/panel.luau +++ b/file-search/panel.luau @@ -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 diff --git a/file-search/plugin.toml b/file-search/plugin.toml index 85eadb2..7a608a9 100644 --- a/file-search/plugin.toml +++ b/file-search/plugin.toml @@ -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 + diff --git a/file-search/translations/en.json b/file-search/translations/en.json index 82f2aa8..bff0804 100644 --- a/file-search/translations/en.json +++ b/file-search/translations/en.json @@ -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" }