local config = require "frecency.config" local fs = require "frecency.fs" local os_util = require "frecency.os_util" local log = require "frecency.log" local Job = require "plenary.job" local async = require "plenary.async" --[[@as FrecencyPlenaryAsync]] ---@class FrecencyFinder ---@field config FrecencyFinderConfig ---@field closed boolean ---@field entries FrecencyEntry[] ---@field scanned_entries FrecencyEntry[] ---@field entry_maker FrecencyEntryMakerInstance ---@field path? string ---@field private database FrecencyDatabase ---@field private rx FrecencyPlenaryAsyncControlChannelRx ---@field private tx FrecencyPlenaryAsyncControlChannelTx ---@field private scan_rx FrecencyPlenaryAsyncControlChannelRx ---@field private scan_tx FrecencyPlenaryAsyncControlChannelTx ---@field private need_scan_db boolean ---@field private need_scan_dir boolean ---@field private seen table ---@field private process VimSystemObj? ---@field private recency FrecencyRecency ---@field private state FrecencyState local Finder = {} ---@class FrecencyFinderConfig ---@field chunk_size? integer default: 1000 ---@field ignore_filenames? string[] default: {} ---@field sleep_interval? integer default: 50 ---@param database FrecencyDatabase ---@param entry_maker FrecencyEntryMakerInstance ---@param need_scandir boolean ---@param path string? ---@param recency FrecencyRecency ---@param state FrecencyState ---@param finder_config? FrecencyFinderConfig ---@return FrecencyFinder Finder.new = function(database, entry_maker, need_scandir, path, recency, state, finder_config) local tx, rx = async.control.channel.mpsc() local scan_tx, scan_rx = async.control.channel.mpsc() local self = setmetatable({ config = vim.tbl_extend("force", { chunk_size = 1000, sleep_interval = 50 }, finder_config or {}), closed = false, database = database, entry_maker = entry_maker, path = path, recency = recency, state = state, seen = {}, entries = {}, scanned_entries = {}, need_scan_db = true, need_scan_dir = need_scandir and path, rx = rx, tx = tx, scan_rx = scan_rx, scan_tx = scan_tx, }, { __index = Finder, ---@param self FrecencyFinder __call = function(self, ...) return self:find(...) end, }) if self.config.ignore_filenames then for _, name in ipairs(self.config.ignore_filenames or {}) do self.seen[name] = true end end return self end ---@param epoch? integer ---@return nil function Finder:start(epoch) local ok if config.workspace_scan_cmd ~= "LUA" and self.need_scan_dir then ---@type string[][] local cmds = config.workspace_scan_cmd and { config.workspace_scan_cmd } or { { "fdfind", "-Htf", "-E", ".git" }, { "fd", "-Htf", "-E", ".git" }, { "rg", "-.g", "!.git", "--files" } } for _, c in ipairs(cmds) do ok = self:scan_dir_cmd(c) if ok then log.debug("scan_dir_cmd: " .. vim.inspect(c)) break end end end async.void(function() -- NOTE: return to the main loop to show the main window async.util.scheduler() for _, file in ipairs(self:get_results(self.path, epoch)) do file.path = os_util.normalize_sep(file.path) local entry = self.entry_maker(file) self.tx.send(entry) end self.tx.send(nil) if self.need_scan_dir and not ok then log.debug "scan_dir_lua" async.util.scheduler() self:scan_dir_lua() end end)() end ---@param cmd string[] ---@return boolean function Finder:scan_dir_cmd(cmd) local function stdout(err, chunk) if not self.closed and not err and chunk then for name in chunk:gmatch "[^\n]+" do local cleaned = name:gsub("^%./", "") local fullpath = os_util.join_path(self.path, cleaned) local entry = self.entry_maker { id = 0, count = 0, path = fullpath, score = 0 } self.scan_tx.send(entry) end end end local function on_exit() self.process = nil self:close() self.scan_tx.send(nil) end local ok if vim.system then ---@diagnostic disable-next-line: assign-type-mismatch ok, self.process = pcall(vim.system, cmd, { cwd = self.path, text = true, stdout = stdout, }, on_exit) else -- for Neovim v0.9.x ok, self.process = pcall(function() local args = {} for i, arg in ipairs(cmd) do if i > 1 then table.insert(args, arg) end end log.debug { cmd = cmd[1], args = args } local job = Job:new { cwd = self.path, command = cmd[1], args = args, on_stdout = stdout, on_exit = on_exit, } job:start() return job.handle end) end if not ok then self.process = nil end return ok end ---@async ---@return nil function Finder:scan_dir_lua() local count = 0 for name in fs.scan_dir(self.path) do if self.closed then break end local fullpath = os_util.join_path(self.path, name) local entry = self.entry_maker { id = 0, count = 0, path = fullpath, score = 0 } self.scan_tx.send(entry) count = count + 1 if count % self.config.chunk_size == 0 then async.util.sleep(self.config.sleep_interval) end end self.scan_tx.send(nil) end ---@async ---@param _ string ---@param process_result fun(entry: FrecencyEntry): nil ---@param process_complete fun(): nil ---@return nil function Finder:find(_, process_result, process_complete) if self:process_table(process_result, self.entries) then return end if self.need_scan_db then if self:process_channel(process_result, self.entries, self.rx) then return end self.need_scan_db = false end -- HACK: This is needed for heavy workspaces to show up entries immediately. async.util.scheduler() if self:process_table(process_result, self.scanned_entries) then return end if self.need_scan_dir then if self:process_channel(process_result, self.scanned_entries, self.scan_rx, #self.entries) then return end self.need_scan_dir = false end process_complete() end ---@param process_result fun(entry: FrecencyEntry): nil ---@param entries FrecencyEntry[] ---@return boolean? function Finder:process_table(process_result, entries) for _, entry in ipairs(entries) do if process_result(entry) then return true end end end ---@async ---@param process_result fun(entry: FrecencyEntry): nil ---@param entries FrecencyEntry[] ---@param rx FrecencyPlenaryAsyncControlChannelRx ---@param start_index? integer ---@return boolean? function Finder:process_channel(process_result, entries, rx, start_index) -- HACK: This is needed for small workspaces that it shows up entries fast. async.util.sleep(self.config.sleep_interval) local index = #entries > 0 and entries[#entries].index or start_index or 0 local count = 0 while true do local entry = rx.recv() if not entry then break elseif not self.seen[entry.filename] then self.seen[entry.filename] = true index = index + 1 entry.index = index table.insert(entries, entry) if process_result(entry) then return true end end count = count + 1 if count % self.config.chunk_size == 0 then self:reflow_results() end end end ---@param workspace? string ---@param epoch? integer ---@return FrecencyFile[] function Finder:get_results(workspace, epoch) log.debug { workspace = workspace or "NONE" } local start_fetch = os.clock() local files = self.database:get_entries(workspace, epoch) log.debug(("it takes %f seconds in fetching entries"):format(os.clock() - start_fetch)) local start_results = os.clock() local elapsed_recency = 0 for _, file in ipairs(files) do local start_recency = os.clock() file.score = file.ages and self.recency:calculate(file.count, file.ages) or 0 file.ages = nil elapsed_recency = elapsed_recency + (os.clock() - start_recency) end log.debug(("it takes %f seconds in calculating recency"):format(elapsed_recency)) log.debug(("it takes %f seconds in making results"):format(os.clock() - start_results)) local start_sort = os.clock() table.sort(files, function(a, b) return a.score > b.score or (a.score == b.score and a.path > b.path) end) log.debug(("it takes %f seconds in sorting"):format(os.clock() - start_sort)) return files end function Finder:close() self.closed = true if self.process then self.process:kill(9) end end ---@async ---@return nil function Finder:reflow_results() local picker = self.state:get() if not picker then return end async.util.scheduler() local function reflow() local bufnr = picker.results_bufnr local win = picker.results_win if not bufnr or not win or not vim.api.nvim_buf_is_valid(bufnr) or not vim.api.nvim_win_is_valid(win) then return end picker:clear_extra_rows(bufnr) if picker.sorting_strategy == "descending" then local manager = picker.manager if not manager then return end local worst_line = picker:get_row(manager:num_results()) local wininfo = vim.fn.getwininfo(win)[1] local bottom = vim.api.nvim_buf_line_count(bufnr) if not self.reflowed or worst_line > wininfo.botline then self.reflowed = true vim.api.nvim_win_set_cursor(win, { bottom, 0 }) end end end if vim.in_fast_event() then reflow() else vim.schedule(reflow) end end return Finder