cbochs/grapple.nvim

github github
marks
stars 291
issues 3
subscribers 3
forks 9
CREATED

2022-10-24

UPDATED

8 hours ago


Grapple.nvim

Theme: kanagawa

grapple-showcase

Introduction

Grapple is a plugin that aims to provide immediate navigation to important files (and their last known cursor location). Similar to harpoon. See the quickstart section to get started.

Features

  • Persistent tags on file paths to track and restore cursor location
  • Scoped tags for fine-grained, per-project tagging (i.e. git branch)
  • Rich well-defined Grapple and Scope APIs
  • Toggleable windows to manage tags and scopes as a regular vim buffer
  • Integration with telescope.nvim
  • Integration with portal.nvim for additional jump options

Requirements

Quickstart

  • Install Grapple.nvim using your preferred package manager
  • Add a keybind to tag, untag, or toggle a path. For example,
-- Lua
vim.keymap.set("n", "<leader>m", require("grapple").toggle)
vim.keymap.set("n", "<leader>M", require("grapple").toggle_tags)

-- User command
vim.keymap.set("n", "<leader>1", "<cmd>Grapple select index=1<cr>")

Next steps

Installation

{
    "cbochs/grapple.nvim",
    dependencies = {
        { "nvim-tree/nvim-web-devicons", lazy = true }
    },

}
use {
    "cbochs/grapple.nvim",
    requires = { "nvim-tree/nvim-web-devicons" }
}
Plug "nvim-tree/nvim-web-devicons"
Plug "cbochs/grapple.nvim"

Settings

The following are the default settings for Grapple. Setup is not required, but settings may be overridden by passing them as table arguments to the Grapple.setup function.

require("grapple").setup({
    ---Grapple save location
    ---@type string
    save_path = vim.fs.joinpath(vim.fn.stdpath("data"), "grapple"),

    ---Show icons next to tags or scopes in Grapple windows
    ---Requires "nvim-tree/nvim-web-devicons"
    ---@type boolean
    icons = true,

    ---Highlight the current selection in Grapple windows
    ---Also, indicates when a tag path does not exist
    ---@type boolean
    status = true,

    ---Default scope to use when managing Grapple tags
    ---@type string
    scope = "git",

    ---User-defined scopes or overrides
    ---For more information, please see the Scopes section
    ---@type grapple.scope_definition[]
    scopes = {},

    ---User-defined tags title function for Grapple windows
    ---By default, uses the resolved scope's ID
    ---@type fun(scope: grapple.resolved_scope): string?
    tag_title = nil,

    ---User-defined scopes title function for Grapple windows
    ---By default, renders "Grapple Scopes"
    ---@type fun(): string?
    scope_title = nil,

    ---User-defined loaded scopes title function for Grapple windows
    ---By default, renders "Grapple Loaded Scopes"
    ---@type fun(): string?
    loaded_title = nil,

    ---Additional window options for Grapple windows
    ---See :h nvim_open_win
    ---@type grapple.vim.win_opts
    win_opts = {
        -- Can be fractional
        width = 80,
        height = 12,
        row = 0.5,
        col = 0.5,

        relative = "editor",
        border = "single",
        focusable = false,
        style = "minimal",
        title_pos = "center",

        -- Custom: fallback title for Grapple windows
        title = "Grapple",

        -- Custom: adds padding around window title
        title_padding = " ",
    },
})

Usage

In general, the API is as follows:

Lua: require("grapple").{method}(...) Command: :Grapple [method] [opts...]

Where opts in the user command is a list of value arguments and key=value keyword arguments. For example,

:Grapple cycle forward scope=cwd

Has the equivalent form

require("grapple").cycle("forward", { scope = "cwd" })

Grapple API

Grapple.tag

Create a grapple tag.

Command: :Grapple tag [buffer={buffer}] [path={path}] [index={index}] [name={name}] [scope={scope}]

API: require("grapple").tag(opts)

opts?: grapple.options

  • buffer?: integer (default: 0)
  • path?: string
  • index?: integer
  • name?: string not implemented
  • scope?: string

Note: only one tag can be created per scope per file. If a tag already exists for the given file or buffer, it will be overridden with the new tag.

-- Tag the current buffer
require("grapple").tag()

-- Tag a file by its file path
require("grapple").tag({ path = "some_file.lua" })

-- Tag the current buffer in a different scope
require("grapple").tag({ scope = "global" })

Grapple.untag

Remove a Grapple tag.

API: require("grapple").untag(opts)

opts?: grapple.options (one of)

Note: Tag is removed based on one of (in order): index, name, path, buffer

-- Remove a tag on the current buffer
require("grapple").untag()

-- Remove a tag on a file
require("grapple").untag({ file_path = "{file_path}" })

-- Remove a tag on the current buffer in a different scope
require("grapple").untag({ scope = "global" })

Grapple.toggle

Toggle a Grapple tag.

API: require("grapple").toggle(opts)

opts?: grapple.options

-- Toggle a tag on the current buffer
require("grapple").toggle()

Grapple.select

Select a Grapple tag.

