Skip to content

Commit e22f2f9

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

File tree

3 files changed

+197
-4
lines changed

3 files changed

+197
-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: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,171 @@
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 path.utf8_char_len
101+
local component_charlen = 0
102+
local byte_i = 1
103+
while byte_i <= component_len do
104+
local c_len = path.utf8_char_len(component, byte_i) or 1
105+
component_charlen = component_charlen + 1
106+
byte_i = byte_i + c_len
107+
end
108+
109+
-- Only conceal if the component has more characters than shorten_len
110+
if component_charlen > shorten_len then
111+
-- Handle special case: component starts with '.' (hidden files/dirs)
112+
local keep_chars = shorten_len
113+
if string.byte(component, 1) == DOT_BYTE and component_charlen > shorten_len + 1 then
114+
-- Keep the dot plus shorten_len characters
115+
keep_chars = shorten_len + 1
116+
end
117+
118+
-- Bounds check to prevent errors
119+
keep_chars = math.min(keep_chars, component_charlen)
120+
121+
-- Convert character count to byte offset using path.utf8_char_len
122+
local keep_bytes = 0
123+
local char_count = 0
124+
byte_i = 1
125+
while byte_i <= component_len and char_count < keep_chars do
126+
local c_len = path.utf8_char_len(component, byte_i) or 1
127+
keep_bytes = keep_bytes + c_len
128+
char_count = char_count + 1
129+
byte_i = byte_i + c_len
130+
end
131+
132+
-- Calculate 0-indexed byte positions in the full line for extmark
133+
-- path_start is 1-indexed, component_start is 1-indexed within path_portion
134+
local line_offset = path_start - 1 + component_start - 1
135+
local conceal_start = line_offset + keep_bytes
136+
local conceal_end = line_offset + component_len -- end of component (before separator)
137+
138+
if conceal_end > conceal_start then
139+
pcall(api.nvim_buf_set_extmark, buf, PathShortener._ns, row, conceal_start, {
140+
end_col = conceal_end,
141+
conceal = "",
142+
ephemeral = true,
143+
})
144+
end
145+
end
146+
prev_sep = sep_pos
147+
sep_pos = path.find_next_separator(path_portion, sep_pos + 1)
148+
end
149+
end
150+
151+
---Register a window for path shortening
152+
---@param winid integer window ID
153+
---@param bufnr integer buffer number
154+
---@param shorten_len integer|boolean number of characters to keep
155+
function PathShortener.attach(winid, bufnr, shorten_len)
156+
if not winid or not bufnr then return end
157+
PathShortener.setup()
158+
shorten_len = (shorten_len == true) and 1 or tonumber(shorten_len) or 1
159+
PathShortener._wins[winid] = { bufnr = bufnr, shorten_len = shorten_len }
160+
end
161+
162+
---Unregister a window from path shortening
163+
---@param winid integer window ID
164+
function PathShortener.detach(winid)
165+
if not winid then return end
166+
PathShortener._wins[winid] = nil
167+
end
168+
8169
---@alias fzf-lua.win.previewPos "up"|"down"|"left"|"right"
9170
---@alias fzf-lua.win.previewLayout { pos: fzf-lua.win.previewPos, size: number, str: string }
10171

@@ -773,6 +934,26 @@ function FzfWin:treesitter_attach()
773934
self.on_closes.tsinjector = self.tsinjector.attach(self, self.fzf_bufnr, self._o._treesitter)
774935
end
775936

937+
function FzfWin:path_shorten_detach()
938+
PathShortener.detach(self.fzf_winid)
939+
-- Reset conceallevel when path shortening is disabled (e.g., on window reuse)
940+
if api.nvim_win_is_valid(self.fzf_winid) then
941+
vim.wo[self.fzf_winid].conceallevel = 0
942+
vim.wo[self.fzf_winid].concealcursor = ""
943+
end
944+
end
945+
946+
function FzfWin:path_shorten_attach()
947+
local path_shorten = self._o.winopts.path_shorten
948+
if not path_shorten then return end
949+
-- Enable conceallevel for the fzf window to show concealed text
950+
if api.nvim_win_is_valid(self.fzf_winid) then
951+
vim.wo[self.fzf_winid].conceallevel = 2
952+
vim.wo[self.fzf_winid].concealcursor = "nvic"
953+
end
954+
PathShortener.attach(self.fzf_winid, self.fzf_bufnr, path_shorten)
955+
end
956+
776957
---@param buf integer
777958
function FzfWin:close_buf(buf)
778959
utils.nvim_buf_delete(buf, { force = true })
@@ -850,6 +1031,12 @@ function FzfWin:create()
8501031
self:setup_autocmds()
8511032
self:setup_keybinds()
8521033
self:treesitter_attach()
1034+
-- attach/detach path shortening
1035+
if self._o.winopts.path_shorten then
1036+
self:path_shorten_attach()
1037+
else
1038+
self:path_shorten_detach()
1039+
end
8531040
self:reset_winhl(self.fzf_winid)
8541041
-- also recall the user's 'on_create' (#394)
8551042
if type(self.winopts.on_create) == "function" then
@@ -931,6 +1118,8 @@ function FzfWin:create()
9311118
self:setup_autocmds()
9321119
self:setup_keybinds()
9331120
self:treesitter_attach()
1121+
-- Use extmarks to visually shorten paths while keeping full paths for actions
1122+
self:path_shorten_attach()
9341123
self:reset_winhl(self.fzf_winid)
9351124

9361125
-- potential workarond for `<C-c>` freezing neovim (#1091)
@@ -1007,6 +1196,8 @@ function FzfWin:close(fzf_bufnr, hide)
10071196
if not hide and self.fzf_bufnr then
10081197
self:close_buf(self.fzf_bufnr)
10091198
end
1199+
-- Detach path shortening decoration provider
1200+
PathShortener.detach(self.fzf_winid)
10101201
for k, _ in pairs(self.on_closes) do
10111202
self.on_closes[k](hide)
10121203
self.on_closes[k] = nil

0 commit comments

Comments
 (0)