Skip to content

Commit 3d019a2

Browse files
committed
feat: path_shorten using extmarks
1 parent d9908fd commit 3d019a2

File tree

3 files changed

+187
-4
lines changed

3 files changed

+187
-4
lines changed

lua/fzf-lua/defaults.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ end
7272
---@field fullscreen? boolean
7373
---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.
7474
---@field treesitter? fzf-lua.config.TreesitterWinopts|boolean
75+
---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.
76+
---@field path_shorten? boolean|integer
7577
---Callback after the creation of the fzf-lua main terminal window.
7678
---@field on_create? fun(e: { winid?: integer, bufnr?: integer })
7779
---Callback after closing the fzf-lua window.

lua/fzf-lua/path.lua

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ end
273273
---@param str string
274274
---@param start_idx integer
275275
---@return integer?
276-
local function find_next_separator(str, start_idx)
276+
function M.find_next_separator(str, start_idx)
277277
local SEPARATOR_BYTES = utils._if_win(
278278
{ M.fslash_byte, M.bslash_byte }, { M.fslash_byte })
279279
for i = start_idx or 1, #str do
@@ -288,7 +288,7 @@ end
288288
---@param s string
289289
---@param i? integer
290290
---@return integer?
291-
local function utf8_char_len(s, i)
291+
function M.utf8_char_len(s, i)
292292
-- Get byte count of unicode character (RFC 3629)
293293
local c = string_byte(s, i or 1)
294294
if not c then
@@ -321,7 +321,7 @@ local function utf8_sub(s, from, to)
321321
local byte_i, utf8_i = from, from
322322
-- Concat utf8 chars until "to" or end of string
323323
while byte_i <= #s and (not to or utf8_i <= to) do
324-
local c_len = utf8_char_len(s, byte_i) ---@cast c_len-?
324+
local c_len = M.utf8_char_len(s, byte_i) ---@cast c_len-?
325325
local c = string_sub(s, byte_i, byte_i + c_len - 1)
326326
ret = ret .. c
327327
byte_i = byte_i + c_len
@@ -343,7 +343,7 @@ function M.shorten(path, max_len, sep)
343343
start_idx = 4
344344
end
345345
repeat
346-
local i = find_next_separator(path, start_idx)
346+
local i = M.find_next_separator(path, start_idx)
347347
local end_idx = i and start_idx + math.min(i - start_idx, max_len) - 1 or nil
348348
local part = utf8_sub(path, start_idx, end_idx) ---@cast i-?
349349
if end_idx and part == "." and i - start_idx > 1 then

lua/fzf-lua/win.lua

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,161 @@
11
local utils = require "fzf-lua.utils"
22
local libuv = require "fzf-lua.libuv"
33
local actions = require "fzf-lua.actions"
4+
local path = require "fzf-lua.path"
45

56
local api = vim.api
67
local fn = vim.fn
78

