Neovim quick file switcher

Initial commit

ejri.dev 18cb65f0

+10
.editorconfig
···
+
root = true
+
+
[*]
+
indent_size = 4
+
tab_width = 4
+
+
[*.lua]
+
indent_style = tab
+
indent_size = 4
+
tab_width = 4
+1
.gitignore
···
+
.mise.local.toml
+21
LICENSE
···
+
MIT License
+
+
Copyright (c) 2025 Eric Richards
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy
+
of this software and associated documentation files (the "Software"), to deal
+
in the Software without restriction, including without limitation the rights
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+
copies of the Software, and to permit persons to whom the Software is
+
furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in all
+
copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+
SOFTWARE.
+44
README.md
···
+
# javelin.nvim
+
+
Yet Another Harpoon Clone.
+
+
A Neovim plugin to quick switch between files, essentially a simplified version of harpoon.
+
One main difference is `use_git_root` which will make the lists per project, rather than purely
+
based on the current directory.
+
+
## Dependencies
+
+
```
+
folke/snacks.nvim
+
```
+
+
## Configuration
+
+
```lua
+
{
+
use_git_root = true,
+
-- Passthrough options to Snacks.win
+
menu = {
+
width = 0.6,
+
height = 8,
+
title_pos = "center",
+
border = "rounded",
+
keys = {
+
["<CR>"] = "goto",
+
q = "close",
+
["<Esc>"] = "close",
+
},
+
},
+
}
+
```
+
+
## Commands
+
+
- `:Javelin open` - Open popup menu, list of javelin'd items
+
- `:Javelin add` - Add current buffer to list
+
+
## Functions
+
+
- `require("javelin").open()` - Open popup menu, list of javelin'd items
+
- `require("javelin").add()` - Add current buffer to list
+
- `require("javelin").select(num)` - Open buffer at `num` position in the list
+37
cliff.toml
···
+
[changelog]
+
# https://keats.github.io/tera/docs/#introduction
+
body = """
+
{% for group, commits in commits | group_by(attribute="group") %}
+
=== {{ group | striptags | trim | upper_first }}
+
{% for commit in commits %}
+
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
+
{% if commit.breaking %}[**breaking**] {% endif %}\
+
{{ commit.message | upper_first }}\
+
{% endfor %}
+
{% endfor %}\n
+
"""
+
# remove the leading and trailing s
+
trim = true
+
+
[git]
+
conventional_commits = true
+
filter_unconventional = true
+
split_commits = false
+
+
# Just things end users will care about
+
commit_parsers = [
+
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
+
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
+
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
+
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
+
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
+
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
+
{ message = "^chore|^ci|^refactor", skip = true },
+
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
+
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
+
{ message = ".*", group = "<!-- 10 -->💼 Other" },
+
]
+
# filter out the commits that are not matched by commit parsers
+
filter_commits = false
+
topo_order = false
+
sort_commits = "oldest"
+2
doc.sh
···
+
#!/usr/bin/env bash
+
nix-shell -p panvimdoc --run 'panvimdoc --project-name javelin.nvim --input-file README.md --vim-version "Neovim >= 0.10"'
doc/.gitkeep

This is a binary file and will not be displayed.

