mirror of
https://github.com/kristoferssolo/telescope-frecency.nvim.git
synced 2025-10-21 20:10:38 +00:00
Now it uses realpath for registering and validating DB. This means, if you have entries that has filenames differing only for case, it can deal with them as they exist. Before this, it has miscalculated scores for such cases. For example, in case you have `/path/to/foo.lua` and `/path/to/Foo.lua`, it registers entries for each file. Now it detects accurate filename for the specified one, and removes it in validation. * test: separate logic for utils * fix!: register realpath for consistency * refactor: convert fs module from class * refactor: move db initialization phase to start() * fix: run database:start() truly asynchronously * fix: call each functions with async wrapping * refactor: add types for args in command * fix: run register() synchronously Because vim.api.nvim_* cannot be used in asynchronous functions. * docs: add note for calling setup() twice * fix: run non-fast logic on next tick
328 lines
9.3 KiB
Lua
328 lines
9.3 KiB
Lua
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<string, boolean>
|
|
---@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
|