|
1 | 1 | local utils = require "fzf-lua.utils" |
2 | 2 | local libuv = require "fzf-lua.libuv" |
3 | 3 | local actions = require "fzf-lua.actions" |
| 4 | +local path = require "fzf-lua.path" |
4 | 5 |
|
5 | 6 | local api = vim.api |
6 | 7 | local fn = vim.fn |
7 | 8 |
|
| 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 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. |
| 103 | + local _, component_charlen = vim.str_utfindex(component, "utf-32") |
| 104 | + component_charlen = component_charlen or component_len -- fallback to byte length |
| 105 | + |
| 106 | + -- Only conceal if the component has more characters than shorten_len |
| 107 | + if component_charlen > shorten_len then |
| 108 | + -- Handle special case: component starts with '.' (hidden files/dirs) |
| 109 | + local keep_chars = shorten_len |
| 110 | + if string.byte(component, 1) == DOT_BYTE and component_charlen > shorten_len + 1 then |
| 111 | + -- Keep the dot plus shorten_len characters |
| 112 | + keep_chars = shorten_len + 1 |
| 113 | + end |
| 114 | + |
| 115 | + -- Bounds check to prevent errors |
| 116 | + keep_chars = math.min(keep_chars, component_charlen) |
| 117 | + |
| 118 | + -- Convert character count to byte offset using vim.str_byteindex |
| 119 | + 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 |
| 135 | + end |
| 136 | + prev_sep = sep_pos |
| 137 | + sep_pos = path.find_next_separator(path_portion, sep_pos + 1) |
| 138 | + end |
| 139 | +end |
| 140 | + |
| 141 | +---Register a window for path shortening |
| 142 | +---@param winid integer window ID |
| 143 | +---@param bufnr integer buffer number |
| 144 | +---@param shorten_len integer|boolean number of characters to keep |
| 145 | +function PathShortener.attach(winid, bufnr, shorten_len) |
| 146 | + if not winid or not bufnr then return end |
| 147 | + PathShortener.setup() |
| 148 | + shorten_len = (shorten_len == true) and 1 or tonumber(shorten_len) or 1 |
| 149 | + PathShortener._wins[winid] = { bufnr = bufnr, shorten_len = shorten_len } |
| 150 | +end |
| 151 | + |
| 152 | +---Unregister a window from path shortening |
| 153 | +---@param winid integer window ID |
| 154 | +function PathShortener.detach(winid) |
| 155 | + if not winid then return end |
| 156 | + PathShortener._wins[winid] = nil |
| 157 | +end |
| 158 | + |
8 | 159 | ---@alias fzf-lua.win.previewPos "up"|"down"|"left"|"right" |
9 | 160 | ---@alias fzf-lua.win.previewLayout { pos: fzf-lua.win.previewPos, size: number, str: string } |
10 | 161 |
|
@@ -773,6 +924,26 @@ function FzfWin:treesitter_attach() |
773 | 924 | self.on_closes.tsinjector = self.tsinjector.attach(self, self.fzf_bufnr, self._o._treesitter) |
774 | 925 | end |
775 | 926 |
|
| 927 | +function FzfWin:path_shorten_detach() |
| 928 | + PathShortener.detach(self.fzf_winid) |
| 929 | + -- Reset conceallevel when path shortening is disabled (e.g., on window reuse) |
| 930 | + if api.nvim_win_is_valid(self.fzf_winid) then |
| 931 | + vim.wo[self.fzf_winid].conceallevel = 0 |
| 932 | + vim.wo[self.fzf_winid].concealcursor = "" |
| 933 | + end |
| 934 | +end |
| 935 | + |
| 936 | +function FzfWin:path_shorten_attach() |
| 937 | + local path_shorten = self._o.winopts.path_shorten |
| 938 | + if not path_shorten then return end |
| 939 | + -- Enable conceallevel for the fzf window to show concealed text |
| 940 | + if api.nvim_win_is_valid(self.fzf_winid) then |
| 941 | + vim.wo[self.fzf_winid].conceallevel = 2 |
| 942 | + vim.wo[self.fzf_winid].concealcursor = "nvic" |
| 943 | + end |
| 944 | + PathShortener.attach(self.fzf_winid, self.fzf_bufnr, path_shorten) |
| 945 | +end |
| 946 | + |
776 | 947 | ---@param buf integer |
777 | 948 | function FzfWin:close_buf(buf) |
778 | 949 | utils.nvim_buf_delete(buf, { force = true }) |
@@ -850,6 +1021,12 @@ function FzfWin:create() |
850 | 1021 | self:setup_autocmds() |
851 | 1022 | self:setup_keybinds() |
852 | 1023 | self:treesitter_attach() |
| 1024 | + -- attach/detach path shortening |
| 1025 | + if self._o.winopts.path_shorten then |
| 1026 | + self:path_shorten_attach() |
| 1027 | + else |
| 1028 | + self:path_shorten_detach() |
| 1029 | + end |
853 | 1030 | self:reset_winhl(self.fzf_winid) |
854 | 1031 | -- also recall the user's 'on_create' (#394) |
855 | 1032 | if type(self.winopts.on_create) == "function" then |
@@ -931,6 +1108,8 @@ function FzfWin:create() |
931 | 1108 | self:setup_autocmds() |
932 | 1109 | self:setup_keybinds() |
933 | 1110 | self:treesitter_attach() |
| 1111 | + -- Use extmarks to visually shorten paths while keeping full paths for actions |
| 1112 | + self:path_shorten_attach() |
934 | 1113 | self:reset_winhl(self.fzf_winid) |
935 | 1114 |
|
936 | 1115 | -- potential workarond for `<C-c>` freezing neovim (#1091) |
@@ -1007,6 +1186,8 @@ function FzfWin:close(fzf_bufnr, hide) |
1007 | 1186 | if not hide and self.fzf_bufnr then |
1008 | 1187 | self:close_buf(self.fzf_bufnr) |
1009 | 1188 | end |
| 1189 | + -- Detach path shortening decoration provider |
| 1190 | + PathShortener.detach(self.fzf_winid) |
1010 | 1191 | for k, _ in pairs(self.on_closes) do |
1011 | 1192 | self.on_closes[k](hide) |
1012 | 1193 | self.on_closes[k] = nil |
|
0 commit comments