API: require("grapple").select(opts)

opts?: grapple.options (one of)

Note: Tag is selected based on one of (in order): index, name, path, buffer

-- Select the third tag
require("grapple").select({ index = 3 })

Grapple.exists

Return if a tag exists. Used for statusline components

API: require("grapple").exists(opts)

returns: boolean

opts?: grapple.options (one of)

Note: Tag is searched based on one of (in order): index, name, path, buffer

-- Check whether the current buffer is tagged or not
require("grapple").exists()

-- Check for a tag in a different scope
require("grapple").exists({ scope = "global" })

Grapple.cycle

Cycle through and select the next or previous available tag for a given scope.

Command: :Grapple cycle {direction} [opts...]

API:

  • require("grapple").cycle(direction, opts)
  • require("grapple").cycle_backward(opts)
  • require("grapple").cycle_forward(opts)

direction: "backward" | "forward" opts?: grapple.options (one of)

Note: Starting tag is searched based on one of (in order): index, name, path, buffer

-- Cycle to the previous tagged file
require("grapple").cycle_backward()

-- Cycle to the next tagged file
require("grapple").cycle_forward()

Grapple.reset

Clear all tags for a scope.

Command: :Grapple reset [scope={scope}] [id={id}]

API: require("grapple").reset(opts)

opts?: table

  • scope?: string scope name (default: settings.scope)
  • id?: string the ID of a resolved scope
-- Reset the current scope
require("grapple").reset()

-- Reset a scope (dynamic)
require("grapple").reset({ scope = "git" })

-- Reset a specific resolved scope ID
require("grapple").reset({ id = "~/git" })

Grapple.quickfix

Open the quickfix window populated with paths from a given scope

API: require("grapple").quickfix(opts)

opts?: table

  • scope?: string scope name (default: settings.scope)
  • id?: string the ID of a resolved scope
-- Open the quickfix window for the current scope
require("grapple").quickfix()

-- Open the quickfix window for a specified scope
require("grapple").quickfix("global")

Scope API

Grapple.define_scope

Create a user-defined scope.

API: require("grapple").define_scope(definition)

definition: grapple.scope_definition

For more examples, see settings.lua

-- Define a scope during setup
require("grapple").setup({
    scope = "cwd_branch",

    scopes = {
        {
            name = "cwd_branch",
            desc = "Current working directory and git branch",
            fallback = "cwd",
            cache = {
                event = { "BufEnter", "FocusGained" },
                debounce = 1000, -- ms
            },
            resolver = function()
                local git_files = vim.fs.find(".git", {
                    upward = true,
                    stop = vim.loop.os_homedir(),
                })

                if #git_files == 0 then
                    return
                end

                local root = vim.loop.cwd()

                local result = vim.fn.system({ "git", "symbolic-ref", "--short", "HEAD" })
                local branch = vim.trim(string.gsub(result, "\n", ""))

                local id = string.format("%s:%s", root, branch)
                local path = root

                return id, path
            end,
        }
    }
})

-- Define a scope outside of setup
require("grapple").define_scope({
    name = "projects",
    desc = "Project directory"
    fallback = "cwd",
    cache = { event = "DirChanged" },
    resolver = function()
        local projects_dir = vim.fs.find("projects", {
            upwards = true,
            stop = vim.loop.os_homedir()
        })

        if #projects_dir == 0 then
            return nil, nil, "Not in projects dir"
        end

        local path = projects_dir[1]
        local id = path
        return id, path, nil
    end
})

-- Use the scope
require("grapple").use_scope("projects")

Grapple.use_scope

Change the currently selected scope.

API: require("grapple").use_scope(scope)

scope: string scope name

-- Clear the cached value (if any) for the "git" scope
require("grapple").use_scope("git_branch")

Grapple.clear_cache

Clear any cached value for a given scope.

API: require("grapple").clear_cache(scope)

scope?: string scope name (default: settings.scope)

-- Clear the cached value for the initial working directory scope
require("grapple").clear_cache("static")

Tags

A tag is a persistent tag on a path or buffer. It is a means of indicating a file you want to return to. When a file is tagged, Grapple will save your cursor location so that when you jump back, your cursor is placed right where you left off. In a sense, tags are like file-level marks (:h mark).

Once a tag has been added to a scope, it may be selected by index, cycled through, or jumped to using plugins such as portal.nvim.

Scopes

A scope is a means of namespacing tags to a specific project. Scopes are resolved dynamically to produce a unique identifier for a set of tags (i.e. a root directory). This identifier determines where tags are created and deleted. Note, different scopes may resolve the same identifier (i.e. lsp and git scopes may share the same root directory).

Scopes can also be cached. Each scope may define a set of events and/or patterns for an autocommand (:h autocmd), an interval for a timer, or to be cached indefinitely (unless invalidated explicitly). Some examples of this are the cwd scope which only updates on DirChanged.

