Skip to content

Commit cffe957

Browse files
committed
feat(path_shorten): use ephemeral extmarks
1 parent e4b5075 commit cffe957

File tree

1 file changed

+142
-152
lines changed

1 file changed

+142
-152
lines changed

lua/fzf-lua/win.lua

Lines changed: 142 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -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 }>
118118
local PathShortener = {}
119119

120-
PathShortener._attached = {}
120+
PathShortener._wins = {}
121121

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

125125
function 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+
})
135145
end
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
261255
end
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
286273
end
287274

288275
---@alias fzf-lua.win.previewPos "up"|"down"|"left"|"right"
@@ -1301,8 +1288,8 @@ function FzfWin:treesitter_attach()
13011288
})
13021289
end
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)
13221309
end
13231310

13241311
function 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

Comments
 (0)