diff --git a/file-search/README.md b/file-search/README.md new file mode 100644 index 0000000..2117ea3 --- /dev/null +++ b/file-search/README.md @@ -0,0 +1,130 @@ +# File Search + +A [noctalia](https://github.com/noctalia-dev/noctalia) v5 bar plugin: fuzzy +search files and folders as you type, with [fzf](https://github.com/junegunn/fzf) +as the matching subsystem. Click the bar glyph to open a search panel; picking +a result opens it with the system MIME association (`xdg-open`) — directories +open in your file manager. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `nightwatch75/file-search` | +| Entries | Bar widget: `file-search`; panel: `panel`; launcher provider: `launcher` | +| Launcher Prefix | `/fs` | + +## Usage + +Add the `file-search` widget from Noctalia's widget picker and click it to +open the search panel. You can also open the panel directly or bind it in +your compositor: + +```sh +noctalia msg panel-toggle nightwatch75/file-search:panel +``` + +| Action | Effect | +|--------------|-------------------------------------------------| +| 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 | + +In the panel: + +| Key | Action | +|---------|-------------------------------------| +| `Enter` | Open the top match | +| `Esc` | Close the panel (noctalia default) | + +In the noctalia launcher (keyboard-first flow, native navigation): + +| Key | Action | +|-------------|-------------------------------------------| +| `/fs `| Fuzzy search files and folders | +| `↑` / `↓` | Move through the results | +| `Enter` | Open the selected result (MIME/xdg-open) | + +With an empty `/fs` query the list also offers *Rebuild search index*; the +index is shared with the panel and built on demand when missing. + +## Features + +- Live results while you type: the search folder is walked once with `find` + into a cache, then every keystroke is fuzzy-matched through + `fzf --filter`, so typing stays responsive even on large trees +- Configurable bar glyph, search folder (defaults to `~`), excluded folder + names (`.git, node_modules, .cache, .venv` by default, matched anywhere in + the tree), hidden entries on/off, max results +- `Enter` opens the top match; every result row opens on click via the + system MIME association — files in their default app, folders in the file + manager +- Launcher provider for a keyboard-first flow: type `/fs ` in the + noctalia launcher and navigate the results with the native arrow keys + + `Enter` (plugin panels cannot receive arrow keys in the current Luau API, + so the launcher is the keyboard way to browse results) +- Folder results are marked with a trailing `/` and a folder glyph +- Index rebuilds automatically when the relevant settings change, and on + demand via the panel's refresh button (external file changes are picked up + on rebuild) +- Panel placement (attached/floating), position and open-near-click are the + standard per-panel settings noctalia exposes in Settings → Plugins + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `search_folder` | `folder` | *(empty)* | Root folder the search indexes. Empty = your home folder. | +| `exclude_dirs` | `string` | `.git, node_modules, .cache, .venv` | Folder names skipped while indexing, separated by `,` or `;`, matched anywhere in the tree. | +| `show_hidden` | `bool` | `false` | Index files and folders whose name starts with a dot. | +| `max_results` | `int` | `50` | How many matches the panel lists at most (10–200). | +| `glyph` (widget) | `glyph` | `search` | Icon shown on the bar. | + +## Requirements + +- noctalia ≥ 5.0.0 +- [`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 +- `mktemp`, `mv`, `wc`, `head`, `rm` — GNU coreutils, standard on any Linux + desktop + +## Install + +Install **File Search** from Noctalia's plugin store (*Settings → Plugins*), +then add the widget to a bar from *Settings → Bar*. Plugin options live in +*Settings → Plugins*. + +For local development, add your working copy as a path source instead +(`.luau` edits hot-reload): + +```sh +noctalia msg plugins source add dev path /path/to/plugins +noctalia msg plugins enable nightwatch75/file-search +``` + +## Notes + +- The index lives in the plugin's private data directory + (`noctalia.pluginDataDir()`, by default + `~/.local/state/noctalia/plugins/data/nightwatch75/file-search/` — honors + `NOCTALIA_STATE_HOME`/`XDG_STATE_HOME`): `index.list` is a plain list of + paths relative to the search folder, and `index.meta` records which folder + and exclusions built it, so both the panel and the launcher rebuild + automatically after a settings change. +- Both files are written to `mktemp`-created private files and renamed into + place, so a rebuild never writes through a symlink planted at the cache + path. +- Names containing a newline are excluded from the index (they would break + the one-record-per-line format), and every record is validated against + the search root before being opened. +- Excluded entries match by folder/file *name* (`find -name`), not by path; + entries containing `/` are skipped and logged. +- With hidden entries off, anything starting with a dot is pruned — both + hidden folders (not descended into) and hidden files. +- Unreadable subtrees are silently skipped (permission errors don't fail the + index). + +## License + +MIT. diff --git a/file-search/file-search.luau b/file-search/file-search.luau new file mode 100644 index 0000000..86d1127 --- /dev/null +++ b/file-search/file-search.luau @@ -0,0 +1,70 @@ +--!nonstrict +-- file-search — bar widget that toggles the fuzzy search panel. +-- +-- The panel (panel.luau) publishes its open state on the shared +-- "file_search_open" state key; the glyph lights up while it is open. +-- +-- 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 + +local PANEL_ID = "nightwatch75/file-search:panel" + +local open = false + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +local function searchRoot() + local dir = noctalia.getConfig("search_folder") + if dir == nil or dir == "" then + dir = noctalia.getenv("HOME") or "/tmp" + else + dir = noctalia.expandPath(dir) + end + dir = dir:gsub("/+$", "") + if dir == "" then + dir = "/" + end + return dir +end + +local function render() + barWidget.setGlyph(noctalia.getConfig("glyph")) + local root = searchRoot() + if open then + barWidget.setGlyphColor("primary") + barWidget.setTooltip(noctalia.tr("tooltip_open", { path = root })) + else + barWidget.setGlyphColor("on_surface") + barWidget.setTooltip(noctalia.tr("tooltip_closed", { path = root })) + end +end + +noctalia.state.watch("file_search_open", function(value) + open = value == true + render() +end) + +-- Periodic re-render keeps the glyph and tooltip in sync with settings changes. +function update() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +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/launcher.luau b/file-search/launcher.luau new file mode 100644 index 0000000..cb591dd --- /dev/null +++ b/file-search/launcher.luau @@ -0,0 +1,278 @@ +--!nonstrict +-- file-search — launcher provider: the same fuzzy search with the native +-- keyboard flow of the noctalia launcher (type, arrows, Enter). +-- +-- `/fs ` fuzzy-matches over the index file the panel builds; when the +-- index is missing it is built here on demand. Activating a result opens it +-- with the system MIME association (xdg-open) — directories open in the +-- file manager. Late async results map back through the query echo of +-- launcher.setResults, so fzf can answer out of band. + +local MAX_RESULTS = 9 + +local searching = false +local indexing = false +local pendingQuery = nil -- latest query typed while a search/index ran + +local runQuery + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function trim(value) + return (value:gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +-- Private per-plugin storage (XDG state); the host creates the directory on +-- every call. nil (with a log line) when no state directory resolves. +local function dataDir() + local dir, err = noctalia.pluginDataDir() + if dir == nil then + noctalia.log("file-search: pluginDataDir failed: " .. tostring(err)) + return nil + end + return dir +end + +-- Shell header shared by every cache command. The .meta sidecar holds the +-- settings fingerprint the cache was built with, so the panel and the +-- launcher can both tell a stale index apart. +local function cacheSh(dir) + return "CACHE_DIR=" .. shellQuote(dir) + .. '\nCACHE="$CACHE_DIR/index.list"\nMETA="$CACHE_DIR/index.meta"' +end + +local function searchRoot() + local dir = noctalia.getConfig("search_folder") + if dir == nil or dir == "" then + dir = noctalia.getenv("HOME") or "/tmp" + else + dir = noctalia.expandPath(dir) + end + dir = dir:gsub("/+$", "") + if dir == "" then + dir = "/" + end + return dir +end + + +-- Same exclusion set as the panel: names from the setting plus hidden +-- entries unless enabled. +local function excludeNames() + local raw = noctalia.getConfig("exclude_dirs") + if type(raw) ~= "string" then + raw = "" + end + local names = {} + for entry in raw:gmatch("[^,;]+") do + local name = trim(entry) + if name ~= "" and not name:find("/") then + table.insert(names, name) + end + end + if noctalia.getConfig("show_hidden") ~= true then + table.insert(names, ".*") + end + return names +end + +-- Must build the same string as the panel's indexKey(): the fingerprint in +-- the .meta sidecar is how the two entries recognize each other's index. +local function indexKey() + return searchRoot() .. "\n" .. table.concat(excludeNames(), "\n") +end + +-- The cache on disk is current when its fingerprint matches the settings, +-- whether the panel or the launcher built it. +local function cacheFresh(dir) + return noctalia.readFile(dir .. "/index.meta") == indexKey() +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. +local function safeRel(rel) + if rel == "" or rel:sub(1, 1) == "/" or rel:find("\n", 1, true) then + return false + end + for part in rel:gmatch("[^/]+") do + if part == ".." then + return false + end + end + return true +end + +local function noopRow(titleKey) + return { id = "noop", title = tr(titleKey), glyph = "info-circle" } +end + +local function buildIndex(query) + if indexing then + pendingQuery = query + return + end + local dir = dataDir() + if dir == nil then + launcher.setResults(query, { noopRow("err_index") }) + return + end + indexing = true + pendingQuery = query + + local key = indexKey() + -- Names containing a newline would each forge extra one-per-fragment + -- records (a crafted name can smuggle '..' lines into the index), so + -- they are pruned unconditionally, before the user's exclusions. + local names = { "-name " .. shellQuote("*\n*") } + for _, name in ipairs(excludeNames()) do + table.insert(names, "-name " .. shellQuote(name)) + end + local prune = "\\( " .. table.concat(names, " -o ") .. " \\) -prune -o " + -- find's own exit status is ignored, so permission errors inside the + -- tree don't fail the build. Cache and fingerprint are written to + -- mktemp-created private files and renamed into place: rename replaces + -- a planted symlink at the destination instead of following it. The + -- host guarantees $CACHE_DIR exists (created by pluginDataDir above). + local cmd = cacheSh(dir) + .. '\nTMP=$(mktemp "$CACHE_DIR/index.list.XXXXXX") || exit 1\n' + .. "find " .. shellQuote(searchRoot()) .. " -mindepth 1 " .. prune + .. "-type d -printf '%P/\\n' -o -printf '%P\\n' > \"$TMP\" 2>/dev/null\n" + .. 'mv -f "$TMP" "$CACHE" || exit 1\n' + .. 'TMPM=$(mktemp "$CACHE_DIR/index.meta.XXXXXX") || exit 1\n' + .. "printf '%s' " .. shellQuote(key) .. ' > "$TMPM"\n' + .. 'mv -f "$TMPM" "$META" || exit 1' + + launcher.setResults(query, { noopRow("launcher.indexing") }) + local ok = noctalia.runAsync(cmd, function(result) + indexing = false + local queued = pendingQuery + pendingQuery = nil + if result.exitCode == 0 and not result.timedOut then + runQuery(queued or query) + else + launcher.setResults(queued or query, { noopRow("err_index") }) + end + end, 180000) + if not ok then + indexing = false + launcher.setResults(query, { noopRow("err_spawn") }) + end +end + +runQuery = function(query) + if indexing then + pendingQuery = query + return + end + if searching then + pendingQuery = query + return + end + local dir = dataDir() + if dir == nil then + launcher.setResults(query, { noopRow("err_index") }) + return + end + searching = true + local text = trim(query) + local cmd + if text == "" then + cmd = cacheSh(dir) .. '\nhead -n ' .. MAX_RESULTS .. ' "$CACHE" 2>/dev/null' + else + cmd = cacheSh(dir) .. "\nfzf --filter=" .. shellQuote(text) + .. ' < "$CACHE" 2>/dev/null | head -n ' .. MAX_RESULTS + end + local ok = noctalia.runAsync(cmd, function(result) + searching = false + local rows = {} + if not result.timedOut then + for line in (result.stdout or ""):gmatch("[^\n]+") do + local isDir = line:sub(-1) == "/" + local rel = line:gsub("/+$", "") + table.insert(rows, { + id = "open:" .. line, + title = rel:match("[^/]+$") or rel, + subtitle = line, + glyph = isDir and "folder" or "file", + }) + end + end + if #rows == 0 and text ~= "" then + table.insert(rows, noopRow("launcher.no_results")) + end + if text == "" then + table.insert(rows, { + id = "reindex", + title = tr("launcher.reindex_title"), + subtitle = searchRoot(), + glyph = "refresh", + }) + end + launcher.setResults(query, rows) + if pendingQuery ~= nil and pendingQuery ~= query then + local nextQuery = pendingQuery + pendingQuery = nil + runQuery(nextQuery) + end + end, 15000) + if not ok then + searching = false + end +end + +function onQuery(query) + if not noctalia.commandExists("fzf") then + launcher.setResults(query, { noopRow("err_no_fzf") }) + return + end + local dir = dataDir() + if dir == nil then + launcher.setResults(query, { noopRow("err_index") }) + return + end + -- A missing or stale index (no meta, or built with a different root or + -- exclusion set) is rebuilt before searching: stale relative paths + -- joined to a new root could resolve to unrelated files. + if not cacheFresh(dir) then + buildIndex(query) + return + end + runQuery(query) +end + +function onActivate(id) + if id == "reindex" then + local dir = dataDir() + if dir == nil then + return + end + -- Drop cache and fingerprint so the next keystroke rebuilds against + -- fresh disk state (a surviving .meta would read as a fresh index). + noctalia.runAsync(cacheSh(dir) .. '\nrm -f "$CACHE" "$META"', function(_result) + noctalia.notify(tr("title"), tr("launcher.reindex_done")) + end) + return + end + local rel = id:match("^open:(.+)$") + if rel == nil then + return + end + if not safeRel(rel) then + noctalia.log("file-search: refusing unsafe index record: " .. rel) + noctalia.notify(tr("title"), tr("err_bad_record")) + 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") +end diff --git a/file-search/panel.luau b/file-search/panel.luau new file mode 100644 index 0000000..55a97bf --- /dev/null +++ b/file-search/panel.luau @@ -0,0 +1,387 @@ +--!nonstrict +-- file-search — fuzzy search panel, fzf as the matching subsystem. +-- +-- On open the search folder is walked once with `find` into a cache file +-- (excluded directory names are pruned, hidden entries too unless enabled); +-- after that every keystroke runs `fzf --filter=` over the cache, so +-- typing stays responsive even on large trees. Results update live; picking +-- one opens it with the system MIME association (xdg-open) — directories +-- open in the file manager. Enter opens the top match. +-- +-- The index is rebuilt when the panel opens with changed settings, or on +-- demand via the refresh button. The bar widget mirrors the panel's open +-- state through the shared "file_search_open" state key. + +local query = "" +local results = {} -- relative paths; directories keep a trailing "/" +local total = nil -- entries in the index, shown in the footer +local indexing = false +local searching = false +local errMsg = nil +local fzfMissing = false +local inputRev = 0 -- bumped to reseed the query input on open +local haveIndex = false -- the cache on disk matches the current settings + +local render +local runSearch +local buildIndex + +-- Per-row callbacks need distinct global names; the reconciler dispatches +-- callbacks by name only. getfenv() is the script environment lua_getglobal +-- reads from, so assigning into it defines the callback the host will find. +local env = getfenv() +local function rowCallback(prefix, index, fn) + local name = prefix .. "_" .. index + env[name] = fn + return name +end + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function trim(value) + return (value:gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +-- Private per-plugin storage (XDG state); the host creates the directory on +-- every call. nil (with a log line) when no state directory resolves. +local function dataDir() + local dir, err = noctalia.pluginDataDir() + if dir == nil then + noctalia.log("file-search: pluginDataDir failed: " .. tostring(err)) + return nil + end + return dir +end + +-- Shell header shared by every cache command. The .meta sidecar holds the +-- settings fingerprint the cache was built with, so the panel and the +-- launcher can both tell a stale index apart. +local function cacheSh(dir) + return "CACHE_DIR=" .. shellQuote(dir) + .. '\nCACHE="$CACHE_DIR/index.list"\nMETA="$CACHE_DIR/index.meta"' +end + +local function searchRoot() + local dir = noctalia.getConfig("search_folder") + if dir == nil or dir == "" then + dir = noctalia.getenv("HOME") or "/tmp" + else + dir = noctalia.expandPath(dir) + end + dir = dir:gsub("/+$", "") + if dir == "" then + dir = "/" + end + return dir +end + +local function maxResults() + return math.max(10, math.min(200, tonumber(noctalia.getConfig("max_results")) or 50)) +end + +-- Excluded directory names from the setting, split on ',' or ';'. Matching +-- is by basename (find -name), so entries containing '/' are skipped and +-- logged. Hidden entries are folded in as an extra '.*' pattern. +local function excludeNames() + local raw = noctalia.getConfig("exclude_dirs") + if type(raw) ~= "string" then + raw = "" + end + local names = {} + for entry in raw:gmatch("[^,;]+") do + local name = trim(entry) + if name:find("/") then + noctalia.log("file-search: ignoring exclude entry with '/': '" .. name .. "'") + elseif name ~= "" then + table.insert(names, name) + end + end + if noctalia.getConfig("show_hidden") ~= true then + table.insert(names, ".*") + end + return names +end + +local function indexKey() + return searchRoot() .. "\n" .. table.concat(excludeNames(), "\n") +end + +-- The cache on disk is current when its fingerprint matches the settings, +-- whether the panel or the launcher built it. +local function cacheFresh(dir) + return noctalia.readFile(dir .. "/index.meta") == indexKey() +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. +local function safeRel(rel) + if rel == "" or rel:sub(1, 1) == "/" or rel:find("\n", 1, true) then + return false + end + for part in rel:gmatch("[^/]+") do + if part == ".." then + return false + end + end + return true +end + +buildIndex = function() + if fzfMissing or indexing then + return + end + local dir = dataDir() + if dir == nil then + errMsg = tr("err_index") + render() + return + end + indexing = true + errMsg = nil + local key = indexKey() + + -- Names containing a newline would each forge extra one-per-fragment + -- records (a crafted name can smuggle '..' lines into the index), so + -- they are pruned unconditionally, before the user's exclusions. + local names = { "-name " .. shellQuote("*\n*") } + for _, name in ipairs(excludeNames()) do + table.insert(names, "-name " .. shellQuote(name)) + end + local prune = "\\( " .. table.concat(names, " -o ") .. " \\) -prune -o " + -- %P prints paths relative to the root; the trailing '/' marks + -- directories so rows get the right glyph. find's own exit status is + -- ignored, so permission errors inside the tree don't fail the build. + -- Cache and fingerprint are written to mktemp-created private files and + -- renamed into place: rename replaces a planted symlink at the + -- destination instead of following it. The host guarantees $CACHE_DIR + -- exists (created by pluginDataDir above). + local cmd = cacheSh(dir) + .. '\nTMP=$(mktemp "$CACHE_DIR/index.list.XXXXXX") || exit 1\n' + .. "find " .. shellQuote(searchRoot()) .. " -mindepth 1 " .. prune + .. "-type d -printf '%P/\\n' -o -printf '%P\\n' > \"$TMP\" 2>/dev/null\n" + .. 'mv -f "$TMP" "$CACHE" || exit 1\n' + .. 'TMPM=$(mktemp "$CACHE_DIR/index.meta.XXXXXX") || exit 1\n' + .. "printf '%s' " .. shellQuote(key) .. ' > "$TMPM"\n' + .. 'mv -f "$TMPM" "$META" || exit 1\n' + .. 'wc -l < "$CACHE"' + + render() + local ok = noctalia.runAsync(cmd, function(result) + indexing = false + if result.exitCode == 0 and not result.timedOut then + haveIndex = true + total = tonumber(trim(result.stdout or "")) or 0 + if indexKey() ~= key then + buildIndex() -- settings changed while the walk was running + else + runSearch() + end + else + haveIndex = false + errMsg = tr("err_index") + end + render() + end, 180000) + if not ok then + indexing = false + errMsg = tr("err_spawn") + render() + end +end + +runSearch = function() + if fzfMissing or indexing or searching or not haveIndex then + return + end + local dir = dataDir() + if dir == nil then + return + end + searching = true + local q = query + local limit = maxResults() + local cmd + if trim(q) == "" then + cmd = cacheSh(dir) .. '\nhead -n ' .. limit .. ' "$CACHE" 2>/dev/null' + else + -- head closes the pipe early; the pipeline status stays head's (0). + cmd = cacheSh(dir) .. "\nfzf --filter=" .. shellQuote(q) + .. ' < "$CACHE" 2>/dev/null | head -n ' .. limit + end + local ok = noctalia.runAsync(cmd, function(result) + searching = false + if not result.timedOut then + results = {} + for line in (result.stdout or ""):gmatch("[^\n]+") do + table.insert(results, line) + end + end + -- The query moved on while this search ran: chase it. The stale + -- rows rendered below are overwritten as soon as it completes. + if query ~= q then + runSearch() + end + render() + end, 15000) + if not ok then + searching = false + end +end + +local function openEntry(rel) + if not safeRel(rel) then + noctalia.log("file-search: refusing unsafe index record: " .. rel) + noctalia.notify(tr("title"), tr("err_bad_record")) + 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 + +local function resultRow(rel, index) + local isDir = rel:sub(-1) == "/" + return ui.button({ + key = "hit-" .. index, + glyph = isDir and "folder" or "file", + text = rel, + variant = "ghost", + contentAlign = "start", + onClick = rowCallback("openHit", index, function() + openEntry(rel) + end), + }) +end + +local function statusFooter() + if indexing then + return ui.label({ text = tr("status_indexing", { path = searchRoot() }), fontSize = 11, color = "secondary" }) + end + local shown = #results + return ui.label({ + text = tr("counts", { shown = shown, total = total or 0 }), + fontSize = 11, + color = "on_surface_variant", + }) +end + +render = function() + local children = { + ui.row({ gap = 6, align = "center" }, { + ui.label({ + text = tr("title"), + fontSize = 16, + fontWeight = "bold", + color = "on_surface", + flexGrow = 1, + }), + ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("tip_refresh"), onClick = "onRefreshIndex" }), + ui.button({ glyph = "close", variant = "ghost", tooltip = tr("tip_close"), onClick = "onClosePanel" }), + }), + ui.input({ + key = "query-" .. inputRev, + value = query, + focus = true, + placeholder = tr("search_placeholder"), + onChange = "onQueryChanged", + onSubmit = "onOpenFirst", + }), + } + + if fzfMissing then + table.insert(children, ui.label({ text = tr("err_no_fzf"), color = "error" })) + elseif errMsg ~= nil then + table.insert(children, ui.label({ text = errMsg, color = "error" })) + else + if #results == 0 and not indexing and trim(query) ~= "" then + table.insert(children, ui.label({ + text = tr("no_results", { query = query }), + color = "on_surface_variant", + flexGrow = 1, + })) + else + local rows = {} + for index, rel in ipairs(results) do + table.insert(rows, resultRow(rel, index)) + end + table.insert(children, ui.scroll({ flexGrow = 1, gap = 2 }, rows)) + end + table.insert(children, statusFooter()) + end + + panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children)) +end + +function onOpen(_context) + fzfMissing = not noctalia.commandExists("fzf") + query = "" + results = {} + inputRev += 1 + noctalia.state.set("file_search_open", true) + if not fzfMissing then + local dir = dataDir() + if dir == nil then + errMsg = tr("err_index") + else + haveIndex = cacheFresh(dir) + if not haveIndex then + buildIndex() + else + runSearch() + end + end + end + render() +end + +function onClose() + noctalia.state.set("file_search_open", false) +end + +function onConfigChanged() + if fzfMissing or indexing then + return + end + local dir = dataDir() + if dir == nil then + return + end + haveIndex = cacheFresh(dir) + if not haveIndex then + buildIndex() + else + runSearch() + end +end + +function onQueryChanged(value) + query = value + runSearch() +end + +function onOpenFirst(value) + query = value + if results[1] ~= nil then + openEntry(results[1]) + end +end + +function onRefreshIndex() + haveIndex = false + buildIndex() +end + +function onClosePanel() + panel.close() +end diff --git a/file-search/plugin.toml b/file-search/plugin.toml new file mode 100644 index 0000000..1d690c6 --- /dev/null +++ b/file-search/plugin.toml @@ -0,0 +1,78 @@ +# File Search — fuzzy file & folder search from the bar, powered by fzf. +# The bar glyph toggles a search panel: the search folder is indexed with +# `find` (honoring the excluded directories), every keystroke is matched +# through `fzf --filter`, and picking a result opens it with the system +# MIME association (xdg-open). + +id = "nightwatch75/file-search" +name = "File Search" +version = "0.0.9" +plugin_api = 3 +author = "nightwatch75" +license = "MIT" +# The exact commands the plugin spawns (fzf aside, findutils + coreutils + +# xdg-utils on any Linux desktop). +dependencies = ["fzf", "find", "mktemp", "mv", "wc", "head", "rm", "xdg-open"] +tags = ["bar", "launcher", "panel", "utility", "productivity"] +icon = "search" +description = "Search files and folders as you type, fuzzy-matched with fzf; open results with the system MIME association." + +# Plugin-level settings: shared by the bar widget (tooltip, folder actions) +# and the panel (index + search). + +[[setting]] +key = "search_folder" +type = "folder" +label_key = "settings.search_folder.label" +description_key = "settings.search_folder.description" + +[[setting]] +key = "exclude_dirs" +type = "string" +label_key = "settings.exclude_dirs.label" +description_key = "settings.exclude_dirs.description" +default = ".git, node_modules, .cache, .venv" + +[[setting]] +key = "show_hidden" +type = "bool" +label_key = "settings.show_hidden.label" +description_key = "settings.show_hidden.description" +default = false + +[[setting]] +key = "max_results" +type = "int" +label_key = "settings.max_results.label" +description_key = "settings.max_results.description" +default = 50 +min = 10 +max = 200 + +# Keyboard-first flow: the noctalia launcher provides native arrows + Enter +# navigation, which plugin panels cannot receive from the current Luau API. +[[launcher_provider]] +id = "launcher" +entry = "launcher.luau" +prefix = "fs" +glyph = "search" +include_in_global_search = false + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 520 +height = 480 +placement = "attached" +open_near_click = true + +[[widget]] +id = "file-search" +entry = "file-search.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + description_key = "settings.glyph.description" + default = "search" diff --git a/file-search/thumbnail.webp b/file-search/thumbnail.webp new file mode 100644 index 0000000..5cd5a93 Binary files /dev/null and b/file-search/thumbnail.webp differ diff --git a/file-search/translations/en.json b/file-search/translations/en.json new file mode 100644 index 0000000..99d3f5b --- /dev/null +++ b/file-search/translations/en.json @@ -0,0 +1,30 @@ +{ + "title": "File Search", + "tooltip_open": "File search open — {path}\nClick to close", + "tooltip_closed": "File search — {path}\nClick to search · right-click: folder · middle-click: copy path", + "copied_path": "Search folder path copied to clipboard", + "search_placeholder": "Type to search files and folders…", + "status_indexing": "Indexing {path}…", + "no_results": "No matches for \"{query}\"", + "counts": "{shown} shown · {total} indexed", + "err_index": "Failed to index the search folder", + "err_no_fzf": "fzf not found — install fzf to use this plugin", + "err_spawn": "Could not run the search command", + "err_bad_record": "Ignored an invalid index record — rebuild the index", + "tip_refresh": "Rebuild the file index", + "tip_close": "Close", + "launcher.indexing": "Indexing files…", + "launcher.no_results": "No matches", + "launcher.reindex_title": "Rebuild search index", + "launcher.reindex_done": "Search index dropped — it rebuilds on the next search", + "settings.search_folder.label": "Search folder", + "settings.search_folder.description": "Root folder the search indexes. Defaults to your home folder when empty.", + "settings.exclude_dirs.label": "Excluded folders", + "settings.exclude_dirs.description": "Folder names skipped while indexing, separated by ',' or ';' (matched anywhere in the tree), e.g. '.git, node_modules, .cache'.", + "settings.show_hidden.label": "Include hidden entries", + "settings.show_hidden.description": "Index files and folders whose name starts with a dot.", + "settings.max_results.label": "Max results", + "settings.max_results.description": "How many matches the panel lists at most.", + "settings.glyph.label": "Glyph", + "settings.glyph.description": "Icon shown on the bar." +}