bookmarks-v1.3 feat(add /bk launcher provider) (#165)
* 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>
@@ -1,28 +1,55 @@
|
|||||||
# Bookmarks
|
# Bookmarks
|
||||||
|
|
||||||
A bookmarks/shortcuts plugin for Noctalia. You can add folders and commands to a list of bookmarks.
|

|
||||||
Reorder bookmarks, categorize them with folders, set glyphs and labels, and simply left click to
|
|
||||||
execute. All of it is user defined, stored in a JSON file you can backup.
|
A user-managed bookmarks plugin for Noctalia. You can add bookmarks by selecting a glyph, a label,
|
||||||
|
and a shell command to execute. You can backup your bookmarks as well!
|
||||||
|
|
||||||
## Plugin
|
## Plugin
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ------- | ---------------------------------- |
|
| --------------- | ----------------------------------------------------------------- |
|
||||||
| ID | `dunarand/bookmarks` |
|
| ID | `dunarand/bookmarks` |
|
||||||
| Entries | Bar widget: `bar`; panel: `panel`; |
|
| Entries | Bar widget: `bar`; panel: `panel`; Launcher provider: `provider`; |
|
||||||
|
| Launcher Prefix | `/bk` |
|
||||||
Bar widget `bar` is not required. Instead, you can assign a keybind to directly open the `panel`,
|
|
||||||
which is shown in the Usage section.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Noctalia v5.0.0 or higher
|
- Noctalia v5.0.0 or higher
|
||||||
- `nohup` (Optional): For "Run in background" wrapper toggle
|
- `nohup` (Optional): For "Run in background" wrapper toggle
|
||||||
|
|
||||||
|
## IPC & Keybinds
|
||||||
|
|
||||||
|
1. Open the bookmarks panel:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
noctalia msg panel-toggle dunarand/bookmarks:panel
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Open the bookmarks panel in search mode (immediately puts you into search mode) so that you can
|
||||||
|
use your bookmarks panel as a launcher:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
noctalia msg panel-toggle dunarand/bookmarks:panel search
|
||||||
|
```
|
||||||
|
|
||||||
|
You can interact with the bookmarks list via keybinds:
|
||||||
|
|
||||||
|
| Keybind | Purpose |
|
||||||
|
| ------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| `CTRL + J` or Down Arrow | Select the next (below) item |
|
||||||
|
| `CTRL + K` or Up Arrow | Select the previous (above) item |
|
||||||
|
| `CTRL + H` or Left Arrow | Return to the root level when in a folder |
|
||||||
|
| `CTRL + L` or Right Arrow | Get into a folder |
|
||||||
|
| `CTRL + F` | Search bookmarks |
|
||||||
|
| `CTRL + N` | New bookmark |
|
||||||
|
| `Enter` / `Return` | Execute the selected bookmark's command / Navigate into a folder |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
You can add the widget to your bar or assign the following command to your compositor keybinds. For
|
The plugin ships a bar widget, a panel, and a launcher provider. The bar widget just launches the
|
||||||
example, in Hyprland v0.55+
|
panel. The panel is able to be toggled via IPC. For example, in Hyprland v0.55 or higher, you can
|
||||||
|
assign it to a keybind as follows:
|
||||||
|
|
||||||
```
|
```
|
||||||
hl.bind(
|
hl.bind(
|
||||||
@@ -31,27 +58,14 @@ hl.bind(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
You can interact with the bookmarks list via keybinds:
|
|
||||||
|
|
||||||
| Keybind | Purpose |
|
|
||||||
| ------------------------ | ---------------------------------------------------------------- |
|
|
||||||
| `CTRL + N` or Down Arrow | Select the next (below) item |
|
|
||||||
| `CTRL + P` or Up Arrow | Select the previous (above) item |
|
|
||||||
| `CTRL + S` | Search bookmarks |
|
|
||||||
| `Enter` / `Return` | Execute the selected bookmark's command / Navigate into a folder |
|
|
||||||
|
|
||||||
- Bookmarks and folders are listed in the main panel.
|
- Bookmarks and folders are listed in the main panel.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- "?" button shows tooltips: bookmark command and its description.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
- You can create or edit bookmarks by assigning them a glyph, a label, a command, and an
|
- You can create or edit bookmarks by assigning them a glyph, a label, a command, and an
|
||||||
optional description.
|
optional description.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- "Run in background" toggle wraps the command you defined in the following way:
|
- "Run in background" toggle wraps the command you defined in the following way:
|
||||||
|
|
||||||
@@ -70,24 +84,86 @@ You can interact with the bookmarks list via keybinds:
|
|||||||
|
|
||||||
- You can create folders and nest other bookmarks within folders.
|
- You can create folders and nest other bookmarks within folders.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Folders cannot nest other folders. This is by design and it'll not change unless I find a
|
Folders cannot nest other folders. This is by design and it'll not change unless I find a
|
||||||
genuine usecase. You can edit folders by clicking on the "pen" icon next to its name.
|
genuine use case. You can edit folders by clicking on the "pen" icon next to its name.
|
||||||
|
|
||||||
- You can press the "eye" icon to enter edit mode where you can edit, delete, and reorder bookmarks.
|
- You can press the "eye" icon to enter edit mode where you can edit, delete, and reorder bookmarks.
|
||||||
|
This is a setting that you can disable.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- Each bookmark can be edited anytime.
|
- You can also use your launcher to query your bookmarks with the `/bk` prefix.
|
||||||
|
|
||||||

|

|
||||||
|
- Queries are folder-aware, meaning if you nested a bookmark inside a folder, the folder name will
|
||||||
|
be displayed as well.
|
||||||
|
|
||||||
|
## Bookmarks Data
|
||||||
|
|
||||||
The saved bookmarks are written to `$NOCTALIA_STATE_HOME/plugins/data/dunarand/bookmarks/data.json`.
|
The saved bookmarks are written to `$NOCTALIA_STATE_HOME/plugins/data/dunarand/bookmarks/data.json`.
|
||||||
By default, `$NOCTALIA_STATE_HOME` should point to `~/.local/state/noctalia`. You can point to a
|
By default, `$NOCTALIA_STATE_HOME` should point to `~/.local/state/noctalia`. You can point to a
|
||||||
different location for saving and backing up your bookmarks. This setting is configurable via
|
different location for saving and backing up your bookmarks. This setting is configurable via
|
||||||
**plugin settings** under Settings -> Plugins. Only JSON format is accepted.
|
**plugin settings** under Settings -> Plugins. Only JSON format is accepted.
|
||||||
|
|
||||||
|
You can manage the bookmark data via external scripts.
|
||||||
|
|
||||||
|
**Root**
|
||||||
|
|
||||||
|
```
|
||||||
|
[ <entry>, <entry>]
|
||||||
|
```
|
||||||
|
|
||||||
|
The root is a JSON array of entries. Order in the array is display order (used directly by drag-and
|
||||||
|
drop reordering).
|
||||||
|
|
||||||
|
**Entry**
|
||||||
|
|
||||||
|
Two types of entries exist: `bookmark` and `folder`.
|
||||||
|
|
||||||
|
### Bookmarks
|
||||||
|
|
||||||
|
```JSON
|
||||||
|
{
|
||||||
|
"type": "bookmark",
|
||||||
|
"glyph": "bookmark",
|
||||||
|
"label": "My Bookmark",
|
||||||
|
"cmd": "firefox",
|
||||||
|
"description": "Opens Firefox",
|
||||||
|
"runInBackground": false,
|
||||||
|
"runInTerminal": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Type | Required / Default | Notes |
|
||||||
|
| ------------------------------------------------- | ------- | ------------------------------------------- | ----------------------------------------------- |
|
||||||
|
| `type` | string | `"bookmark"` | |
|
||||||
|
| `glyph` | string | falls back to `"bookmark"` if empty/missing | |
|
||||||
|
| `label` | string | required, enforced at save time | `""` fails validation |
|
||||||
|
| `cmd` | string | required for bookmarks | shell command to execute, enforced at save time |
|
||||||
|
| `description` | string | optional, defaults to `""` | shown in the info tooltip if enabled |
|
||||||
|
| `runInBackground` | boolean | optional, defaults to `false` | mutually exclusive with `runInTerminal` in |
|
||||||
|
| the UI but the schema itself doesn't enforce that |
|
||||||
|
| `runInTerminal` | boolean | optional, defaults to `false` |
|
||||||
|
|
||||||
|
### Folders
|
||||||
|
|
||||||
|
```JSON
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"glyph": "folder",
|
||||||
|
"label": "My Folder",
|
||||||
|
"items": [ <bookmark>, <bookmark>, ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Type | Required/Default | Notes |
|
||||||
|
| ------------------------------------ | ------ | ----------------------------------------------------------------------- | ---------- |
|
||||||
|
| `type` | string | `"folder"` |
|
||||||
|
| `glyph` | string | falls back to `"folder"` if empty/missing |
|
||||||
|
| `label` | string | required |
|
||||||
|
| `items` | array | optional, treated as `{}` if missing, `folder.items=folder.items or {}` | contents — |
|
||||||
|
| bookmarks only, one level of nesting |
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
The bar widget has the following settings:
|
The bar widget has the following settings:
|
||||||
@@ -101,21 +177,25 @@ The bar widget has the following settings:
|
|||||||
The plugin itself has the following settings:
|
The plugin itself has the following settings:
|
||||||
|
|
||||||
| Setting | Type | Default | Description |
|
| Setting | Type | Default | Description |
|
||||||
| ------------------ | ------ | ------- | ------------------------------------------------------------------------------------- |
|
| -------------------- | -------- | ----------- | ------------------------------------------------------------------------------------- |
|
||||||
| `data_path` | `file` | | data.json file to store the saved bookmarks. Leave empty to use the default location. |
|
| `data_path` | `file` | | data.json file to store the saved bookmarks. Leave empty to use the default location. |
|
||||||
| `show_info_button` | `bool` | `true` | Shows the "?" tooltip button on the bookmark entries. |
|
| `show_info_button` | `bool` | `true` | Shows the "?" tooltip button on the bookmark entries. |
|
||||||
|
| `enable_bk_provider` | `bool` | `true` | Enable bookmarks launcher provider |
|
||||||
|
| `bk_sort_by` | `select` | `"history"` | Provider's sorting strategy. Options: `"history"`, `"usage_count"` |
|
||||||
|
|
||||||
## IPC
|
## Changelog
|
||||||
|
|
||||||
1. Open the bookmarks panel:
|
### v1.3.0
|
||||||
|
|
||||||
```sh
|
- Added `/bk` launcher provider
|
||||||
noctalia msg panel-toggle dunarand/bookmarks:panel
|
- Changed the following keybinds (check IPC & Keybinds section for what they do)
|
||||||
```
|
- CTRL + N changed to CTRL + J
|
||||||
|
- CTRL + P changed to CTRL + K
|
||||||
2. Open the bookmarks panel in search mode (immediately puts you into search mode) so that you can
|
- CTRL + S changed to CTRL + F
|
||||||
use your bookmarks panel as a launcher:
|
- Added the following keybinds (check IPC & Keybinds section for what they do)
|
||||||
|
- CTRL + H or Left Arrow
|
||||||
```sh
|
- CTRL + L or Right Arrow
|
||||||
noctalia msg panel-toggle dunarand/bookmarks:panel search
|
- CTRL + N
|
||||||
```
|
- Index tracking on root level for keyboard-centric usage
|
||||||
|
- Previously, when using a keyboard to navigate, going into a folder would reset the selected
|
||||||
|
entry's index to 1 causing fat fingers to be annoying
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 277 KiB |
@@ -0,0 +1,292 @@
|
|||||||
|
-- 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
|
||||||
@@ -33,6 +33,37 @@ local focusSearchOnRender = false -- one-shot: grabs the search input on the nex
|
|||||||
|
|
||||||
local selectedIndex = nil -- 1-based position within the currently visible list, or nil
|
local selectedIndex = nil -- 1-based position within the currently visible list, or nil
|
||||||
|
|
||||||
|
-- Last selected index per scope, keyed by currentFolder (0 for root, since
|
||||||
|
-- folder indices start at 1). Restores position when re-entering a folder
|
||||||
|
-- or coming back to root instead of resetting to 1.
|
||||||
|
local rememberedSelection = {}
|
||||||
|
|
||||||
|
local function rememberSelection(folder)
|
||||||
|
rememberedSelection[folder or 0] = selectedIndex
|
||||||
|
end
|
||||||
|
|
||||||
|
local function recallSelection(folder, rowCount)
|
||||||
|
if rowCount == nil or rowCount <= 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return math.min(math.max(rememberedSelection[folder or 0] or 1, 1), rowCount)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ui.scroll has no scroll-into-view API, so instead of scrolling to the
|
||||||
|
-- selection we only render a window of rows around it - the selection is
|
||||||
|
-- then always inside what's drawn. ROWS_PER_SCREEN is a rough fit for the
|
||||||
|
-- panel's height; doesn't need to be exact.
|
||||||
|
local ROWS_PER_SCREEN = 9
|
||||||
|
|
||||||
|
local function visibleWindow(count, selected)
|
||||||
|
if count <= ROWS_PER_SCREEN or selected == nil then
|
||||||
|
return 1, math.min(count, ROWS_PER_SCREEN)
|
||||||
|
end
|
||||||
|
local startIndex =
|
||||||
|
math.max(1, math.min(selected - ROWS_PER_SCREEN // 2, count - ROWS_PER_SCREEN + 1))
|
||||||
|
return startIndex, startIndex + ROWS_PER_SCREEN - 1
|
||||||
|
end
|
||||||
|
|
||||||
local draftKind = "bookmark" -- "bookmark" | "folder", set when the form opens
|
local draftKind = "bookmark" -- "bookmark" | "folder", set when the form opens
|
||||||
local draftGlyph = "bookmark"
|
local draftGlyph = "bookmark"
|
||||||
local draftLabel = ""
|
local draftLabel = ""
|
||||||
@@ -689,9 +720,10 @@ local function listView()
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
|
local startIndex, endIndex = visibleWindow(#results, selectedIndex)
|
||||||
local rows = {}
|
local rows = {}
|
||||||
for rowIndex, result in ipairs(results) do
|
for rowIndex = startIndex, endIndex do
|
||||||
table.insert(rows, searchResultRow(result, rowIndex))
|
table.insert(rows, searchResultRow(results[rowIndex], rowIndex))
|
||||||
end
|
end
|
||||||
table.insert(children, ui.scroll({ flexGrow = 1, gap = 2 }, rows))
|
table.insert(children, ui.scroll({ flexGrow = 1, gap = 2 }, rows))
|
||||||
end
|
end
|
||||||
@@ -762,15 +794,20 @@ local function listView()
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
local rows = {}
|
|
||||||
local count = #list
|
local count = #list
|
||||||
for index, entry in ipairs(list) do
|
local startIndex, endIndex = visibleWindow(count, selectedIndex)
|
||||||
|
local rows = {}
|
||||||
|
for index = startIndex, endIndex do
|
||||||
if editMode then
|
if editMode then
|
||||||
table.insert(rows, insertionGap(index, "before"))
|
table.insert(rows, insertionGap(index, "before"))
|
||||||
end
|
end
|
||||||
table.insert(rows, entryRow(entry, index))
|
table.insert(rows, entryRow(list[index], index))
|
||||||
end
|
end
|
||||||
if editMode and count > 0 then
|
-- The trailing "after last row" gap only belongs at the very end of
|
||||||
|
-- the real list, not at the end of a windowed slice that's cut off
|
||||||
|
-- partway through - an "after" gap mid-list would duplicate the
|
||||||
|
-- "before" gap of the row right after the window.
|
||||||
|
if editMode and count > 0 and endIndex == count then
|
||||||
table.insert(rows, insertionGap(count, "after"))
|
table.insert(rows, insertionGap(count, "after"))
|
||||||
end
|
end
|
||||||
table.insert(children, ui.scroll({ flexGrow = 1, gap = 2 }, rows))
|
table.insert(children, ui.scroll({ flexGrow = 1, gap = 2 }, rows))
|
||||||
@@ -976,16 +1013,20 @@ function _onOpenFolder(index)
|
|||||||
if entry == nil or entryType(entry) ~= "folder" then
|
if entry == nil or entryType(entry) ~= "folder" then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- Remember where we were in root before diving into the folder.
|
||||||
|
rememberSelection(currentFolder)
|
||||||
-- currentFolder is only ever set from root (folders are one level deep),
|
-- currentFolder is only ever set from root (folders are one level deep),
|
||||||
-- so `index` here is always an index into the root `bookmarks` array.
|
-- so `index` here is always an index into the root `bookmarks` array.
|
||||||
currentFolder = index
|
currentFolder = index
|
||||||
selectedIndex = 1
|
selectedIndex = recallSelection(currentFolder, #(entry.items or {}))
|
||||||
render()
|
render()
|
||||||
end
|
end
|
||||||
|
|
||||||
function _onBackToRoot()
|
function _onBackToRoot()
|
||||||
|
-- Remember where we were inside the folder before leaving it.
|
||||||
|
rememberSelection(currentFolder)
|
||||||
currentFolder = nil
|
currentFolder = nil
|
||||||
selectedIndex = 1
|
selectedIndex = recallSelection(currentFolder, #bookmarks)
|
||||||
render()
|
render()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1043,7 +1084,11 @@ function _onDeleteFolder()
|
|||||||
noctalia.tr("title"),
|
noctalia.tr("title"),
|
||||||
noctalia.tr("folder_deleted", { label = removed.label or "" })
|
noctalia.tr("folder_deleted", { label = removed.label or "" })
|
||||||
)
|
)
|
||||||
|
-- Folder selections are now stale (indices after the deleted one
|
||||||
|
-- shifted), so drop everything but root's.
|
||||||
|
rememberedSelection = { [0] = rememberedSelection[0] }
|
||||||
currentFolder = nil
|
currentFolder = nil
|
||||||
|
selectedIndex = recallSelection(currentFolder, #bookmarks)
|
||||||
render()
|
render()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1309,10 +1354,14 @@ end
|
|||||||
-- `noctalia msg panel-open dunarand/bookmarks:panel search`, or any other
|
-- `noctalia msg panel-open dunarand/bookmarks:panel search`, or any other
|
||||||
-- IPC caller) open the panel straight into a focused search box, turning
|
-- IPC caller) open the panel straight into a focused search box, turning
|
||||||
-- the bookmark list into a fast fuzzy launcher without a second keypress.
|
-- the bookmark list into a fast fuzzy launcher without a second keypress.
|
||||||
-- Root-only, same as ctrl+s: searching already isn't offered inside a
|
-- Root-only, same as ctrl+f: searching already isn't offered inside a
|
||||||
-- folder, so context is ignored there.
|
-- folder, so context is ignored there.
|
||||||
|
--
|
||||||
|
-- To open in search mode and close from a single keybind:
|
||||||
|
-- noctalia msg panel-toggle dunarand/bookmarks:panel search
|
||||||
function onOpen(context)
|
function onOpen(context)
|
||||||
view = "list"
|
view = "list"
|
||||||
|
|
||||||
editingIndex = nil
|
editingIndex = nil
|
||||||
editingRoot = false
|
editingRoot = false
|
||||||
editMode = false
|
editMode = false
|
||||||
@@ -1320,6 +1369,7 @@ function onOpen(context)
|
|||||||
searchQuery = ""
|
searchQuery = ""
|
||||||
searchRev += 1
|
searchRev += 1
|
||||||
selectedIndex = 1
|
selectedIndex = 1
|
||||||
|
rememberedSelection = {}
|
||||||
loadBookmarks()
|
loadBookmarks()
|
||||||
noctalia.state.set("bookmarks_open", true)
|
noctalia.state.set("bookmarks_open", true)
|
||||||
if context == "search" then
|
if context == "search" then
|
||||||
@@ -1332,12 +1382,18 @@ function onClose()
|
|||||||
noctalia.state.set("bookmarks_open", false)
|
noctalia.state.set("bookmarks_open", false)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Keyboard navigation: ctrl+n/down and ctrl+p/up move the selection through
|
-- Keyboard navigation:
|
||||||
-- the currently visible rows (search results or the active folder's list),
|
-- - ctrl+j/down and ctrl+k/up move the selection through the currently
|
||||||
-- looping past either end; Return activates whatever is selected; ctrl+s
|
-- visible rows (search results or the active folder's contents),
|
||||||
-- jumps focus to the search box (root view only, since that's the only
|
-- wrapping past either end.
|
||||||
-- place it's rendered). Only acts on key-down (pressed == true) so each
|
-- - ctrl+l/right enters the selected folder, if any.
|
||||||
-- chord fires once per press.
|
-- - ctrl+h/left returns from a folder to the root list.
|
||||||
|
-- - Return activates the selected row (opens a folder or runs a bookmark).
|
||||||
|
-- - ctrl+f focuses the search box (root view only, since folders don't
|
||||||
|
-- have one).
|
||||||
|
-- - ctrl+n opens the new-bookmark form.
|
||||||
|
-- root-only - same restriction as the "New Folder" button).
|
||||||
|
-- Only acts on key-down (pressed == true) so each chord fires once per press.
|
||||||
function onKey(chord, pressed)
|
function onKey(chord, pressed)
|
||||||
if not pressed then
|
if not pressed then
|
||||||
return
|
return
|
||||||
@@ -1346,7 +1402,7 @@ function onKey(chord, pressed)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if chord == "ctrl+s" then
|
if chord == "ctrl+f" then
|
||||||
if currentFolder == nil then
|
if currentFolder == nil then
|
||||||
searchRev += 1
|
searchRev += 1
|
||||||
focusSearchOnRender = true
|
focusSearchOnRender = true
|
||||||
@@ -1355,6 +1411,18 @@ function onKey(chord, pressed)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if chord == "ctrl+n" then
|
||||||
|
_onNewBookmark()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if chord == "ctrl+h" or chord == "left" then
|
||||||
|
if currentFolder ~= nil then
|
||||||
|
_onBackToRoot()
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local rows = visibleRows()
|
local rows = visibleRows()
|
||||||
local count = #rows
|
local count = #rows
|
||||||
if count == 0 then
|
if count == 0 then
|
||||||
@@ -1362,21 +1430,28 @@ function onKey(chord, pressed)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if chord == "ctrl+n" or chord == "down" then
|
if chord == "ctrl+j" or chord == "down" then
|
||||||
if selectedIndex == nil then
|
if selectedIndex == nil then
|
||||||
selectedIndex = 1
|
selectedIndex = 1
|
||||||
else
|
else
|
||||||
selectedIndex = selectedIndex % count + 1
|
selectedIndex = selectedIndex % count + 1
|
||||||
end
|
end
|
||||||
render()
|
render()
|
||||||
elseif chord == "ctrl+p" or chord == "up" then
|
elseif chord == "ctrl+k" or chord == "up" then
|
||||||
if selectedIndex == nil then
|
if selectedIndex == nil then
|
||||||
selectedIndex = count
|
selectedIndex = count
|
||||||
else
|
else
|
||||||
selectedIndex = (selectedIndex - 2) % count + 1
|
selectedIndex = (selectedIndex - 2) % count + 1
|
||||||
end
|
end
|
||||||
render()
|
render()
|
||||||
elseif chord == "Return" then
|
elseif chord == "ctrl+l" or chord == "right" then
|
||||||
|
local row = rows[selectedIndex]
|
||||||
|
if row ~= nil and row.kind == "entry" then
|
||||||
|
if entryType(row.entry) == "folder" then
|
||||||
|
_onOpenFolder(row.index)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif chord == "return" then
|
||||||
local row = rows[selectedIndex]
|
local row = rows[selectedIndex]
|
||||||
activateRow(row)
|
activateRow(row)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
id = "dunarand/bookmarks"
|
id = "dunarand/bookmarks"
|
||||||
name = "Bookmarks"
|
name = "Bookmarks"
|
||||||
version = "1.2.1"
|
version = "1.3.0"
|
||||||
plugin_api = 13
|
plugin_api = 13
|
||||||
author = "dunarand"
|
author = "dunarand"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -49,6 +49,37 @@ entry = "bar.luau"
|
|||||||
description_key = "settings.text.description"
|
description_key = "settings.text.description"
|
||||||
default = "Bookmarks"
|
default = "Bookmarks"
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "enable_bk_provider"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.enable_bk_provider.label"
|
||||||
|
description_key = "settings.enable_bk_provider.description"
|
||||||
|
default = true
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "bk_sort_by"
|
||||||
|
type = "select"
|
||||||
|
label_key = "settings.bk_sort_by.label"
|
||||||
|
description_key = "settings.bk_sort_by.description"
|
||||||
|
default = "history"
|
||||||
|
visible_when = { key = "enable_bk_provider", values = ["true"] }
|
||||||
|
|
||||||
|
[[setting.options]]
|
||||||
|
value = "history"
|
||||||
|
label_key = "settings.bk_sort_by.options.history"
|
||||||
|
[[setting.options]]
|
||||||
|
value = "usage_count"
|
||||||
|
label_key = "settings.bk_sort_by.options.usage_count"
|
||||||
|
|
||||||
|
[[launcher_provider]]
|
||||||
|
id = "provider"
|
||||||
|
entry = "bk_provider.luau"
|
||||||
|
prefix = "bk"
|
||||||
|
glyph = "bookmark"
|
||||||
|
include_in_global_search = false
|
||||||
|
debounce_ms = 100
|
||||||
|
|
||||||
|
|
||||||
[[panel]]
|
[[panel]]
|
||||||
id = "panel"
|
id = "panel"
|
||||||
entry = "panel.luau"
|
entry = "panel.luau"
|
||||||
@@ -57,4 +88,4 @@ height = 470
|
|||||||
placement = "attached"
|
placement = "attached"
|
||||||
position = "auto"
|
position = "auto"
|
||||||
open_near_click = true
|
open_near_click = true
|
||||||
capture_keys = ["ctrl+n", "ctrl+p", "up", "down", "Return", "ctrl+s"]
|
capture_keys = ["ctrl+h", "ctrl+j", "ctrl+k", "ctrl+l", "left", "down", "up", "right", "ctrl+n", "ctrl+f", "return"]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 51 KiB |
@@ -40,10 +40,22 @@
|
|||||||
"save_button": "Save",
|
"save_button": "Save",
|
||||||
"search_placeholder": "Search bookmarks...",
|
"search_placeholder": "Search bookmarks...",
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"bk_sort_by": {
|
||||||
|
"description": "How bookmarks are ordered in \"/bk\" results when there's no search text.",
|
||||||
|
"label": "/bk sort order",
|
||||||
|
"options": {
|
||||||
|
"history": "Recently used",
|
||||||
|
"usage_count": "Most used"
|
||||||
|
}
|
||||||
|
},
|
||||||
"data_path": {
|
"data_path": {
|
||||||
"description": "Where data.json is stored. Leave empty to use the default plugin data directory.",
|
"description": "Where data.json is stored. Leave empty to use the default plugin data directory.",
|
||||||
"label": "Data file"
|
"label": "Data file"
|
||||||
},
|
},
|
||||||
|
"enable_bk_provider": {
|
||||||
|
"description": "Adds a \"/bk\" launcher command to fuzzy-search and run your bookmarks.",
|
||||||
|
"label": "Enable /bk launcher provider"
|
||||||
|
},
|
||||||
"glyph": {
|
"glyph": {
|
||||||
"description": "Bar widget's glyph",
|
"description": "Bar widget's glyph",
|
||||||
"label": "Glyph"
|
"label": "Glyph"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"err_save_failed": "Kaydedilemedi: {error}",
|
"err_save_failed": "Kaydedilemedi: {error}",
|
||||||
"folder_deleted": "\"{label}\" klasörü ve içeriği silindi",
|
"folder_deleted": "\"{label}\" klasörü ve içeriği silindi",
|
||||||
"folder_name_field": "Klasör ismi",
|
"folder_name_field": "Klasör ismi",
|
||||||
"glyph_field": "Sembol (ör. bookmark)",
|
"glyph_field": "Simge (ör. bookmark)",
|
||||||
"hide_controls_tooltip": "Sıralama/düzenleme/silme kontrollerini gizle",
|
"hide_controls_tooltip": "Sıralama/düzenleme/silme kontrollerini gizle",
|
||||||
"info_cmd_line": "Komut: {cmd}",
|
"info_cmd_line": "Komut: {cmd}",
|
||||||
"info_description_line": "Açıklama: {description}",
|
"info_description_line": "Açıklama: {description}",
|
||||||
@@ -40,24 +40,32 @@
|
|||||||
"save_button": "Kaydet",
|
"save_button": "Kaydet",
|
||||||
"search_placeholder": "Yer imlerinde ara...",
|
"search_placeholder": "Yer imlerinde ara...",
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"bk_sort_by": {
|
||||||
|
"description": "\"/bk\" sonuçlarının arama yapılmadığı durumda yer imlerini nasıl sıralacağını belirler.",
|
||||||
|
"label": "/bk sıralama önceliği",
|
||||||
|
"options": {
|
||||||
|
"history": "Son kullanılanlar",
|
||||||
|
"usage_count": "En sık kullanılanlar"
|
||||||
|
}
|
||||||
|
},
|
||||||
"data_path": {
|
"data_path": {
|
||||||
"description": "data.json dosyasının saklandığı konum. Varsayılan eklenti veri konumunu kullanmak için boş bırakın.",
|
"description": "data.json dosyasının saklandığı konum. Varsayılan eklenti veri konumunu kullanmak için boş bırakın.",
|
||||||
"label": "Veri dosyası"
|
"label": "Veri dosyası"
|
||||||
},
|
},
|
||||||
"glyph": {
|
"glyph": {
|
||||||
"description": "Bar aracının sembolü (ör. bookmark)",
|
"description": "Çubuk aracının simgesi (ör. bookmark)",
|
||||||
"label": "Sembol"
|
"label": "Simge"
|
||||||
},
|
},
|
||||||
"show_info_button": {
|
"show_info_button": {
|
||||||
"description": "Yer imlerinde (klasörlerde değil) üzerine gelindiğinde açıklamalarını ve komutlarını görüntülemek için bir \"?\" düğmesi göster.",
|
"description": "Yer imlerinde (klasörlerde değil) üzerine gelindiğinde açıklamalarını ve komutlarını görüntülemek için bir \"?\" düğmesi göster.",
|
||||||
"label": "Bilgi butonunu göster"
|
"label": "Bilgi butonunu göster"
|
||||||
},
|
},
|
||||||
"text": {
|
"text": {
|
||||||
"description": "Bar aracının etiketi. Barda sadece sembolün görünmesi için boş bırakın.",
|
"description": "Çubuk aracının etiketi. Çubukta sadece simgenin görünmesi için boş bırakın.",
|
||||||
"label": "Yazı"
|
"label": "Yazı"
|
||||||
},
|
},
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
"description": "Bar aracının isim etiketi",
|
"description": "Çubuk aracının isim etiketi",
|
||||||
"label": "İsim etiketi"
|
"label": "İsim etiketi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||