9+
---@class fzf-lua.PathShortener
10+
---@field _ns integer?
11+
---@field _wins table<integer, { bufnr: integer, shorten_len: integer }>
12+
local PathShortener = {}
13+
14+
PathShortener._wins = {}
15+
16+
-- Hoist constant byte values for performance
17+
local DOT_BYTE = path.dot_byte
18+
19+
function PathShortener.setup()
20+
if PathShortener._ns then return end
21+
PathShortener._ns = api.nvim_create_namespace("fzf-lua.win.path_shorten")
22+
23+
-- Register decoration provider with ephemeral extmarks
24+
api.nvim_set_decoration_provider(PathShortener._ns, {
25+
on_win = function(_, winid, bufnr, _topline, _botline)
26+
-- Only process registered fzf windows
27+
local win_data = PathShortener._wins[winid]
28+
if not win_data or win_data.bufnr ~= bufnr then
29+
return false
30+
end
31+
return true -- Continue to on_line callbacks
32+
end,
33+
on_line = function(_, winid, bufnr, row)
34+
local win_data = PathShortener._wins[winid]
35+
if not win_data then return end
36+
PathShortener._apply_line(bufnr, row, win_data.shorten_len)
37+
end,
38+
})
39+
end
40+
41+
---Apply path shortening to a single line using ephemeral extmarks
42+
---@param buf integer buffer number
43+
---@param row integer 0-indexed line number
44+
---@param shorten_len integer number of characters to keep
45+
function PathShortener._apply_line(buf, row, shorten_len)
46+
local lines = api.nvim_buf_get_lines(buf, row, row + 1, false)
47+
local line = lines[1]
48+
if not line or #line == 0 then return end
49+
50+
-- Find the path portion of the line
51+
-- Lines may have prefixes like icons separated by nbsp (U+2002)
52+
-- Format: [fzf_pointer] [git_icon nbsp] [file_icon nbsp] path[:line:col:text]
53+
-- When file_icons=false, there's no nbsp separator before the path
54+
-- The fzf terminal also adds pointer/marker prefix (e.g., "> " or " ")
55+
local path_start = 1
56+
local last_nbsp = line:find(utils.nbsp, 1, true)
57+
if last_nbsp then
58+
-- Find the last nbsp (there may be multiple with git_icons + file_icons)
59+
repeat
60+
path_start = last_nbsp + #utils.nbsp
61+
last_nbsp = line:find(utils.nbsp, path_start, true)
62+
until not last_nbsp
63+
else
64+
-- No nbsp means no icons - skip fzf's pointer/marker prefix
65+
-- Paths start with: alphanumeric, `/`, `~`, `.`, or drive letter (Windows)
66+
-- Skip leading whitespace and pointer characters until we hit a path char
67+
local first_path_char = line:find("[%w/~%.]")
68+
if first_path_char then
69+
path_start = first_path_char
70+
end
71+
end
72+
73+
-- Find where the path ends (at first colon after path_start, if any)
74+
-- But be careful with Windows paths like C:\...
75+
local path_end = #line
76+
local colon_search_start = path_start
77+
-- On Windows, skip the drive letter colon (e.g., C:)
78+
-- Check if char at path_start+1 is colon AND char at path_start+2 is a path separator
79+
if string.byte(line, path_start + 1) == path.colon_byte
80+
and path.byte_is_separator(string.byte(line, path_start + 2)) then
81+
colon_search_start = path_start + 2
82+
end
83+
local colon_pos = line:find(":", colon_search_start)
84+
if colon_pos then
85+
path_end = colon_pos - 1
86+
end
87+
88+
-- Now process the path portion for shortening
89+
-- We need to conceal directory components, keeping only `shorten_len` chars
90+
local path_portion = line:sub(path_start, path_end)
91+
92+
-- Use path.find_next_separator to iterate through directory components
93+
local prev_sep = 0
94+
local sep_pos = path.find_next_separator(path_portion, 1)
95+
while sep_pos do
96+
local component_start = prev_sep + 1
97+
local component = path_portion:sub(component_start, sep_pos - 1)
98+
local component_len = #component
99+
100+
-- Count UTF-8 characters using vim.str_utfindex
101+
-- Use "utf-32" to count code points (actual characters), not bytes.
102+
-- "utf-8" would give byte positions, "utf-16" gives UTF-16 code units.
103+
local _, component_charlen = vim.str_utfindex(component, "utf-32")
104+
component_charlen = component_charlen or component_len -- fallback to byte length
105+
106+
-- Only conceal if the component has more characters than shorten_len
107+
if component_charlen > shorten_len then
108+
-- Handle special case: component starts with '.' (hidden files/dirs)
109+
local keep_chars = shorten_len
110+
if string.byte(component, 1) == DOT_BYTE and component_charlen > shorten_len + 1 then
111+
-- Keep the dot plus shorten_len characters
112+
keep_chars = shorten_len + 1
113+
end
114+
115+
-- Bounds check to prevent errors
116+
keep_chars = math.min(keep_chars, component_charlen)
117+
118+
-- Convert character count to byte offset using vim.str_byteindex
119+
local keep_bytes = vim.str_byteindex(component, "utf-32", keep_chars, false)
120+
keep_bytes = keep_bytes or keep_chars -- fallback to character count (ASCII approximation)
121+
122+
-- Calculate 0-indexed byte positions in the full line for extmark
123+
-- path_start is 1-indexed, component_start is 1-indexed within path_portion
124+
local line_offset = path_start - 1 + component_start - 1
125+
local conceal_start = line_offset + keep_bytes
126+
local conceal_end = line_offset + component_len -- end of component (before separator)
127+
128+
if conceal_end > conceal_start then
129+
pcall(api.nvim_buf_set_extmark, buf, PathShortener._ns, row, conceal_start, {
130+
end_col = conceal_end,
131+
conceal = "",
132+
ephemeral = true,
133+
})
134+
end
135+
end
136+
prev_sep = sep_pos
137+
sep_pos = path.find_next_separator(path_portion, sep_pos + 1)
138+
end
139+
end
140+
141+
---Register a window for path shortening
142+
---@param winid integer window ID
143+
---@param bufnr integer buffer number
144+
---@param shorten_len integer|boolean number of characters to keep
145+
function PathShortener.attach(winid, bufnr, shorten_len)
146+
if not winid or not bufnr then return end
147+
PathShortener.setup()
148+
shorten_len = (shorten_len == true) and 1 or tonumber(shorten_len) or 1
149+
PathShortener._wins[winid] = { bufnr = bufnr, shorten_len = shorten_len }
150+
end
151+
152+
---Unregister a window from path shortening
153+
---@param winid integer window ID
154+
function PathShortener.detach(winid)
155+
if not winid then return end
156+
PathShortener._wins[winid] = nil
157+
end
158+
8159
---@alias fzf-lua.win.previewPos "up"|"down"|"left"|"right"
9160
---@alias fzf-lua.win.previewLayout { pos: fzf-lua.win.previewPos, size: number, str: string }
10161

