From a81c240909221eabe47576dbfd3a2e2fa326ae7d Mon Sep 17 00:00:00 2001 From: Karavellas <149634176+pompos02@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:31:12 -0700 Subject: [PATCH 1/2] feat(nvim): add relative path display option (#815) Restores the original commit by @pompos02, reverted in be043d7. --- README.md | 1 + doc/fff.nvim.txt | 1 + lua/fff/conf.lua | 1 + lua/fff/picker_ui/file_renderer.lua | 65 ++++++++--- tests/file_renderer_spec.lua | 110 ++++++++++++++++++ tests/picker_ui_snap.lua | 19 +++ ...ap.lua---relative_path---query_main_bottom | 51 ++++++++ ..._snap.lua---relative_path---query_main_top | 51 ++++++++ 8 files changed, 283 insertions(+), 16 deletions(-) create mode 100644 tests/file_renderer_spec.lua create mode 100644 tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_bottom create mode 100644 tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_top diff --git a/README.md b/README.md index 581b38896..1ac854a4d 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,7 @@ require('fff').setup({ status_text_color = false, -- true to color filenames by git status }, file_picker = { + display_relative_path = false, -- show paths as `path/to/file` instead of `file path/to` fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches in file picker results }, select = { diff --git a/doc/fff.nvim.txt b/doc/fff.nvim.txt index 65965e324..1431faaa0 100644 --- a/doc/fff.nvim.txt +++ b/doc/fff.nvim.txt @@ -283,6 +283,7 @@ Defaults are sensible. Override only what you care about. status_text_color = false, -- true to color filenames by git status }, file_picker = { + display_relative_path = false, -- show paths as `path/to/file` instead of `file path/to` fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches in file picker results }, select = { diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index 54bfebc0e..7ece30cfa 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -452,6 +452,7 @@ local function init() -- find_files settings file_picker = { current_file_label = '(current)', + display_relative_path = false, fuzzy_query_highlighting = false, }, -- grep settings diff --git a/lua/fff/picker_ui/file_renderer.lua b/lua/fff/picker_ui/file_renderer.lua index caed1e36f..116780980 100644 --- a/lua/fff/picker_ui/file_renderer.lua +++ b/lua/fff/picker_ui/file_renderer.lua @@ -2,6 +2,8 @@ --- Simple renderer for file items with 2 functions: render_line and apply_highlights local M = {} +local path_separator = package.config:sub(1, 1) + --- File Item structure from Rust --- @class FileItem --- @field path string Absolute file path @@ -49,15 +51,24 @@ function M.render_line(item, ctx, item_idx) -- luacheck: ignore item_idx end end - -- Format filename and path - -- Don't reserve space for frecency - path takes priority + local display_relative_path = ctx.mode ~= 'grep' + and ctx.suggestion_source ~= 'grep' + and ctx.config.file_picker + and ctx.config.file_picker.display_relative_path local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0 - local available_width = math.max(ctx.max_path_width - icon_width, 40) + local min_width = display_relative_path and 0 or 40 + local available_width = math.max(ctx.max_path_width - icon_width, min_width) local filename, dir_path = ctx.format_file_display(item, available_width) - -- Build line - local line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency) - or string.format('%s %s%s', filename, dir_path, frecency) + local line + if display_relative_path then + local display_path = dir_path ~= '' and (dir_path .. path_separator .. filename) or filename + line = icon and string.format('%s %s%s', icon, display_path, frecency) + or string.format('%s%s', display_path, frecency) + else + line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency) + or string.format('%s %s%s', filename, dir_path, frecency) + end local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5) table.insert(lines, line .. string.rep(' ', padding)) @@ -84,8 +95,13 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont -- Get icon and paths local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false) + local display_relative_path = ctx.mode ~= 'grep' + and ctx.suggestion_source ~= 'grep' + and ctx.config.file_picker + and ctx.config.file_picker.display_relative_path local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0 - local available_width = math.max(ctx.max_path_width - icon_width, 40) + local min_width = display_relative_path and 0 or 40 + local available_width = math.max(ctx.max_path_width - icon_width, min_width) local filename, dir_path = ctx.format_file_display(item, available_width) -- 1. Cursor highlight @@ -116,6 +132,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont local git_text_hl = item.git_status and highlights.get_git_text_highlight(item.git_status) or nil if git_text_hl and git_text_hl ~= '' and not is_current_file then local filename_start = #icon + 1 + if display_relative_path and #dir_path > 0 then filename_start = filename_start + #dir_path + #path_separator end vim.api.nvim_buf_set_extmark( buf, ns_id, @@ -142,16 +159,22 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont -- 5. Directory path (dimmed) if #filename > 0 and #dir_path > 0 then - local prefix_len = #filename + 1 -- filename bytes + space - if icon then - prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space + local prefix_len + local path_end + if display_relative_path then + prefix_len = icon and (#icon + 1) or 0 + path_end = prefix_len + #dir_path + #path_separator + else + prefix_len = #filename + 1 + if icon then prefix_len = prefix_len + #icon + 1 end + path_end = prefix_len + #dir_path end vim.api.nvim_buf_set_extmark( buf, ns_id, line_idx - 1, prefix_len, - { end_col = prefix_len + #dir_path, hl_group = ctx.config.hl.directory_path } + { end_col = path_end, hl_group = ctx.config.hl.directory_path } ) end @@ -229,11 +252,21 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont local filename_rel_start = math.max(0, #rel_path - #filename) local filename_rel_end = filename_rel_start + #filename local filename_line_start = icon and (#icon + 1) or 0 - local dir_line_start = filename_line_start + #filename + 1 - local segments = { { filename_rel_start, filename_rel_end, filename_line_start } } - - if original_dir_path ~= '' and dir_path == original_dir_path then - segments[#segments + 1] = { 0, #original_dir_path, dir_line_start } + local segments + + if display_relative_path then + if dir_path ~= '' then filename_line_start = filename_line_start + #dir_path + #path_separator end + segments = { { filename_rel_start, filename_rel_end, filename_line_start } } + if original_dir_path ~= '' and dir_path == original_dir_path then + local path_line_start = icon and (#icon + 1) or 0 + segments[#segments + 1] = { 0, filename_rel_start, path_line_start } + end + else + local dir_line_start = filename_line_start + #filename + 1 + segments = { { filename_rel_start, filename_rel_end, filename_line_start } } + if original_dir_path ~= '' and dir_path == original_dir_path then + segments[#segments + 1] = { 0, #original_dir_path, dir_line_start } + end end local function apply_segment(raw_start, raw_end, segment) diff --git a/tests/file_renderer_spec.lua b/tests/file_renderer_spec.lua new file mode 100644 index 000000000..45d36da09 --- /dev/null +++ b/tests/file_renderer_spec.lua @@ -0,0 +1,110 @@ +---@diagnostic disable: undefined-field, need-check-nil +local renderer = require('fff.picker_ui.file_renderer') + +local icons = require('fff.file_picker.icons') +local highlights = require('fff.highlights') +local file_picker = require('fff.file_picker') +local rust = require('fff.rust') + +local path_separator = package.config:sub(1, 1) +local directory = table.concat({ 'src', 'components' }, path_separator) +local relative_path = directory .. path_separator .. 'main.lua' + +local original_get_icon = icons.get_icon +local original_get_git_text_highlight = highlights.get_git_text_highlight +local original_should_show_git_border = highlights.should_show_git_border +local original_get_file_score = file_picker.get_file_score + +local function make_context() + return { + cursor = 0, + query = 'sm', + max_path_width = 80, + win_width = 80, + debug_enabled = false, + selected_files = {}, + config = { + file_picker = { display_relative_path = true, fuzzy_query_highlighting = true }, + git = { status_text_color = true }, + hl = { directory_path = 'Comment', matched = 'Search' }, + }, + format_file_display = function() return 'main.lua', directory end, + } +end + +local function highlight_ranges(buf, ns, group) + local ranges = {} + for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true })) do + local details = mark[4] + if details.hl_group == group then ranges[#ranges + 1] = { mark[3], details.end_col } end + end + table.sort(ranges, function(a, b) return a[1] < b[1] end) + return ranges +end + +describe('file renderer relative path display', function() + before_each(function() + icons.get_icon = function() return 'I', 'Icon' end + highlights.get_git_text_highlight = function() return 'GitText' end + highlights.should_show_git_border = function() return false end + file_picker.get_file_score = function() return nil end + end) + + after_each(function() + icons.get_icon = original_get_icon + highlights.get_git_text_highlight = original_get_git_text_highlight + highlights.should_show_git_border = original_should_show_git_border + file_picker.get_file_score = original_get_file_score + end) + + it('renders a natural path and highlights only its filename for git status', function() + local item = { + name = 'main.lua', + relative_path = relative_path, + git_status = 'modified', + match_ranges = { { 0, 1 }, { 15, 16 } }, + } + local ctx = make_context() + local line = renderer.render_line(item, ctx)[1] + assert.are.equal('I ' .. relative_path, vim.trim(line)) + + local buf = vim.api.nvim_create_buf(false, true) + local ns = vim.api.nvim_create_namespace('fff-file-renderer-test') + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { line }) + renderer.apply_highlights(item, ctx, 1, buf, ns, 1, line) + + local filename_start = assert(line:find('main.lua', 1, true)) - 1 + assert.are.same({ { filename_start, filename_start + #'main.lua' } }, highlight_ranges(buf, ns, 'GitText')) + assert.are.same({ { 2, 3 }, { filename_start, filename_start + 1 } }, highlight_ranges(buf, ns, 'Search')) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('shortens a long directory while keeping the filename visible', function() + local filename = 'main.lua' + local long_directory = table.concat({ 'very', 'long', 'nested', 'directory' }, path_separator) + local item = { name = filename, relative_path = long_directory .. path_separator .. filename } + local filename_rel_start = #item.relative_path - #filename + item.match_ranges = { { filename_rel_start, filename_rel_start + 1 } } + local ctx = make_context() + ctx.max_path_width = 22 + ctx.win_width = 22 + ctx.format_file_display = function(_, available_width) + local directory_width = math.max(available_width - vim.fn.strdisplaywidth(filename) - 1, 0) + return filename, rust.shorten_path(long_directory, directory_width, 'middle') + end + + local line = vim.trim(renderer.render_line(item, ctx)[1]) + assert.is_true(line:sub(-#filename) == filename) + assert.is_nil(line:find(long_directory, 1, true)) + assert.is_true(vim.fn.strdisplaywidth(line) <= ctx.max_path_width) + + local buf = vim.api.nvim_create_buf(false, true) + local ns = vim.api.nvim_create_namespace('fff-file-renderer-test') + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { line }) + renderer.apply_highlights(item, ctx, 1, buf, ns, 1, line) + + local filename_start = assert(line:find(filename, 1, true)) - 1 + assert.are.same({ { filename_start, filename_start + 1 } }, highlight_ranges(buf, ns, 'Search')) + vim.api.nvim_buf_delete(buf, { force = true }) + end) +end) diff --git a/tests/picker_ui_snap.lua b/tests/picker_ui_snap.lua index 2807a2a34..6641d6c59 100644 --- a/tests/picker_ui_snap.lua +++ b/tests/picker_ui_snap.lua @@ -28,6 +28,7 @@ local function setup(geometry, opts) child.o.columns = geometry.cols local debug_enabled = opts.debug == true + local display_relative_path = opts.display_relative_path == true -- Default show_file_info hides timings: Modified/Accessed timestamps drift -- between runs and would otherwise force `ignore_text` on those rows. Tests -- can override (or restore) by passing `opts.show_file_info`. @@ -48,6 +49,9 @@ local function setup(geometry, opts) prompt = '> ', frecency = { enabled = true, db_path = %q }, history = { enabled = true, db_path = %q }, + file_picker = { + display_relative_path = %s, + }, logging = { enabled = false }, debug = { enabled = %s, @@ -63,6 +67,7 @@ local function setup(geometry, opts) geometry.winborder or '', fixture.frecency_db, fixture.history_db, + tostring(display_relative_path), tostring(debug_enabled), tostring(debug_enabled), vim.inspect(show_file_info) @@ -184,6 +189,20 @@ for _, prompt in ipairs(PROMPT_POSITIONS) do end T['debug_wide'] = debug_wide_set +T['relative_path'] = MiniTest.new_set({ + hooks = { + pre_case = function() setup(LAYOUTS[3], { display_relative_path = true }) end, + post_case = teardown, + }, +}) + +for _, prompt in ipairs(PROMPT_POSITIONS) do + T['relative_path']['query_main_' .. prompt] = function() + open_picker(prompt, 'main') + assert_snapshot_match() + end +end + T['combo'] = MiniTest.new_set({ hooks = { pre_case = function() setup({ cols = 140, rows = 32 }) end, diff --git a/tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_bottom b/tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_bottom new file mode 100644 index 000000000..8cb57d62d --- /dev/null +++ b/tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_bottom @@ -0,0 +1,51 @@ +--|---------|---------|---------|---------|---------|---------|---------| +01| +02|~ +03|~ ╭ FFFiles ─────────────────────────────────────────────╮ +04|~ │ src/components/button.tsx │ +05|~ │ docs/license.md │ +06|~ │ tests/regression.rs │ +07|~ │ src/components/menu.tsx │ +08|~ │ docs/changelog.md │ +09|~ │ docs/intro.md │ +10|~ │ docs/contributing.md │ +11|~ │ tests/integration.rs │ +12|~ │ src/components/input.tsx │ +13|~ │ tests/main_test.rs │ +14|~ │ src/main_utils.rs │ +15|~ │ src/main_runner.rs │ +16|~ │ src/main_loop.rs │ +17|~ │ src/main_helper.rs │ +18|~ │ src/main.rs │ +19|~ ├──────────────────────────────────────────────────────┤ +20|~ │> main 19/32 │ +21|~ ╰──────────────────────────────────────────────────────╯ +22|~ +23|[No Name] 0,1 All +24|-- INSERT -- 1,7 All + +--|---------|---------|---------|---------|---------|---------|---------| +01|0000000000000000000000000000000000000000000000000000000000000000000000 +02|1111111111111111111111111111111111111111111111111111111111111111111111 +03|1111111123333333332222222222222222222222222222222222222222222222111111 +04|1111111124455555555555555522222222222222222222222222222222222222111111 +05|1111111124455555222222222222222222222222222222222222222222222222111111 +06|1111111124455555522222222222222222222222222222222222222222222222111111 +07|1111111124455555555555555522222222222222222222222222222222222222111111 +08|1111111124455555222222222222222222222222222222222222222222222222111111 +09|1111111124455555222222222222222222222222222222222222222222222222111111 +10|1111111124455555222222222222222222222222222222222222222222222222111111 +11|1111111124455555522222222222222222222222222222222222222222222222111111 +12|1111111124455555555555555522222222222222222222222222222222222222111111 +13|1111111124455555566662222222222222222222222222222222222222222222111111 +14|1111111124455556666222222222222222222222222222222222222222222222111111 +15|1111111124455556666222222222222222222222222222222222222222222222111111 +16|1111111124455556666222222222222222222222222222222222222222222222111111 +17|1111111124455556666222222222222222222222222222222222222222222222111111 +18|1111111127788886666999999999999999999999999999999999999999999992111111 +19|1111111122222222222222222222222222222222222222222222222222222222111111 +20|1111111122222222222222222222222222222222222222222222222244444222111111 +21|1111111122222222222222222222222222222222222222222222222222222222111111 +22|1111111111111111111111111111111111111111111111111111111111111111111111 +23|:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +24|;;;;;;;;;;;;<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_top b/tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_top new file mode 100644 index 000000000..9c9ce8735 --- /dev/null +++ b/tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_top @@ -0,0 +1,51 @@ +--|---------|---------|---------|---------|---------|---------|---------| +01| +02|~ +03|~ ╭ FFFiles ─────────────────────────────────────────────╮ +04|~ │> main 19/32 │ +05|~ ├──────────────────────────────────────────────────────┤ +06|~ │ src/main.rs │ +07|~ │ src/main_helper.rs │ +08|~ │ src/main_loop.rs │ +09|~ │ src/main_runner.rs │ +10|~ │ src/main_utils.rs │ +11|~ │ tests/main_test.rs │ +12|~ │ src/components/input.tsx │ +13|~ │ tests/integration.rs │ +14|~ │ docs/contributing.md │ +15|~ │ docs/intro.md │ +16|~ │ docs/changelog.md │ +17|~ │ src/components/menu.tsx │ +18|~ │ tests/regression.rs │ +19|~ ╰──────────────────────────────────────────────────────╯ +20|~ +21|~ +22|~ +23|[No Name] 0,1 All +24|-- INSERT -- 1,7 All + +--|---------|---------|---------|---------|---------|---------|---------| +01|0000000000000000000000000000000000000000000000000000000000000000000000 +02|1111111111111111111111111111111111111111111111111111111111111111111111 +03|1111111123333333332222222222222222222222222222222222222222222222111111 +04|1111111122222222222222222222222222222222222222222222222244444222111111 +05|1111111122222222222222222222222222222222222222222222222222222222111111 +06|1111111125566667777888888888888888888888888888888888888888888882111111 +07|1111111124499997777222222222222222222222222222222222222222222222111111 +08|1111111124499997777222222222222222222222222222222222222222222222111111 +09|1111111124499997777222222222222222222222222222222222222222222222111111 +10|1111111124499997777222222222222222222222222222222222222222222222111111 +11|1111111124499999977772222222222222222222222222222222222222222222111111 +12|1111111124499999999999999922222222222222222222222222222222222222111111 +13|1111111124499999922222222222222222222222222222222222222222222222111111 +14|1111111124499999222222222222222222222222222222222222222222222222111111 +15|1111111124499999222222222222222222222222222222222222222222222222111111 +16|1111111124499999222222222222222222222222222222222222222222222222111111 +17|1111111124499999999999999922222222222222222222222222222222222222111111 +18|1111111124499999922222222222222222222222222222222222222222222222111111 +19|1111111122222222222222222222222222222222222222222222222222222222111111 +20|1111111111111111111111111111111111111111111111111111111111111111111111 +21|1111111111111111111111111111111111111111111111111111111111111111111111 +22|1111111111111111111111111111111111111111111111111111111111111111111111 +23|:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +24|;;;;;;;;;;;;<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< From e75e72afb658e827546b1c15c85071d5755d280a Mon Sep 17 00:00:00 2001 From: gustav-fff <286169375+gustav-fff@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:46:31 -0700 Subject: [PATCH 2/2] refactor(nvim): make path-first display a project-wide layout option Applies @dmtrKovalenko's review of #815 on top of the original commit: - `file_picker.display_relative_path` -> `layout.show_path_first`. The option is project-wide now, so grep file group headers follow it too instead of being excluded. - Extract the name section rendering (display text + byte offsets for the git, directory and fuzzy highlights) into `lua/fff/picker_ui/file_name_renderer.lua`. `file_renderer` no longer branches on the layout in four places. - Sync the README config block with `conf.lua` defaults and move `file_picker` directly after `layout`: added `wrap_around`, `keymaps.cycle_forward_query`, `logging.enabled`, `file_picker.current_file_label`, fixed `prompt` and `layout.path_shorten_strategy` defaults. Mirrored into the generated vimdoc. No deprecation shim: `display_relative_path` was reverted before any release, no user config can reference it. --- README.md | 18 ++-- doc/fff.nvim.txt | 18 ++-- lua/fff/conf.lua | 4 +- lua/fff/picker_ui/file_name_renderer.lua | 84 +++++++++++++++++++ lua/fff/picker_ui/file_renderer.lua | 75 ++--------------- tests/file_renderer_spec.lua | 37 +++++++- tests/picker_ui_snap.lua | 14 ++-- ...lua---show_path_first---query_main_bottom} | 0 ...ap.lua---show_path_first---query_main_top} | 0 9 files changed, 161 insertions(+), 89 deletions(-) create mode 100644 lua/fff/picker_ui/file_name_renderer.lua rename tests/screenshots/{tests-picker_ui_snap.lua---relative_path---query_main_bottom => tests-picker_ui_snap.lua---show_path_first---query_main_bottom} (100%) rename tests/screenshots/{tests-picker_ui_snap.lua---relative_path---query_main_top => tests-picker_ui_snap.lua---show_path_first---query_main_top} (100%) diff --git a/README.md b/README.md index 1ac854a4d..092949358 100644 --- a/README.md +++ b/README.md @@ -325,12 +325,13 @@ Defaults are sensible. Override only what you care about. ```lua require('fff').setup({ base_path = vim.fn.getcwd(), - prompt = '> ', + prompt = '🪿 ', title = 'FFFiles', max_results = 100, max_threads = 4, lazy_sync = true, prompt_vim_mode = false, + wrap_around = false, -- true to wrap the cursor around when moving past the first/last item follow_symlinks = false, -- Allow indexing the user's $HOME directory. Enabled by default. -- Disable if you strictly sure you don't want this, as it makes whole fff error hard @@ -354,8 +355,15 @@ require('fff').setup({ flex = { size = 130, wrap = 'top' }, min_list_height = 10, -- do not display anything except the list below this threshold show_scrollbar = true, - path_shorten_strategy = 'middle_number', -- 'middle_number' | 'middle' | 'end' | 'start' + path_shorten_strategy = 'middle', -- 'middle' | 'middle_number' | 'end' | 'start' + -- 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right' anchor = 'center', + show_path_first = false, -- true renders results as `path/to/file` instead of `file path/to` + }, + -- find_files specific rendering + file_picker = { + current_file_label = '(current)', -- virtual text marking the buffer the picker was opened from + fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches, not just the literal query }, preview = { enabled = true, @@ -389,6 +397,7 @@ require('fff').setup({ grep_jump_to_next_file = { '', '' }, grep_jump_to_prev_file = { '', '' }, cycle_previous_query = '', + cycle_forward_query = '', -- unbound by default, wipes the whole input line -- clear_query = '', -- overrides preview_scroll_up in insert mode toggle_select = '', @@ -413,10 +422,6 @@ require('fff').setup({ git = { status_text_color = false, -- true to color filenames by git status }, - file_picker = { - display_relative_path = false, -- show paths as `path/to/file` instead of `file path/to` - fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches in file picker results - }, select = { -- Return winid to open the chosen file in, or nil to open in the original window select_window = function(current_buf, action) --[[ default impl ]] end, @@ -448,6 +453,7 @@ require('fff').setup({ }, }, logging = { + enabled = true, -- logs will be written in a parent directory of this file path in files like -- `++.`. Run :FFFOpenLog to open current one log_file = vim.fn.stdpath('log') .. '/fff.log', diff --git a/doc/fff.nvim.txt b/doc/fff.nvim.txt index 1431faaa0..a1087b547 100644 --- a/doc/fff.nvim.txt +++ b/doc/fff.nvim.txt @@ -194,12 +194,13 @@ Defaults are sensible. Override only what you care about. >lua require('fff').setup({ base_path = vim.fn.getcwd(), - prompt = '> ', + prompt = '🪿 ', title = 'FFFiles', max_results = 100, max_threads = 4, lazy_sync = true, prompt_vim_mode = false, + wrap_around = false, -- true to wrap the cursor around when moving past the first/last item follow_symlinks = false, -- Allow indexing the user's $HOME directory. Enabled by default. -- Disable if you strictly sure you don't want this, as it makes whole fff error hard @@ -223,8 +224,15 @@ Defaults are sensible. Override only what you care about. flex = { size = 130, wrap = 'top' }, min_list_height = 10, -- do not display anything except the list below this threshold show_scrollbar = true, - path_shorten_strategy = 'middle_number', -- 'middle_number' | 'middle' | 'end' | 'start' + path_shorten_strategy = 'middle', -- 'middle' | 'middle_number' | 'end' | 'start' + -- 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right' anchor = 'center', + show_path_first = false, -- true renders results as `path/to/file` instead of `file path/to` + }, + -- find_files specific rendering + file_picker = { + current_file_label = '(current)', -- virtual text marking the buffer the picker was opened from + fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches, not just the literal query }, preview = { enabled = true, @@ -258,6 +266,7 @@ Defaults are sensible. Override only what you care about. grep_jump_to_next_file = { '', '' }, grep_jump_to_prev_file = { '', '' }, cycle_previous_query = '', + cycle_forward_query = '', -- unbound by default, wipes the whole input line -- clear_query = '', -- overrides preview_scroll_up in insert mode toggle_select = '', @@ -282,10 +291,6 @@ Defaults are sensible. Override only what you care about. git = { status_text_color = false, -- true to color filenames by git status }, - file_picker = { - display_relative_path = false, -- show paths as `path/to/file` instead of `file path/to` - fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches in file picker results - }, select = { -- Return winid to open the chosen file in, or nil to open in the original window select_window = function(current_buf, action) --[[ default impl ]] end, @@ -317,6 +322,7 @@ Defaults are sensible. Override only what you care about. }, }, logging = { + enabled = true, -- logs will be written in a parent directory of this file path in files like -- `++.`. Run :FFFOpenLog to open current one log_file = vim.fn.stdpath('log') .. '/fff.log', diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index 7ece30cfa..31aa8a32f 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -9,6 +9,7 @@ local M = {} --- @field min_list_height number --- @field show_scrollbar boolean --- @field path_shorten_strategy string +--- @field show_path_first boolean --- @field border? 'single'|'double'|'rounded'|'solid'|'shadow'|'none'|table Border preset; falls back to `vim.o.winborder` when nil --- @class FffPreviewConfig @@ -278,6 +279,8 @@ local function init() -- 'end': truncates from the end, keeps the start (home/user/projects) -- 'start': truncates from the start, keeps the end (.../parts/ai_extracted) path_shorten_strategy = 'middle', + -- Render results as `path/to/file` instead of `file path/to` + show_path_first = false, }, preview = { enabled = true, @@ -452,7 +455,6 @@ local function init() -- find_files settings file_picker = { current_file_label = '(current)', - display_relative_path = false, fuzzy_query_highlighting = false, }, -- grep settings diff --git a/lua/fff/picker_ui/file_name_renderer.lua b/lua/fff/picker_ui/file_name_renderer.lua new file mode 100644 index 000000000..1c40ee7f5 --- /dev/null +++ b/lua/fff/picker_ui/file_name_renderer.lua @@ -0,0 +1,84 @@ +local M = {} + +local path_separator = package.config:sub(1, 1) + +--- @class FffFileNameLayout +--- @field text string Icon + name section of the rendered line +--- @field filename string File name as returned by `ctx.format_file_display` +--- @field dir_path string Shortened directory, '' when the file sits at the root +--- @field filename_col number 0-based byte column of the file name inside `text` +--- @field dir_col number 0-based byte column of `dir_path` inside `text` +--- @field dir_end_col number Byte column past `dir_path`, separator included when path first +--- @field path_first boolean + +--- True when file names render as `dir/name` instead of `name dir`. +--- @param config FffConfig|nil +--- @return boolean +function M.is_path_first(config) return (config and config.layout and config.layout.show_path_first) == true end + +--- Build the icon + name section of a file line together with its byte offsets. +--- @param item FileItem File item from Rust +--- @param ctx ListRenderContext Render context with all state +--- @param icon string|nil Already resolved file icon +--- @return FffFileNameLayout +function M.build(item, ctx, icon) + local path_first = M.is_path_first(ctx.config) + local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0 + -- name-first needs a floor so the directory column does not jump around, + -- path-first has nothing to align and takes whatever the window gives + local available_width = math.max(ctx.max_path_width - icon_width, path_first and 0 or 40) + local filename, dir_path = ctx.format_file_display(item, available_width) + + local prefix = icon and (icon .. ' ') or '' + local prefix_len = #prefix + if path_first then + local separator = dir_path ~= '' and path_separator or '' + local dir_end_col = prefix_len + #dir_path + #separator + return { + text = prefix .. dir_path .. separator .. filename, + filename = filename, + dir_path = dir_path, + filename_col = dir_end_col, + dir_col = prefix_len, + dir_end_col = dir_end_col, + path_first = true, + } + end + + local dir_col = prefix_len + #filename + 1 + return { + text = prefix .. filename .. ' ' .. dir_path, + filename = filename, + dir_path = dir_path, + filename_col = prefix_len, + dir_col = dir_col, + dir_end_col = dir_col + #dir_path, + path_first = false, + } +end + +--- Map fuzzy match ranges over `item.relative_path` onto line byte columns. +--- Segments are `{ source_start, source_end, target_col }` triples. +--- @param item FileItem File item from Rust +--- @param layout FffFileNameLayout Layout returned by `M.build` +--- @return number[][] +function M.fuzzy_segments(item, layout) + local rel_path = item.relative_path or '' + if type(rel_path) ~= 'string' then rel_path = tostring(rel_path) end + + local filename_rel_start = math.max(0, #rel_path - #layout.filename) + local segments = { { filename_rel_start, filename_rel_start + #layout.filename, layout.filename_col } } + + local parent_dir = vim.fn.fnamemodify(rel_path, ':h') + if parent_dir == '.' then parent_dir = '' end + + -- a shortened directory breaks the byte mapping, only map an intact one + if parent_dir ~= '' and layout.dir_path == parent_dir then + local dir_source_end = layout.path_first and filename_rel_start or #parent_dir + segments[#segments + 1] = { 0, dir_source_end, layout.dir_col } + end + + return segments +end + +return M diff --git a/lua/fff/picker_ui/file_renderer.lua b/lua/fff/picker_ui/file_renderer.lua index 116780980..ca1045633 100644 --- a/lua/fff/picker_ui/file_renderer.lua +++ b/lua/fff/picker_ui/file_renderer.lua @@ -2,7 +2,7 @@ --- Simple renderer for file items with 2 functions: render_line and apply_highlights local M = {} -local path_separator = package.config:sub(1, 1) +local file_name_renderer = require('fff.picker_ui.file_name_renderer') --- File Item structure from Rust --- @class FileItem @@ -51,24 +51,7 @@ function M.render_line(item, ctx, item_idx) -- luacheck: ignore item_idx end end - local display_relative_path = ctx.mode ~= 'grep' - and ctx.suggestion_source ~= 'grep' - and ctx.config.file_picker - and ctx.config.file_picker.display_relative_path - local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0 - local min_width = display_relative_path and 0 or 40 - local available_width = math.max(ctx.max_path_width - icon_width, min_width) - local filename, dir_path = ctx.format_file_display(item, available_width) - - local line - if display_relative_path then - local display_path = dir_path ~= '' and (dir_path .. path_separator .. filename) or filename - line = icon and string.format('%s %s%s', icon, display_path, frecency) - or string.format('%s%s', display_path, frecency) - else - line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency) - or string.format('%s %s%s', filename, dir_path, frecency) - end + local line = file_name_renderer.build(item, ctx, icon).text .. frecency local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5) table.insert(lines, line .. string.rep(' ', padding)) @@ -95,14 +78,8 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont -- Get icon and paths local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false) - local display_relative_path = ctx.mode ~= 'grep' - and ctx.suggestion_source ~= 'grep' - and ctx.config.file_picker - and ctx.config.file_picker.display_relative_path - local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0 - local min_width = display_relative_path and 0 or 40 - local available_width = math.max(ctx.max_path_width - icon_width, min_width) - local filename, dir_path = ctx.format_file_display(item, available_width) + local name_layout = file_name_renderer.build(item, ctx, icon) + local filename, dir_path = name_layout.filename, name_layout.dir_path -- 1. Cursor highlight if is_cursor then @@ -131,8 +108,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont if ctx.config.git and ctx.config.git.status_text_color and icon and #filename > 0 then local git_text_hl = item.git_status and highlights.get_git_text_highlight(item.git_status) or nil if git_text_hl and git_text_hl ~= '' and not is_current_file then - local filename_start = #icon + 1 - if display_relative_path and #dir_path > 0 then filename_start = filename_start + #dir_path + #path_separator end + local filename_start = name_layout.filename_col vim.api.nvim_buf_set_extmark( buf, ns_id, @@ -159,22 +135,12 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont -- 5. Directory path (dimmed) if #filename > 0 and #dir_path > 0 then - local prefix_len - local path_end - if display_relative_path then - prefix_len = icon and (#icon + 1) or 0 - path_end = prefix_len + #dir_path + #path_separator - else - prefix_len = #filename + 1 - if icon then prefix_len = prefix_len + #icon + 1 end - path_end = prefix_len + #dir_path - end vim.api.nvim_buf_set_extmark( buf, ns_id, line_idx - 1, - prefix_len, - { end_col = path_end, hl_group = ctx.config.hl.directory_path } + name_layout.dir_col, + { end_col = name_layout.dir_end_col, hl_group = ctx.config.hl.directory_path } ) end @@ -242,32 +208,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont local ranges = item.match_ranges if not ranges or #ranges == 0 then return end - local rel_path = item.relative_path or '' - if type(rel_path) ~= 'string' then rel_path = tostring(rel_path) end - - local original_dir_path = '' - local parent_dir = vim.fn.fnamemodify(rel_path, ':h') - if parent_dir ~= '.' and parent_dir ~= '' then original_dir_path = parent_dir end - - local filename_rel_start = math.max(0, #rel_path - #filename) - local filename_rel_end = filename_rel_start + #filename - local filename_line_start = icon and (#icon + 1) or 0 - local segments - - if display_relative_path then - if dir_path ~= '' then filename_line_start = filename_line_start + #dir_path + #path_separator end - segments = { { filename_rel_start, filename_rel_end, filename_line_start } } - if original_dir_path ~= '' and dir_path == original_dir_path then - local path_line_start = icon and (#icon + 1) or 0 - segments[#segments + 1] = { 0, filename_rel_start, path_line_start } - end - else - local dir_line_start = filename_line_start + #filename + 1 - segments = { { filename_rel_start, filename_rel_end, filename_line_start } } - if original_dir_path ~= '' and dir_path == original_dir_path then - segments[#segments + 1] = { 0, #original_dir_path, dir_line_start } - end - end + local segments = file_name_renderer.fuzzy_segments(item, name_layout) local function apply_segment(raw_start, raw_end, segment) local source_start, source_end, target_start = segment[1], segment[2], segment[3] diff --git a/tests/file_renderer_spec.lua b/tests/file_renderer_spec.lua index 45d36da09..9d1a44146 100644 --- a/tests/file_renderer_spec.lua +++ b/tests/file_renderer_spec.lua @@ -24,7 +24,8 @@ local function make_context() debug_enabled = false, selected_files = {}, config = { - file_picker = { display_relative_path = true, fuzzy_query_highlighting = true }, + layout = { show_path_first = true }, + file_picker = { fuzzy_query_highlighting = true }, git = { status_text_color = true }, hl = { directory_path = 'Comment', matched = 'Search' }, }, @@ -42,7 +43,7 @@ local function highlight_ranges(buf, ns, group) return ranges end -describe('file renderer relative path display', function() +describe('file renderer path first display', function() before_each(function() icons.get_icon = function() return 'I', 'Icon' end highlights.get_git_text_highlight = function() return 'GitText' end @@ -107,4 +108,36 @@ describe('file renderer relative path display', function() assert.are.same({ { filename_start, filename_start + 1 } }, highlight_ranges(buf, ns, 'Search')) vim.api.nvim_buf_delete(buf, { force = true }) end) + + it('applies to grep file group headers as well', function() + local item = { name = 'main.lua', relative_path = relative_path } + local ctx = make_context() + ctx.mode = 'grep' + ctx.suggestion_source = 'grep' + assert.are.equal('I ' .. relative_path, vim.trim(renderer.render_line(item, ctx)[1])) + end) + + it('keeps the name first layout and its offsets when disabled', function() + local item = { + name = 'main.lua', + relative_path = relative_path, + git_status = 'modified', + match_ranges = { { 0, 1 }, { 15, 16 } }, + } + local ctx = make_context() + ctx.config.layout.show_path_first = false + local line = renderer.render_line(item, ctx)[1] + assert.are.equal('I main.lua ' .. directory, vim.trim(line)) + + local buf = vim.api.nvim_create_buf(false, true) + local ns = vim.api.nvim_create_namespace('fff-file-renderer-test') + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { line }) + renderer.apply_highlights(item, ctx, 1, buf, ns, 1, line) + + assert.are.same({ { 2, 2 + #'main.lua' } }, highlight_ranges(buf, ns, 'GitText')) + assert.are.same({ { 11, 11 + #directory } }, highlight_ranges(buf, ns, 'Comment')) + -- rel_path byte 15 is the 'm' of main.lua, byte 0 is the 's' of src + assert.are.same({ { 2, 3 }, { 11, 12 } }, highlight_ranges(buf, ns, 'Search')) + vim.api.nvim_buf_delete(buf, { force = true }) + end) end) diff --git a/tests/picker_ui_snap.lua b/tests/picker_ui_snap.lua index 6641d6c59..e4ce1d807 100644 --- a/tests/picker_ui_snap.lua +++ b/tests/picker_ui_snap.lua @@ -28,7 +28,7 @@ local function setup(geometry, opts) child.o.columns = geometry.cols local debug_enabled = opts.debug == true - local display_relative_path = opts.display_relative_path == true + local show_path_first = opts.show_path_first == true -- Default show_file_info hides timings: Modified/Accessed timestamps drift -- between runs and would otherwise force `ignore_text` on those rows. Tests -- can override (or restore) by passing `opts.show_file_info`. @@ -49,8 +49,8 @@ local function setup(geometry, opts) prompt = '> ', frecency = { enabled = true, db_path = %q }, history = { enabled = true, db_path = %q }, - file_picker = { - display_relative_path = %s, + layout = { + show_path_first = %s, }, logging = { enabled = false }, debug = { @@ -67,7 +67,7 @@ local function setup(geometry, opts) geometry.winborder or '', fixture.frecency_db, fixture.history_db, - tostring(display_relative_path), + tostring(show_path_first), tostring(debug_enabled), tostring(debug_enabled), vim.inspect(show_file_info) @@ -189,15 +189,15 @@ for _, prompt in ipairs(PROMPT_POSITIONS) do end T['debug_wide'] = debug_wide_set -T['relative_path'] = MiniTest.new_set({ +T['show_path_first'] = MiniTest.new_set({ hooks = { - pre_case = function() setup(LAYOUTS[3], { display_relative_path = true }) end, + pre_case = function() setup(LAYOUTS[3], { show_path_first = true }) end, post_case = teardown, }, }) for _, prompt in ipairs(PROMPT_POSITIONS) do - T['relative_path']['query_main_' .. prompt] = function() + T['show_path_first']['query_main_' .. prompt] = function() open_picker(prompt, 'main') assert_snapshot_match() end diff --git a/tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_bottom b/tests/screenshots/tests-picker_ui_snap.lua---show_path_first---query_main_bottom similarity index 100% rename from tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_bottom rename to tests/screenshots/tests-picker_ui_snap.lua---show_path_first---query_main_bottom diff --git a/tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_top b/tests/screenshots/tests-picker_ui_snap.lua---show_path_first---query_main_top similarity index 100% rename from tests/screenshots/tests-picker_ui_snap.lua---relative_path---query_main_top rename to tests/screenshots/tests-picker_ui_snap.lua---show_path_first---query_main_top