Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lua/fzf-lua/defaults.lua
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ end
---@field fullscreen? boolean
---Use treesitter highlighting in fzf's main window. NOTE: Only works for file-like entries where treesitter parser exists and is loaded for the filetype.
---@field treesitter? fzf-lua.config.TreesitterWinopts|boolean
---Use extmarks with conceal to visually shorten paths while keeping full paths for actions/preview. Set to `true` for 1 char, or a number for custom length. NOTE: Unlike the picker `path_shorten` option, this doesn't modify the actual entry text, making it compatible with `combine()`. NOTE: This option has no effect when using `fzf-tmux` as the fzf window runs in a tmux popup outside of Neovim where extmarks are not available.
---@field path_shorten? boolean|integer
---Callback after the creation of the fzf-lua main terminal window.
---@field on_create? fun(e: { winid?: integer, bufnr?: integer })
---Callback after closing the fzf-lua window.
Expand Down
8 changes: 4 additions & 4 deletions lua/fzf-lua/path.lua
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ end
---@param str string
---@param start_idx integer
---@return integer?
local function find_next_separator(str, start_idx)
function M.find_next_separator(str, start_idx)
local SEPARATOR_BYTES = utils._if_win(
{ M.fslash_byte, M.bslash_byte }, { M.fslash_byte })
for i = start_idx or 1, #str do
Expand All @@ -288,7 +288,7 @@ end
---@param s string
---@param i? integer
---@return integer?
local function utf8_char_len(s, i)
function M.utf8_char_len(s, i)
-- Get byte count of unicode character (RFC 3629)
local c = string_byte(s, i or 1)
if not c then
Expand Down Expand Up @@ -321,7 +321,7 @@ local function utf8_sub(s, from, to)
local byte_i, utf8_i = from, from
-- Concat utf8 chars until "to" or end of string
while byte_i <= #s and (not to or utf8_i <= to) do
local c_len = utf8_char_len(s, byte_i) ---@cast c_len-?
local c_len = M.utf8_char_len(s, byte_i) ---@cast c_len-?
local c = string_sub(s, byte_i, byte_i + c_len - 1)
ret = ret .. c
byte_i = byte_i + c_len
Expand All @@ -343,7 +343,7 @@ function M.shorten(path, max_len, sep)
start_idx = 4
end
repeat
local i = find_next_separator(path, start_idx)
local i = M.find_next_separator(path, start_idx)
local end_idx = i and start_idx + math.min(i - start_idx, max_len) - 1 or nil
local part = utf8_sub(path, start_idx, end_idx) ---@cast i-?
if end_idx and part == "." and i - start_idx > 1 then
Expand Down
194 changes: 194 additions & 0 deletions lua/fzf-lua/win.lua
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,166 @@ function TSInjector._attach_lang(buf, lang, regions)
parser:set_included_regions(regions)
end

---@class fzf-lua.PathShortener
---@field _ns integer?
---@field _wins table<integer, { bufnr: integer, shorten_len: integer }>
local PathShortener = {}

PathShortener._wins = {}

-- Hoist constant byte values for performance
local DOT_BYTE = path.dot_byte

function PathShortener.setup()
if PathShortener._ns then return end
PathShortener._ns = api.nvim_create_namespace("fzf-lua.win.path_shorten")

-- Register decoration provider with ephemeral extmarks
api.nvim_set_decoration_provider(PathShortener._ns, {
on_win = function(_, winid, bufnr, _topline, _botline)
-- Only process registered fzf windows
local win_data = PathShortener._wins[winid]
if not win_data or win_data.bufnr ~= bufnr then
return false
end
return true -- Continue to on_line callbacks
end,
on_line = function(_, winid, bufnr, row)
local win_data = PathShortener._wins[winid]
if not win_data then return end
PathShortener._apply_line(bufnr, row, win_data.shorten_len)
end,
})
end

