@@ -7,20 +7,27 @@ local api = vim.api
77local fn = vim .fn
88
99--- @class fzf-lua.PathShortener
10- --- @field _ns integer ?
11- --- @field _wins table<integer , { bufnr : integer , shorten_len : integer } >
10+ --- @field _ns integer ? namespace for ephemeral conceal extmarks
11+ --- @field _ns_padding integer ? namespace for persistent inline VT padding
12+ --- @field _wins table<integer , fzf-lua.PathShortener.WinData>
13+ --- @field _bufs table<integer , boolean> buffers with active on_lines attachment
1214local PathShortener = {}
1315
1416PathShortener ._wins = {}
17+ PathShortener ._bufs = {}
1518
1619-- Hoist constant byte values for performance
1720local DOT_BYTE = path .dot_byte
1821
1922function PathShortener .setup ()
2023 if PathShortener ._ns then return end
2124 PathShortener ._ns = api .nvim_create_namespace (" fzf-lua.win.path_shorten" )
25+ PathShortener ._ns_padding = api .nvim_create_namespace (" fzf-lua.win.path_shorten.padding" )
2226
23- -- Register decoration provider with ephemeral extmarks
27+ -- Register decoration provider with ephemeral conceal extmarks.
28+ -- Conceal preserves all terminal highlights (fzf match highlighting,
29+ -- cursor line bg, formatter ANSI colors) unlike overlay VT which
30+ -- replaces the underlying terminal cell content entirely.
2431 api .nvim_set_decoration_provider (PathShortener ._ns , {
2532 on_win = function (_ , winid , bufnr , _topline , _botline )
2633 -- Only process registered fzf windows
@@ -33,25 +40,24 @@ function PathShortener.setup()
3340 on_line = function (_ , winid , bufnr , row )
3441 local win_data = PathShortener ._wins [winid ]
3542 if not win_data then return end
36- PathShortener ._apply_line (bufnr , row , win_data .shorten_len )
43+ PathShortener ._apply_conceal (bufnr , row , win_data .shorten_len )
3744 end ,
3845 })
3946end
4047
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
48+ --- Parse a terminal buffer line and return conceal/padding information.
49+ --- Identifies directory components in the path that can be shortened and
50+ --- calculates both the conceal byte ranges and the total display width
51+ --- that will be lost to concealing (for compensating inline VT padding).
52+ --- @param line string the terminal buffer line text
53+ --- @param shorten_len integer number of characters to keep per component
54+ --- @return table ? result with conceal_ranges , total_concealed_width , padding_col
55+ function PathShortener ._parse_line (line , shorten_len )
56+ if # line == 0 then return nil end
4957
5058 -- Find the path portion of the line
5159 -- Lines may have prefixes like icons separated by nbsp (U+2002)
5260 -- 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 " ")
5561 local path_start = 1
5662 local last_nbsp = line :find (utils .nbsp , 1 , true )
5763 if last_nbsp then
@@ -61,21 +67,27 @@ function PathShortener._apply_line(buf, row, shorten_len)
6167 last_nbsp = line :find (utils .nbsp , path_start , true )
6268 until not last_nbsp
6369 else
64- -- No nbsp means no icons - skip fzf's pointer/marker prefix
65- -- Paths start with: alphanumeric, `/`, `~`, `.`, or drive letter (Windows)
70+ -- No nbsp: either file_icons=false entry or a non-entry line (prompt/header)
71+ -- Filter out fzf's prompt/info line which contains the match counter
72+ if line :find (" %d+/%d+" ) then return nil end
6673 -- Skip leading whitespace and pointer characters until we hit a path char
6774 local first_path_char = line :find (" [%w/~%.]" )
68- if first_path_char then
69- path_start = first_path_char
70- end
75+ if not first_path_char then return nil end
76+ path_start = first_path_char
77+ -- Reject lines where space appears before the first path separator
78+ -- This filters out header lines like "cwd: /path/to/dir"
79+ local sub = line :sub (path_start )
80+ local first_sep = path .find_next_separator (sub , 1 )
81+ if not first_sep then return nil end
82+ local first_space = sub :find (" " )
83+ if first_space and first_space < first_sep then return nil end
7184 end
7285
7386 -- Find where the path ends (at first colon after path_start, if any)
7487 -- But be careful with Windows paths like C:\...
7588 local path_end = # line
7689 local colon_search_start = path_start
7790 -- 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
7991 if string.byte (line , path_start + 1 ) == path .colon_byte
8092 and path .byte_is_separator (string.byte (line , path_start + 2 )) then
8193 colon_search_start = path_start + 2
@@ -85,57 +97,130 @@ function PathShortener._apply_line(buf, row, shorten_len)
8597 path_end = colon_pos - 1
8698 end
8799
88- -- Now process the path portion for shortening
89- -- We need to conceal directory components, keeping only `shorten_len` chars
90100 local path_portion = line :sub (path_start , path_end )
91101
92- -- Use path.find_next_separator to iterate through directory components
102+ -- Iterate directory components and calculate conceal ranges
103+ local conceal_ranges = {}
104+ local total_concealed_width = 0
93105 local prev_sep = 0
94106 local sep_pos = path .find_next_separator (path_portion , 1 )
107+
95108 while sep_pos do
96109 local component_start = prev_sep + 1
97110 local component = path_portion :sub (component_start , sep_pos - 1 )
111+
112+ -- Stop processing if component contains whitespace - this means we've
113+ -- passed the actual path into fzf UI text (e.g., trailing spaces before border)
114+ if component :find (" %s" ) then
115+ break
116+ end
117+
98118 local component_len = # component
99119
100120 -- 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.
103121 local _ , component_charlen = vim .str_utfindex (component , " utf-32" )
104- component_charlen = component_charlen or component_len -- fallback to byte length
122+ component_charlen = component_charlen or component_len
105123
106- -- Only conceal if the component has more characters than shorten_len
107124 if component_charlen > shorten_len then
108125 -- Handle special case: component starts with '.' (hidden files/dirs)
109126 local keep_chars = shorten_len
110127 if string.byte (component , 1 ) == DOT_BYTE and component_charlen > shorten_len + 1 then
111- -- Keep the dot plus shorten_len characters
112128 keep_chars = shorten_len + 1
113129 end
114-
115- -- Bounds check to prevent errors
116130 keep_chars = math.min (keep_chars , component_charlen )
117131
118- -- Convert character count to byte offset using vim.str_byteindex
119132 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)
133+ keep_bytes = keep_bytes or keep_chars
121134
122135 -- 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
124136 local line_offset = path_start - 1 + component_start - 1
125137 local conceal_start = line_offset + keep_bytes
126- local conceal_end = line_offset + component_len -- end of component (before separator)
138+ local conceal_end = line_offset + component_len
127139
128140 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- })
141+ conceal_ranges [# conceal_ranges + 1 ] = { conceal_start , conceal_end }
142+ local concealed_text = component :sub (keep_bytes + 1 )
143+ total_concealed_width = total_concealed_width + fn .strdisplaywidth (concealed_text )
134144 end
135145 end
146+
136147 prev_sep = sep_pos
137148 sep_pos = path .find_next_separator (path_portion , sep_pos + 1 )
138149 end
150+
151+ if # conceal_ranges == 0 then return nil end
152+
153+ -- Determine padding placement for inline VT that compensates concealed width.
154+ -- Search for fzf's border/scrollbar character │ (U+2502) in the line.
155+ -- For native fzf previewer (bat), this is the border between list and preview panes.
156+ -- For builtin previewer, this is the scrollbar at the end of some lines.
157+ -- Place padding right before the border to push it back to its original
158+ -- display column, compensating for the concealed width.
159+ local padding_col
160+ local border_char = " \xe2\x94\x82 " -- │ U+2502 BOX DRAWINGS LIGHT VERTICAL
161+ local border_pos = line :find (border_char , path_start , true )
162+ if border_pos then
163+ padding_col = border_pos - 1 -- 0-indexed, right before │
164+ else
165+ padding_col = # line -- 0-indexed, after last byte
166+ end
167+
168+ return {
169+ conceal_ranges = conceal_ranges ,
170+ total_concealed_width = total_concealed_width ,
171+ padding_col = padding_col ,
172+ }
173+ end
174+
175+ --- Apply ephemeral conceal extmarks for a single line (called from decoration provider).
176+ --- Concealing preserves all terminal highlights on non-concealed characters.
177+ --- @param buf integer buffer number
178+ --- @param row integer 0-indexed line number
179+ --- @param shorten_len integer number of characters to keep
180+ function PathShortener ._apply_conceal (buf , row , shorten_len )
181+ local lines = api .nvim_buf_get_lines (buf , row , row + 1 , false )
182+ local line = lines [1 ]
183+ if not line or # line == 0 then return end
184+
185+ local result = PathShortener ._parse_line (line , shorten_len )
186+ if not result then return end
187+
188+ for _ , range in ipairs (result .conceal_ranges ) do
189+ pcall (api .nvim_buf_set_extmark , buf , PathShortener ._ns , row , range [1 ], {
190+ end_col = range [2 ],
191+ conceal = " " ,
192+ ephemeral = true ,
193+ })
194+ end
195+ end
196+
197+ --- Update non-ephemeral inline VT padding for changed lines.
198+ --- Called from nvim_buf_attach on_lines callback (outside rendering phase),
199+ --- which is safe for setting non-ephemeral extmarks. Ephemeral inline VT
200+ --- does not render in terminal buffers, so we must use non-ephemeral.
201+ --- The padding compensates for the display width lost to concealing,
202+ --- preventing fzf's scrollbar and preview border from shifting left.
203+ --- @param buf integer buffer number
204+ --- @param firstline integer first changed line (0-indexed )
205+ --- @param new_lastline integer end of changed range (0-indexed , exclusive )
206+ --- @param shorten_len integer number of characters to keep
207+ function PathShortener ._update_padding (buf , firstline , new_lastline , shorten_len )
208+ -- Clear existing padding extmarks in the changed range
209+ pcall (api .nvim_buf_clear_namespace , buf , PathShortener ._ns_padding , firstline , new_lastline )
210+
211+ local lines = api .nvim_buf_get_lines (buf , firstline , new_lastline , false )
212+ for i , line in ipairs (lines ) do
213+ if line and # line > 0 then
214+ local result = PathShortener ._parse_line (line , shorten_len )
215+ if result and result .total_concealed_width > 0 then
216+ local row = firstline + i - 1
217+ pcall (api .nvim_buf_set_extmark , buf , PathShortener ._ns_padding , row , result .padding_col , {
218+ virt_text = { { string.rep (" " , result .total_concealed_width ) } },
219+ virt_text_pos = " inline" ,
220+ })
221+ end
222+ end
223+ end
139224end
140225
141226--- Register a window for path shortening
@@ -146,14 +231,55 @@ function PathShortener.attach(winid, bufnr, shorten_len)
146231 if not winid or not bufnr then return end
147232 PathShortener .setup ()
148233 shorten_len = (shorten_len == true ) and 1 or tonumber (shorten_len ) or 1
149- PathShortener ._wins [winid ] = { bufnr = bufnr , shorten_len = shorten_len }
234+ PathShortener ._wins [winid ] = {
235+ bufnr = bufnr ,
236+ shorten_len = shorten_len ,
237+ }
238+
239+ -- Attach to buffer for content changes (to manage non-ephemeral padding extmarks).
240+ -- on_lines fires outside the rendering phase, making it safe to set
241+ -- non-ephemeral extmarks without causing infinite redraw loops.
242+ if not PathShortener ._bufs [bufnr ] then
243+ PathShortener ._bufs [bufnr ] = true
244+ api .nvim_buf_attach (bufnr , false , {
245+ on_lines = function (_ , buf , _ , firstline , _lastline , new_lastline )
246+ -- Find shorten_len for this buffer
247+ local sl
248+ for _ , wd in pairs (PathShortener ._wins ) do
249+ if wd .bufnr == buf then
250+ sl = wd .shorten_len
251+ break
252+ end
253+ end
254+ if not sl then
255+ PathShortener ._bufs [buf ] = nil
256+ return true -- detach
257+ end
258+ -- Defer padding update to the next event loop tick.
259+ -- Setting non-ephemeral extmarks directly within on_lines during
260+ -- terminal I/O can cause the terminal grid to miscalculate line
261+ -- widths, breaking the floating window layout. vim.schedule moves
262+ -- the extmark update out of the terminal I/O handler.
263+ vim .schedule (function ()
264+ if not api .nvim_buf_is_valid (buf ) then return end
265+ PathShortener ._update_padding (buf , firstline , new_lastline , sl )
266+ end )
267+ end ,
268+ })
269+ end
150270end
151271
152272--- Unregister a window from path shortening
153273--- @param winid integer window ID
154274function PathShortener .detach (winid )
155275 if not winid then return end
276+ local win_data = PathShortener ._wins [winid ]
156277 PathShortener ._wins [winid ] = nil
278+ -- Clear padding extmarks and mark buffer for detach
279+ if win_data and win_data .bufnr then
280+ pcall (api .nvim_buf_clear_namespace , win_data .bufnr , PathShortener ._ns_padding , 0 , - 1 )
281+ PathShortener ._bufs [win_data .bufnr ] = nil
282+ end
157283end
158284
159285--- @alias fzf-lua.win.previewPos " up" | " down" | " left" | " right"
0 commit comments