+64
doc/javelin.nvim.txt
···
+
*javelin.nvim.txt* For Neovim >= 0.10 Last change: 2025 March 28
+
+
==============================================================================
+
Table of Contents *javelin.nvim-table-of-contents*
+
+
1. javelin.nvim |javelin.nvim-javelin.nvim|
+
- Dependencies |javelin.nvim-javelin.nvim-dependencies|
+
- Configuration |javelin.nvim-javelin.nvim-configuration|
+
- Commands |javelin.nvim-javelin.nvim-commands|
+
- Functions |javelin.nvim-javelin.nvim-functions|
+
+
==============================================================================
+
1. javelin.nvim *javelin.nvim-javelin.nvim*
+
+
Yet Another Harpoon Clone.
+
+
A Neovim plugin to quick switch between files, essentially a simplified version
+
of harpoon. One main difference is `use_git_root` which will make the lists per
+
project, rather than purely based on the current directory.
+
+
+
DEPENDENCIES *javelin.nvim-javelin.nvim-dependencies*
+
+
>
+
folke/snacks.nvim
+
<
+
+
+
CONFIGURATION *javelin.nvim-javelin.nvim-configuration*
+
+
>lua
+
{
+
use_git_root = true,
+
-- Passthrough options to Snacks.win
+
menu = {
+
width = 0.6,
+
height = 8,
+
title_pos = "center",
+
border = "rounded",
+
keys = {
+
["<CR>"] = "goto",
+
q = "close",
+
["<Esc>"] = "close",
+
},
+
},
+
}
+
<
+
+
+
COMMANDS *javelin.nvim-javelin.nvim-commands*
+
+
- `:Javelin open` - Open popup menu, list of javelin’d items
+
- `:Javelin add` - Add current buffer to list
+
+
+
FUNCTIONS *javelin.nvim-javelin.nvim-functions*
+
+
- `require("javelin").open()` - Open popup menu, list of javelin’d items
+
- `require("javelin").add()` - Add current buffer to list
+
- `require("javelin").select(num)` - Open buffer at `num` position in the list
+
+
Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>
+
+
vim:tw=78:ts=8:noet:ft=help:norl:
+4
lazy.lua
···
+
return {
+
{ "folke/snacks.nvim" },
+
{ "https://git.sr.ht/~ejri/javelin.nvim" },
+
}
+134
lua/javelin/data.lua
···
+
---@module 'snacks'
+
+
local log = require("javelin.log")
+
+
local Data = {}
+
+
---@type JavelinConfig
+
local options
+
+
local data_dir
+
local data_cache = {}
+
+
---@param path string
+
---@return string
+
local function hash_path(path)
+
return vim.fn.sha256(vim.fn.resolve(path))
+
end
+
+
---@param root_path string
+
local function get_data_file(root_path)
+
return vim.fn.resolve(data_dir .. "/" .. hash_path(root_path))
+
end
+
+
---@param root_path string
+
---@param path string
+
---@return string
+
local function normalize_path(root_path, path)
+
root_path = root_path .. "/"
+
if vim.startswith(path, root_path) then
+
return path:sub(#root_path + 1)
+
end
+
+
return path
+
end
+
+
---@param root_path string
+
---@param list string[]
+
---@return string[]
+
local function normalize_list(root_path, list)
+
local new_list = {}
+
for _, path in ipairs(list) do
+
if path ~= "" then
+
table.insert(new_list, path)
+
end
+
end
+
+
for i, path in ipairs(new_list) do
+
new_list[i] = normalize_path(root_path, path)
+
end
+
+
return new_list
+
end
+
+
---@param root_path string
+
local function load(root_path)
+
local data_file = get_data_file(root_path)
+
if not vim.uv.fs_stat(data_file) then
+
return {}
+
end
+
+
return normalize_list(root_path, vim.fn.readfile(data_file))
+
end
+
+
---@param root_path string
+
local function write(root_path)
+
vim.fn.writefile(Data.get_list(root_path), get_data_file(root_path))
+
end
+
+
---@param root_path string
+
---@param path string
+
local function add_path(root_path, path)
+
local list = Data.get_list(root_path)
+
table.insert(list, normalize_path(root_path, vim.fn.fnamemodify(path, ":~")))
+
write(root_path)
+
end
+
+
---@param root_path string
+
---@return string[]
+
function Data.get_list(root_path)
+
local hash = hash_path(root_path)
+
if data_cache[hash] == nil then
+
data_cache[hash] = load(root_path)
+
end
+
+
return data_cache[hash]
+
end
+
+
---@return string[]
+
function Data.get_current()
+
return Data.get_list(Data.get_root())
+
end
+
+
function Data.add_current()
+
local filename = vim.api.nvim_buf_get_name(0)
+
local buftype = vim.api.nvim_get_option_value("buftype", { buf = 0 })
+
+
if filename ~= "" and buftype == "" then
+
add_path(Data.get_root(), filename)
+
else
+
log.warn("Cannot add this buffer")
+
end
+
end
+
+
---@param root_path string
+
---@param lines string[]
+
function Data.update(root_path, lines)
+
data_cache[hash_path(root_path)] = normalize_list(root_path, lines)
+
write(root_path)
+
end
+
+
---@return string
+
function Data.get_root()
+
local root = vim.fn.getcwd()
+
+
if options.use_git_root then
+
local git_root = Snacks.git.get_root(root)
+
if git_root then
+
root = git_root
+
end
+
end
+
+
return vim.fn.fnamemodify(root, ":~")
+
end
+
+
function Data.setup(opt)
+
options = opt
+
+
data_dir = vim.fn.resolve(vim.fn.stdpath("data") .. "/javelin")
+
if not vim.uv.fs_stat(data_dir) then
+
vim.uv.fs_mkdir(data_dir, tonumber("755", 8))
+
end
+
end
+
+
return Data
+75
lua/javelin/init.lua
···
+
local ui = require("javelin.ui")
+
local data = require("javelin.data")
+
+
local Javelin = {}
+
+
---@class JavelinConfig
+
local defaults = {
+
use_git_root = true,
+
-- Passthrough options to Snacks.win
+
menu = {
+
width = 0.6,
+
height = 8,
+
title_pos = "center",
+
border = "rounded",
+
keys = {
+
["<CR>"] = "goto",
+
q = "close",
+
["<Esc>"] = "close",
+
},
+
},
+
}
+
+
function Javelin.open()
+
return ui.open()
+
end
+
+
---@param index number
+
function Javelin.select(index)
+
return ui.select(index)
+
end
+
+
function Javelin.add()
+
return data.add_current()
+
end
+
+
local function create_user_command()
+
local commands = {
+
open = Javelin.open,
+
add = Javelin.add,
+
}
+
+
vim.api.nvim_create_user_command("Javelin", function(args)
+
local cmd = vim.trim(args.args or "")
+
if cmd == "" then
+
commands.open()
+
elseif commands[cmd] then
+
commands[cmd]()
+
end
+
end, {
+
desc = "Javelin",
+
nargs = "?",
+
complete = function(_, line)
+
if line:match("^%s*Javelin %w+ ") then
+
return {}
+
end
+
local prefix = line:match("^%s*Javelin (%w*)") or ""
+
return vim.tbl_filter(function(key)
+
return key:find(prefix) == 1
+
end, vim.tbl_keys(commands))
+
end,
+
})
+
end
+
+
---@param opt? JavelinConfig
+
function Javelin.setup(opt)
+
---@type JavelinConfig
+
local options = vim.tbl_deep_extend("force", {}, defaults, opt or {})
+
+
create_user_command()
+
+
ui.setup(options)
+
data.setup(options)
+
end
+
+
return Javelin
+15
lua/javelin/log.lua
···
+
local Log = {}
+
+
function Log.info(msg)
+
return Snacks.notify.info(msg, { title = "javelin.nvim" })
+
end
+
+
function Log.warn(msg)
+
return Snacks.notify.warn(msg, { title = "javelin.nvim" })
+
end
+
+
function Log.error(msg)
+
return Snacks.notify.error(msg, { title = "javelin.nvim" })
+
end
+
+
return Log
+88
lua/javelin/ui.lua
···
+
---@module 'snacks'
+
+
local data = require("javelin.data")
+
local log = require("javelin.log")
+
+
local UI = {}
+
+
---@type JavelinConfig
+
local options
+
+
local function open_buf(file)
+
-- nvim 0.11 only command
+
if vim.fn.exists("*isabsolutepath") then
+
if not vim.fn.isabsolutepath(file) then
+
file = data.get_root() .. "/" .. file
+
end
+
else
+
if file:sub(1, 1) ~= "/" and file:sub(1, 1) ~= "~" then
+
file = data.get_root() .. "/" .. file
+
end
+
end
+
+
local bufnr = vim.fn.bufadd(vim.fn.expand(file))
+
vim.fn.bufload(bufnr)
+
vim.api.nvim_set_option_value("buflisted", true, {
+
buf = bufnr,
+
})
+
vim.api.nvim_set_current_buf(bufnr)
+
end
+
+
---@param index number
+
function UI.select(index)
+
local file = data.get_current()[index]
+
if file ~= nil then
+
open_buf(file)
+
end
+
end
+
+
function UI.open()
+
local root = data.get_root()
+
local initial_text = data.get_list(root)
+
Snacks.win.new({
+
text = initial_text,
+
title = root,
+
title_pos = options.menu.title_pos,
+
width = options.menu.width,
+
height = options.menu.height,
+
border = options.menu.border,
+
keys = options.menu.keys,
+
fixbuf = true,
+
wo = {
+
number = true,
+
},
+
bo = {
+
modifiable = true,
+
readonly = false,
+
buftype = "nofile",
+
bufhidden = "wipe",
+
},
+
actions = {
+
["goto"] = function(self)
+
local file = vim.api.nvim_get_current_line()
+
self:close()
+
open_buf(file)
+
end,
+
},
+
on_close = function(self)
+
if self.buf then
+
local lines = self:lines()
+
if #lines == 1 and lines[1] == "" then
+
lines = {}
+
end
+
if not vim.deep_equal(initial_text, lines) then
+
data.update(root, self:lines())
+
log.info("Javelin updated.")
+
end
+
else
+
log.error("No buf on win")
+
end
+
end,
+
})
+
end
+
+
function UI.setup(opt)
+
options = opt
+
end
+
+
return UI
+3
stylua.toml
···
+
indent_type = "Tabs"
+
indent_width = 4
+
column_width = 120