diff --git a/README.md b/README.md index bb2c8679..19fd4670 100644 --- a/README.md +++ b/README.md @@ -72,110 +72,69 @@ FFF.nvim comes with sensible defaults. Here's the complete configuration with al ```lua require('fff').setup({ - -- Core settings - base_path = vim.fn.getcwd(), -- Base directory for file indexing - max_results = 100, -- Maximum search results to display - max_threads = 4, -- Maximum threads for fuzzy search - prompt = '🪿 ', -- Input prompt symbol - title = 'FFF Files', -- Window title - ui_enabled = true, -- Enable UI (default: true) - - -- Window dimensions - width = 0.8, -- Window width as fraction of screen - height = 0.8, -- Window height as fraction of screen - - -- Preview configuration - preview = { - enabled = true, -- Enable preview pane - width = 0.5, -- Preview width as fraction of window - max_lines = 5000, -- Maximum lines to load - max_size = 10 * 1024 * 1024, -- Maximum file size (10MB) - imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit', -- ImageMagick info format - line_numbers = false, -- Show line numbers in preview - wrap_lines = false, -- Wrap long lines - show_file_info = true, -- Show file info header - binary_file_threshold = 1024, -- Bytes to check for binary detection - filetypes = { -- Per-filetype settings - svg = { wrap_lines = true }, - markdown = { wrap_lines = true }, - text = { wrap_lines = true }, - log = { tail_lines = 100 }, + base_path = vim.fn.getcwd(), + prompt = '🪿 ', + title = 'FFFiles', + max_results = 100, + max_threads = 4, + layout = { + height = 0.8, + width = 0.8, + prompt_position = 'bottom', -- or 'top' + preview_position = 'right', -- or 'left', 'right', 'top', 'bottom' + preview_size = 0.5, }, - }, - - -- Layout configuration (alternative to width/height) - layout = { - prompt_position = 'top', -- Position of prompt ('top' or 'bottom') - preview_position = 'right', -- Position of preview ('right' or 'left') - preview_width = 0.4, -- Width of preview pane - height = 0.8, -- Window height - width = 0.8, -- Window width - }, - - -- Keymaps - keymaps = { - close = '', - select = '', - select_split = '', - select_vsplit = '', - select_tab = '', - move_up = { '', '' }, -- Multiple bindings supported - move_down = { '', '' }, - preview_scroll_up = '', - preview_scroll_down = '', - toggle_debug = '', -- Toggle debug scores display - }, - - -- Highlight groups - hl = { - border = 'FloatBorder', - normal = 'Normal', - cursor = 'CursorLine', - matched = 'IncSearch', - title = 'Title', - prompt = 'Question', - active_file = 'Visual', - frecency = 'Number', - debug = 'Comment', - }, - - -- Frecency tracking (track file access patterns) - frecency = { - enabled = true, -- Enable frecency tracking - db_path = vim.fn.stdpath('cache') .. '/fff_nvim', -- Database location - }, - - -- Logging configuration - logging = { - enabled = true, -- Enable logging - log_file = vim.fn.stdpath('log') .. '/fff.log', -- Log file location - log_level = 'info', -- Log level (debug, info, warn, error) - }, - - -- UI appearance - ui = { - wrap_paths = true, -- Wrap long file paths in list - wrap_indent = 2, -- Indentation for wrapped paths - max_path_width = 80, -- Maximum path width before wrapping - }, - - -- Image preview (requires terminal with image support) - image_preview = { - enabled = true, -- Enable image previews - max_width = 80, -- Maximum image width in columns - max_height = 24, -- Maximum image height in lines - }, - - -- Icons - icons = { - enabled = true, -- Enable file icons - }, - - -- Debug options - debug = { - enabled = false, -- Enable debug mode - show_scores = false, -- Show scoring information (toggle with F2) - }, + preview = { + enabled = true, + max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB + chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines) + binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable) + imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit', + line_numbers = false, + wrap_lines = false, + show_file_info = true, + filetypes = { + svg = { wrap_lines = true }, + markdown = { wrap_lines = true }, + text = { wrap_lines = true }, + }, + }, + keymaps = { + close = '', + select = '', + select_split = '', + select_vsplit = '', + select_tab = '', + move_up = { '', '' }, + move_down = { '', '' }, + preview_scroll_up = '', + preview_scroll_down = '', + toggle_debug = '', + }, + hl = { + border = 'FloatBorder', + normal = 'Normal', + cursor = 'CursorLine', + matched = 'IncSearch', + title = 'Title', + prompt = 'Question', + active_file = 'Visual', + frecency = 'Number', + debug = 'Comment', + }, + frecency = { + enabled = true, + db_path = vim.fn.stdpath('cache') .. '/fff_nvim', + }, + debug = { + enabled = false, -- Set to true to show scores in the UI + show_scores = false, + }, + logging = { + enabled = true, + log_file = vim.fn.stdpath('log') .. '/fff.log', + log_level = 'info', + } }) ``` diff --git a/doc/fff.nvim.txt b/doc/fff.nvim.txt index a879a16e..22cd5634 100644 --- a/doc/fff.nvim.txt +++ b/doc/fff.nvim.txt @@ -114,7 +114,7 @@ all available options: layout = { prompt_position = 'top', -- Position of prompt ('top' or 'bottom') preview_position = 'right', -- Position of preview ('right' or 'left') - preview_width = 0.4, -- Width of preview pane + preview_width = 0.5, -- Width of preview pane height = 0.8, -- Window height width = 0.8, -- Window width }, diff --git a/lua/fff/file_picker/init.lua b/lua/fff/file_picker/init.lua index 80fde5d7..da16e943 100644 --- a/lua/fff/file_picker/init.lua +++ b/lua/fff/file_picker/init.lua @@ -41,9 +41,9 @@ function M.setup(config) preview_down = '', }, layout = { - prompt_position = 'top', + prompt_position = 'bottom', preview_position = 'right', - preview_width = 0.4, + preview_size = 0.4, height = 0.8, width = 0.8, }, @@ -84,15 +84,18 @@ end --- Search files with fuzzy matching using blink.cmp's advanced algorithm --- @param query string Search query --- @param max_results number Maximum number of results (optional) +--- @param max_threads number Maximum number of threads (optional) --- @param current_file string|nil Path to current file to deprioritize (optional) +--- @param reverse_order boolean Reverse order of results --- @return table List of matching files -function M.search_files(query, max_results, max_threads, current_file) +function M.search_files(query, max_results, max_threads, current_file, reverse_order) if not M.state.initialized then return {} end max_results = max_results or M.config.max_results max_threads = max_threads or M.config.max_threads - local ok, search_result = pcall(fuzzy.fuzzy_search_files, query, max_results, max_threads, current_file) + local ok, search_result = + pcall(fuzzy.fuzzy_search_files, query, max_results, max_threads, current_file, reverse_order) if not ok then vim.notify('Failed to search files: ' .. tostring(search_result), vim.log.levels.ERROR) return {} diff --git a/lua/fff/file_picker/preview.lua b/lua/fff/file_picker/preview.lua index d64f1603..7cfe5cf1 100644 --- a/lua/fff/file_picker/preview.lua +++ b/lua/fff/file_picker/preview.lua @@ -288,7 +288,11 @@ function M.is_binary_file_async(file_path, callback) end end - -- Check file content asynchronously + if M.config.binary_file_threshold <= 0 then + callback(false) + return + end + vim.uv.fs_open(file_path, 'r', 438, function(err, fd) if err or not fd then callback(false) diff --git a/lua/fff/main.lua b/lua/fff/main.lua index 20c1067f..988c291c 100644 --- a/lua/fff/main.lua +++ b/lua/fff/main.lua @@ -2,30 +2,128 @@ local fuzzy = require('fff.fuzzy') if not fuzzy then error('Failed to load fff.fuzzy module. Ensure the Rust backend is compiled and available.') end local M = {} + M.config = {} M.state = { initialized = false } +local DEPRECATION_RULES = { + { + -- Top-level width -> layout.width + old_path = { 'width' }, + new_path = { 'layout', 'width' }, + message = 'config.width is deprecated. Use config.layout.width instead.', + }, + { + -- Top-level height -> layout.height + old_path = { 'height' }, + new_path = { 'layout', 'height' }, + message = 'config.height is deprecated. Use config.layout.height instead.', + }, + { + -- preview.width -> layout.preview_size + old_path = { 'preview', 'width' }, + new_path = { 'layout', 'preview_size' }, + message = 'config.preview.width is deprecated. Use config.layout.preview_size instead.', + }, + { + -- layout.preview_width -> layout.preview_size + old_path = { 'layout', 'preview_width' }, + new_path = { 'layout', 'preview_size' }, + message = 'config.layout.preview_width is deprecated. Use config.layout.preview_size instead.', + }, +} + +--- Get value from nested table using path array +--- @param tbl table Source table +--- @param path table Array of keys to traverse +--- @return any|nil Value at path or nil if not found +local function get_nested_value(tbl, path) + local current = tbl + for _, key in ipairs(path) do + if type(current) ~= 'table' or current[key] == nil then return nil end + current = current[key] + end + + return current +end + +--- Set value in nested table using path array, creating intermediate tables +--- @param tbl table Target table +--- @param path table Array of keys to traverse +--- @param value any Value to set +local function set_nested_value(tbl, path, value) + local current = tbl + for i = 1, #path - 1 do + local key = path[i] + if type(current[key]) ~= 'table' then current[key] = {} end + current = current[key] + end + + current[path[#path]] = value +end + +--- Remove value from nested table using path array +--- @param tbl table Target table +--- @param path table Array of keys to traverse +local function remove_nested_value(tbl, path) + if #path == 0 then return end + + local current = tbl + for i = 1, #path - 1 do + local key = path[i] + if type(current[key]) ~= 'table' then return end + current = current[key] + end + + current[path[#path]] = nil +end + +--- Handle deprecated configuration options with migration warnings +--- @param user_config table User provided configuration +--- @return table Migrated configuration +local function handle_deprecated_config(user_config) + if not user_config then return {} end + + local migrated_config = vim.deepcopy(user_config) + + for _, rule in ipairs(DEPRECATION_RULES) do + local old_value = get_nested_value(user_config, rule.old_path) + if old_value ~= nil then + set_nested_value(migrated_config, rule.new_path, old_value) + remove_nested_value(migrated_config, rule.old_path) + + vim.notify('FFF: ' .. rule.message, vim.log.levels.WARN) + end + end + + return migrated_config +end + --- Setup the file picker with the given configuration --- @param config table Configuration options function M.setup(config) local default_config = { base_path = vim.fn.getcwd(), + prompt = '🪿 ', + title = 'FFFiles', max_results = 100, - prompt = '🪿 ', -- Input prompt symbol - title = 'FFF Files', -- Window title - width = 0.8, - height = 0.8, + max_threads = 4, + layout = { + height = 0.8, + width = 0.8, + prompt_position = 'bottom', -- or 'top' + preview_position = 'right', -- or 'left', 'right', 'top', 'bottom' + preview_size = 0.5, + }, preview = { enabled = true, - width = 0.5, - max_size = 1 * 1024 * 1024, -- Keep file size limit for early detection - max_line_length = 1000, -- Keep line length limit for memory safety - chunk_size = 16384, -- Bytes per chunk for dynamic loading (16KB - fits ~100-200 lines) + max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB + chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines) + binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable) imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit', line_numbers = false, wrap_lines = false, show_file_info = true, - binary_file_threshold = 1024, filetypes = { svg = { wrap_lines = true }, markdown = { wrap_lines = true }, @@ -55,19 +153,12 @@ function M.setup(config) frecency = 'Number', debug = 'Comment', }, - layout = { - prompt_position = 'top', - preview_position = 'right', - preview_width = 0.4, - height = 0.8, - width = 0.8, - }, frecency = { enabled = true, db_path = vim.fn.stdpath('cache') .. '/fff_nvim', }, debug = { - enabled = false, + enabled = false, -- Set to true to show scores in the UI show_scores = false, }, logging = { @@ -75,23 +166,11 @@ function M.setup(config) log_file = vim.fn.stdpath('log') .. '/fff.log', log_level = 'info', }, - ui = { - wrap_paths = true, - wrap_indent = 2, - max_path_width = 80, - }, - image_preview = { - enabled = true, - max_width = 80, - max_height = 24, - }, - icons = { - enabled = true, - }, - ui_enabled = true, } - local merged_config = vim.tbl_deep_extend('force', default_config, config or {}) + local migrated_user_config = handle_deprecated_config(config) + local merged_config = vim.tbl_deep_extend('force', default_config, migrated_user_config) + M.config = merged_config if merged_config.logging.enabled then diff --git a/lua/fff/picker_ui.lua b/lua/fff/picker_ui.lua index e2399ea5..89443675 100644 --- a/lua/fff/picker_ui.lua +++ b/lua/fff/picker_ui.lua @@ -5,6 +5,230 @@ local preview = require('fff.file_picker.preview') local icons = require('fff.file_picker.icons') local git_utils = require('fff.git_utils') local main = require('fff.main') +local utils = require('fff.utils') + +local function get_prompt_position() + local config = M.state.config + + if config and config.layout and config.layout.prompt_position then + local terminal_width = vim.o.columns + local terminal_height = vim.o.lines + + return utils.resolve_config_value( + config.layout.prompt_position, + terminal_width, + terminal_height, + function(value) return utils.is_one_of(value, { 'top', 'bottom' }) end, + 'bottom', + 'layout.prompt_position' + ) + end + + return 'bottom' +end + +local function get_preview_position() + local config = M.state.config + + if config and config.layout and config.layout.preview_position then + local terminal_width = vim.o.columns + local terminal_height = vim.o.lines + + return utils.resolve_config_value( + config.layout.preview_position, + terminal_width, + terminal_height, + function(value) return utils.is_one_of(value, { 'left', 'right', 'top', 'bottom' }) end, + 'right', + 'layout.preview_position' + ) + end + + return 'right' +end + +--- Function-based config options: +--- config.layout.width: number|function(terminal_width, terminal_height): number +--- config.layout.height: number|function(terminal_width, terminal_height): number +--- config.layout.preview_size: number|function(terminal_width, terminal_height): number +--- config.layout.preview_position: string|function(terminal_width, terminal_height): string +--- config.layout.prompt_position: string|function(terminal_width, terminal_height): string + +--- @class LayoutConfig +--- @field total_width number +--- @field total_height number +--- @field start_col number +--- @field start_row number +--- @field preview_position string|function Preview position ('left'|'right'|'top'|'bottom') or function(terminal_width, terminal_height): string +--- @field prompt_position string +--- @field debug_enabled boolean +--- @field preview_width number +--- @field preview_height number +--- @field separator_width number +--- @field file_info_height number + +--- Calculate layout dimensions and positions for all windows +--- @param cfg LayoutConfig +--- @return table Layout configuration +function M.calculate_layout_dimensions(cfg) + local BORDER_SIZE = 2 + local PROMPT_HEIGHT = 2 + local SEPARATOR_WIDTH = 1 + local SEPARATOR_HEIGHT = 1 + + if not utils.is_one_of(cfg.preview_position, { 'left', 'right', 'top', 'bottom' }) then + error('Invalid preview position: ' .. tostring(cfg.preview_position)) + end + + local layout = {} + local preview_enabled = M.enabled_preview() + + -- Section 1: Base dimensions and bounds checking + local total_width = math.max(0, cfg.total_width - BORDER_SIZE) + local total_height = math.max(0, cfg.total_height - BORDER_SIZE - PROMPT_HEIGHT) + + -- Section 2: Calculate dimensions based on preview position + if cfg.preview_position == 'left' then + local separator_width = preview_enabled and SEPARATOR_WIDTH or 0 + local list_width = math.max(0, total_width - cfg.preview_width - separator_width) + local list_height = total_height + + layout.list_col = cfg.start_col + cfg.preview_width + 3 -- +3 for borders and separator + layout.list_width = list_width + layout.list_height = list_height + layout.input_col = layout.list_col + layout.input_width = list_width + + if preview_enabled then + layout.preview = { + col = cfg.start_col + 1, + row = cfg.start_row + 1, + width = cfg.preview_width, + height = list_height, + } + end + elseif cfg.preview_position == 'right' then + local separator_width = preview_enabled and SEPARATOR_WIDTH or 0 + local list_width = math.max(0, total_width - cfg.preview_width - separator_width) + local list_height = total_height + + layout.list_col = cfg.start_col + 1 + layout.list_width = list_width + layout.list_height = list_height + layout.input_col = layout.list_col + layout.input_width = list_width + + if preview_enabled then + layout.preview = { + col = cfg.start_col + list_width + 3, -- +3 for borders and separator (matches original) + row = cfg.start_row + 1, + width = cfg.preview_width, + height = list_height, + } + end + elseif cfg.preview_position == 'top' then + local separator_height = preview_enabled and SEPARATOR_HEIGHT or 0 + local list_height = math.max(0, total_height - cfg.preview_height - separator_height) + + layout.list_col = cfg.start_col + 1 + layout.list_width = total_width + layout.list_height = list_height + layout.input_col = layout.list_col + layout.input_width = total_width + layout.list_start_row = cfg.start_row + (preview_enabled and (cfg.preview_height + separator_height) or 0) + 1 + + if preview_enabled then + layout.preview = { + col = cfg.start_col + 1, + row = cfg.start_row + 1, + width = total_width, + height = cfg.preview_height, + } + end + else + local separator_height = preview_enabled and SEPARATOR_HEIGHT or 0 + local list_height = math.max(0, total_height - cfg.preview_height - separator_height) + + layout.list_col = cfg.start_col + 1 + layout.list_width = total_width + layout.list_height = list_height + layout.input_col = layout.list_col + layout.input_width = total_width + layout.list_start_row = cfg.start_row + 1 + + if preview_enabled then + layout.preview = { + col = cfg.start_col + 1, + width = total_width, + height = cfg.preview_height, + } + end + end + + -- Section 3: Position prompt and adjust row positions + if cfg.preview_position == 'left' or cfg.preview_position == 'right' then + if cfg.prompt_position == 'top' then + layout.input_row = cfg.start_row + 1 + layout.list_row = cfg.start_row + PROMPT_HEIGHT + 1 + else + layout.list_row = cfg.start_row + 1 + layout.input_row = cfg.start_row + cfg.total_height - BORDER_SIZE + end + + if layout.preview then + if cfg.prompt_position == 'top' then + layout.preview.row = cfg.start_row + 1 + layout.preview.height = cfg.total_height - BORDER_SIZE + else + layout.preview.row = cfg.start_row + 1 + layout.preview.height = cfg.total_height - BORDER_SIZE + end + end + else + local list_start_row = layout.list_start_row + if cfg.prompt_position == 'top' then + layout.input_row = list_start_row + layout.list_row = list_start_row + BORDER_SIZE + layout.list_height = math.max(0, layout.list_height - BORDER_SIZE) + else + layout.list_row = list_start_row + layout.input_row = list_start_row + layout.list_height + 1 + end + + if cfg.preview_position == 'bottom' and layout.preview then + if cfg.prompt_position == 'top' then + layout.preview.row = layout.list_row + layout.list_height + 1 + else + layout.preview.row = layout.input_row + PROMPT_HEIGHT + end + end + end + + -- Section 4: Position debug panel (if enabled) + if cfg.debug_enabled and preview_enabled and layout.preview then + if cfg.preview_position == 'left' or cfg.preview_position == 'right' then + layout.file_info = { + width = layout.preview.width, + height = cfg.file_info_height, + col = layout.preview.col, + row = layout.preview.row, + } + layout.preview.row = layout.preview.row + cfg.file_info_height + SEPARATOR_HEIGHT + 1 + layout.preview.height = math.max(3, layout.preview.height - cfg.file_info_height - SEPARATOR_HEIGHT - 1) + else + layout.file_info = { + width = layout.preview.width, + height = cfg.file_info_height, + col = layout.preview.col, + row = layout.preview.row, + } + layout.preview.row = layout.preview.row + cfg.file_info_height + SEPARATOR_HEIGHT + 1 + layout.preview.height = math.max(3, layout.preview.height - cfg.file_info_height - SEPARATOR_HEIGHT - 1) + end + end + + return layout +end if main.config and main.config.preview then preview.setup(main.config.preview) end @@ -39,7 +263,6 @@ M.state = { last_preview_file = nil, } ---- Create the picker UI function M.create_ui() local config = M.state.config @@ -50,21 +273,59 @@ function M.create_ui() and main.config.debug and main.config.debug.show_scores - local width = math.floor(vim.o.columns * config.width) - local height = math.floor(vim.o.lines * config.height) + local terminal_width = vim.o.columns + local terminal_height = vim.o.lines + + -- Calculate width and height (support function or number) + local width_ratio = utils.resolve_config_value( + config.layout.width, + terminal_width, + terminal_height, + utils.is_valid_ratio, + 0.8, + 'layout.width' + ) + local height_ratio = utils.resolve_config_value( + config.layout.height, + terminal_width, + terminal_height, + utils.is_valid_ratio, + 0.8, + 'layout.height' + ) + + local width = math.floor(terminal_width * width_ratio) + local height = math.floor(terminal_height * height_ratio) local col = math.floor((vim.o.columns - width) / 2) local row = math.floor((vim.o.lines - height) / 2) - local preview_width = M.enabled_preview() and math.floor(width * config.preview.width) or 0 - local list_width = width - preview_width - 3 -- Account for separators - local list_height = height - 4 -- Same as list window height + local prompt_position = get_prompt_position() + local preview_position = get_preview_position() - local file_info_height = 0 - local preview_height = list_height - if debug_enabled_in_preview then - file_info_height = 10 -- Fixed height of 10 lines for file info - preview_height = list_height - file_info_height -- No subtraction needed - borders are handled by window positioning - end + local preview_size_ratio = utils.resolve_config_value( + config.layout.preview_size, + terminal_width, + terminal_height, + utils.is_valid_ratio, + 0.4, + 'layout.preview_size' + ) + + local layout_config = { + total_width = width, + total_height = height, + start_col = col, + start_row = row, + preview_position = preview_position, + prompt_position = prompt_position, + debug_enabled = debug_enabled_in_preview, + preview_width = M.enabled_preview() and math.floor(width * preview_size_ratio) or 0, + preview_height = M.enabled_preview() and math.floor(height * preview_size_ratio) or 0, + separator_width = 3, + file_info_height = debug_enabled_in_preview and 10 or 0, + } + + local layout = M.calculate_layout_dimensions(layout_config) M.state.input_buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_option(M.state.input_buf, 'bufhidden', 'wipe') @@ -84,25 +345,34 @@ function M.create_ui() M.state.file_info_buf = nil end - M.state.list_win = vim.api.nvim_open_win(M.state.list_buf, false, { + -- Create list window with conditional title based on prompt position + local list_window_config = { relative = 'editor', - width = list_width, - height = list_height, -- Use calculated list height - col = col + 1, - row = row + 1, + width = layout.list_width, + height = layout.list_height, + col = layout.list_col, + row = layout.list_row, border = 'single', style = 'minimal', - title = ' Files ', - title_pos = 'left', - }) + } - if debug_enabled_in_preview then + local title = ' ' .. (M.state.config.title or 'FFFiles') .. ' ' + -- Only add title if prompt is at bottom - when prompt is top, title should be on input + if prompt_position == 'bottom' then + list_window_config.title = title + list_window_config.title_pos = 'left' + end + + M.state.list_win = vim.api.nvim_open_win(M.state.list_buf, false, list_window_config) + + -- Create file info window if debug enabled + if debug_enabled_in_preview and layout.file_info then M.state.file_info_win = vim.api.nvim_open_win(M.state.file_info_buf, false, { relative = 'editor', - width = preview_width, - height = file_info_height, - col = col + list_width + 3, - row = row + 1, + width = layout.file_info.width, + height = layout.file_info.height, + col = layout.file_info.col, + row = layout.file_info.row, border = 'single', style = 'minimal', title = ' File Info ', @@ -112,32 +382,39 @@ function M.create_ui() M.state.file_info_win = nil end - local preview_row = debug_enabled_in_preview and (row + file_info_height + 3) or (row + 1) - local preview_height_adj = debug_enabled_in_preview and preview_height or (list_height + 2) - - if M.enabled_preview() then + -- Create preview window + if M.enabled_preview() and layout.preview then M.state.preview_win = vim.api.nvim_open_win(M.state.preview_buf, false, { relative = 'editor', - width = preview_width, - height = preview_height_adj, - col = col + list_width + 3, - row = preview_row, + width = layout.preview.width, + height = layout.preview.height, + col = layout.preview.col, + row = layout.preview.row, border = 'single', style = 'minimal', - title = ' PREVIEW TEST TITLE ', + title = ' Preview ', title_pos = 'left', }) end - M.state.input_win = vim.api.nvim_open_win(M.state.input_buf, false, { + -- Create input window with conditional title based on prompt position + local input_window_config = { relative = 'editor', - width = list_width, + width = layout.input_width, height = 1, - col = col + 1, - row = row + height - 2, + col = layout.input_col, + row = layout.input_row, border = 'single', style = 'minimal', - }) + } + + -- Add title if prompt is at top - title appears above the prompt + if prompt_position == 'top' then + input_window_config.title = title + input_window_config.title_pos = 'left' + end + + M.state.input_win = vim.api.nvim_open_win(M.state.input_buf, false, input_window_config) M.setup_buffers() M.setup_windows() @@ -295,9 +572,7 @@ function M.focus_input_win() vim.api.nvim_win_call(M.state.input_win, function() vim.cmd('startinsert!') end) end ---- Toggle debug display function M.toggle_debug() - local main = require('fff.main') local old_debug_state = main.config.debug.show_scores main.config.debug.show_scores = not main.config.debug.show_scores local status = main.config.debug.show_scores and 'enabled' or 'disabled' @@ -387,18 +662,35 @@ function M.update_results_sync() end end + local prompt_position = get_prompt_position() + + -- Calculate dynamic max_results based on visible window height + local dynamic_max_results = M.state.config.max_results + if M.state.list_win and vim.api.nvim_win_is_valid(M.state.list_win) then + local win_height = vim.api.nvim_win_get_height(M.state.list_win) + dynamic_max_results = win_height + else + dynamic_max_results = M.state.config.max_results or 100 + end + local results = file_picker.search_files( M.state.query, - M.state.config.max_results, + dynamic_max_results, M.state.config.max_threads, - M.state.current_file_cache + M.state.current_file_cache, + prompt_position == 'bottom' ) -- because the actual files could be different even with same count M.state.items = results M.state.filtered_items = results - M.state.cursor = 1 - M.state.top = 1 + + if prompt_position == 'bottom' then + M.state.cursor = #results > 0 and #results or 1 + else + M.state.cursor = 1 + end + M.render_debounced() end @@ -465,145 +757,135 @@ local function format_file_display(item, max_width) return filename, display_path end ---- Render the list function M.render_list() if not M.state.active then return end local items = M.state.filtered_items - local lines = {} - - local main = require('fff.main') local max_path_width = main.config.ui and main.config.ui.max_path_width or 80 local debug_enabled = main.config and main.config.debug and main.config.debug.show_scores local win_height = vim.api.nvim_win_get_height(M.state.list_win) + local win_width = vim.api.nvim_win_get_width(M.state.list_win) local display_count = math.min(#items, win_height) - local empty_lines_needed = win_height - display_count + local empty_lines_needed = 0 - for i = 1, empty_lines_needed do - table.insert(lines, '') + local prompt_position = get_prompt_position() + local cursor_line = 0 + if #items > 0 then + if prompt_position == 'bottom' then + empty_lines_needed = win_height - display_count + cursor_line = empty_lines_needed + M.state.cursor + else + cursor_line = M.state.cursor + end + cursor_line = math.max(1, math.min(cursor_line, win_height)) end - local end_idx = math.min(#items, display_count) - local items_to_show = {} - for i = 1, end_idx do - table.insert(items_to_show, items[i]) + local padded_lines = {} + if prompt_position == 'bottom' then + for _ = 1, empty_lines_needed do + table.insert(padded_lines, string.rep(' ', win_width + 5)) + end end - local reversed_items = {} - for i = #items_to_show, 1, -1 do - table.insert(reversed_items, items_to_show[i]) - end + local icon_data = {} + local path_data = {} - local line_data = {} + for i = 1, display_count do + local item = items[i] - for i, item in ipairs(reversed_items) do local icon, icon_hl_group = icons.get_icon_display(item.name, item.extension, false) + icon_data[i] = { icon, icon_hl_group } + local frecency = '' - local total_frecency = (item.total_frecency_score or 0) - local access_frecency = (item.access_frecency_score or 0) - local mod_frecency = (item.modification_frecency_score or 0) - - if total_frecency > 0 and debug_enabled then - local indicator = '' - if mod_frecency >= 8 then -- High modification frecency (recently modified git file) - indicator = '🔥' -- Fire for recently modified - elseif access_frecency >= 8 then -- High access frecency (recently accessed) - indicator = '⭐' -- Star for frequently accessed - elseif total_frecency >= 4 then -- Medium total frecency - indicator = '✨' -- Sparkle for moderate activity - elseif total_frecency >= 1 then -- Low frecency - indicator = '•' -- Dot for minimal activity + if debug_enabled then + local total_frecency = (item.total_frecency_score or 0) + local access_frecency = (item.access_frecency_score or 0) + local mod_frecency = (item.modification_frecency_score or 0) + + if total_frecency > 0 then + local indicator = '' + if mod_frecency >= 6 then + indicator = '🔥' + elseif access_frecency >= 4 then + indicator = '⭐' + elseif total_frecency >= 3 then + indicator = '✨' + elseif total_frecency >= 1 then + indicator = '•' + end + frecency = string.format(' %s%d', indicator, total_frecency) end - frecency = string.format(' %s%d', indicator, total_frecency) end - local suffix = frecency - local current_indicator = '' - if item.is_current_file then current_indicator = ' (current)' end + local current_indicator = item.is_current_file and ' (current)' or '' + local available_width = math.max(max_path_width - #icon - 1 - #frecency - #current_indicator, 40) - local available_width = math.max(max_path_width - #icon - 1 - #suffix - #current_indicator, 40) local filename, dir_path = format_file_display(item, available_width) + path_data[i] = { filename, dir_path } - local line - if dir_path ~= '' then - line = string.format('%s %s %s%s%s', icon, filename, dir_path, suffix, current_indicator) - else - line = string.format('%s %s%s%s', icon, filename, suffix, current_indicator) - end - + local line = string.format('%s %s %s%s%s', icon, filename, dir_path, frecency, current_indicator) if item.is_current_file then line = string.format('\027[90m%s\027[0m', line) end - table.insert(lines, line) - line_data[i] = { - filename_len = #filename, - dir_path_len = #dir_path, - icon_highlight = { - hl_group = icon_hl_group, - icon_length = vim.fn.strdisplaywidth(icon), - git_status = item.git_status, - }, - } - end - - local win_width = vim.api.nvim_win_get_width(M.state.list_win) - local padded_lines = {} - for _, line in ipairs(lines) do local line_len = vim.fn.strdisplaywidth(line) - local padding = math.max(0, win_width - line_len + 5) -- +5 extra to ensure full coverage - local padded_line = line .. string.rep(' ', padding) - table.insert(padded_lines, padded_line) + local padding = math.max(0, win_width - line_len + 5) + table.insert(padded_lines, line .. string.rep(' ', padding)) end vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', true) vim.api.nvim_buf_set_lines(M.state.list_buf, 0, -1, false, padded_lines) vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', false) - if #items > 0 then - -- cursor=1 means first/best item, which appears at the last line after reversal - local cursor_line = empty_lines_needed + (display_count - M.state.cursor + 1) - - if cursor_line > 0 and cursor_line <= win_height then - vim.api.nvim_win_set_cursor(M.state.list_win, { cursor_line, 0 }) - - vim.api.nvim_buf_clear_namespace(M.state.list_buf, M.state.ns_id, 0, -1) - - vim.api.nvim_buf_add_highlight( - M.state.list_buf, - M.state.ns_id, - M.state.config.hl.active_file, - cursor_line - 1, - 0, - -1 - ) - - local current_line = vim.api.nvim_buf_get_lines(M.state.list_buf, cursor_line - 1, cursor_line, false)[1] or '' - local line_len = vim.fn.strdisplaywidth(current_line) - local remaining_width = math.max(0, vim.api.nvim_win_get_width(M.state.list_win) - line_len) - - if remaining_width > 0 then - vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, cursor_line - 1, -1, { - virt_text = { { string.rep(' ', remaining_width), M.state.config.hl.active_file } }, - virt_text_pos = 'eol', - }) - end + vim.api.nvim_buf_clear_namespace(M.state.list_buf, M.state.ns_id, 0, -1) + + if #items > 0 and cursor_line > 0 and cursor_line <= win_height then + vim.api.nvim_win_set_cursor(M.state.list_win, { cursor_line, 0 }) + + -- Cursor line highlighting + vim.api.nvim_buf_add_highlight( + M.state.list_buf, + M.state.ns_id, + M.state.config.hl.active_file, + cursor_line - 1, + 0, + -1 + ) + + -- Fill remaining width for cursor line + local current_line = padded_lines[cursor_line] or '' + local line_len = vim.fn.strdisplaywidth(current_line) + local remaining_width = math.max(0, win_width - line_len) + + if remaining_width > 0 then + vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, cursor_line - 1, -1, { + virt_text = { { string.rep(' ', remaining_width), M.state.config.hl.active_file } }, + virt_text_pos = 'eol', + }) end - for line_idx, line_content in ipairs(lines) do - if line_content ~= '' then -- Skip empty lines - local content_line_idx = line_idx - empty_lines_needed + for i = 1, display_count do + local item = items[i] + + local line_idx = empty_lines_needed + i + local is_cursor_line = line_idx == cursor_line + local line_content = padded_lines[line_idx] + + if line_content then + local icon, icon_hl_group = unpack(icon_data[i]) + local filename, dir_path = unpack(path_data[i]) - local icon_info = line_data[content_line_idx].icon_highlight - if icon_info and icon_info.hl_group and icon_info.icon_length > 0 then + -- Icon highlighting + if icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then vim.api.nvim_buf_add_highlight( M.state.list_buf, M.state.ns_id, - icon_info.hl_group, + icon_hl_group, line_idx - 1, 0, - icon_info.icon_length + vim.fn.strdisplaywidth(icon) ) end + -- Frecency highlighting if debug_enabled then local star_start, star_end = line_content:find('⭐%d+') if star_start then @@ -618,47 +900,28 @@ function M.render_list() end end - local debug_start, debug_end = line_content:find('%[%d+|[^%]]*%]') - if debug_start then + local icon_match = line_content:match('^%S+') + if icon_match and #filename > 0 and #dir_path > 0 then + local prefix_len = #icon_match + 1 + #filename + 1 vim.api.nvim_buf_add_highlight( M.state.list_buf, M.state.ns_id, - M.state.config.hl.debug, + 'Comment', line_idx - 1, - debug_start - 1, - debug_end + prefix_len, + prefix_len + #dir_path ) end - local icon_match = line_content:match('^%S+') -- First non-space sequence (icon) - if icon_match then - local filename_len = line_data[content_line_idx].filename_len - local dir_path_len = line_data[content_line_idx].dir_path_len - - if filename_len > 0 and dir_path_len > 0 then - local prefix_len = #icon_match + 1 + filename_len + 1 -- icon + space + filename + space - - vim.api.nvim_buf_add_highlight( - M.state.list_buf, - M.state.ns_id, - 'Comment', - line_idx - 1, - prefix_len, - prefix_len + dir_path_len - ) - end - end - - local is_cursor_line = line_idx == cursor_line - local border_char = ' ' -- render space so it is highlighted + local border_char = ' ' local border_hl = nil - if icon_info and icon_info.git_status and git_utils.should_show_border(icon_info.git_status) then - border_char = git_utils.get_border_char(icon_info.git_status) + if item.git_status and git_utils.should_show_border(item.git_status) then + border_char = git_utils.get_border_char(item.git_status) if is_cursor_line then - border_hl = git_utils.get_border_highlight_selected(icon_info.git_status) + border_hl = git_utils.get_border_highlight_selected(item.git_status) else - border_hl = git_utils.get_border_highlight(icon_info.git_status) + border_hl = git_utils.get_border_highlight(item.git_status) end end @@ -696,30 +959,58 @@ function M.update_preview() end if M.state.last_preview_file == item.path then return end - - local preview = require('fff.file_picker.preview') preview.clear() M.state.last_preview_file = item.path - local relative_path = item.relative_path or item.path -- Use relative path if available - local win_width = vim.api.nvim_win_get_width(M.state.preview_win) - local max_title_width = win_width - 4 -- Account for border and padding + local relative_path = item.relative_path or item.path + local max_title_width = vim.api.nvim_win_get_width(M.state.preview_win) local title - if #relative_path <= max_title_width then + local target_length = max_title_width + + if #relative_path + 2 <= target_length then title = string.format(' %s ', relative_path) else - local filename = vim.fn.fnamemodify(relative_path, ':t') - local dirname = vim.fn.fnamemodify(relative_path, ':h') - local available_dir_width = max_title_width - #filename - 6 -- Account for '.../' and spaces + local available_chars = target_length - 2 - if available_dir_width > 10 then - local truncated_dir = '...' .. dirname:sub(-available_dir_width + 3) - title = string.format(' %s/%s ', truncated_dir, filename) + local filename = vim.fn.fnamemodify(relative_path, ':t') + if available_chars <= 3 then + title = filename else - if #filename > max_title_width - 4 then filename = filename:sub(1, max_title_width - 7) .. '...' end - title = string.format(' %s ', filename) + if #filename + 5 <= available_chars then + local normalized_path = vim.fs.normalize(relative_path) + local path_parts = vim.split(normalized_path, '[/\\]', { plain = false }) + + local segments = {} + for _, part in ipairs(path_parts) do + if part ~= '' then table.insert(segments, part) end + end + + local segments_to_show = { filename } + local current_length = #filename + 4 -- 4 for '../' prefix and spaces + + for i = #segments - 1, 1, -1 do + local segment = segments[i] + local new_length = current_length + #segment + 1 -- +1 for '/' + + if new_length <= available_chars then + table.insert(segments_to_show, 1, segment) + current_length = new_length + else + break + end + end + + if #segments_to_show == #segments then + title = string.format(' %s ', table.concat(segments_to_show, '/')) + else + title = string.format(' ../%s ', table.concat(segments_to_show, '/')) + end + else + local truncated_filename = filename:sub(1, available_chars - 3) .. '...' + title = string.format(' %s ', truncated_filename) + end end end @@ -800,28 +1091,26 @@ function M.update_status(progress) }) end ---- Move cursor up (towards worse results, which are visually higher) function M.move_up() if not M.state.active then return end + if #M.state.filtered_items == 0 then return end - if M.state.cursor < #M.state.filtered_items then - M.state.cursor = M.state.cursor + 1 - M.render_list() - M.update_preview() - M.update_status() - end + M.state.cursor = math.max(M.state.cursor - 1, 1) + + M.render_list() + M.update_preview() + M.update_status() end ---- Move cursor down (towards better results, which are visually lower) function M.move_down() if not M.state.active then return end + if #M.state.filtered_items == 0 then return end - if M.state.cursor > 1 then - M.state.cursor = M.state.cursor - 1 - M.render_list() - M.update_preview() - M.update_status() - end + M.state.cursor = math.min(M.state.cursor + 1, #M.state.filtered_items) + + M.render_list() + M.update_preview() + M.update_status() end --- Scroll preview up by half window height @@ -916,7 +1205,6 @@ function M.close() M.state.items = {} M.state.filtered_items = {} M.state.cursor = 1 - M.state.top = 1 M.state.query = '' M.state.ns_id = nil M.state.last_preview_file = nil diff --git a/lua/fff/rust/file_picker.rs b/lua/fff/rust/file_picker.rs index a3a2496f..c9530824 100644 --- a/lua/fff/rust/file_picker.rs +++ b/lua/fff/rust/file_picker.rs @@ -161,11 +161,15 @@ impl FilePicker { max_results: usize, max_threads: usize, current_file: Option<&'a str>, + reverse_order: bool, ) -> SearchResult<'a> { let max_threads = max_threads.max(1); debug!( - "Fuzzy search: query='{}', max_results={}, max_threads={}, current_file={:?}", - query, max_results, max_threads, current_file + ?query, + ?max_results, + ?max_threads, + ?current_file, + "Fuzzy search", ); let total_files = files.len(); @@ -178,17 +182,19 @@ impl FilePicker { max_threads, current_file, max_results, + reverse_order, }; let time = std::time::Instant::now(); let (items, scores, total_matched) = match_and_score_files(files, &context); + debug!( - "Fuzzy search completed in {:?}: found {} results for query '{}', top result {:?}", - time.elapsed(), - total_matched, - query, - items.first(), + ?query, + completed_in = ?time.elapsed(), + top_position = ?items.first(), + "Fuzzy search completed", ); + SearchResult { items, scores, diff --git a/lua/fff/rust/lib.rs b/lua/fff/rust/lib.rs index 7735fc2d..e1509c56 100644 --- a/lua/fff/rust/lib.rs +++ b/lua/fff/rust/lib.rs @@ -94,7 +94,13 @@ pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> { pub fn fuzzy_search_files( lua: &Lua, - (query, max_results, max_threads, current_file): (String, usize, usize, Option), + (query, max_results, max_threads, current_file, order_reverse): ( + String, + usize, + usize, + Option, + bool, + ), ) -> LuaResult { let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else { return Err(Error::FilePickerMissing)?; @@ -106,6 +112,7 @@ pub fn fuzzy_search_files( max_results, max_threads, current_file.as_deref(), + order_reverse, ); results.into_lua(lua) diff --git a/lua/fff/rust/score.rs b/lua/fff/rust/score.rs index 7a4b24db..a02eca18 100644 --- a/lua/fff/rust/score.rs +++ b/lua/fff/rust/score.rs @@ -68,7 +68,7 @@ pub fn match_and_score_files<'a>( }; let mut next_filename_match_index = 0; - let mut results: Vec<_> = path_matches + let results: Vec<_> = path_matches .into_iter() .enumerate() .map(|(index, path_match)| { @@ -148,16 +148,7 @@ pub fn match_and_score_files<'a>( }) .collect(); - results.sort_by(|a, b| { - b.1.total - .cmp(&a.1.total) - .then_with(|| b.0.modified.cmp(&a.0.modified)) - }); - - let total_matched = results.len(); - results.truncate(context.max_results); - let (items, scores) = results.into_iter().unzip(); - (items, scores, total_matched) + sort_and_truncate(results, context) } /// Check if a filename is a special entry point file that deserves bonus scoring @@ -189,7 +180,7 @@ fn score_all_by_frecency<'a>( files: &'a [FileItem], context: &ScoringContext, ) -> (Vec<&'a FileItem>, Vec, usize) { - let mut results: Vec<_> = files + let results: Vec<_> = files .par_iter() .map(|file| { let total_frecency_score = file.access_frecency_score as i32 @@ -216,15 +207,7 @@ fn score_all_by_frecency<'a>( }) .collect(); - results.sort_by(|a, b| { - b.1.total - .cmp(&a.1.total) - .then_with(|| b.0.modified.cmp(&a.0.modified)) - }); - let total_matched = results.len(); - results.truncate(context.max_results); - let (items, scores) = results.into_iter().unzip(); - (items, scores, total_matched) + sort_and_truncate(results, context) } #[inline] @@ -242,3 +225,32 @@ fn calculate_file_bonus(file: &FileItem, context: &ScoringContext) -> i32 { bonus } + +/// Dynamically sorts and returns the top results either in ascending or descending order +fn sort_and_truncate<'a>( + mut results: Vec<(&'a FileItem, Score)>, + context: &ScoringContext, +) -> (Vec<&'a FileItem>, Vec, usize) { + let total_matched = results.len(); + if context.reverse_order { + results.sort_by(|a, b| { + a.1.total + .cmp(&b.1.total) + .then_with(|| a.0.modified.cmp(&b.0.modified)) + }); + + if results.len() > context.max_results { + results.drain(0..(total_matched - context.max_results)); + } + } else { + results.sort_by(|a, b| { + b.1.total + .cmp(&a.1.total) + .then_with(|| b.0.modified.cmp(&a.0.modified)) + }); + + results.truncate(context.max_results); + } + let (items, scores) = results.into_iter().unzip(); + (items, scores, total_matched) +} diff --git a/lua/fff/rust/types.rs b/lua/fff/rust/types.rs index e423b683..141708b3 100644 --- a/lua/fff/rust/types.rs +++ b/lua/fff/rust/types.rs @@ -34,6 +34,7 @@ pub struct ScoringContext<'a> { pub max_results: usize, pub max_typos: u16, pub max_threads: usize, + pub reverse_order: bool, } #[derive(Debug, Clone, Default)] diff --git a/lua/fff/utils.lua b/lua/fff/utils.lua index a6f6b69a..41188784 100644 --- a/lua/fff/utils.lua +++ b/lua/fff/utils.lua @@ -17,4 +17,46 @@ function M.format_file_size(size) end end +--- Safely resolve a config value that can be either a static value or a function +--- @param config_value any The config value (can be function or static value) +--- @param terminal_width number Terminal width for function calls +--- @param terminal_height number Terminal height for function calls +--- @param validator function Function to validate the result +--- @param fallback any Fallback value if function fails or returns invalid value +--- @param error_context string Context for error messages +--- @return any The resolved and validated value +function M.resolve_config_value(config_value, terminal_width, terminal_height, validator, fallback, error_context) + if type(config_value) == 'function' then + local success, result = pcall(config_value, terminal_width, terminal_height) + + if success and validator(result) then + return result + else + if not success then + vim.notify('FFF: Error in ' .. error_context .. ' function: ' .. tostring(result), vim.log.levels.WARN) + end + return fallback + end + else + return config_value + end +end + +--- Validate numeric ratio (0 < value <= 1) +--- @param value any Value to validate +--- @return boolean True if valid numeric ratio +function M.is_valid_ratio(value) return type(value) == 'number' and value > 0 and value <= 1 end + +--- Validate position string +--- @param value any Value to validate +--- @param values table List of valid values strings +--- @return boolean True if valid position +function M.is_one_of(value, values) + if type(value) ~= 'string' then return false end + for _, pos in ipairs(values) do + if value == pos then return true end + end + return false +end + return M diff --git a/src/bin/jemalloc_profile.rs b/src/bin/jemalloc_profile.rs index ffd9994c..229ea2b1 100644 --- a/src/bin/jemalloc_profile.rs +++ b/src/bin/jemalloc_profile.rs @@ -89,6 +89,7 @@ fn test_search_memory_pattern( 50 + (i % 50), // Vary result count 1 + (i % 4), // Vary thread count None, + false, // prompt_position not relevant for test ); (search_result.items.len(), search_result.total_matched) } else { diff --git a/src/bin/test_memory_leak.rs b/src/bin/test_memory_leak.rs index 5456d505..19c77373 100644 --- a/src/bin/test_memory_leak.rs +++ b/src/bin/test_memory_leak.rs @@ -205,6 +205,7 @@ fn main() -> Result<(), Box> { max_results, max_threads, None, + false, // prompt_position not relevant for test ); let duration = search_start.elapsed(); (search_result.items.len(), duration) diff --git a/src/bin/test_watcher.rs b/src/bin/test_watcher.rs index 967b7bbd..945f8d46 100644 --- a/src/bin/test_watcher.rs +++ b/src/bin/test_watcher.rs @@ -156,7 +156,7 @@ fn main() -> Result<(), Box> { let timestamp = chrono::Local::now().format("%H:%M:%S"); let file_picker = FILE_PICKER.read().unwrap(); let files = file_picker.as_ref().unwrap().get_files(); - let search_results = FilePicker::fuzzy_search(files, "rs", 5, 2, None); + let search_results = FilePicker::fuzzy_search(files, "rs", 5, 2, None, false); println!( "🔍 [{}] Search test 'rs': {} matches",