Skip to content

Conversation

@madmaxieee
Copy link
Contributor

Adds a snacks picker frontend for fff.nvim. From this reddit post.

@StevenJPx2
Copy link

@dmtrKovalenko this would be awesome!

@madmaxieee madmaxieee force-pushed the main branch 2 times, most recently from 8690a3c to 1834d05 Compare September 18, 2025 02:18
@0xKahi
Copy link

0xKahi commented Sep 19, 2025

any plans to merge this would be amazing

@madmaxieee
Copy link
Contributor Author

should happen in a week or two, you can use my fork if you want to try it now.

@madmaxieee
Copy link
Contributor Author

@dmtrKovalenko are you planning to merge this?

@StevenJPx2
Copy link

I'm using your fork as my plugin 😆

@quolpr
Copy link

quolpr commented Oct 2, 2025

@madmaxieee it looks like latest master doesn't work with your plugin.

Took your files from this PR, put to my local nvim config + pull latest master of dmtrKovalenko/fff.nvim.

I did it like this:

function start()
  local M = {}

  local conf = require('fff.conf')
  local file_picker = require('fff.file_picker')

  ---@class FFFSnacksState
  ---@field current_file_cache? string
  ---@field config table FFF config
  M.state = { config = {} }

  local staged_status = {
    staged_new = true,
    staged_modified = true,
    staged_deleted = true,
    renamed = true,
  }

  local status_map = {
    untracked = 'untracked',
    modified = 'modified',
    deleted = 'deleted',
    renamed = 'renamed',
    staged_new = 'added',
    staged_modified = 'modified',
    staged_deleted = 'deleted',
    ignored = 'ignored',
    -- clean = "",
    -- clear = "",
    unknown = 'untracked',
  }

  --- tweaked version of `Snacks.picker.format.file_git_status`
  --- @type snacks.picker.format
  local function format_file_git_status(item, picker)
    local ret = {} ---@type snacks.picker.Highlight[]
    local status = item.status

    local hl = 'SnacksPickerGitStatus'
    if status.unmerged then
      hl = 'SnacksPickerGitStatusUnmerged'
    elseif status.staged then
      hl = 'SnacksPickerGitStatusStaged'
    else
      hl = 'SnacksPickerGitStatus' .. status.status:sub(1, 1):upper() .. status.status:sub(2)
    end

    local icon = picker.opts.icons.git[status.status]
    if status.staged then icon = picker.opts.icons.git.staged end

    local text_icon = status.status:sub(1, 1):upper()
    text_icon = status.status == 'untracked' and '?' or status.status == 'ignored' and '!' or text_icon

    ret[#ret + 1] = { icon, hl }
    ret[#ret + 1] = { ' ', virtual = true }

    ret[#ret + 1] = {
      col = 0,
      virt_text = { { text_icon, hl }, { ' ' } },
      virt_text_pos = 'right_align',
      hl_mode = 'combine',
    }
    return ret
  end

  ---@type snacks.picker.Config
  M.source = {
    title = 'FFFiles',
    finder = function(opts, ctx)
      -- initialization code from require('fff.picker_ui').open
      -- on_show does not seem to be called before finder
      if not M.state.current_file_cache then
        local current_buf = vim.api.nvim_get_current_buf()
        if current_buf and vim.api.nvim_buf_is_valid(current_buf) then
          local current_file = vim.api.nvim_buf_get_name(current_buf)
          if current_file ~= '' and vim.fn.filereadable(current_file) == 1 then
            M.state.current_file_cache = current_file
          else
            M.state.current_file_cache = nil
          end
        end
      end
      if not file_picker.is_initialized() then
        if not file_picker.setup() then
          vim.notify('Failed to initialize file picker', vim.log.levels.ERROR)
          return {}
        end
      end
      local config = conf.get()
      M.state.config = vim.tbl_deep_extend('force', config or {}, opts or {})

      local fff_result = file_picker.search_files(
        ctx.filter.search,
        opts.limit or M.state.config.max_results,
        M.state.config.max_threads,
        M.state.current_file_cache,
        false
      )

      ---@type snacks.picker.finder.Item[]
      local items = {}
      for _, fff_item in ipairs(fff_result) do
        ---@type snacks.picker.finder.Item
        local item = {
          text = fff_item.name,
          file = fff_item.path,
          score = fff_item.total_frecency_score,
          -- HACK: in original snacks implementation status is a string of
          -- `git status --porcelain` output
          status = status_map[fff_item.git_status] and {
            status = status_map[fff_item.git_status],
            staged = staged_status[fff_item.git_status] or false,
            unmerged = fff_item.git_status == 'unmerged',
          },
        }
        items[#items + 1] = item
      end

      return items
    end,
    format = function(item, picker)
      ---@type snacks.picker.Highlight[]
      local ret = {}

      if item.label then
        ret[#ret + 1] = { item.label, 'SnacksPickerLabel' }
        ret[#ret + 1] = { ' ', virtual = true }
      end

      if item.status then
        vim.list_extend(ret, format_file_git_status(item, picker))
      else
        ret[#ret + 1] = { '  ', virtual = true }
      end

      vim.list_extend(ret, require('snacks').picker.format.filename(item, picker))

      if item.line then
        require('snacks').picker.highlight.format(item, item.line, ret)
        table.insert(ret, { ' ' })
      end
      return ret
    end,
    on_close = function() M.state.current_file_cache = nil end,
    formatters = {
      file = {
        filename_first = true,
      },
    },
    live = true,
  }

  return M
end

return {
  'dmtrKovalenko/fff.nvim',
  build = function()
    -- this will download prebuild binary or try to use existing rustup toolchain to build from source
    -- (if you are using lazy you can use gb for rebuilding a plugin if needed)
    require("fff.download").download_or_build_binary()
  end,
  dependencies = { 'folke/snacks.nvim' },
  -- if you are using nixos
  -- build = "nix run .#release",
  opts = {                -- (optional)
    debug = {
      enabled = true,     -- we expect your collaboration at least during the beta
      show_scores = true, -- to help us optimize the scoring system, feel free to share your scores!
    },
  },
  config = function(_, opts)
    require('fff').setup(opts)


    start()
  end,
  -- No need to lazy-load with lazy.nvim.
  -- This plugin initializes itself lazily.
  lazy = false,
  keys = {
    {
      "ff", -- try it if you didn't it is a banger keybinding for a picker
      function() require('fff').find_files() end,
      desc = 'FFFind files',
    }
  }
}

And I get this error:

Error executing vim.schedule lua callback: error loading module 'blink_cmp_fuzzy' from file '/Users/quolpr/.local/share/nvim/lazy/fff.nvim/lua/../target/libfff_nvim.dylib':
        dlsym(0x92987180, luaopen_blink_cmp_fuzzy): symbol not found
stack traceback:
        [C]: at 0x0103251790
        [C]: at 0x0103251b88
        [C]: in function 'require'
        ...l/share/nvim/lazy/blink.cmp/lua/blink/cmp/fuzzy/init.lua:17: in function 'set_implementation'
        .../.local/share/nvim/lazy/blink.cmp/lua/blink/cmp/init.lua:24: in function ''
        vim/_editor.lua: in function <vim/_editor.lua:0>

And it's a bit weird, because the error is connected with blink.cmp. And if I will remove "start()", fff or blink works correctly works.

The error itself somehow connected with this line. If I comment it, I don't see any error.

local file_picker = require('fff.file_picker')

@quolpr
Copy link

quolpr commented Oct 2, 2025

I think I actually found a solution. Maybe some weird race condition happen, but this way, with lazy calling start() works for me correctly:

function start()
  local M = {}

  local conf = require('fff.conf')
  local file_picker = require('fff.file_picker')

  -- local file_picker = nil
  -- local conf = nil

  ---@class FFFSnacksState
  ---@field current_file_cache? string
  ---@field config table FFF config
  M.state = { config = {} }

  local staged_status = {
    staged_new = true,
    staged_modified = true,
    staged_deleted = true,
    renamed = true,
  }

  local status_map = {
    untracked = 'untracked',
    modified = 'modified',
    deleted = 'deleted',
    renamed = 'renamed',
    staged_new = 'added',
    staged_modified = 'modified',
    staged_deleted = 'deleted',
    ignored = 'ignored',
    -- clean = "",
    -- clear = "",
    unknown = 'untracked',
  }

  --- tweaked version of `Snacks.picker.format.file_git_status`
  --- @type snacks.picker.format
  local function format_file_git_status(item, picker)
    local ret = {} ---@type snacks.picker.Highlight[]
    local status = item.status

    local hl = 'SnacksPickerGitStatus'
    if status.unmerged then
      hl = 'SnacksPickerGitStatusUnmerged'
    elseif status.staged then
      hl = 'SnacksPickerGitStatusStaged'
    else
      hl = 'SnacksPickerGitStatus' .. status.status:sub(1, 1):upper() .. status.status:sub(2)
    end

    local icon = picker.opts.icons.git[status.status]
    if status.staged then icon = picker.opts.icons.git.staged end

    local text_icon = status.status:sub(1, 1):upper()
    text_icon = status.status == 'untracked' and '?' or status.status == 'ignored' and '!' or text_icon

    ret[#ret + 1] = { icon, hl }
    ret[#ret + 1] = { ' ', virtual = true }

    ret[#ret + 1] = {
      col = 0,
      virt_text = { { text_icon, hl }, { ' ' } },
      virt_text_pos = 'right_align',
      hl_mode = 'combine',
    }
    return ret
  end

  ---@type snacks.picker.Config
  M.source = {
    title = 'FFFiles',
    finder = function(opts, ctx)
      -- initialization code from require('fff.picker_ui').open
      -- on_show does not seem to be called before finder
      if not M.state.current_file_cache then
        local current_buf = vim.api.nvim_get_current_buf()
        if current_buf and vim.api.nvim_buf_is_valid(current_buf) then
          local current_file = vim.api.nvim_buf_get_name(current_buf)
          if current_file ~= '' and vim.fn.filereadable(current_file) == 1 then
            M.state.current_file_cache = current_file
          else
            M.state.current_file_cache = nil
          end
        end
      end
      if not file_picker.is_initialized() then
        if not file_picker.setup() then
          vim.notify('Failed to initialize file picker', vim.log.levels.ERROR)
          return {}
        end
      end
      local config = conf.get()
      M.state.config = vim.tbl_deep_extend('force', config or {}, opts or {})

      local fff_result = file_picker.search_files(
        ctx.filter.search,
        opts.limit or M.state.config.max_results,
        M.state.config.max_threads,
        M.state.current_file_cache,
        false
      )

      ---@type snacks.picker.finder.Item[]
      local items = {}
      for _, fff_item in ipairs(fff_result) do
        ---@type snacks.picker.finder.Item
        local item = {
          text = fff_item.name,
          file = fff_item.path,
          score = fff_item.total_frecency_score,
          -- HACK: in original snacks implementation status is a string of
          -- `git status --porcelain` output
          status = status_map[fff_item.git_status] and {
            status = status_map[fff_item.git_status],
            staged = staged_status[fff_item.git_status] or false,
            unmerged = fff_item.git_status == 'unmerged',
          },
        }
        items[#items + 1] = item
      end

      return items
    end,
    format = function(item, picker)
      ---@type snacks.picker.Highlight[]
      local ret = {}

      if item.label then
        ret[#ret + 1] = { item.label, 'SnacksPickerLabel' }
        ret[#ret + 1] = { ' ', virtual = true }
      end

      if item.status then
        vim.list_extend(ret, format_file_git_status(item, picker))
      else
        ret[#ret + 1] = { '  ', virtual = true }
      end

      vim.list_extend(ret, require('snacks').picker.format.filename(item, picker))

      if item.line then
        require('snacks').picker.highlight.format(item, item.line, ret)
        table.insert(ret, { ' ' })
      end
      return ret
    end,
    on_close = function() M.state.current_file_cache = nil end,
    formatters = {
      file = {
        filename_first = true,
      },
    },
    live = true,
  }

  return M
end

return {
  'dmtrKovalenko/fff.nvim',
  build = function()
    -- this will download prebuild binary or try to use existing rustup toolchain to build from source
    -- (if you are using lazy you can use gb for rebuilding a plugin if needed)
    require("fff.download").download_or_build_binary()
  end,
  dependencies = { 'folke/snacks.nvim' },
  -- if you are using nixos
  -- build = "nix run .#release",
  opts = {                -- (optional)
    debug = {
      enabled = true,     -- we expect your collaboration at least during the beta
      show_scores = true, -- to help us optimize the scoring system, feel free to share your scores!
    },
  },
  config = function(_, opts)
    require('fff').setup(opts)


    -- start() lazy load

    vim.api.nvim_create_user_command('FFFSnacks', function()
      if Snacks and pcall(require, 'snacks.picker') then
        Snacks.picker(start().source)
      else
        vim.notify('Snacks is not loaded', vim.log.levels.ERROR)
      end
    end, {
      desc = 'Open FFF in snacks picker',
    })
  end,
  -- No need to lazy-load with lazy.nvim.
  -- This plugin initializes itself lazily.
  lazy = false,
  keys = {
    {
      "ff", -- try it if you didn't it is a banger keybinding for a picker
      function() require('fff').find_files() end,
      desc = 'FFFind files',
    }
  }
}

@madmaxieee
Copy link
Contributor Author

fff is lazy loaded by default. Calling the setup function only puts your config into vim.g.fff. The initialization would only be done on UIEnter. In my branch I put my init code into the same init function to guarantee that the snacks picker is only available after fff finished initialization.

@mrjared16
Copy link

I think I actually found a solution. Maybe some weird race condition happen, but this way, with lazy calling start() works for me correctly:

function start()
  local M = {}

  local conf = require('fff.conf')
  local file_picker = require('fff.file_picker')

I thought I was the only one hitting this issue, so I patched it myself (it started after the patch that allowed downloading the binary directly). Both blink.cmp and fff.nvim pollute the cpath (when require runs, it checks .lua first, then .so for the symbol). I don’t remember the exact order, but if you load blink.cmp and fff.nvim in the wrong order, blink will try to use fff.nvim’s .so, which doesn’t have its specific symbols. That’s what caused the error you saw.

Since I didn’t want to sacrifice lazy loading, I just hardcoded the cpath for blink.cmp in
~/.local/share/nvim/lazy/blink.cmp/lua/blink/cmp/fuzzy/rust/init.lua:

local lib_path = '/home/<your_username>/.local/share/nvim/lazy/blink.cmp/target/release/libblink_cmp_fuzzy.so'
local lib, err = package.loadlib(lib_path, 'luaopen_blink_cmp_fuzzy')
if not lib then
  error('Failed to load libblink_cmp_fuzzy: ' .. err)
end
return lib()

I also ran into a git issue (it stopped detecting git changes). In
~/.local/share/nvim/lazy/fff.nvim/lua/fff/rust/git.rs I changed:

let repo_path = repo.path().parent()?;

to:

let repo_path = repo.workdir();

This somehow seemed to fix the problem. Hope this helps someone else

Copy link
Contributor

@sQVe sQVe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, 👋🏼

Thanks for putting this together. The integration works well, but after thinking through the implications, I don't think this belongs in fff.nvim core. Let me explain why.

The maintenance trap: If we merge this, we're now responsible for tracking Snacks API changes.

Slippery slope: Once we accept one picker integration, what do we say when someone opens a Telescope PR? Or fzf-lua? Or the next popular picker framework?

Better approach: This should live as fff-snacks.nvim - a separate bridge plugin. Here's why that's actually better for everyone:

  • Maintenance lives where it should: People who care about this integration maintain it
  • Version pinning: You can specify compatible versions of both plugins
  • Faster iteration: No need to wait for fff.nvim releases
  • fff.nvim stays focused: One job, do it well

Here's how FFF can help: A public API in FFF to expose some of the functionality would be essential for bridge plugins:

  • fff.api.search(query, opts) - get results
  • fff.api.format(item) - get text + highlights
  • fff.api.status(item) - get git status info

This would make your bridge plugin way simpler - probably ~30 lines instead of 166. Interested in collaborating on that?

@madmaxieee
Copy link
Contributor Author

@mrjared16 Thanks for the clarification, I never noticed this since I don't use prebuilt binary before. So that is a simpler fix (kinda?) for you.

@madmaxieee
Copy link
Contributor Author

@sQVe I totally agree with these arguments. Honestly I was planning to create a standalone plugin in the first place.

link: https://github.com/madmaxieee/fff-snacks.nvim.

The API is really cool, I would love to see that! Thanks for your great work.

@quolpr
Copy link

quolpr commented Oct 3, 2025

@madmaxieee that's cool, thanks for the package!

Current way of configuring cpath use the full path of the downloaded
binary without any '?', this causes any require statement that can't
resolve before this cpath component to always resolve to the downloaded
libfff_nvim, thus causing some unexpected bug in some other plugins or
user code.
@dmtrKovalenko dmtrKovalenko reopened this Oct 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants