@@ -114,175 +114,162 @@ end
114114
115115--- @class fzf-lua.PathShortener
116116--- @field _ns integer ?
117- --- @field _attached table<integer , boolean >
117+ --- @field _wins table<integer , { bufnr : integer , shorten_len : integer } >
118118local PathShortener = {}
119119
120- PathShortener ._attached = {}
120+ PathShortener ._wins = {}
121121
122122-- Hoist constant byte values for performance
123123local DOT_BYTE = path .dot_byte
124124
125125function PathShortener .setup ()
126- PathShortener ._ns = PathShortener ._ns or api .nvim_create_namespace (" fzf-lua.win.path_shorten" )
127- end
128-
129- function PathShortener .clear (buf )
130- if not buf or not PathShortener ._ns then return end
131- PathShortener ._attached [buf ] = nil
132- if api .nvim_buf_is_valid (buf ) then
133- api .nvim_buf_clear_namespace (buf , PathShortener ._ns , 0 , - 1 )
134- end
126+ if PathShortener ._ns then return end
127+ PathShortener ._ns = api .nvim_create_namespace (" fzf-lua.win.path_shorten" )
128+
129+ -- Register decoration provider with ephemeral extmarks
130+ api .nvim_set_decoration_provider (PathShortener ._ns , {
131+ on_win = function (_ , winid , bufnr , _topline , _botline )
132+ -- Only process registered fzf windows
133+ local win_data = PathShortener ._wins [winid ]
134+ if not win_data or win_data .bufnr ~= bufnr then
135+ return false
136+ end
137+ return true -- Continue to on_line callbacks
138+ end ,
139+ on_line = function (_ , winid , bufnr , row )
140+ local win_data = PathShortener ._wins [winid ]
141+ if not win_data then return end
142+ PathShortener ._apply_line (bufnr , row , win_data .shorten_len )
143+ end ,
144+ })
135145end
136146
137- --- Apply path shortening using extmarks with conceal
147+ --- Apply path shortening to a single line using ephemeral extmarks
138148--- @param buf integer buffer number
139- --- @param lines string[] buffer lines
140- --- @param shorten_len integer number of characters to keep (default 1 )
141- --- @param start_lnum integer ? 0-indexed starting line number for incremental updates
142- function PathShortener .apply (buf , lines , shorten_len , start_lnum )
143- if not buf or not api .nvim_buf_is_valid (buf ) then return end
144- if not PathShortener ._ns then PathShortener .setup () end
145- shorten_len = shorten_len and tonumber (shorten_len ) or 1
146- if shorten_len < 1 then shorten_len = 1 end
147-
148- -- Clear extmarks for the affected range only (incremental) or all (full refresh)
149- if start_lnum then
150- api .nvim_buf_clear_namespace (buf , PathShortener ._ns , start_lnum , start_lnum + # lines )
149+ --- @param row integer 0-indexed line number
150+ --- @param shorten_len integer number of characters to keep
151+ function PathShortener ._apply_line (buf , row , shorten_len )
152+ local lines = api .nvim_buf_get_lines (buf , row , row + 1 , false )
153+ local line = lines [1 ]
154+ if not line or # line == 0 then return end
155+
156+ -- Find the path portion of the line
157+ -- Lines may have prefixes like icons separated by nbsp (U+2002)
158+ -- Format: [fzf_pointer] [git_icon nbsp] [file_icon nbsp] path[:line:col:text]
159+ -- When file_icons=false, there's no nbsp separator before the path
160+ -- The fzf terminal also adds pointer/marker prefix (e.g., "> " or " ")
161+ local path_start = 1
162+ local last_nbsp = line :find (utils .nbsp , 1 , true )
163+ if last_nbsp then
164+ -- Find the last nbsp (there may be multiple with git_icons + file_icons)
165+ repeat
166+ path_start = last_nbsp + # utils .nbsp
167+ last_nbsp = line :find (utils .nbsp , path_start , true )
168+ until not last_nbsp
151169 else
152- api .nvim_buf_clear_namespace (buf , PathShortener ._ns , 0 , - 1 )
153- end
154-
155- for idx , line in ipairs (lines ) do
156- -- Calculate 0-indexed line number: for incremental updates use start_lnum offset
157- local lnum0 = (start_lnum or 0 ) + idx - 1
158-
159- -- Only process non-empty lines
160- if # line > 0 then
161- -- Find the path portion of the line
162- -- Lines may have prefixes like icons separated by nbsp (U+2002)
163- -- Format: [fzf_pointer] [git_icon nbsp] [file_icon nbsp] path[:line:col:text]
164- -- When file_icons=false, there's no nbsp separator before the path
165- -- The fzf terminal also adds pointer/marker prefix (e.g., "> " or " ")
166- local path_start = 1
167- local last_nbsp = line :find (utils .nbsp , 1 , true )
168- if last_nbsp then
169- -- Find the last nbsp (there may be multiple with git_icons + file_icons)
170- repeat
171- path_start = last_nbsp + # utils .nbsp
172- last_nbsp = line :find (utils .nbsp , path_start , true )
173- until not last_nbsp
174- else
175- -- No nbsp means no icons - skip fzf's pointer/marker prefix
176- -- Paths start with: alphanumeric, `/`, `~`, `.`, or drive letter (Windows)
177- -- Skip leading whitespace and pointer characters until we hit a path char
178- local first_path_char = line :find (" [%w/~%.]" )
179- if first_path_char then
180- path_start = first_path_char
181- end
182- end
183-
184- -- Find where the path ends (at first colon after path_start, if any)
185- -- But be careful with Windows paths like C:\...
186- local path_end = # line
187- local colon_search_start = path_start
188- -- On Windows, skip the drive letter colon (e.g., C:)
189- -- Check if char at path_start+1 is colon AND char at path_start+2 is a path separator
190- if string.byte (line , path_start + 1 ) == path .colon_byte
191- and path .byte_is_separator (string.byte (line , path_start + 2 )) then
192- colon_search_start = path_start + 2
193- end
194- local colon_pos = line :find (" :" , colon_search_start )
195- if colon_pos then
196- path_end = colon_pos - 1
197- end
170+ -- No nbsp means no icons - skip fzf's pointer/marker prefix
171+ -- Paths start with: alphanumeric, `/`, `~`, `.`, or drive letter (Windows)
172+ -- Skip leading whitespace and pointer characters until we hit a path char
173+ local first_path_char = line :find (" [%w/~%.]" )
174+ if first_path_char then
175+ path_start = first_path_char
176+ end
177+ end
198178
199- -- Now process the path portion for shortening
200- -- We need to conceal directory components, keeping only `shorten_len` chars
201- local path_portion = line :sub (path_start , path_end )
202-
203- -- Use path.find_next_separator to iterate through directory components
204- local prev_sep = 0
205- local sep_pos = path .find_next_separator (path_portion , 1 )
206- while sep_pos do
207- local component_start = prev_sep + 1
208- local component = path_portion :sub (component_start , sep_pos - 1 )
209- local component_len = # component
210-
211- -- Count UTF-8 characters using path.utf8_char_len
212- local component_charlen = 0
213- local byte_i = 1
214- while byte_i <= component_len do
215- local c_len = path .utf8_char_len (component , byte_i ) or 1
216- component_charlen = component_charlen + 1
217- byte_i = byte_i + c_len
218- end
179+ -- Find where the path ends (at first colon after path_start, if any)
180+ -- But be careful with Windows paths like C:\...
181+ local path_end = # line
182+ local colon_search_start = path_start
183+ -- On Windows, skip the drive letter colon (e.g., C:)
184+ -- Check if char at path_start+1 is colon AND char at path_start+2 is a path separator
185+ if string.byte (line , path_start + 1 ) == path .colon_byte
186+ and path .byte_is_separator (string.byte (line , path_start + 2 )) then
187+ colon_search_start = path_start + 2
188+ end
189+ local colon_pos = line :find (" :" , colon_search_start )
190+ if colon_pos then
191+ path_end = colon_pos - 1
192+ end
193+
194+ -- Now process the path portion for shortening
195+ -- We need to conceal directory components, keeping only `shorten_len` chars
196+ local path_portion = line :sub (path_start , path_end )
197+
198+ -- Use path.find_next_separator to iterate through directory components
199+ local prev_sep = 0
200+ local sep_pos = path .find_next_separator (path_portion , 1 )
201+ while sep_pos do
202+ local component_start = prev_sep + 1
203+ local component = path_portion :sub (component_start , sep_pos - 1 )
204+ local component_len = # component
205+
206+ -- Count UTF-8 characters using path.utf8_char_len
207+ local component_charlen = 0
208+ local byte_i = 1
209+ while byte_i <= component_len do
210+ local c_len = path .utf8_char_len (component , byte_i ) or 1
211+ component_charlen = component_charlen + 1
212+ byte_i = byte_i + c_len
213+ end
219214
220- -- Only conceal if the component has more characters than shorten_len
221- if component_charlen > shorten_len then
222- -- Handle special case: component starts with '.' (hidden files/dirs)
223- local keep_chars = shorten_len
224- if string.byte (component , 1 ) == DOT_BYTE and component_charlen > shorten_len + 1 then
225- -- Keep the dot plus shorten_len characters
226- keep_chars = shorten_len + 1
227- end
215+ -- Only conceal if the component has more characters than shorten_len
216+ if component_charlen > shorten_len then
217+ -- Handle special case: component starts with '.' (hidden files/dirs)
218+ local keep_chars = shorten_len
219+ if string.byte (component , 1 ) == DOT_BYTE and component_charlen > shorten_len + 1 then
220+ -- Keep the dot plus shorten_len characters
221+ keep_chars = shorten_len + 1
222+ end
228223
229- -- Bounds check to prevent errors
230- keep_chars = math.min (keep_chars , component_charlen )
231-
232- -- Convert character count to byte offset using path.utf8_char_len
233- local keep_bytes = 0
234- local char_count = 0
235- byte_i = 1
236- while byte_i <= component_len and char_count < keep_chars do
237- local c_len = path .utf8_char_len (component , byte_i ) or 1
238- keep_bytes = keep_bytes + c_len
239- char_count = char_count + 1
240- byte_i = byte_i + c_len
241- end
224+ -- Bounds check to prevent errors
225+ keep_chars = math.min (keep_chars , component_charlen )
226+
227+ -- Convert character count to byte offset using path.utf8_char_len
228+ local keep_bytes = 0
229+ local char_count = 0
230+ byte_i = 1
231+ while byte_i <= component_len and char_count < keep_chars do
232+ local c_len = path .utf8_char_len (component , byte_i ) or 1
233+ keep_bytes = keep_bytes + c_len
234+ char_count = char_count + 1
235+ byte_i = byte_i + c_len
236+ end
242237
243- -- Calculate 0-indexed byte positions in the full line for extmark
244- -- path_start is 1-indexed, component_start is 1-indexed within path_portion
245- local line_offset = path_start - 1 + component_start - 1
246- local conceal_start = line_offset + keep_bytes
247- local conceal_end = line_offset + component_len -- end of component (before separator)
248-
249- if conceal_end > conceal_start then
250- pcall (api .nvim_buf_set_extmark , buf , PathShortener ._ns , lnum0 , conceal_start , {
251- end_col = conceal_end ,
252- conceal = " " ,
253- })
254- end
255- end
256- prev_sep = sep_pos
257- sep_pos = path .find_next_separator (path_portion , sep_pos + 1 )
238+ -- Calculate 0-indexed byte positions in the full line for extmark
239+ -- path_start is 1-indexed, component_start is 1-indexed within path_portion
240+ local line_offset = path_start - 1 + component_start - 1
241+ local conceal_start = line_offset + keep_bytes
242+ local conceal_end = line_offset + component_len -- end of component (before separator)
243+
244+ if conceal_end > conceal_start then
245+ pcall (api .nvim_buf_set_extmark , buf , PathShortener ._ns , row , conceal_start , {
246+ end_col = conceal_end ,
247+ conceal = " " ,
248+ ephemeral = true ,
249+ })
258250 end
259251 end
252+ prev_sep = sep_pos
253+ sep_pos = path .find_next_separator (path_portion , sep_pos + 1 )
260254 end
261255end
262256
263- --- Attach path shortening to buffer with on_lines callback
264- --- @param buf integer buffer number
257+ --- Register a window for path shortening
258+ --- @param winid integer window ID
259+ --- @param bufnr integer buffer number
265260--- @param shorten_len integer | boolean number of characters to keep
266- --- @param closing_ref fun (): boolean function to check if window is closing
267- function PathShortener .attach (buf , shorten_len , closing_ref )
268- if not buf or PathShortener ._attached [buf ] then return end
261+ function PathShortener .attach (winid , bufnr , shorten_len )
262+ if not winid or not bufnr then return end
269263 PathShortener .setup ()
270- PathShortener ._attached [buf ] = true
271-
272264 shorten_len = (shorten_len == true ) and 1 or tonumber (shorten_len ) or 1
265+ PathShortener ._wins [winid ] = { bufnr = bufnr , shorten_len = shorten_len }
266+ end
273267
274- api .nvim_buf_attach (buf , false , {
275- on_lines = function (_ , bufnr , _ , firstline , _ , new_lastline )
276- if closing_ref () then
277- PathShortener ._attached [bufnr ] = nil -- Clean up on detach
278- return true
279- end
280- if not PathShortener ._attached [bufnr ] then return true end -- Detach
281- -- Only fetch and process the changed lines (incremental update)
282- local lines = api .nvim_buf_get_lines (bufnr , firstline , new_lastline , false )
283- PathShortener .apply (bufnr , lines , shorten_len , firstline )
284- end
285- })
268+ --- Unregister a window from path shortening
269+ --- @param winid integer window ID
270+ function PathShortener .detach (winid )
271+ if not winid then return end
272+ PathShortener ._wins [winid ] = nil
286273end
287274
288275--- @alias fzf-lua.win.previewPos " up" | " down" | " left" | " right"
@@ -1301,8 +1288,8 @@ function FzfWin:treesitter_attach()
13011288 })
13021289end
13031290
1304- function FzfWin :path_shorten_detach (buf )
1305- PathShortener .clear ( buf )
1291+ function FzfWin :path_shorten_detach ()
1292+ PathShortener .detach ( self . fzf_winid )
13061293 -- Reset conceallevel when path shortening is disabled (e.g., on window reuse)
13071294 if api .nvim_win_is_valid (self .fzf_winid ) then
13081295 vim .wo [self .fzf_winid ].conceallevel = 0
@@ -1318,7 +1305,7 @@ function FzfWin:path_shorten_attach()
13181305 vim .wo [self .fzf_winid ].conceallevel = 2
13191306 vim .wo [self .fzf_winid ].concealcursor = " nvic"
13201307 end
1321- PathShortener .attach (self .fzf_bufnr , path_shorten , function () return self .closing end )
1308+ PathShortener .attach (self .fzf_winid , self .fzf_bufnr , path_shorten )
13221309end
13231310
13241311function FzfWin :set_tmp_buffer (no_wipe )
@@ -1339,7 +1326,10 @@ function FzfWin:set_tmp_buffer(no_wipe)
13391326 if not no_wipe then
13401327 utils .nvim_buf_delete (detached , { force = true })
13411328 TSInjector .clear_cache (detached )
1342- PathShortener .clear (detached )
1329+ -- Re-attach path shortening to the new buffer if needed
1330+ if self ._o .winopts .path_shorten then
1331+ PathShortener .attach (self .fzf_winid , self .fzf_bufnr , self ._o .winopts .path_shorten )
1332+ end
13431333 end
13441334 -- in case buffer exists prematurely
13451335 self :set_winleave_autocmd ()
@@ -1407,7 +1397,7 @@ function FzfWin:create()
14071397 if self ._o .winopts .path_shorten then
14081398 self :path_shorten_attach ()
14091399 else
1410- self :path_shorten_detach (self . fzf_bufnr )
1400+ self :path_shorten_detach ()
14111401 end
14121402 -- also recall the user's 'on_create' (#394)
14131403 if self .winopts .on_create and
@@ -1535,8 +1525,8 @@ function FzfWin:close(fzf_bufnr, hide, hidden)
15351525 end
15361526 -- Clear treesitter buffer cache and deregister decoration callbacks
15371527 self :treesitter_detach (self ._hidden_fzf_bufnr or self .fzf_bufnr )
1538- -- Clear path shortening extmarks and detach callback
1539- PathShortener .clear (self ._hidden_fzf_bufnr or self . fzf_bufnr )
1528+ -- Detach path shortening decoration provider
1529+ PathShortener .detach (self .fzf_winid )
15401530 -- If this is a hidden buffer closure nothing else to do
15411531 if hidden then return end
15421532 if self .fzf_winid and api .nvim_win_is_valid (self .fzf_winid ) then
0 commit comments