* feat: changed keybinds + new keybinds
Changes:
- ctrl+n -> ctrl+j
- ctrl+p -> ctrl+k
Additions:
- ctrl+h or left: return to the root level when in a folder
- ctrl+l or right: get into a folder
* feat: /bk Provider
- feat: /bk Provider
- chore: better preview imgs
- docs: updated README
- chore: updated Turkish translation strings
- chore: updated thumbnail
* feat: keybind changes + index tracking fix
- feat: Some more keybind changes
- Added: ctrl + n for new bookmark
- Changed: ctrl + f for search (changed from ctrl + s)
- fix: Added index tracking for bookmark entries
- For keyboard-centric use, navigating to a folder by accident then
returning to root was annoying since no tracking meant that we'd always
land on the first entry within the folder or the root itself
- docs: updated README to reflected the changes and added a changelog
section
* fix translation merge
---------
Co-authored-by: Lemmy <studio@quadbyte.net>
293 lines
7.6 KiB
Luau
293 lines
7.6 KiB
Luau
-- Launcher provider: "/bk" fuzzy-searches bookmark labels (root + one level
|
|
-- of folders, bookmarks only - same scope as the panel's own search) and
|
|
-- runs the selected one on activation. Reads the same data.json the panel
|
|
-- writes, resolved the same way (configured data_path, else the plugin's
|
|
-- data dir), so no state is shared beyond the file on disk.
|
|
|
|
local function resolveDataPath()
|
|
local configured = noctalia.getConfig("data_path")
|
|
if type(configured) == "string" and noctalia.string.trim(configured) ~= "" then
|
|
return noctalia.expandPath(noctalia.string.trim(configured)), nil
|
|
end
|
|
|
|
local dataDir, dataDirErr = noctalia.pluginDataDir()
|
|
if dataDir == nil then
|
|
return nil, dataDirErr
|
|
end
|
|
return dataDir .. "/data.json", nil
|
|
end
|
|
|
|
local function entryType(entry)
|
|
return entry.type == "folder" and "folder" or "bookmark"
|
|
end
|
|
|
|
-- Reads and decodes data.json fresh on every query. The panel is the only
|
|
-- writer and bookmark lists are small, so there's no need to cache across
|
|
-- queries here.
|
|
local function loadBookmarks()
|
|
local path, pathErr = resolveDataPath()
|
|
if path == nil then
|
|
noctalia.log("bookmarks-provider: could not resolve data path: " .. tostring(pathErr))
|
|
return {}
|
|
end
|
|
if not noctalia.fileExists(path) then
|
|
return {}
|
|
end
|
|
local raw, readErr = noctalia.readFile(path)
|
|
if raw == nil then
|
|
noctalia.log("bookmarks-provider: failed to read data file: " .. tostring(readErr))
|
|
return {}
|
|
end
|
|
local decoded, decodeErr = noctalia.json.decode(raw)
|
|
if type(decoded) ~= "table" then
|
|
noctalia.log("bookmarks-provider: data file is corrupt: " .. tostring(decodeErr))
|
|
return {}
|
|
end
|
|
return decoded
|
|
end
|
|
|
|
-- Flat list of { entry, path } across root and one level of folders,
|
|
-- bookmarks only (folders themselves aren't launchable results).
|
|
local function flatten(bookmarks)
|
|
local flat = {}
|
|
for _, entry in ipairs(bookmarks) do
|
|
if entryType(entry) == "folder" then
|
|
for _, subEntry in ipairs(entry.items or {}) do
|
|
if entryType(subEntry) ~= "folder" then
|
|
table.insert(flat, { entry = subEntry, path = entry.label or "" })
|
|
end
|
|
end
|
|
else
|
|
table.insert(flat, { entry = entry, path = nil })
|
|
end
|
|
end
|
|
return flat
|
|
end
|
|
|
|
-- Bookmarks are indexed by a stable synthetic id (path-qualified label),
|
|
-- rather than by array position, since onActivate only receives the id
|
|
-- string and the list is re-read from disk on every query.
|
|
local function resultId(item)
|
|
return (item.path or "") .. "\30" .. (item.entry.label or "")
|
|
end
|
|
|
|
local function buildResults(query)
|
|
local bookmarks = loadBookmarks()
|
|
local flat = flatten(bookmarks)
|
|
|
|
local results = {}
|
|
for _, item in ipairs(flat) do
|
|
local score
|
|
if query == "" then
|
|
score = 0
|
|
else
|
|
score = noctalia.fuzzyScore(query, item.entry.label or "")
|
|
end
|
|
if score ~= nil then
|
|
table.insert(results, {
|
|
id = resultId(item),
|
|
title = item.entry.label or "",
|
|
subtitle = item.path,
|
|
glyph = item.entry.glyph or "bookmark",
|
|
score = score,
|
|
})
|
|
end
|
|
end
|
|
|
|
table.sort(results, function(a, b)
|
|
return (a.score or 0) > (b.score or 0)
|
|
end)
|
|
return results
|
|
end
|
|
|
|
local function providerEnabled()
|
|
return noctalia.getConfig("enable_bk_provider") == true
|
|
end
|
|
|
|
-- "history" (most-recently-used first) or "usage_count" (most-frequently-
|
|
-- used first). Only affects ordering when the query is empty; a non-empty
|
|
-- query always ranks by fuzzy match score, same as before.
|
|
local function sortBy()
|
|
local value = noctalia.getConfig("bk_sort_by")
|
|
if value == "usage_count" then
|
|
return "usage_count"
|
|
end
|
|
return "history"
|
|
end
|
|
|
|
local function statsPath()
|
|
local dataDir = noctalia.pluginDataDir()
|
|
if dataDir == nil then
|
|
return nil
|
|
end
|
|
return dataDir .. (sortBy() == "history" and "/bk_history.json" or "/bk_usage.json")
|
|
end
|
|
|
|
-- Reads and decodes the current stats file (history array or usage-count
|
|
-- map, per sortBy()), falling back to an empty table when the file is
|
|
-- missing, unreadable, or corrupt. Shared by getSortScores/recordActivation
|
|
-- so the read/decode/default boilerplate only lives in one place.
|
|
local function readStatsData()
|
|
local path = statsPath()
|
|
if path == nil then
|
|
return {}, nil
|
|
end
|
|
local file = noctalia.readFile(path)
|
|
if file == nil then
|
|
return {}, path
|
|
end
|
|
local decoded = noctalia.json.decode(file)
|
|
if type(decoded) ~= "table" then
|
|
return {}, path
|
|
end
|
|
return decoded, path
|
|
end
|
|
|
|
-- Scores used to order results when the query is empty, keyed by result id.
|
|
-- History ranks by recency (higher = more recently used); usage_count ranks
|
|
-- by frequency (higher = used more often). Reads/decodes the stats file
|
|
-- once for the whole batch rather than once per result. Ids missing from
|
|
-- the stats file fall back to 0 (unranked, sorted last).
|
|
local function getSortScores(ids)
|
|
local data = readStatsData()
|
|
local scores = {}
|
|
|
|
if sortBy() == "history" then
|
|
for _, id in ipairs(ids) do
|
|
local i = table.find(data, id)
|
|
scores[id] = i ~= nil and (#data - i) or 0
|
|
end
|
|
else
|
|
for _, id in ipairs(ids) do
|
|
scores[id] = data[id] or 0
|
|
end
|
|
end
|
|
|
|
return scores
|
|
end
|
|
|
|
-- Records an activation for ranking purposes: pushes `id` to the front of
|
|
-- the history list, or increments its usage count, depending on `sort_by`.
|
|
local function recordActivation(id)
|
|
local data, path = readStatsData()
|
|
if path == nil then
|
|
return
|
|
end
|
|
|
|
if sortBy() == "history" then
|
|
local i = table.find(data, id)
|
|
if i ~= nil then
|
|
table.remove(data, i)
|
|
end
|
|
table.insert(data, 1, id)
|
|
else
|
|
data[id] = (data[id] or 0) + 1
|
|
end
|
|
|
|
local encoded = noctalia.json.encode(data)
|
|
if encoded ~= nil then
|
|
noctalia.writeFile(path, encoded)
|
|
end
|
|
end
|
|
|
|
function onQuery(query)
|
|
query = noctalia.string.trim(query)
|
|
|
|
if not providerEnabled() then
|
|
launcher.setResults(query, {})
|
|
return
|
|
end
|
|
|
|
local results = buildResults(query)
|
|
|
|
if query == "" then
|
|
local ids = {}
|
|
for _, result in ipairs(results) do
|
|
table.insert(ids, result.id)
|
|
end
|
|
local scores = getSortScores(ids)
|
|
for _, result in ipairs(results) do
|
|
result.score = scores[result.id] or 0
|
|
end
|
|
table.sort(results, function(a, b)
|
|
return (a.score or 0) > (b.score or 0)
|
|
end)
|
|
end
|
|
|
|
if #results == 0 then
|
|
launcher.setResults(query, {
|
|
{
|
|
id = "",
|
|
title = noctalia.tr("no_bookmarks"),
|
|
glyph = "bookmark-off",
|
|
},
|
|
})
|
|
return
|
|
end
|
|
launcher.setResults(query, results)
|
|
end
|
|
|
|
function onActivate(id)
|
|
if id == "" or not providerEnabled() then
|
|
return
|
|
end
|
|
|
|
local wantPath, wantLabel = id:match("^([^\30]*)\30(.*)$")
|
|
if wantLabel == nil then
|
|
return
|
|
end
|
|
if wantPath == "" then
|
|
wantPath = nil
|
|
end
|
|
|
|
local bookmarks = loadBookmarks()
|
|
for _, item in ipairs(flatten(bookmarks)) do
|
|
if item.entry.label == wantLabel and (item.path or nil) == wantPath then
|
|
local entry = item.entry
|
|
local cmd = entry.cmd
|
|
if type(cmd) ~= "string" or cmd == "" then
|
|
noctalia.notifyError(noctalia.tr("title"), noctalia.tr("err_no_command"))
|
|
return
|
|
end
|
|
local label = entry.label or cmd
|
|
|
|
recordActivation(id)
|
|
|
|
if entry.runInTerminal == true then
|
|
noctalia.runInTerminal(cmd)
|
|
return
|
|
end
|
|
|
|
local finalCmd = cmd
|
|
if entry.runInBackground == true then
|
|
finalCmd = "nohup " .. cmd .. " >/dev/null 2>&1 &"
|
|
end
|
|
|
|
local ok = noctalia.runAsync(finalCmd, function(result)
|
|
if entry.runInBackground == true then
|
|
return
|
|
end
|
|
if result.exitCode ~= 0 then
|
|
local detail = noctalia.string.trim(result.stderr or "")
|
|
local msg = noctalia.tr(
|
|
"err_exit_code",
|
|
{ label = label, code = tostring(result.exitCode) }
|
|
)
|
|
if detail ~= "" then
|
|
msg = msg .. ": " .. detail
|
|
end
|
|
noctalia.notifyError(noctalia.tr("title"), msg)
|
|
end
|
|
end)
|
|
|
|
if not ok then
|
|
noctalia.notifyError(
|
|
noctalia.tr("title"),
|
|
noctalia.tr("err_launch_failed", { label = label })
|
|
)
|
|
end
|
|
return
|
|
end
|
|
end
|
|
end
|