@@ -8,7 +8,7 @@ local fn = vim.fn
88
99--- @class fzf-lua.PathShortener
1010--- @field _ns integer ?
11- --- @field _wins table<integer , { bufnr : integer , shorten_len : integer } >
11+ --- @field _wins table<integer , fzf-lua.PathShortener.WinData >
1212local PathShortener = {}
1313
1414PathShortener ._wins = {}
@@ -22,36 +22,38 @@ function PathShortener.setup()
2222
2323 -- Register decoration provider with ephemeral extmarks
2424 api .nvim_set_decoration_provider (PathShortener ._ns , {
25- on_win = function (_ , winid , bufnr , _topline , _botline )
25+ on_win = function (_ , winid , bufnr , topline , _botline )
2626 -- Only process registered fzf windows
2727 local win_data = PathShortener ._wins [winid ]
2828 if not win_data or win_data .bufnr ~= bufnr then
2929 return false
3030 end
31+ win_data .topline = topline
32+ win_data .winid = winid
3133 return true -- Continue to on_line callbacks
3234 end ,
3335 on_line = function (_ , winid , bufnr , row )
3436 local win_data = PathShortener ._wins [winid ]
3537 if not win_data then return end
36- PathShortener ._apply_line (bufnr , row , win_data . shorten_len )
38+ PathShortener ._apply_line (bufnr , row , win_data )
3739 end ,
3840 })
3941end
4042
41- --- Apply path shortening to a single line using ephemeral extmarks
43+ --- Apply path shortening to a single line using overlay virtual text
44+ --- Uses virt_text_pos="overlay" instead of conceal to preserve line width
45+ --- in terminal buffers, preventing fzf's preview border from shifting
4246--- @param buf integer buffer number
4347--- @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 )
48+ --- @param win_data table window data from PathShortener._wins
49+ function PathShortener ._apply_line (buf , row , win_data )
50+ local shorten_len = win_data .shorten_len
4651 local lines = api .nvim_buf_get_lines (buf , row , row + 1 , false )
4752 local line = lines [1 ]
4853 if not line or # line == 0 then return end
4954
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+ -- Only process selectable entry lines (lines with icon nbsp separator)
56+ -- This skips fzf's prompt, header, and info lines which don't have nbsp
5557 local path_start = 1
5658 local last_nbsp = line :find (utils .nbsp , 1 , true )
5759 if last_nbsp then
@@ -61,21 +63,27 @@ function PathShortener._apply_line(buf, row, shorten_len)
6163 last_nbsp = line :find (utils .nbsp , path_start , true )
6264 until not last_nbsp
6365 else
64- -- No nbsp means no icons - skip fzf's pointer/marker prefix
65- -- Paths start with: alphanumeric, `/`, `~`, `.`, or drive letter (Windows)
66+ -- No nbsp: either file_icons=false entry or a non-entry line (prompt/header)
67+ -- Filter out fzf's prompt/info line which contains the match counter (e.g. "281/281 (0)")
68+ if line :find (" %d+/%d+" ) then return end
6669 -- Skip leading whitespace and pointer characters until we hit a path char
6770 local first_path_char = line :find (" [%w/~%.]" )
68- if first_path_char then
69- path_start = first_path_char
70- end
71+ if not first_path_char then return end
72+ path_start = first_path_char
73+ -- Reject lines where space appears before the first path separator
74+ -- This filters out header lines like "cwd: /path/to/dir"
75+ local sub = line :sub (path_start )
76+ local first_sep = path .find_next_separator (sub , 1 )
77+ if not first_sep then return end
78+ local first_space = sub :find (" " )
79+ if first_space and first_space < first_sep then return end
7180 end
7281
73- -- Find where the path ends (at first colon after path_start, if any)
74- -- But be careful with Windows paths like C:\...
82+ -- Find where the path ends
83+ -- For grep-like results: path ends at first colon (file:line:col:text)
7584 local path_end = # line
7685 local colon_search_start = path_start
7786 -- 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
7987 if string.byte (line , path_start + 1 ) == path .colon_byte
8088 and path .byte_is_separator (string.byte (line , path_start + 2 )) then
8189 colon_search_start = path_start + 2
@@ -85,68 +93,242 @@ function PathShortener._apply_line(buf, row, shorten_len)
8593 path_end = colon_pos - 1
8694 end
8795
88- -- Now process the path portion for shortening
89- -- We need to conceal directory components, keeping only `shorten_len` chars
9096 local path_portion = line :sub (path_start , path_end )
9197
92- -- Use path.find_next_separator to iterate through directory components
98+ -- Formatters add ANSI escape sequences for coloring (e.g., dirname_first, filename_first)
99+ -- We need to strip these for accurate shortening calculations, then let hl_mode="combine"
100+ -- inherit the original coloring from the underlying line
101+ local fmt_opts = win_data .fmt_opts
102+ local has_fmt_hls = fmt_opts and fmt_opts .formatter
103+ and fmt_opts .hl_dir and fmt_opts .hl_file
104+
105+ -- Save the original path portion for width calculation
106+ local original_path_portion = path_portion
107+
108+ -- Strip ANSI codes for shortening calculations
109+ path_portion = utils .strip_ansi_coloring (path_portion )
110+
111+ -- For filename_first formatter, the entry is "filename<sep>parent/dir"
112+ -- With --tabstop=1, the tab is rendered as a space in the terminal buffer
113+ -- Only shorten the directory part after the separator
114+ local filename_first_prefix
115+ local filename_first_prefix_width = 0
116+ if fmt_opts and fmt_opts .formatter and fmt_opts .formatter :find (" filename_first" ) then
117+ -- Try tab first, then fall back to space (fzf renders tab as space with --tabstop=1)
118+ local sep_pos = path_portion :find (" \t " ) or path_portion :find (" " )
119+ if sep_pos then
120+ filename_first_prefix = path_portion :sub (1 , sep_pos ) -- includes the separator
121+ filename_first_prefix_width = fn .strdisplaywidth (filename_first_prefix )
122+ path_portion = path_portion :sub (sep_pos + 1 )
123+ -- Adjust path_start for the extmark col position (stays the same,
124+ -- but we'll prepend the prefix to the overlay)
125+ end
126+ end
127+
128+ -- Find the last path separator to determine the directory/filename boundary
129+ local first_sep = path .find_next_separator (path_portion , 1 )
130+ if not first_sep then return end
131+
132+ local last_sep_in_path = nil
133+ do
134+ local prev_sep = 0
135+ local s = first_sep
136+ while s do
137+ -- Check if the component between prev_sep and s contains whitespace
138+ -- (indicates we've left the path and entered fzf status text)
139+ if s > prev_sep + 1 then
140+ local component = path_portion :sub (prev_sep + 1 , s - 1 )
141+ if component :find (" %s" ) then
142+ break
143+ end
144+ end
145+ last_sep_in_path = s
146+ prev_sep = s
147+ s = path .find_next_separator (path_portion , s + 1 )
148+ end
149+ end
150+
151+ if not last_sep_in_path then return end
152+
153+ -- Determine the actual path boundary
154+ local actual_path_len
155+ if last_sep_in_path == # path_portion
156+ or path_portion :find (" ^%s" , last_sep_in_path + 1 ) then
157+ actual_path_len = last_sep_in_path
158+ else
159+ local filename_start = last_sep_in_path + 1
160+ local filename = path_portion :sub (filename_start )
161+ local filename_trimmed = filename :match (" ^(%S+)" ) or filename
162+ actual_path_len = last_sep_in_path + # filename_trimmed
163+ end
164+ path_portion = path_portion :sub (1 , actual_path_len )
165+
166+ -- Build shortened path: shorten each directory component, keep filename intact
167+ local shortened_parts = {}
168+ local any_shortened = false
169+ -- Track the byte offset of the last separator in the shortened string
170+ -- to split dir/file parts for highlight groups
171+ local shortened_last_sep_byte = 0
172+ local shortened_byte_len = 0
173+
93174 local prev_sep = 0
94175 local sep_pos = path .find_next_separator (path_portion , 1 )
95176 while sep_pos do
96177 local component_start = prev_sep + 1
97178 local component = path_portion :sub (component_start , sep_pos - 1 )
98- local component_len = # component
179+ local sep_char = path_portion : sub ( sep_pos , sep_pos )
99180
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.
181+ -- Count UTF-8 characters
103182 local _ , component_charlen = vim .str_utfindex (component , " utf-32" )
104- component_charlen = component_charlen or component_len -- fallback to byte length
183+ component_charlen = component_charlen or # component
105184
106- -- Only conceal if the component has more characters than shorten_len
107185 if component_charlen > shorten_len then
108- -- Handle special case: component starts with '.' (hidden files/dirs)
109186 local keep_chars = shorten_len
110187 if string.byte (component , 1 ) == DOT_BYTE and component_charlen > shorten_len + 1 then
111- -- Keep the dot plus shorten_len characters
112188 keep_chars = shorten_len + 1
113189 end
114-
115- -- Bounds check to prevent errors
116190 keep_chars = math.min (keep_chars , component_charlen )
117191
118- -- Convert character count to byte offset using vim.str_byteindex
119192 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
193+ keep_bytes = keep_bytes or keep_chars
194+
195+ local short_comp = component :sub (1 , keep_bytes )
196+ shortened_parts [# shortened_parts + 1 ] = short_comp
197+ shortened_byte_len = shortened_byte_len + # short_comp
198+ any_shortened = true
199+ else
200+ shortened_parts [# shortened_parts + 1 ] = component
201+ shortened_byte_len = shortened_byte_len + # component
135202 end
203+ shortened_parts [# shortened_parts + 1 ] = sep_char
204+ shortened_byte_len = shortened_byte_len + # sep_char
205+ shortened_last_sep_byte = shortened_byte_len
206+
136207 prev_sep = sep_pos
137208 sep_pos = path .find_next_separator (path_portion , sep_pos + 1 )
138209 end
210+
211+ -- Add the final component
212+ -- For filename_first, the part after tab is all directories, so shorten it too
213+ -- For dirname_first/normal, this is the filename - never shorten
214+ local has_final_component = prev_sep < # path_portion
215+ if has_final_component then
216+ local final_component = path_portion :sub (prev_sep + 1 )
217+ if filename_first_prefix then
218+ -- In filename_first mode, final component is a directory - shorten it
219+ local _ , final_charlen = vim .str_utfindex (final_component , " utf-32" )
220+ final_charlen = final_charlen or # final_component
221+ if final_charlen > shorten_len then
222+ local keep_chars = shorten_len
223+ if string.byte (final_component , 1 ) == DOT_BYTE and final_charlen > shorten_len + 1 then
224+ keep_chars = shorten_len + 1
225+ end
226+ keep_chars = math.min (keep_chars , final_charlen )
227+ local keep_bytes = vim .str_byteindex (final_component , " utf-32" , keep_chars , false )
228+ keep_bytes = keep_bytes or keep_chars
229+ local short_final = final_component :sub (1 , keep_bytes )
230+ shortened_parts [# shortened_parts + 1 ] = short_final
231+ shortened_byte_len = shortened_byte_len + # short_final
232+ any_shortened = true
233+ -- Update last_sep_byte to include this shortened component
234+ -- so the entire path uses dir highlight in filename_first mode
235+ shortened_last_sep_byte = shortened_byte_len
236+ else
237+ shortened_parts [# shortened_parts + 1 ] = final_component
238+ shortened_byte_len = shortened_byte_len + # final_component
239+ shortened_last_sep_byte = shortened_byte_len
240+ end
241+ else
242+ -- Normal mode: filename - don't shorten
243+ shortened_parts [# shortened_parts + 1 ] = final_component
244+ end
245+ end
246+
247+ if not any_shortened then return end
248+
249+ local shortened_path = table.concat (shortened_parts )
250+ -- Use original path (with ANSI) for width calculation since that's what we overlay
251+ local original_width = fn .strdisplaywidth (utils .strip_ansi_coloring (original_path_portion )) + filename_first_prefix_width
252+ local shortened_width = fn .strdisplaywidth (shortened_path ) + filename_first_prefix_width
253+ local padding = original_width - shortened_width
254+
255+ -- Build the virt_text tuples with appropriate highlights
256+ -- hl_mode="combine" on the extmark merges our fg with the terminal's bg,
257+ -- so cursor line bg from fzf is automatically preserved without detection
258+ local virt_text = {}
259+
260+ -- Build virt_text with shortened path and explicit highlights
261+ if has_fmt_hls then
262+ -- Apply explicit highlights based on path structure
263+ -- For filename_first: everything is directories (use dir_part)
264+ -- For dirname_first: split at last separator
265+ if filename_first_prefix then
266+ -- filename_first: filename prefix uses file_part, directories use dir_part
267+ virt_text [# virt_text + 1 ] = { filename_first_prefix , win_data .fmt_opts .hl_file }
268+ virt_text [# virt_text + 1 ] = { shortened_path , win_data .fmt_opts .hl_dir }
269+ else
270+ -- dirname_first: split at last separator
271+ local dir_str = shortened_path :sub (1 , shortened_last_sep_byte )
272+ local file_str = shortened_path :sub (shortened_last_sep_byte + 1 )
273+ virt_text [# virt_text + 1 ] = { dir_str , win_data .fmt_opts .hl_dir }
274+ if # file_str > 0 then
275+ virt_text [# virt_text + 1 ] = { file_str , win_data .fmt_opts .hl_file }
276+ end
277+ end
278+ else
279+ -- No formatter: single unstyled string
280+ virt_text [# virt_text + 1 ] = { shortened_path }
281+ end
282+
283+ -- Build virt_text with shortened path and explicit highlights
284+ -- Use a single virt_text entry to ensure proper overlay behavior
285+ local virt_text_str
286+ local virt_text_hl
287+
288+ if has_fmt_hls then
289+ if filename_first_prefix then
290+ -- filename_first: combine prefix and path, apply dir highlight to all
291+ -- (the filename in the prefix will keep its original ANSI color via combine)
292+ virt_text_str = filename_first_prefix .. shortened_path .. (padding > 0 and string.rep (" " , padding ) or " " )
293+ virt_text_hl = win_data .fmt_opts .hl_dir
294+ else
295+ -- dirname_first: split at last separator for different highlights
296+ local dir_str = shortened_path :sub (1 , shortened_last_sep_byte )
297+ local file_str = shortened_path :sub (shortened_last_sep_byte + 1 )
298+ virt_text_str = dir_str .. file_str .. (padding > 0 and string.rep (" " , padding ) or " " )
299+ -- Apply dir highlight to the whole string - file part will get its coloring
300+ -- from the underlying ANSI codes via hl_mode="combine"
301+ virt_text_hl = win_data .fmt_opts .hl_dir
302+ end
303+ else
304+ -- No formatter: plain text with padding
305+ virt_text_str = shortened_path .. (padding > 0 and string.rep (" " , padding ) or " " )
306+ virt_text_hl = nil
307+ end
308+
309+ -- Overlay the shortened+padded path on top of the original
310+ pcall (api .nvim_buf_set_extmark , buf , PathShortener ._ns , row , path_start - 1 , {
311+ virt_text = { { virt_text_str , virt_text_hl } },
312+ virt_text_pos = " overlay" ,
313+ hl_mode = " combine" ,
314+ ephemeral = true ,
315+ })
139316end
140317
141318--- Register a window for path shortening
142319--- @param winid integer window ID
143320--- @param bufnr integer buffer number
144321--- @param shorten_len integer | boolean number of characters to keep
145- function PathShortener .attach (winid , bufnr , shorten_len )
322+ --- @param fmt_opts ? { hl_dir ?: string , hl_file ?: string , formatter ?: string }
323+ function PathShortener .attach (winid , bufnr , shorten_len , fmt_opts )
146324 if not winid or not bufnr then return end
147325 PathShortener .setup ()
148326 shorten_len = (shorten_len == true ) and 1 or tonumber (shorten_len ) or 1
149- PathShortener ._wins [winid ] = { bufnr = bufnr , shorten_len = shorten_len }
327+ PathShortener ._wins [winid ] = {
328+ bufnr = bufnr ,
329+ shorten_len = shorten_len ,
330+ fmt_opts = fmt_opts ,
331+ }
150332end
151333
152334--- Unregister a window from path shortening
@@ -927,22 +1109,18 @@ end
9271109
9281110function FzfWin :path_shorten_detach ()
9291111 PathShortener .detach (self .fzf_winid )
930- -- Reset conceallevel when path shortening is disabled (e.g., on window reuse)
931- if api .nvim_win_is_valid (self .fzf_winid ) then
932- vim .wo [self .fzf_winid ].conceallevel = 0
933- vim .wo [self .fzf_winid ].concealcursor = " "
934- end
9351112end
9361113
9371114function FzfWin :path_shorten_attach ()
9381115 local path_shorten = self ._o .winopts .path_shorten
9391116 if not path_shorten then return end
940- -- Enable conceallevel for the fzf window to show concealed text
941- if api .nvim_win_is_valid (self .fzf_winid ) then
942- vim .wo [self .fzf_winid ].conceallevel = 2
943- vim .wo [self .fzf_winid ].concealcursor = " nvic"
944- end
945- PathShortener .attach (self .fzf_winid , self .fzf_bufnr , path_shorten )
1117+ PathShortener .attach (self .fzf_winid , self .fzf_bufnr , path_shorten , {
1118+ hl_dir = self ._o .hls and self ._o .hls .dir_part ,
1119+ hl_file = self ._o .hls and self ._o .hls .file_part ,
1120+ formatter = self ._o .formatter ,
1121+ fmt_to = self ._o ._fmt and self ._o ._fmt .to ,
1122+ opts = self ._o ,
1123+ })
9461124end
9471125
9481126--- @param buf integer
0 commit comments