---Apply path shortening to a single line using ephemeral extmarks
---@param buf integer buffer number
---@param row integer 0-indexed line number
---@param shorten_len integer number of characters to keep
function PathShortener._apply_line(buf, row, shorten_len)
local lines = api.nvim_buf_get_lines(buf, row, row + 1, false)
local line = lines[1]
if not line or #line == 0 then return end

-- Find the path portion of the line
-- Lines may have prefixes like icons separated by nbsp (U+2002)
-- Format: [fzf_pointer] [git_icon nbsp] [file_icon nbsp] path[:line:col:text]
-- When file_icons=false, there's no nbsp separator before the path
-- The fzf terminal also adds pointer/marker prefix (e.g., "> " or " ")
local path_start = 1
local last_nbsp = line:find(utils.nbsp, 1, true)
if last_nbsp then
-- Find the last nbsp (there may be multiple with git_icons + file_icons)
repeat
path_start = last_nbsp + #utils.nbsp
last_nbsp = line:find(utils.nbsp, path_start, true)
until not last_nbsp
else
-- No nbsp means no icons - skip fzf's pointer/marker prefix
-- Paths start with: alphanumeric, `/`, `~`, `.`, or drive letter (Windows)
-- Skip leading whitespace and pointer characters until we hit a path char
local first_path_char = line:find("[%w/~%.]")
if first_path_char then
path_start = first_path_char
end
end

-- Find where the path ends (at first colon after path_start, if any)
-- But be careful with Windows paths like C:\...
local path_end = #line
local colon_search_start = path_start
-- On Windows, skip the drive letter colon (e.g., C:)
-- Check if char at path_start+1 is colon AND char at path_start+2 is a path separator
if string.byte(line, path_start + 1) == path.colon_byte
and path.byte_is_separator(string.byte(line, path_start + 2)) then
colon_search_start = path_start + 2
end
local colon_pos = line:find(":", colon_search_start)
if colon_pos then
path_end = colon_pos - 1
end

-- Now process the path portion for shortening
-- We need to conceal directory components, keeping only `shorten_len` chars
local path_portion = line:sub(path_start, path_end)

-- Use path.find_next_separator to iterate through directory components
local prev_sep = 0
local sep_pos = path.find_next_separator(path_portion, 1)
while sep_pos do
local component_start = prev_sep + 1
local component = path_portion:sub(component_start, sep_pos - 1)
local component_len = #component

-- Count UTF-8 characters using path.utf8_char_len
local component_charlen = 0
local byte_i = 1
while byte_i <= component_len do
local c_len = path.utf8_char_len(component, byte_i) or 1
component_charlen = component_charlen + 1
byte_i = byte_i + c_len
end

-- Only conceal if the component has more characters than shorten_len
if component_charlen > shorten_len then
-- Handle special case: component starts with '.' (hidden files/dirs)
local keep_chars = shorten_len
if string.byte(component, 1) == DOT_BYTE and component_charlen > shorten_len + 1 then
-- Keep the dot plus shorten_len characters
keep_chars = shorten_len + 1
end

-- Bounds check to prevent errors
keep_chars = math.min(keep_chars, component_charlen)

-- Convert character count to byte offset using path.utf8_char_len
local keep_bytes = 0
local char_count = 0
byte_i = 1
while byte_i <= component_len and char_count < keep_chars do
local c_len = path.utf8_char_len(component, byte_i) or 1
keep_bytes = keep_bytes + c_len
char_count = char_count + 1
byte_i = byte_i + c_len
end

-- Calculate 0-indexed byte positions in the full line for extmark
-- path_start is 1-indexed, component_start is 1-indexed within path_portion
local line_offset = path_start - 1 + component_start - 1
local conceal_start = line_offset + keep_bytes
local conceal_end = line_offset + component_len -- end of component (before separator)

if conceal_end > conceal_start then
pcall(api.nvim_buf_set_extmark, buf, PathShortener._ns, row, conceal_start, {
end_col = conceal_end,
conceal = "",
ephemeral = true,
})
end
end
prev_sep = sep_pos
sep_pos = path.find_next_separator(path_portion, sep_pos + 1)
end
end

