Neovim quick file switcher

Initial commit

ejri.dev 18cb65f0

+10
.editorconfig
··· 1 + root = true 2 + 3 + [*] 4 + indent_size = 4 5 + tab_width = 4 6 + 7 + [*.lua] 8 + indent_style = tab 9 + indent_size = 4 10 + tab_width = 4
+1
.gitignore
··· 1 + .mise.local.toml
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Eric Richards 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+44
README.md
··· 1 + # javelin.nvim 2 + 3 + Yet Another Harpoon Clone. 4 + 5 + A Neovim plugin to quick switch between files, essentially a simplified version of harpoon. 6 + One main difference is `use_git_root` which will make the lists per project, rather than purely 7 + based on the current directory. 8 + 9 + ## Dependencies 10 + 11 + ``` 12 + folke/snacks.nvim 13 + ``` 14 + 15 + ## Configuration 16 + 17 + ```lua 18 + { 19 + use_git_root = true, 20 + -- Passthrough options to Snacks.win 21 + menu = { 22 + width = 0.6, 23 + height = 8, 24 + title_pos = "center", 25 + border = "rounded", 26 + keys = { 27 + ["<CR>"] = "goto", 28 + q = "close", 29 + ["<Esc>"] = "close", 30 + }, 31 + }, 32 + } 33 + ``` 34 + 35 + ## Commands 36 + 37 + - `:Javelin open` - Open popup menu, list of javelin'd items 38 + - `:Javelin add` - Add current buffer to list 39 + 40 + ## Functions 41 + 42 + - `require("javelin").open()` - Open popup menu, list of javelin'd items 43 + - `require("javelin").add()` - Add current buffer to list 44 + - `require("javelin").select(num)` - Open buffer at `num` position in the list
+37
cliff.toml
··· 1 + [changelog] 2 + # https://keats.github.io/tera/docs/#introduction 3 + body = """ 4 + {% for group, commits in commits | group_by(attribute="group") %} 5 + === {{ group | striptags | trim | upper_first }} 6 + {% for commit in commits %} 7 + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ 8 + {% if commit.breaking %}[**breaking**] {% endif %}\ 9 + {{ commit.message | upper_first }}\ 10 + {% endfor %} 11 + {% endfor %}\n 12 + """ 13 + # remove the leading and trailing s 14 + trim = true 15 + 16 + [git] 17 + conventional_commits = true 18 + filter_unconventional = true 19 + split_commits = false 20 + 21 + # Just things end users will care about 22 + commit_parsers = [ 23 + { message = "^feat", group = "<!-- 0 -->🚀 Features" }, 24 + { message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" }, 25 + { message = "^doc", group = "<!-- 3 -->📚 Documentation" }, 26 + { message = "^perf", group = "<!-- 4 -->⚡ Performance" }, 27 + { message = "^style", group = "<!-- 5 -->🎨 Styling" }, 28 + { message = "^test", group = "<!-- 6 -->🧪 Testing" }, 29 + { message = "^chore|^ci|^refactor", skip = true }, 30 + { body = ".*security", group = "<!-- 8 -->🛡️ Security" }, 31 + { message = "^revert", group = "<!-- 9 -->◀️ Revert" }, 32 + { message = ".*", group = "<!-- 10 -->💼 Other" }, 33 + ] 34 + # filter out the commits that are not matched by commit parsers 35 + filter_commits = false 36 + topo_order = false 37 + sort_commits = "oldest"
+2
doc.sh
··· 1 + #!/usr/bin/env bash 2 + 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
··· 1 + *javelin.nvim.txt* For Neovim >= 0.10 Last change: 2025 March 28 2 + 3 + ============================================================================== 4 + Table of Contents *javelin.nvim-table-of-contents* 5 + 6 + 1. javelin.nvim |javelin.nvim-javelin.nvim| 7 + - Dependencies |javelin.nvim-javelin.nvim-dependencies| 8 + - Configuration |javelin.nvim-javelin.nvim-configuration| 9 + - Commands |javelin.nvim-javelin.nvim-commands| 10 + - Functions |javelin.nvim-javelin.nvim-functions| 11 + 12 + ============================================================================== 13 + 1. javelin.nvim *javelin.nvim-javelin.nvim* 14 + 15 + Yet Another Harpoon Clone. 16 + 17 + A Neovim plugin to quick switch between files, essentially a simplified version 18 + of harpoon. One main difference is `use_git_root` which will make the lists per 19 + project, rather than purely based on the current directory. 20 + 21 + 22 + DEPENDENCIES *javelin.nvim-javelin.nvim-dependencies* 23 + 24 + > 25 + folke/snacks.nvim 26 + < 27 + 28 + 29 + CONFIGURATION *javelin.nvim-javelin.nvim-configuration* 30 + 31 + >lua 32 + { 33 + use_git_root = true, 34 + -- Passthrough options to Snacks.win 35 + menu = { 36 + width = 0.6, 37 + height = 8, 38 + title_pos = "center", 39 + border = "rounded", 40 + keys = { 41 + ["<CR>"] = "goto", 42 + q = "close", 43 + ["<Esc>"] = "close", 44 + }, 45 + }, 46 + } 47 + < 48 + 49 + 50 + COMMANDS *javelin.nvim-javelin.nvim-commands* 51 + 52 + - `:Javelin open` - Open popup menu, list of javelin’d items 53 + - `:Javelin add` - Add current buffer to list 54 + 55 + 56 + FUNCTIONS *javelin.nvim-javelin.nvim-functions* 57 + 58 + - `require("javelin").open()` - Open popup menu, list of javelin’d items 59 + - `require("javelin").add()` - Add current buffer to list 60 + - `require("javelin").select(num)` - Open buffer at `num` position in the list 61 + 62 + Generated by panvimdoc <https://github.com/kdheepak/panvimdoc> 63 + 64 + vim:tw=78:ts=8:noet:ft=help:norl:
+4
lazy.lua
··· 1 + return { 2 + { "folke/snacks.nvim" }, 3 + { "https://git.sr.ht/~ejri/javelin.nvim" }, 4 + }
+134
lua/javelin/data.lua
··· 1 + ---@module 'snacks' 2 + 3 + local log = require("javelin.log") 4 + 5 + local Data = {} 6 + 7 + ---@type JavelinConfig 8 + local options 9 + 10 + local data_dir 11 + local data_cache = {} 12 + 13 + ---@param path string 14 + ---@return string 15 + local function hash_path(path) 16 + return vim.fn.sha256(vim.fn.resolve(path)) 17 + end 18 + 19 + ---@param root_path string 20 + local function get_data_file(root_path) 21 + return vim.fn.resolve(data_dir .. "/" .. hash_path(root_path)) 22 + end 23 + 24 + ---@param root_path string 25 + ---@param path string 26 + ---@return string 27 + local function normalize_path(root_path, path) 28 + root_path = root_path .. "/" 29 + if vim.startswith(path, root_path) then 30 + return path:sub(#root_path + 1) 31 + end 32 + 33 + return path 34 + end 35 + 36 + ---@param root_path string 37 + ---@param list string[] 38 + ---@return string[] 39 + local function normalize_list(root_path, list) 40 + local new_list = {} 41 + for _, path in ipairs(list) do 42 + if path ~= "" then 43 + table.insert(new_list, path) 44 + end 45 + end 46 + 47 + for i, path in ipairs(new_list) do 48 + new_list[i] = normalize_path(root_path, path) 49 + end 50 + 51 + return new_list 52 + end 53 + 54 + ---@param root_path string 55 + local function load(root_path) 56 + local data_file = get_data_file(root_path) 57 + if not vim.uv.fs_stat(data_file) then 58 + return {} 59 + end 60 + 61 + return normalize_list(root_path, vim.fn.readfile(data_file)) 62 + end 63 + 64 + ---@param root_path string 65 + local function write(root_path) 66 + vim.fn.writefile(Data.get_list(root_path), get_data_file(root_path)) 67 + end 68 + 69 + ---@param root_path string 70 + ---@param path string 71 + local function add_path(root_path, path) 72 + local list = Data.get_list(root_path) 73 + table.insert(list, normalize_path(root_path, vim.fn.fnamemodify(path, ":~"))) 74 + write(root_path) 75 + end 76 + 77 + ---@param root_path string 78 + ---@return string[] 79 + function Data.get_list(root_path) 80 + local hash = hash_path(root_path) 81 + if data_cache[hash] == nil then 82 + data_cache[hash] = load(root_path) 83 + end 84 + 85 + return data_cache[hash] 86 + end 87 + 88 + ---@return string[] 89 + function Data.get_current() 90 + return Data.get_list(Data.get_root()) 91 + end 92 + 93 + function Data.add_current() 94 + local filename = vim.api.nvim_buf_get_name(0) 95 + local buftype = vim.api.nvim_get_option_value("buftype", { buf = 0 }) 96 + 97 + if filename ~= "" and buftype == "" then 98 + add_path(Data.get_root(), filename) 99 + else 100 + log.warn("Cannot add this buffer") 101 + end 102 + end 103 + 104 + ---@param root_path string 105 + ---@param lines string[] 106 + function Data.update(root_path, lines) 107 + data_cache[hash_path(root_path)] = normalize_list(root_path, lines) 108 + write(root_path) 109 + end 110 + 111 + ---@return string 112 + function Data.get_root() 113 + local root = vim.fn.getcwd() 114 + 115 + if options.use_git_root then 116 + local git_root = Snacks.git.get_root(root) 117 + if git_root then 118 + root = git_root 119 + end 120 + end 121 + 122 + return vim.fn.fnamemodify(root, ":~") 123 + end 124 + 125 + function Data.setup(opt) 126 + options = opt 127 + 128 + data_dir = vim.fn.resolve(vim.fn.stdpath("data") .. "/javelin") 129 + if not vim.uv.fs_stat(data_dir) then 130 + vim.uv.fs_mkdir(data_dir, tonumber("755", 8)) 131 + end 132 + end 133 + 134 + return Data
+75
lua/javelin/init.lua
··· 1 + local ui = require("javelin.ui") 2 + local data = require("javelin.data") 3 + 4 + local Javelin = {} 5 + 6 + ---@class JavelinConfig 7 + local defaults = { 8 + use_git_root = true, 9 + -- Passthrough options to Snacks.win 10 + menu = { 11 + width = 0.6, 12 + height = 8, 13 + title_pos = "center", 14 + border = "rounded", 15 + keys = { 16 + ["<CR>"] = "goto", 17 + q = "close", 18 + ["<Esc>"] = "close", 19 + }, 20 + }, 21 + } 22 + 23 + function Javelin.open() 24 + return ui.open() 25 + end 26 + 27 + ---@param index number 28 + function Javelin.select(index) 29 + return ui.select(index) 30 + end 31 + 32 + function Javelin.add() 33 + return data.add_current() 34 + end 35 + 36 + local function create_user_command() 37 + local commands = { 38 + open = Javelin.open, 39 + add = Javelin.add, 40 + } 41 + 42 + vim.api.nvim_create_user_command("Javelin", function(args) 43 + local cmd = vim.trim(args.args or "") 44 + if cmd == "" then 45 + commands.open() 46 + elseif commands[cmd] then 47 + commands[cmd]() 48 + end 49 + end, { 50 + desc = "Javelin", 51 + nargs = "?", 52 + complete = function(_, line) 53 + if line:match("^%s*Javelin %w+ ") then 54 + return {} 55 + end 56 + local prefix = line:match("^%s*Javelin (%w*)") or "" 57 + return vim.tbl_filter(function(key) 58 + return key:find(prefix) == 1 59 + end, vim.tbl_keys(commands)) 60 + end, 61 + }) 62 + end 63 + 64 + ---@param opt? JavelinConfig 65 + function Javelin.setup(opt) 66 + ---@type JavelinConfig 67 + local options = vim.tbl_deep_extend("force", {}, defaults, opt or {}) 68 + 69 + create_user_command() 70 + 71 + ui.setup(options) 72 + data.setup(options) 73 + end 74 + 75 + return Javelin
+15
lua/javelin/log.lua
··· 1 + local Log = {} 2 + 3 + function Log.info(msg) 4 + return Snacks.notify.info(msg, { title = "javelin.nvim" }) 5 + end 6 + 7 + function Log.warn(msg) 8 + return Snacks.notify.warn(msg, { title = "javelin.nvim" }) 9 + end 10 + 11 + function Log.error(msg) 12 + return Snacks.notify.error(msg, { title = "javelin.nvim" }) 13 + end 14 + 15 + return Log
+88
lua/javelin/ui.lua
··· 1 + ---@module 'snacks' 2 + 3 + local data = require("javelin.data") 4 + local log = require("javelin.log") 5 + 6 + local UI = {} 7 + 8 + ---@type JavelinConfig 9 + local options 10 + 11 + local function open_buf(file) 12 + -- nvim 0.11 only command 13 + if vim.fn.exists("*isabsolutepath") then 14 + if not vim.fn.isabsolutepath(file) then 15 + file = data.get_root() .. "/" .. file 16 + end 17 + else 18 + if file:sub(1, 1) ~= "/" and file:sub(1, 1) ~= "~" then 19 + file = data.get_root() .. "/" .. file 20 + end 21 + end 22 + 23 + local bufnr = vim.fn.bufadd(vim.fn.expand(file)) 24 + vim.fn.bufload(bufnr) 25 + vim.api.nvim_set_option_value("buflisted", true, { 26 + buf = bufnr, 27 + }) 28 + vim.api.nvim_set_current_buf(bufnr) 29 + end 30 + 31 + ---@param index number 32 + function UI.select(index) 33 + local file = data.get_current()[index] 34 + if file ~= nil then 35 + open_buf(file) 36 + end 37 + end 38 + 39 + function UI.open() 40 + local root = data.get_root() 41 + local initial_text = data.get_list(root) 42 + Snacks.win.new({ 43 + text = initial_text, 44 + title = root, 45 + title_pos = options.menu.title_pos, 46 + width = options.menu.width, 47 + height = options.menu.height, 48 + border = options.menu.border, 49 + keys = options.menu.keys, 50 + fixbuf = true, 51 + wo = { 52 + number = true, 53 + }, 54 + bo = { 55 + modifiable = true, 56 + readonly = false, 57 + buftype = "nofile", 58 + bufhidden = "wipe", 59 + }, 60 + actions = { 61 + ["goto"] = function(self) 62 + local file = vim.api.nvim_get_current_line() 63 + self:close() 64 + open_buf(file) 65 + end, 66 + }, 67 + on_close = function(self) 68 + if self.buf then 69 + local lines = self:lines() 70 + if #lines == 1 and lines[1] == "" then 71 + lines = {} 72 + end 73 + if not vim.deep_equal(initial_text, lines) then 74 + data.update(root, self:lines()) 75 + log.info("Javelin updated.") 76 + end 77 + else 78 + log.error("No buf on win") 79 + end 80 + end, 81 + }) 82 + end 83 + 84 + function UI.setup(opt) 85 + options = opt 86 + end 87 + 88 + return UI
+3
stylua.toml
··· 1 + indent_type = "Tabs" 2 + indent_width = 4 3 + column_width = 120