@@ -773,6 +924,26 @@ function FzfWin:treesitter_attach()
773924
self.on_closes.tsinjector = self.tsinjector.attach(self, self.fzf_bufnr, self._o._treesitter)
774925
end
775926

927+
function FzfWin:path_shorten_detach()
928+
PathShortener.detach(self.fzf_winid)
929+
-- Reset conceallevel when path shortening is disabled (e.g., on window reuse)
930+
if api.nvim_win_is_valid(self.fzf_winid) then
931+
vim.wo[self.fzf_winid].conceallevel = 0
932+
vim.wo[self.fzf_winid].concealcursor = ""
933+
end
934+
end
935+
936+
function FzfWin:path_shorten_attach()
937+
local path_shorten = self._o.winopts.path_shorten
938+
if not path_shorten then return end
939+
-- Enable conceallevel for the fzf window to show concealed text
940+
if api.nvim_win_is_valid(self.fzf_winid) then
941+
vim.wo[self.fzf_winid].conceallevel = 2
942+
vim.wo[self.fzf_winid].concealcursor = "nvic"
943+
end
944+
PathShortener.attach(self.fzf_winid, self.fzf_bufnr, path_shorten)
945+
end
946+
776947
---@param buf integer
777948
function FzfWin:close_buf(buf)
778949
utils.nvim_buf_delete(buf, { force = true })
@@ -850,6 +1021,12 @@ function FzfWin:create()
8501021
self:setup_autocmds()
8511022
self:setup_keybinds()
8521023
self:treesitter_attach()
1024+
-- attach/detach path shortening
1025+
if self._o.winopts.path_shorten then
1026+
self:path_shorten_attach()
1027+
else
1028+
self:path_shorten_detach()
1029+
end
8531030
self:reset_winhl(self.fzf_winid)
8541031
-- also recall the user's 'on_create' (#394)
8551032
if type(self.winopts.on_create) == "function" then
@@ -931,6 +1108,8 @@ function FzfWin:create()
9311108
self:setup_autocmds()
9321109
self:setup_keybinds()
9331110
self:treesitter_attach()
1111+
-- Use extmarks to visually shorten paths while keeping full paths for actions
1112+
self:path_shorten_attach()
9341113
self:reset_winhl(self.fzf_winid)
9351114

9361115
-- potential workarond for `<C-c>` freezing neovim (#1091)
@@ -1007,6 +1186,8 @@ function FzfWin:close(fzf_bufnr, hide)
10071186
if not hide and self.fzf_bufnr then
10081187
self:close_buf(self.fzf_bufnr)
10091188
end
1189+
-- Detach path shortening decoration provider
1190+
PathShortener.detach(self.fzf_winid)
10101191
for k, _ in pairs(self.on_closes) do
10111192
self.on_closes[k](hide)
10121193
self.on_closes[k] = nil

0 commit comments

Comments
 (0)