---Register a window for path shortening
---@param winid integer window ID
---@param bufnr integer buffer number
---@param shorten_len integer|boolean number of characters to keep
function PathShortener.attach(winid, bufnr, shorten_len)
if not winid or not bufnr then return end
PathShortener.setup()
shorten_len = (shorten_len == true) and 1 or tonumber(shorten_len) or 1
PathShortener._wins[winid] = { bufnr = bufnr, shorten_len = shorten_len }
end

---Unregister a window from path shortening
---@param winid integer window ID
function PathShortener.detach(winid)
if not winid then return end
PathShortener._wins[winid] = nil
end

---@alias fzf-lua.win.previewPos "up"|"down"|"left"|"right"
---@alias fzf-lua.win.previewLayout { pos: fzf-lua.win.previewPos, size: number, str: string }

Expand Down Expand Up @@ -1145,6 +1305,26 @@ function FzfWin:treesitter_attach()
})
end

function FzfWin:path_shorten_detach()
PathShortener.detach(self.fzf_winid)
-- Reset conceallevel when path shortening is disabled (e.g., on window reuse)
if api.nvim_win_is_valid(self.fzf_winid) then
vim.wo[self.fzf_winid].conceallevel = 0
vim.wo[self.fzf_winid].concealcursor = ""
end
end

function FzfWin:path_shorten_attach()
local path_shorten = self._o.winopts.path_shorten
if not path_shorten then return end
-- Enable conceallevel for the fzf window to show concealed text
if api.nvim_win_is_valid(self.fzf_winid) then
vim.wo[self.fzf_winid].conceallevel = 2
vim.wo[self.fzf_winid].concealcursor = "nvic"
end
PathShortener.attach(self.fzf_winid, self.fzf_bufnr, path_shorten)
end

function FzfWin:set_tmp_buffer(no_wipe)
if not self:validate() then return end
-- Store the [would be] detached buffer number
Expand All @@ -1163,6 +1343,10 @@ function FzfWin:set_tmp_buffer(no_wipe)
if not no_wipe then
utils.nvim_buf_delete(detached, { force = true })
TSInjector.clear_cache(detached)
-- Re-attach path shortening to the new buffer if needed
if self._o.winopts.path_shorten then
PathShortener.attach(self.fzf_winid, self.fzf_bufnr, self._o.winopts.path_shorten)
end
end
-- in case buffer exists prematurely
self:set_winleave_autocmd()
Expand Down Expand Up @@ -1228,6 +1412,12 @@ function FzfWin:create()
else
self:treesitter_detach(self.fzf_bufnr)
end
-- attach/detach path shortening
if self._o.winopts.path_shorten then
self:path_shorten_attach()
else
self:path_shorten_detach()
end
-- also recall the user's 'on_create' (#394)
if self.winopts.on_create and
type(self.winopts.on_create) == "function" then
Expand Down Expand Up @@ -1293,6 +1483,8 @@ function FzfWin:create()
self:set_redraw_autocmd()
-- Use treesitter to highlight results on the main fzf window
self:treesitter_attach()
-- Use extmarks to visually shorten paths while keeping full paths for actions
self:path_shorten_attach()

self:reset_win_highlights(self.fzf_winid)

Expand Down Expand Up @@ -1352,6 +1544,8 @@ function FzfWin:close(fzf_bufnr, hide, hidden)
end
-- Clear treesitter buffer cache and deregister decoration callbacks
self:treesitter_detach(self._hidden_fzf_bufnr or self.fzf_bufnr)
-- Detach path shortening decoration provider
PathShortener.detach(self.fzf_winid)
-- If this is a hidden buffer closure nothing else to do
if hidden then return end
if self.fzf_winid and api.nvim_win_is_valid(self.fzf_winid) then
Expand Down