|
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 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 | + |
8 | 169 | ---@alias fzf-lua.win.previewPos "up"|"down"|"left"|"right" |
9 | 170 | ---@alias fzf-lua.win.previewLayout { pos: fzf-lua.win.previewPos, size: number, str: string } |
10 | 171 |
|
@@ -773,6 +934,26 @@ function FzfWin:treesitter_attach() |
773 | 934 | self.on_closes.tsinjector = self.tsinjector.attach(self, self.fzf_bufnr, self._o._treesitter) |
774 | 935 | end |
775 | 936 |
|
| 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 | + |
776 | 957 | ---@param buf integer |
777 | 958 | function FzfWin:close_buf(buf) |
778 | 959 | utils.nvim_buf_delete(buf, { force = true }) |
@@ -850,6 +1031,12 @@ function FzfWin:create() |
850 | 1031 | self:setup_autocmds() |
851 | 1032 | self:setup_keybinds() |
852 | 1033 | 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 |
853 | 1040 | self:reset_winhl(self.fzf_winid) |
854 | 1041 | -- also recall the user's 'on_create' (#394) |
855 | 1042 | if type(self.winopts.on_create) == "function" then |
@@ -931,6 +1118,8 @@ function FzfWin:create() |
931 | 1118 | self:setup_autocmds() |
932 | 1119 | self:setup_keybinds() |
933 | 1120 | self:treesitter_attach() |
| 1121 | + -- Use extmarks to visually shorten paths while keeping full paths for actions |
| 1122 | + self:path_shorten_attach() |
934 | 1123 | self:reset_winhl(self.fzf_winid) |
935 | 1124 |
|
936 | 1125 | -- potential workarond for `<C-c>` freezing neovim (#1091) |
@@ -1007,6 +1196,8 @@ function FzfWin:close(fzf_bufnr, hide) |
1007 | 1196 | if not hide and self.fzf_bufnr then |
1008 | 1197 | self:close_buf(self.fzf_bufnr) |
1009 | 1198 | end |
| 1199 | + -- Detach path shortening decoration provider |
| 1200 | + PathShortener.detach(self.fzf_winid) |
1010 | 1201 | for k, _ in pairs(self.on_closes) do |
1011 | 1202 | self.on_closes[k](hide) |
1012 | 1203 | self.on_closes[k] = nil |
|
0 commit comments