Bookmarks for files and directories, with metadata.
Not a "recently used files" list — a workspace navigation layer. Every bookmark carries a description and free-form labels, and everything is fuzzy-searchable from one picker.
This plugin ships almost no UI of its own. It leans on your existing picker
(snacks, telescope or fzf-lua), the Snacks explorer, and vim.ui.input for
editing.
★ runtime.py Runtime scheduler core runtime hot src/runtime.py
★ src Sources core ssd src
⚠ removed.py old/removed.py
- Neovim >= 0.10
- A picker, for
picker()only — the API works without one: snacks.nvim (picker enabled), telescope.nvim or fzf-lua
Whichever is installed is used, in that order. Pin one with
picker = { backend = "telescope" }. The query language lives in the plugin,
not in the picker, so label: and scope: behave identically on all three.
{
"tya5/fsbookmark.nvim",
dependencies = { "folke/snacks.nvim" },
opts = {},
keys = {
{ "<leader>ma", function() require("fsbookmark").add() end, desc = "Add bookmark" },
{ "<leader>mf", function() require("fsbookmark").picker() end, desc = "Find bookmarks" },
{ "<leader>mt", function() require("fsbookmark").toggle() end, desc = "Toggle bookmark" },
{ "<leader>me", function() require("fsbookmark").edit() end, desc = "Edit bookmark" },
{ "<leader>mr", function() require("fsbookmark").remove() end, desc = "Remove bookmark" },
},
}opts = {} is enough — lazy.nvim calls setup() for you, which also registers
the default keymaps. If you define your own keys as above, set
opts = { keys = { enabled = false } } to avoid registering them twice.
The defaults are already LazyVim-shaped, with one deliberate choice: the keymap
prefix is <leader>m, not <leader>b. LazyVim owns <leader>b as its
buffer group, and which-key auto-expands buffer-local maps into it.
To also get Snacks.picker.fsbookmark() and a ★ marker in the Snacks
explorer:
{
"folke/snacks.nvim",
opts = {
picker = {
sources = {
-- Must be a table: Snacks walks `sources` on setup, so a bare
-- function here raises before the picker ever opens.
fsbookmark = {
config = function(opts) return require("fsbookmark.picker").source(opts) end,
},
explorer = {
format = function(item, picker) return require("fsbookmark.explorer").format(item, picker) end,
},
},
},
},
},
{
"folke/which-key.nvim",
optional = true,
opts = { spec = { { "<leader>m", group = "bookmarks", icon = "★" } } },
},Without registering the source, Snacks.picker.fsbookmark is nil — Snacks only
exposes named pickers it knows about. require("fsbookmark").picker() always works.
| Keymap | Action |
|---|---|
<leader>ma |
Add bookmark |
<leader>mf |
Find bookmarks |
<leader>mt |
Toggle bookmark |
<leader>me |
Edit bookmark |
<leader>mr |
Remove bookmark |
mb |
Toggle (in an explorer buffer) |
Also :FSBookmark {add,remove,toggle,edit,list,share,unshare,prune,save,load} [path].
| Key | Action |
|---|---|
<CR> |
Open |
<C-e> |
Edit |
<C-x> |
Delete |
<C-l> |
Reload |
<C-y> |
Copy path |
<C-o> |
Reveal in explorer |
<C-h> |
Share / unshare (see below) |
Telescope and fzf-lua support <C-e> (edit), <C-x> (delete) and <C-y>
(copy path).
Snacks already owns <C-a><C-b><C-c><C-d><C-f><C-g><C-j><C-k><C-n><C-p><C-q>
<C-r><C-s><C-t><C-u><C-v><C-w> inside the picker, so the obvious <C-d> for
delete and <C-r> for reload are not available — binding them makes Snacks log
a duplicate-mapping warning and shadows its own scroll keys.
<C-x> respects multi-selection.
The picker runs as a live Snacks source, so the prompt is handed to this
plugin's own parser rather than to the built-in matcher — label:core typed
into the picker means the same thing as it does in search().
Path, description and labels are all fuzzy-matched. Multiple terms are ANDed.
runtime -- anything matching "runtime"
runtime hot -- both terms must match
label:core -- only bookmarks labelled "core"
label:core label:ssd
label:core scheduler
scope:workspace -- only this project's bookmarks
scope:shared -- only the repository's checked-in ones
label: filters are ANDed with each other and with the free text. Multiple
scope: filters are ORed — scope:global scope:workspace means "either",
which is the only reading that isn't the empty set.
The path is fuzzy-matched on its basename and substring-matched on the full path — scattered subsequence matching over an absolute path matches everything.
No custom edit UI. edit() prompts through vim.ui.input twice:
Description: Runtime scheduler
Labels (csv): core,runtime,hot
Cancelling either prompt aborts the edit. Editing an unregistered path registers it first.
local fsbookmark = require("fsbookmark")
fsbookmark.add(path?, { description = "...", labels = { "core" } }) --> bookmark, created
fsbookmark.remove(path?) --> bookmark|nil
fsbookmark.toggle(path?) --> boolean (added?)
fsbookmark.get(path?) --> bookmark|nil
fsbookmark.list() --> bookmark[]
fsbookmark.search(query) --> bookmark[]
fsbookmark.update(path, { description = ..., labels = ... }) --> bookmark|nil
fsbookmark.labels() --> string[]
fsbookmark.workspace() --> root|nil, name|nil
fsbookmark.share(path?) --> bookmark|nil, err|nil
fsbookmark.unshare(path?) --> bookmark|nil, err|nil
fsbookmark.is_broken(bookmark) --> boolean
fsbookmark.open(bookmark|path)
fsbookmark.picker(opts?)
fsbookmark.edit(path?, on_done?)
fsbookmark.save()
fsbookmark.load()path defaults to the current buffer everywhere. Paths are normalized
(absolute, ~ expanded, no trailing slash) before use, so any spelling of the
same path hits the same bookmark.
{
id = "...",
path = "/abs/path",
type = "file" | "directory",
description = "",
labels = { "core", "runtime" },
scope = "global", -- "global" | "workspace" | "shared"; derived, never stored
source = "manual", -- reserved for git/lsp/recent providers
metadata = {}, -- free-form; for other plugins and future fields
created_at = 1721000000,
updated_at = 1721000000,
}vim.api.nvim_create_autocmd("User", {
pattern = { "FSBookmarkAdd", "FSBookmarkRemove", "FSBookmarkUpdate" },
callback = function(args)
vim.print(args.data.path) -- the bookmark
end,
})Defaults:
require("fsbookmark").setup({
dir = nil, -- defaults to stdpath("data")/fsbookmark/bookmarks
workspace = { enabled = true },
shared = { enabled = true, file = ".fsbookmark.json" },
autosave = true,
watch = true, -- follow renames; flag missing paths as broken
icons = { bookmark = "★", broken = "⚠", global = "🌍", workspace = "📁", shared = "👥" },
picker = {
keys = {
edit = "<c-e>", delete = "<c-x>", reload = "<c-l>",
yank = "<c-y>", reveal = "<c-o>", share = "<c-h>",
},
backend = "auto", -- "snacks" | "telescope" | "fzf-lua"
},
explorer = { enabled = true, key = "mb" }, -- use "b" with the Snacks explorer, where `m` is move
keys = { enabled = true, prefix = "<leader>m" },
open_directory = nil, -- fun(path); defaults to Snacks explorer / oil / neo-tree
})A workspace is the project you currently have open. It is resolved
automatically — LSP workspace root, then the nearest .git, then the cwd — and
never appears in the API. There is no workspace list, no switcher, and nothing
to configure per project.
It decides one thing: which file a bookmark is saved to.
root = /work/reyn
add("/work/reyn/src/runtime.py") -> workspace file
add("~/.config/nvim") -> global file
Anything under the root belongs to the project; anything else is global. The
picker and search() always show global plus the current workspace merged
together, so bookmarks from other projects stay out of your way.
scope is derived from the file a bookmark lives in — it is not stored, and
you never set it. Renaming a file across the root boundary moves it between
files automatically.
Turn the whole thing off with workspace = { enabled = false } and everything
lives in global.json.
A repository can ship its own bookmarks — a map of where to start reading —
in a checked-in .fsbookmark.json at the workspace root.
{
"version": 1,
"bookmarks": [
{ "path": "src/runtime.py", "description": "The scheduler. Start here.",
"labels": ["core", "entry"] }
]
}It is read automatically and merged in, marked 👥 in the picker. Paths are stored relative to the repository root, so the file means the same thing on everyone's machine.
Nothing is ever written there implicitly. Your own bookmarks stay in your personal files; a bookmark moves into the shared file only when you say so:
:FSBookmark share -- promote the current bookmark
:FSBookmark unshare -- take it back out
or <C-h> in the picker, which toggles between the two. That way a personal
note can never end up in a commit by accident.
Turn it off with shared = { enabled = false }.
stdpath("data")/fsbookmark/bookmarks/
global.json
workspace/
reyn-3f8a1c9e2b04.json
<workspace root>/.fsbookmark.json -- checked into the repository
{ "version": 1, "root": "/work/reyn", "name": "reyn", "bookmarks": [ ... ] }Written atomically (temp file + rename). A corrupt file is reported and treated as empty rather than crashing — it is never overwritten until you change something.
An older fsbookmark/bookmarks.json is moved to bookmarks/global.json
automatically on first load.
A bookmark whose path no longer exists is shown as ⚠ rather than being deleted
behind your back. Renames performed inside Neovim (:saveas, Snacks/oil rename)
are followed automatically. :FSBookmark prune removes all broken ones.
require("fsbookmark.explorer").toggle() bookmarks the path under the cursor.
It knows how to read the cursor path from the Snacks explorer, neo-tree and oil,
and falls back to the current buffer's file.
The ★ marker in the Snacks explorer needs the format override shown above.
Snacks has no chaining hook for third-party decorations, so that override is
last-writer-wins with any other plugin doing the same.
Run :checkhealth fsbookmark first — it covers every failure below.
| Symptom | Cause |
|---|---|
:FSBookmark is not a command |
The plugin directory isn't on runtimepath. |
| Keymaps do nothing | setup() was never called. With lazy.nvim, add opts = {}. |
<leader>m… does nothing |
Another plugin already owns the mapping — it is never clobbered. Change keys.prefix, or set keys.enabled = false and map it yourself. |
Snacks.picker.fsbookmark is nil |
The source isn't registered under picker.sources. require("fsbookmark").picker() works either way. |
| "snacks.nvim with the picker enabled is required" | snacks is missing, or its picker is disabled. |
| Bookmarks don't persist | The data directory isn't writable, or autosave = false and Neovim was killed rather than exited. |
| A file got bookmarked twice | Shouldn't happen — paths are normalized and symlinks resolved. Please open an issue with both paths. |
Nothing here overwrites your data: a corrupt bookmarks.json is reported and
treated as empty, and is left on disk untouched until you change something.
Custom TUI, custom picker, SQLite, git/cloud sync, tag hierarchies, tree view.
Also deliberately absent: bookmark "providers" that would surface git,
recent or LSP entries alongside manual ones. gitsigns, Snacks.picker.recent
and the LSP pickers already cover those, and composing them here would change
what list() means — add(), remove() and update() could then only apply
to part of it. bookmark.source stays reserved so this remains possible
without a storage migration, but composition would go behind its own API
rather than redefining list().
MIT