The following scopes are made available by default:

  • global: tags are scoped to a global namespace
  • static: tags are scoped to neovim's initial working directory
  • cwd: tags are scoped to the current working directory
  • lsp: tags are scoped to the root directory of the current buffer's attached LSP server, fallback: cwd
  • git: tags are scoped to the current git repository, fallback: cwd
  • git_branch: tags are scoped to the current git directory and git branch, fallback: cwd

It is also possible to create your own custom scope. See the Scope API for more information.

-- Use a builtin scope
require("grapple").setup({
    scope = "git_branch",
})

-- Define a custom scope
require("grapple").setup({
    scope = "custom",

    scopes = {
        name = "custom",
        fallback = "cwd",
        cache = { event = "DirChanged" },
        resolver = function()
            local path = vim.env.HOME
            local id = path
            return id, path
        end
    }
})

Grapple Windows

Popup windows are made available to enable easy management of tags and scopes. The opened buffer is given its own syntax (grapple) and file type (grapple) and can be modified like a regular buffer; meaning items can be selected, modified, reordered, or deleted with well-known vim motions. The floating window can be toggled or closed with either q or <esc>.

Tags Window

Open a floating window with all the tags for a given scope. This buffer is modifiable. Several actions are available by default:

  • Selection (<cr>): select the tag under the cursor
  • Split (horizontal) (<c-s>): select the tag under the cursor (split)
  • Split (vertical) (|): select the tag under the cursor (vsplit)
  • Quick select (1-9): select the tag at a given index
  • Deletion: delete a line to delete the tag
  • Reordering: move a line to move a tag
  • Quickfix (<c-q>): send all tags to the quickfix list (:h quickfix)
  • Go up (-): navigate up to the scopes window

API:

  • require("grapple").open_tags(opts)
  • require("grapple").toggle_tags(opts)

opts?: table

  • scope?: string scope name
  • id?: string the ID of a resolved scope
-- Open the tags window for the current scope
require("grapple").open_tags()

-- Open the tags window for a different scope
require("grapple").open_tags("global")

Scopes Window

Open a floating window with all defined scopes. This buffer is not modifiable. Some basic actions are available by default:

  • Selection (<cr>): open the tags window for the scope under the cursor
  • Quick select (1-9): open the tags window for the scope at a given index
  • Change (<s-cr>): change the current scope to the one under the cursor
  • Go up (-): navigate across to the loaded scopes window

API:

  • require("grapple").open_scopes()
  • require("grapple").toggle_scopes()
-- Open the scopes window
require("grapple").open_scopes()

Loaded Scopes Window

Open a floating window with all loaded scopes. This buffer is not modifiable. Some basic actions are available by default:

  • Selection (<cr>): open the tags window for the loaded scope under the cursor
  • Quick select (1-9): open tags window for the loaded scope at a given index
  • Deletion (x): reset the tags for the loaded scope under the cursor
  • Go up (-): navigate across to the scopes window

API:

  • require("grapple").open_loaded()
  • require("grapple").toggle_loaded()
-- Open the scopes window
require("grapple").open_loaded()

Persistent State

Grapple saves all scopes to a common directory. The default directory is named grapple and lives in Neovim's "data" directory (:h standard-path). Each scope will be saved as its own individually serialized JSON blob.

By default, no scopes are loaded on startup. When require("grapple").setup() is called, the default scope will be loaded. Otherwise, scopes will be loaded on demand.

Integrations

Telescope

You can use telescope.nvim to search through your tagged files instead of the built in popup windows.

Load the extension with

require("telescope").load_extension("grapple")

Then use this command to see the grapple tags for the project in a telescope window

:Telescope grapple tags

Statusline

A statusline component can be easily added to show whether a buffer is tagged.

lualine.nvim statusline

require("lualine").setup({
    sections = {
        lualine_b = {
            {
                require("grapple").statusline,
                cond = require("grapple").exists
            }
        }
    }
})

Grapple Types

grapple.options

Options available for most top-level tagging actions (e.g. tag, untag, select, toggle, etc).

Type: table

  • buffer: integer (default: 0)
  • path: string file path or URI (overrides buffer)
  • name: string tag name
  • index: integer tag insertion or deletion index (default: end of list)
  • scope: string scope name (default settings.scope)

grapple.cache.options

Options available for defining how a scope should be cached. Using the value of true will indicate a value should be cached indefinitely and is equivalent to providing an empty set of options ({}).

Type: table | boolean

  • event?: string | string[] autocmd event (:h autocmd)
  • pattern?: string autocmd pattern, useful for User events
  • interval?: integer timer interval
  • debounce?: integer debounce interval

grapple.scope_definition

Used for defining new scopes.

Type: table

grapple.scope_resolver

Used for defining new scopes. Must return a tuple of (id, path, err). If successful, an id must be provided with an optional absolute path path. If unsuccessful, id must be nil with an optional err explaining what when wrong.

Type: function

Returns: string? id, string? path, string? err

grapple.resolved_scope

Result from observing a scope at a point in time.

Type class

  • name: string scope name
  • id: string resolved scope ID
  • path: string | nil resolved scope path
  • :tags(): returns all tags for the given ID

Contributors

Thanks to these wonderful people for their contributions!

Inspiration and Thanks