diff --git a/locate-files/README.md b/locate-files/README.md new file mode 100644 index 0000000..d783113 --- /dev/null +++ b/locate-files/README.md @@ -0,0 +1,125 @@ +# Locate Files + +A Noctalia v5 launcher provider that maintains a private index of the user's +home directory, searches it with `plocate`, falls back to `fzf` for typos, and +opens results with `xdg-open`. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `yihao/locate-files` | +| Entries | Launcher provider: `files`; service: `indexer` | +| Launcher prefix | `/f` | + +## Requirements + +- Noctalia 5.0.0 or newer +- `locate` and `updatedb` from `plocate` +- `fzf` +- `flock` from `util-linux` +- `xdg-open` + +The plugin creates and refreshes its own database; it does not use the system +database under `/var/lib/plocate/`. + +## Usage + +1. Enable `yihao/locate-files` in **Settings > Plugins**. +2. Open the launcher and type `/f` followed by part of a path or file name. +3. Select a result to open it in its default application. + +Dot-paths are hidden by default. Add search options after a `--` separator: + +```text +/f invoice -- --hidden +/f project -- --type file +/f cache -- --type directory +/f config -- --hidden --type=directory +/f invioce -- --fuzzy +``` + +Supported options are `-h`/`--hidden`, `--fuzzy`, and +`-t`/`--type file|directory`. Type aliases `f`, `d`, and `dir` are accepted. + +Use the launcher action below to rebuild the index immediately: + +```text +/f --reindex +``` + +Searches run asynchronously with a 250 ms debounce and return at most 50 paths +by default. Stale responses are discarded by Noctalia when the query changes. +Change the result limit from the plugin settings in **Settings > Plugins**. + +Substring search through `plocate` remains the default. Fuzzy search runs after +an exact query has no usable results, can be selected as the default backend, or +can be disabled except for `--fuzzy`. Fuzzy search needs at least three +characters and uses a bounded 150-candidate `fzf` result set by default. The +launcher scores those candidates with filename-first Noctalia fuzzy scores, a +small `fzf` rank bonus, optional file/directory weights, and a global-search +bias. It does not attempt to read an internal `fzf` score. + +The provider is disabled from unprefixed global search by default. Enable it in +Noctalia's launcher-provider configuration if desired; the **Global search score +bias** setting keeps weak file matches below applications. Advanced plugin +settings also expose the fuzzy candidate limit and file/directory score weights. + +The same settings page provides **Pruned paths**, **Pruned directory names**, and +an **Index refresh interval**. Pruned paths are omitted when the database is +built, so changing them triggers a rebuild. Relative prune paths are resolved +from the home directory; home-relative paths such as `~/Downloads` are also +supported. + +Pruned directory names apply at every depth and are literal, not regex or glob +patterns. Add `node_modules`, `.git`, or `__pycache__` to omit every matching +directory's contents. Names containing slashes or spaces are ignored; use +**Pruned paths** for a specific directory such as `Projects/example/bin`. + +## Settings + +| Setting | Default | Description | +| --- | --- | --- | +| Maximum results | `50` | Maximum launcher rows returned per query. | +| Pruned paths | `[]` | Home-relative or absolute directories excluded during indexing. | +| Pruned directory names | `[]` | Literal directory names excluded at every depth. | +| Index refresh interval | `60` minutes | Time between automatic index refreshes, from 1 minute to 7 days. | +| Fuzzy search | `fallback` | Fallback after no exact result, always fuzzy, or only `--fuzzy`. | +| Fuzzy candidate limit | `150` | Advanced: fzf candidates scored by the launcher, from 100 to 200. | +| Global search score bias | `-100` | Advanced: score adjustment when global launcher search is enabled. | +| File and directory score weights | `0` | Advanced: type-specific ranking adjustments. | + +## Private Index + +The `indexer` service builds the database automatically when it is missing and +refreshes it every 60 minutes by default. The frequency is configurable from the +plugin settings between 1 and 10080 minutes (7 days). + +## IPC + +Rebuild the private index immediately: + +```sh +noctalia msg plugin yihao/locate-files:indexer all reindex +``` + +The database, NUL-delimited `paths.cache`, lock, log, status, and metadata files +live under the directory returned by `noctalia.pluginDataDir()`. Index builds use +`umask 077`, enforce mode `0600` on both search artifacts, run detached, and are +serialized with `flock`. The cache is exported from the staged private database +and atomically replaced after every successful build. A previous generation +remains usable during routine refreshes. Searches pause during a +prune-configuration rebuild so removed paths are not served from an old index. + +## Implementation + +- `plugin.toml` declares the launcher, index service, dependencies, and settings. +- `indexer.luau` schedules builds and shares index status with the launcher. +- `update-index.sh` stages `updatedb` output and exports `paths.cache` outside + Noctalia's callback timeout. +- `launcher.luau` runs the private `locate` database by default, then uses + `fzf --read0 --print0 --scheme=path --filter` on `paths.cache` when selected. + It applies query options, ranks bounded candidates, and publishes launcher rows. +- Selecting a row opens its safely shell-quoted absolute path with `xdg-open`. +- `translations/en.json` supplies the settings and launcher text required by + the v5 manifest API. diff --git a/locate-files/indexer.luau b/locate-files/indexer.luau new file mode 100644 index 0000000..b6fda9e --- /dev/null +++ b/locate-files/indexer.luau @@ -0,0 +1,285 @@ +--!nonstrict + +local ACTIVE_POLL_MS = 5000 + +local dataDir = noctalia.pluginDataDir() +local pluginDir = noctalia.pluginDir() +local home = noctalia.getenv("HOME") + +local database = dataDir ~= nil and dataDir .. "/home.db" or nil +local pathsCache = dataDir ~= nil and dataDir .. "/paths.cache" or nil +local statusFile = dataDir ~= nil and dataDir .. "/index.status" or nil +local metadataFile = dataDir ~= nil and dataDir .. "/index-metadata.json" or nil +local helper = pluginDir ~= nil and pluginDir .. "/update-index.sh" or nil + +local indexing = false +local pending = false +local sequence = 0 +local activeToken = nil +local activeSignature = nil + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +local function normalizePath(value) + local path = noctalia.string.trim(value) + if path == "" then + return nil + end + if path:sub(1, 1) ~= "/" and path:sub(1, 1) ~= "~" then + path = home .. "/" .. path + end + path = noctalia.expandPath(path) + if path ~= "/" then + path = path:gsub("/+$", "") + end + return path +end + +local function isWithin(path, root) + return root == "/" or path == root or path:sub(1, #root + 1) == root .. "/" +end + +local function configuredPrunes() + local paths = {} + for _, value in ipairs(noctalia.getConfig("blacklist_paths") or {}) do + local path = normalizePath(value) + if path ~= nil and isWithin(path, home) then + local redundant = false + for i = #paths, 1, -1 do + if isWithin(path, paths[i]) then + redundant = true + break + end + if isWithin(paths[i], path) then + table.remove(paths, i) + end + end + if not redundant then + table.insert(paths, path) + end + end + end + + if dataDir ~= nil and isWithin(dataDir, home) then + table.insert(paths, dataDir) + end + table.sort(paths) + return paths +end + +local function configuredPruneNames() + local names = {} + local seen = {} + for _, value in ipairs(noctalia.getConfig("blacklist_names") or {}) do + local name = noctalia.string.trim(value) + if name ~= "" and not name:find("/") and not name:find("%s") and not seen[name] then + seen[name] = true + table.insert(names, name) + end + end + table.sort(names) + return names +end + +local function configSignature(prunes, pruneNames) + return noctalia.json.encode({ root = home, prunes = prunes, pruneNames = pruneNames, formatVersion = 3 }) or "" +end + +local function readStatus() + local raw = statusFile ~= nil and noctalia.readFile(statusFile) or nil + if raw == nil then + return nil, nil, nil + end + local status, token, detail = noctalia.string.trim(raw):match("^([^|]+)|([^|]+)|?(.*)$") + return status, token, detail +end + +local function publish(status, detail) + noctalia.state.set("index_status", status) + noctalia.state.set("index_detail", detail or "") +end + +local function readMetadata() + local raw = metadataFile ~= nil and noctalia.readFile(metadataFile) or nil + if raw == nil then + return nil + end + local value = noctalia.json.decode(raw) + return type(value) == "table" and value or nil +end + +local function saveMetadata(signature) + local metadata = { + signature = signature, + builtAt = tonumber(noctalia.formatTime("%s")) or 0, + } + local encoded = noctalia.json.encode(metadata, true) + if encoded ~= nil then + noctalia.writeFile(metadataFile, encoded) + end +end + +local function buildCommand(token, prunes, pruneNames) + local parts = { + "sh", + shellQuote(helper), + shellQuote(database), + shellQuote(pathsCache), + shellQuote(statusFile), + shellQuote(token), + "updatedb", + "--require-visibility", "no", + "--database-root", shellQuote(home), + "--output", shellQuote(database .. ".tmp." .. token), + "--prunenames", "''", + "--prunepaths", "''", + } + for _, path in ipairs(prunes) do + table.insert(parts, "--add-single-prunepath") + table.insert(parts, shellQuote(path)) + end + for _, name in ipairs(pruneNames) do + table.insert(parts, "--add-prunenames") + table.insert(parts, shellQuote(name)) + end + return table.concat(parts, " ") +end + +local function startIndex() + if indexing then + pending = true + return + end + if database == nil or pathsCache == nil or statusFile == nil or metadataFile == nil or helper == nil or home == nil then + publish("error", "plugin directories are unavailable") + return + end + if not noctalia.commandExists("updatedb") or not noctalia.commandExists("locate") or not noctalia.commandExists("flock") then + publish("error", "updatedb, locate, or flock is not installed") + return + end + + local prunes = configuredPrunes() + local pruneNames = configuredPruneNames() + sequence += 1 + activeToken = noctalia.formatTime("%s") .. "-" .. tostring(sequence) + activeSignature = configSignature(prunes, pruneNames) + indexing = true + publish("building", "") + noctalia.setUpdateInterval(ACTIVE_POLL_MS) + + if not noctalia.runAsync(buildCommand(activeToken, prunes, pruneNames)) then + indexing = false + publish("error", "could not start updatedb") + end +end + +local function indexHasCurrentConfig() + if database == nil or pathsCache == nil or not noctalia.fileExists(database) or not noctalia.fileExists(pathsCache) then + return false + end + local metadata = readMetadata() + if metadata == nil then + return false + end + local signature = configSignature(configuredPrunes(), configuredPruneNames()) + if metadata.signature ~= signature then + return false + end + return true +end + +local function indexIsCurrent() + if not indexHasCurrentConfig() then + return false + end + local metadata = readMetadata() + local refreshMinutes = noctalia.getConfig("index_refresh_minutes") or 60 + local now = tonumber(noctalia.formatTime("%s")) or 0 + return now - (metadata.builtAt or 0) < refreshMinutes * 60 +end + +local function scheduleIdlePoll() + local metadata = readMetadata() + if metadata == nil or metadata.builtAt == nil then + noctalia.setUpdateInterval(60000) + return + end + local refreshMinutes = noctalia.getConfig("index_refresh_minutes") or 60 + local refreshMs = refreshMinutes * 60 * 1000 + local now = tonumber(noctalia.formatTime("%s")) or 0 + local elapsedMs = (now - metadata.builtAt) * 1000 + local remainingMs = refreshMs - elapsedMs + if remainingMs <= 0 then + noctalia.setUpdateInterval(1000) + return + end + noctalia.setUpdateInterval(remainingMs) +end + +local function ensureIndex() + if not indexing and not indexIsCurrent() then + if not indexHasCurrentConfig() then + noctalia.state.set("index_requires_rebuild", true) + end + startIndex() + end +end + +function update() + local status, token, detail = readStatus() + if indexing and status == "ready" then + indexing = false + if token == activeToken and activeSignature ~= nil then + saveMetadata(activeSignature) + end + local currentSignature = configSignature(configuredPrunes(), configuredPruneNames()) + local matchesCurrentConfig = token == activeToken and activeSignature == currentSignature + if pending or not matchesCurrentConfig then + noctalia.state.set("index_requires_rebuild", true) + else + noctalia.state.set("index_requires_rebuild", false) + publish("ready", "") + end + elseif indexing and status == "error" then + indexing = false + publish("error", detail) + elseif status == "running" then + publish("building", "") + end + + if not indexing and pending then + pending = false + startIndex() + return + end + + ensureIndex() + if indexing then + noctalia.setUpdateInterval(ACTIVE_POLL_MS) + else + scheduleIdlePoll() + end +end + +function onConfigChanged() + if not indexIsCurrent() then + noctalia.state.set("index_requires_rebuild", true) + startIndex() + end +end + +function onIpc(event, _payload) + if event == "reindex" then + startIndex() + end +end + +noctalia.state.watch("reindex_request", function(_value) + startIndex() +end) + +publish("checking", "") +update() diff --git a/locate-files/launcher.luau b/locate-files/launcher.luau new file mode 100644 index 0000000..2a44ca7 --- /dev/null +++ b/locate-files/launcher.luau @@ -0,0 +1,356 @@ +--!nonstrict + +local DEFAULT_MAX_RESULTS = 50 +local DEFAULT_FUZZY_CANDIDATE_LIMIT = 150 +local MAX_CANDIDATES = 500 +local CANDIDATE_MULTIPLIER = 4 +local MIN_FUZZY_QUERY_LENGTH = 3 + +local TYPE_ALIASES = { + any = "any", + file = "file", + f = "file", + directory = "directory", + dir = "directory", + d = "directory", +} + +local dataDir = noctalia.pluginDataDir() +local database = dataDir ~= nil and dataDir .. "/home.db" or nil +local pathsCache = dataDir ~= nil and dataDir .. "/paths.cache" or nil +local queryGeneration = 0 + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +local function buildExactCommand(request, limit) + return "locate -d " .. shellQuote(database) + .. " -0 -e -i -l " .. tostring(limit) .. " -- " .. shellQuote(request.text) +end + +local function buildFuzzyCommand(request, limit) + return "fzf --read0 --print0 --scheme=path --filter=" .. shellQuote(request.text) + .. " < " .. shellQuote(pathsCache) .. " | head -z -n " .. tostring(limit) +end + +local function showStatus(query, titleKey, glyph, subtitle) + launcher.setResults(query, { + { + id = "", + title = noctalia.tr(titleKey), + subtitle = subtitle, + glyph = glyph, + }, + }) +end + +local function parseFlags(rawFlags) + local flags = {} + for flag in rawFlags:gmatch("%S+") do + table.insert(flags, flag) + end + + local options = { hidden = false, kind = "any", forceFuzzy = false } + local i = 1 + while i <= #flags do + local flag = flags[i] + if flag == "--hidden" or flag == "-h" then + options.hidden = true + elseif flag == "--fuzzy" then + options.forceFuzzy = true + elseif flag == "--type" or flag == "-t" then + i += 1 + if flags[i] == nil then + return nil, noctalia.tr("launcher.flags.type_missing") + end + options.kind = flags[i] + else + local kind = flag:match("^%-%-type=(.+)$") + if kind == nil then + return nil, noctalia.tr("launcher.flags.unknown", { flag = flag }) + end + options.kind = kind + end + i += 1 + end + + local normalizedKind = TYPE_ALIASES[options.kind] + if normalizedKind == nil then + return nil, noctalia.tr("launcher.flags.type_invalid", { value = options.kind }) + end + options.kind = normalizedKind + return options, nil +end + +local function parseRequest(query, generation) + local input = noctalia.string.trim(query) + local text, rawFlags = input:match("^(.-)%s+%-%-%s*(.*)$") + if text == nil then + text = input + rawFlags = "" + end + + local options, err = parseFlags(rawFlags) + if err ~= nil then + return nil, err + end + + local fuzzyMode = noctalia.getConfig("fuzzy_mode") or "fallback" + if fuzzyMode ~= "fallback" and fuzzyMode ~= "always" and fuzzyMode ~= "off" then + fuzzyMode = "fallback" + end + + return { + query = query, + text = noctalia.string.trim(text), + hidden = options.hidden, + kind = options.kind, + forceFuzzy = options.forceFuzzy, + fuzzyMode = fuzzyMode, + maxResults = noctalia.getConfig("max_results") or DEFAULT_MAX_RESULTS, + generation = generation, + }, nil +end + +local function isCurrent(request) + return request.generation == queryGeneration +end + +local function fuzzyEligible(request) + return #request.text >= MIN_FUZZY_QUERY_LENGTH +end + +local function fuzzyCandidateLimit() + local configured = tonumber(noctalia.getConfig("fuzzy_candidate_limit")) or DEFAULT_FUZZY_CANDIDATE_LIMIT + return math.max(100, math.min(math.floor(configured), 200)) +end + +local function exactCandidateLimit(request) + local needsFiltering = not request.hidden or request.kind ~= "any" + local multiplier = needsFiltering and CANDIDATE_MULTIPLIER or 1 + return math.min(request.maxResults * multiplier, MAX_CANDIDATES) +end + +local function kindMatches(kind, info) + if kind == "any" then + return true + end + return info ~= nil and (kind == "directory") == info.isDir +end + +local function needsFileInfo(request) + if request.kind ~= "any" then + return true + end + return (tonumber(noctalia.getConfig("file_weight")) or 0) ~= 0 + or (tonumber(noctalia.getConfig("directory_weight")) or 0) ~= 0 +end + +local function pathTitle(path) + local cleanPath = path == "/" and path or path:gsub("/+$", "") + return cleanPath:match("([^/]+)$") or cleanPath +end + +local function scoreCandidate(path, request, source, rank, candidateLimit, info) + local filenameScore = noctalia.fuzzyScore(request.text, pathTitle(path)) + local pathScore = noctalia.fuzzyScore(request.text, path) + local lexicalScore + if filenameScore ~= nil then + lexicalScore = filenameScore * 5 + (pathScore or 0) * 0.25 + elseif pathScore ~= nil then + lexicalScore = pathScore + else + return nil + end + + local rankBonus = 0 + if source == "fuzzy" then + rankBonus = (candidateLimit - rank + 1) / candidateLimit * 2 + end + + local typeWeight = 0 + if info ~= nil then + typeWeight = tonumber(noctalia.getConfig(info.isDir and "directory_weight" or "file_weight")) or 0 + end + + return lexicalScore + rankBonus + typeWeight + (tonumber(noctalia.getConfig("global_score_bias")) or -100) +end + +local function collectResults(stdout, request, source, candidateLimit) + local rows = {} + local needsInfo = needsFileInfo(request) + local rank = 0 + for path in stdout:gmatch("([^%z]+)") do + rank += 1 + local hidden = path:find("/%.") ~= nil + if request.hidden or not hidden then + local info = needsInfo and noctalia.fileInfo(path) or nil + if kindMatches(request.kind, info) then + local score = scoreCandidate(path, request, source, rank, candidateLimit, info) + if score ~= nil then + table.insert(rows, { + id = path, + title = pathTitle(path), + subtitle = path, + glyph = info ~= nil and info.isDir and "folder" or "file", + score = score, + rank = rank, + }) + end + end + end + end + table.sort(rows, function(a, b) + if a.score == b.score then + return a.rank < b.rank + end + return a.score > b.score + end) + while #rows > request.maxResults do + table.remove(rows) + end + for _, row in ipairs(rows) do + row.rank = nil + end + return rows +end + +local function searchFailed(result) + return result.timedOut or (result.exitCode ~= 0 and noctalia.string.trim(result.stderr) ~= "") +end + +local function publishRows(rows, request) + if not isCurrent(request) then + return + end + if #rows > 0 then + launcher.setResults(request.query, rows) + else + showStatus(request.query, "launcher.no_results", "file-off", request.text) + end +end + +local function startFuzzySearch(request) + if not fuzzyEligible(request) then + if isCurrent(request) then + showStatus(request.query, "launcher.fuzzy_too_short", "search", noctalia.tr("launcher.fuzzy_too_short_subtitle")) + end + return + end + if not noctalia.commandExists("fzf") then + if isCurrent(request) then + showStatus(request.query, "launcher.error", "alert-triangle", "fzf is not installed") + end + return + end + + local candidateLimit = fuzzyCandidateLimit() + local started = noctalia.runAsync(buildFuzzyCommand(request, candidateLimit), function(result) + if not isCurrent(request) then + return + end + if searchFailed(result) then + showStatus(request.query, "launcher.error", "alert-triangle", noctalia.string.trim(result.stderr)) + return + end + publishRows(collectResults(result.stdout, request, "fuzzy", candidateLimit), request) + end) + if not started and isCurrent(request) then + showStatus(request.query, "launcher.error", "alert-triangle", nil) + end +end + +local function startExactSearch(request) + local candidateLimit = exactCandidateLimit(request) + local started = noctalia.runAsync(buildExactCommand(request, candidateLimit), function(result) + if not isCurrent(request) then + return + end + if searchFailed(result) then + showStatus(request.query, "launcher.error", "alert-triangle", noctalia.string.trim(result.stderr)) + return + end + + local rows = collectResults(result.stdout, request, "exact", candidateLimit) + if #rows == 0 and request.fuzzyMode == "fallback" and fuzzyEligible(request) then + startFuzzySearch(request) + return + end + publishRows(rows, request) + end) + if not started and isCurrent(request) then + showStatus(request.query, "launcher.error", "alert-triangle", nil) + end +end + +local function startSearch(request) + if request.forceFuzzy or request.fuzzyMode == "always" then + startFuzzySearch(request) + else + startExactSearch(request) + end +end + +function onQuery(query) + queryGeneration += 1 + local generation = queryGeneration + if noctalia.string.trim(query) == "--reindex" then + launcher.setResults(query, { + { + id = "action:reindex", + title = noctalia.tr("launcher.reindex"), + subtitle = noctalia.tr("launcher.reindex_subtitle"), + glyph = "database-cog", + }, + }) + return + end + + local request, parseError = parseRequest(query, generation) + if parseError ~= nil then + showStatus(query, "launcher.flags.error", "alert-triangle", parseError) + return + end + + if request.text == "" then + showStatus(query, "launcher.hint", "search", noctalia.tr("launcher.hint_subtitle")) + return + end + + local requiresFuzzy = request.forceFuzzy or request.fuzzyMode == "always" + if requiresFuzzy and not fuzzyEligible(request) then + showStatus(query, "launcher.fuzzy_too_short", "search", noctalia.tr("launcher.fuzzy_too_short_subtitle")) + return + end + + if not noctalia.commandExists("locate") then + showStatus(query, "launcher.missing", "alert-triangle", noctalia.tr("launcher.missing_subtitle")) + return + end + + local indexStatus = noctalia.state.get("index_status") + local requiresRebuild = noctalia.state.get("index_requires_rebuild") == true + if database == nil or pathsCache == nil or not noctalia.fileExists(database) or not noctalia.fileExists(pathsCache) or requiresRebuild then + local failed = indexStatus == "error" + local titleKey = failed and "launcher.index_error" or "launcher.index_building" + local subtitle = failed and noctalia.state.get("index_detail") or noctalia.tr("launcher.index_building_subtitle") + showStatus(query, titleKey, failed and "alert-triangle" or "database-cog", subtitle) + return + end + + showStatus(query, "launcher.searching", "loader", nil) + startSearch(request) +end + +function onActivate(path) + if path == "action:reindex" then + local request = noctalia.state.get("reindex_request") or 0 + noctalia.state.set("reindex_request", request + 1) + noctalia.notify(noctalia.tr("title"), noctalia.tr("launcher.reindex_started")) + return + end + if path ~= "" and not noctalia.runAsync("xdg-open " .. shellQuote(path)) then + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("launcher.open_error")) + end +end diff --git a/locate-files/plugin.toml b/locate-files/plugin.toml new file mode 100644 index 0000000..a4edfe9 --- /dev/null +++ b/locate-files/plugin.toml @@ -0,0 +1,110 @@ +id = "yihao/locate-files" +name = "Locate Files" +version = "0.1.0" +plugin_api = 3 +author = "yihao" +license = "MIT" +dependencies = ["locate", "updatedb", "fzf", "flock", "xdg-open"] +tags = ["launcher", "productivity", "utility"] +icon = "file-search" +description = "Find and open files from Noctalia's launcher using locate." + +[[setting]] +key = "max_results" +type = "int" +label_key = "settings.max_results.label" +description_key = "settings.max_results.description" +default = 50 +min = 1 +max = 200 + +[[setting]] +key = "blacklist_paths" +type = "string_list" +label_key = "settings.blacklist_paths.label" +description_key = "settings.blacklist_paths.description" +default = [] + +[[setting]] +key = "blacklist_names" +type = "string_list" +label_key = "settings.blacklist_names.label" +description_key = "settings.blacklist_names.description" +default = [] + +[[setting]] +key = "index_refresh_minutes" +type = "int" +label_key = "settings.index_refresh_minutes.label" +description_key = "settings.index_refresh_minutes.description" +default = 60 +min = 1 +max = 10080 + +[[setting]] +key = "fuzzy_mode" +type = "select" +label_key = "settings.fuzzy_mode.label" +description_key = "settings.fuzzy_mode.description" +default = "fallback" +options = [ + { value = "fallback", label_key = "settings.fuzzy_mode.fallback" }, + { value = "always", label_key = "settings.fuzzy_mode.always" }, + { value = "off", label_key = "settings.fuzzy_mode.off" }, +] + +[[setting]] +key = "fuzzy_candidate_limit" +type = "int" +label_key = "settings.fuzzy_candidate_limit.label" +description_key = "settings.fuzzy_candidate_limit.description" +default = 150 +min = 100 +max = 200 +step = 10 +advanced = true + +[[setting]] +key = "global_score_bias" +type = "int" +label_key = "settings.global_score_bias.label" +description_key = "settings.global_score_bias.description" +default = -100 +min = -1000 +max = 1000 +step = 10 +advanced = true + +[[setting]] +key = "file_weight" +type = "int" +label_key = "settings.file_weight.label" +description_key = "settings.file_weight.description" +default = 0 +min = -1000 +max = 1000 +step = 10 +advanced = true + +[[setting]] +key = "directory_weight" +type = "int" +label_key = "settings.directory_weight.label" +description_key = "settings.directory_weight.description" +default = 0 +min = -1000 +max = 1000 +step = 10 +advanced = true + +[[launcher_provider]] +id = "files" +entry = "launcher.luau" +prefix = "f" +glyph = "file-search" +include_in_global_search = true +debounce_ms = 250 + +[[service]] +id = "indexer" +entry = "indexer.luau" diff --git a/locate-files/thumbnail.webp b/locate-files/thumbnail.webp new file mode 100644 index 0000000..2542fec Binary files /dev/null and b/locate-files/thumbnail.webp differ diff --git a/locate-files/translations/en.json b/locate-files/translations/en.json new file mode 100644 index 0000000..42ecda4 --- /dev/null +++ b/locate-files/translations/en.json @@ -0,0 +1,44 @@ +{ + "title": "Locate Files", + "settings.max_results.label": "Maximum results", + "settings.max_results.description": "Maximum number of paths returned for each search.", + "settings.blacklist_paths.label": "Pruned paths", + "settings.blacklist_paths.description": "Directories excluded when building the private home index. Relative paths are resolved from your home directory.", + "settings.blacklist_names.label": "Pruned directory names", + "settings.blacklist_names.description": "Literal directory names excluded everywhere in the private index, such as node_modules or .git. Names cannot contain slashes or spaces.", + "settings.index_refresh_minutes.label": "Index refresh interval", + "settings.index_refresh_minutes.description": "Minutes between automatic updates of the private home index.", + "settings.fuzzy_mode.label": "Fuzzy search", + "settings.fuzzy_mode.description": "Use fuzzy matching after no exact result, always, or only when forced with --fuzzy.", + "settings.fuzzy_mode.fallback": "After no exact result", + "settings.fuzzy_mode.always": "Always", + "settings.fuzzy_mode.off": "Only when forced", + "settings.fuzzy_candidate_limit.label": "Fuzzy candidate limit", + "settings.fuzzy_candidate_limit.description": "Maximum fzf matches ranked by the launcher before results are shown.", + "settings.global_score_bias.label": "Global search score bias", + "settings.global_score_bias.description": "Score added to file results when this provider is enabled in global search.", + "settings.file_weight.label": "File score weight", + "settings.file_weight.description": "Score added to regular file results.", + "settings.directory_weight.label": "Directory score weight", + "settings.directory_weight.description": "Score added to directory results.", + "launcher.hint": "Type a file name to search", + "launcher.hint_subtitle": "Results come from the locate database", + "launcher.missing": "locate is not installed", + "launcher.missing_subtitle": "Install plocate or another locate implementation", + "launcher.searching": "Searching files...", + "launcher.no_results": "No files found", + "launcher.fuzzy_too_short": "Fuzzy search needs at least 3 characters", + "launcher.fuzzy_too_short_subtitle": "Keep typing to search the full private path index", + "launcher.error": "File search failed", + "launcher.flags.error": "Invalid search options", + "launcher.flags.unknown": "Unknown option: {flag}", + "launcher.flags.type_missing": "--type requires file or directory", + "launcher.flags.type_invalid": "Invalid type: {value}. Use file or directory.", + "launcher.index_building": "Building the home file index", + "launcher.index_building_subtitle": "Search will be available when indexing finishes", + "launcher.index_error": "Home file indexing failed", + "launcher.reindex": "Rebuild the home file index", + "launcher.reindex_subtitle": "Refresh the database now", + "launcher.reindex_started": "Home file indexing started", + "launcher.open_error": "Could not start xdg-open" +} diff --git a/locate-files/update-index.sh b/locate-files/update-index.sh new file mode 100644 index 0000000..865bd9c --- /dev/null +++ b/locate-files/update-index.sh @@ -0,0 +1,51 @@ +#!/bin/sh + +set -u +umask 077 + +if [ "$#" -lt 5 ]; then + exit 2 +fi + +database=$1 +paths_cache=$2 +status_file=$3 +token=$4 +shift 4 + +staged_database="$database.tmp.$token" +staged_cache="$paths_cache.tmp.$token" + +cleanup() { + rm -f "$staged_database" "$staged_cache" +} + +trap cleanup EXIT HUP INT TERM + +exec 9>"$database.lock" +if ! flock -n 9; then + exit 0 +fi + +write_status() { + status=$1 + printf '%s|%s\n' "$status" "$token" >"$status_file.tmp" + mv -f "$status_file.tmp" "$status_file" +} + +write_status running + +if "$@" >"$database.log" 2>&1 \ + && locate -d "$staged_database" -0 / >"$staged_cache" 2>>"$database.log" \ + && [ -s "$staged_database" ] \ + && [ -s "$staged_cache" ] \ + && chmod 600 "$staged_database" "$staged_cache" \ + && mv -f "$staged_cache" "$paths_cache" \ + && mv -f "$staged_database" "$database"; then + trap - EXIT HUP INT TERM + write_status ready +else + code=$? + printf 'error|%s|%s\n' "$token" "$code" >"$status_file.tmp" + mv -f "$status_file.tmp" "$status_file" +fi