mirror of
https://github.com/kristoferssolo/telescope-frecency.nvim.git
synced 2025-10-21 20:10:38 +00:00
refactor: reach the master (#93)
* refactor(db): use new sql.nvim api * refactor(*): create const, algo and add more util * refactor(picker): try to cleanup and make it easy to read FIXME: it seems that I didn't refactor the entry maker right. * refactor(*): move util to lua/frecency * misc(db): flag possible bug * refactor(db): reflect changes introduced in https://github.com/tami5/sql.nvim/pull/96 * refactor(db): move set_config to frecency.lua * new(db): ignore term and octo paths This may possibly fix the issue with entry.count being nil. * misc(picker): move health out of export * refactor(util) * refactor(db): general * misc(*): nothing major * refactor(db): abbreviate table namespace access * refactor(picker): working-ish * fix(*): general * refactor(picker): move fd function to the end of the file * refactor(*): remove the need for db.init * new(db): use foreign keys * sync with sqlite.lua@#105 * feat: add settings for StyLua * fix: detect the valid module in healthcheck See #65 * style: fix by StyLua * fix: detect CWD tag to cut paths See #66 * fix: show icon before directory See #67 * fix: deal with show_filter_column option validly * feat: support opts.workspace (#68) * doc(readme): update config example (#33) remove comma causing error. * doc(readme): fix packer install instructions (#34) Co-authored-by: tami5 <65782666+tami5@users.noreply.github.com> * doc: follow sqlite new release (#40) * refactor(sql_wrapper): follow sqlite new release * update readme * refactor: follow telescope's interface changes See #46 * feat: add default_workspace tag See #43 * fix: fetch workspaces in addition to completing See #72 * Update url for sqlite dependency (#64) The old repository on github redirects to this one. * fix: use vim.notify not to block outputs See #75 * feat: opts.path_display to customize See #76 * Enable to specify tags to show the tails (#77) * Enable to specify tags to show the tails * Add doc for show_filter_column * feat: use more reasonable matcher to sort See #73 * Fix broken Frecency Algorithm link in README.md (#82) MDN appear to have removed the Frecency Algorithm page linked in the README document. Updating link to use archived version. * fix: set the telescope default filetype in prompt See #81 * feat: use the newer API to define autocmds See #79 * refactor: use new API and add check for devicons * feat: detect entries when it has added new ones See #87 * fix: use valid widths to show entries * fix: use substr matcher to use sorting by scores See #94 * Revert "fix: set the telescope default filetype in prompt" This reverts commit 4937f7045438412e31a77374f91230bdbcbeb34d. * fix: enable to filter by valid paths * refactor: do nothing until calling setup() See #80 * fix: fix typo for `workspaces` * fix: show `0` score for unindexed instead of `nil` * style: fix by StyLua * fix: return a valid entry with get() It have used where() to get the entry and where() uses get() to fetch from DB. But this table has been customized by overwriting get(), so it has a bug to return the same entry every time it calls. This fixes it. * refactor: get the buffer name explicitly * fix: clean up logic to validate DB * feat: enable to work db_root option * fix: show no msg when no need to validate in auto --------- Co-authored-by: Tami <65782666+tami5@users.noreply.github.com> Co-authored-by: Munif Tanjim <hello@muniftanjim.dev> Co-authored-by: premell <65544203+premell@users.noreply.github.com> Co-authored-by: Anshuman Medhi <amedhi@connect.ust.hk> Co-authored-by: Lucas Hoffmann <lucc@users.noreply.github.com> Co-authored-by: Rohan Orton <rohan.orton@gmail.com>
This commit is contained in:
parent
0a4a521471
commit
3f709af755
6
.stylua.toml
Normal file
6
.stylua.toml
Normal file
@ -0,0 +1,6 @@
|
||||
column_width = 120
|
||||
line_endings = "Unix"
|
||||
indent_type = "Spaces"
|
||||
indent_width = 2
|
||||
quote_style = "AutoPreferDouble"
|
||||
no_call_parentheses = true
|
||||
21
lua/frecency/algo.lua
Normal file
21
lua/frecency/algo.lua
Normal file
@ -0,0 +1,21 @@
|
||||
local const = require "frecency.const"
|
||||
local algo = {}
|
||||
|
||||
algo.calculate_file_score = function(file)
|
||||
if not file.count or file.count == 0 then
|
||||
return 0
|
||||
end
|
||||
local recency_score = 0
|
||||
for _, ts in pairs(file.timestamps) do
|
||||
for _, rank in ipairs(const.recency_modifier) do
|
||||
if ts.age <= rank.age then
|
||||
recency_score = recency_score + rank.value
|
||||
goto continue
|
||||
end
|
||||
end
|
||||
::continue::
|
||||
end
|
||||
return file.count * recency_score / const.max_timestamps
|
||||
end
|
||||
|
||||
return algo
|
||||
18
lua/frecency/const.lua
Normal file
18
lua/frecency/const.lua
Normal file
@ -0,0 +1,18 @@
|
||||
return {
|
||||
max_timestamps = 10,
|
||||
db_remove_safety_threshold = 10,
|
||||
-- modifier used as a weight in the recency_score calculation:
|
||||
recency_modifier = {
|
||||
[1] = { age = 240, value = 100 }, -- past 4 hours
|
||||
[2] = { age = 1440, value = 80 }, -- past day
|
||||
[3] = { age = 4320, value = 60 }, -- past 3 days
|
||||
[4] = { age = 10080, value = 40 }, -- past week
|
||||
[5] = { age = 43200, value = 20 }, -- past month
|
||||
[6] = { age = 129600, value = 10 }, -- past 90 days
|
||||
},
|
||||
ignore_patterns = {
|
||||
"*.git/*",
|
||||
"*/tmp/*",
|
||||
"term://*",
|
||||
},
|
||||
}
|
||||
192
lua/frecency/db.lua
Normal file
192
lua/frecency/db.lua
Normal file
@ -0,0 +1,192 @@
|
||||
local util = require "frecency.util"
|
||||
local const = require "frecency.const"
|
||||
local algo = require "frecency.algo"
|
||||
local sqlite = require "sqlite"
|
||||
local p = require "plenary.path"
|
||||
local s = sqlite.lib
|
||||
|
||||
---@class FrecencySqlite: sqlite_db
|
||||
---@field files sqlite_tbl
|
||||
---@field timestamps sqlite_tbl
|
||||
|
||||
---@class FrecencyDBConfig
|
||||
---@field db_root string: default "${stdpath.data}"
|
||||
---@field ignore_patterns table: extra ignore patterns: default empty
|
||||
---@field safe_mode boolean: When enabled, the user will be prompted when entries > 10, default true
|
||||
---@field auto_validate boolean: When this to false, stale entries will never be automatically removed, default true
|
||||
|
||||
---@class FrecencyDB
|
||||
---@field sqlite FrecencySqlite
|
||||
---@field config FrecencyConfig
|
||||
local db = {
|
||||
config = {
|
||||
db_root = vim.fn.stdpath "data",
|
||||
ignore_patterns = {},
|
||||
db_safe_mode = true,
|
||||
auto_validate = true,
|
||||
},
|
||||
}
|
||||
|
||||
---Set database configuration
|
||||
---@param config FrecencyDBConfig
|
||||
function db.set_config(config)
|
||||
db.config = vim.tbl_extend("keep", config, db.config)
|
||||
db.sqlite = sqlite {
|
||||
uri = db.config.db_root .. "/file_frecency.sqlite3",
|
||||
files = {
|
||||
id = true,
|
||||
count = { "integer", default = 0, required = true },
|
||||
path = "string",
|
||||
},
|
||||
timestamps = {
|
||||
id = true,
|
||||
timestamp = { "real", default = s.julianday "now" },
|
||||
file_id = { "integer", reference = "files.id", on_delete = "cascade" },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
---Get timestamps with a computed filed called age.
|
||||
---If file_id is nil, then get all timestamps.
|
||||
---@param opts table
|
||||
---- { file_id } number: id file_id corresponding to `files.id`. return all if { file_id } is nil
|
||||
---- { with_age } boolean: whether to include age, default false.
|
||||
---@return table { id, file_id, age }
|
||||
---@overload func()
|
||||
function db.get_timestamps(opts)
|
||||
opts = opts or {}
|
||||
local where = opts.file_id and { file_id = opts.file_id } or nil
|
||||
local compute_age = opts.with_age and s.cast((s.julianday() - s.julianday "timestamp") * 24 * 60, "integer") or nil
|
||||
return db.sqlite.timestamps:__get { where = where, keys = { age = compute_age, "id", "file_id" } }
|
||||
end
|
||||
|
||||
---Trim database entries
|
||||
---@param file_id any
|
||||
function db.trim_timestamps(file_id)
|
||||
local timestamps = db.get_timestamps { file_id = file_id, with_age = true }
|
||||
local trim_at = timestamps[(#timestamps - const.max_timestamps) + 1]
|
||||
if trim_at then
|
||||
db.sqlite.timestamps:remove { file_id = file_id, id = "<" .. trim_at.id }
|
||||
end
|
||||
end
|
||||
|
||||
---Get file entries
|
||||
---@param opts table:
|
||||
---- { ws_path } string: get files with matching workspace path.
|
||||
---- { show_unindexed } boolean: whether to include unindexed files, false if no ws_path is given.
|
||||
---- { with_score } boolean: whether to include score in the result and sort the files by score.
|
||||
---@overload func()
|
||||
---@return table[]: files entries
|
||||
function db.get_files(opts)
|
||||
opts = opts or {}
|
||||
local query = {}
|
||||
if opts.ws_path then
|
||||
query.contains = { path = { opts.ws_path .. "*" } }
|
||||
elseif opts.path then
|
||||
query.where = { path = opts.path }
|
||||
end
|
||||
local files = db.sqlite.files:__get(query)
|
||||
|
||||
if vim.F.if_nil(opts.with_score, true) then
|
||||
---NOTE: this might get slower with big db, it might be better to query with db.get_timestamp.
|
||||
---TODO: test the above assumption
|
||||
local timestamps = db.get_timestamps { with_age = true }
|
||||
for _, file in ipairs(files) do
|
||||
file.timestamps = util.tbl_match("file_id", file.id, timestamps)
|
||||
file.score = algo.calculate_file_score(file)
|
||||
end
|
||||
|
||||
table.sort(files, function(a, b)
|
||||
return a.score > b.score
|
||||
end)
|
||||
end
|
||||
|
||||
if opts.ws_path and opts.show_unindexed then
|
||||
util.include_unindexed(files, opts.ws_path)
|
||||
end
|
||||
|
||||
return files
|
||||
end
|
||||
---Insert or update a given path
|
||||
---@param path string
|
||||
---@return number: row id
|
||||
---@return boolean: true if it has inserted
|
||||
function db.insert_or_update_files(path)
|
||||
local entry = (db.get_files({ path = path })[1] or {})
|
||||
local file_id = entry.id
|
||||
local has_added_entry = not file_id
|
||||
|
||||
if file_id then
|
||||
db.sqlite.files:update { where = { id = file_id }, set = { count = entry.count + 1 } }
|
||||
else
|
||||
file_id = db.sqlite.files:insert { path = path }
|
||||
end
|
||||
return file_id, has_added_entry
|
||||
end
|
||||
|
||||
---Add or update file path
|
||||
---@param path string|nil: path to file or use current
|
||||
---@return boolean: true if it has added an entry
|
||||
---@overload func()
|
||||
function db.update(path)
|
||||
path = path or vim.fn.expand "%:p"
|
||||
if vim.b.telescope_frecency_registered or util.path_invalid(path, db.ignore_patterns) then
|
||||
-- print "ignoring autocmd"
|
||||
return
|
||||
else
|
||||
vim.b.telescope_frecency_registered = 1
|
||||
end
|
||||
--- Insert or update path
|
||||
local file_id, has_added_entry = db.insert_or_update_files(path)
|
||||
--- Register timestamp for this update.
|
||||
db.sqlite.timestamps:insert { file_id = file_id }
|
||||
--- Trim timestamps to max_timestamps per file
|
||||
db.trim_timestamps(file_id)
|
||||
return has_added_entry
|
||||
end
|
||||
|
||||
---Remove unlinked file entries, along with timestamps linking to it.
|
||||
---@param entries table[]|table|nil: if nil it will remove all entries
|
||||
---@param silent boolean: whether to notify user on changes made, default false
|
||||
function db.remove(entries, silent)
|
||||
if type(entries) == "nil" then
|
||||
local count = db.sqlite.files:count()
|
||||
db.sqlite.files:remove()
|
||||
if not vim.F.if_nil(silent, false) then
|
||||
vim.notify(("Telescope-frecency: removed all entries. number of entries removed %d ."):format(count))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
entries = (entries[1] and entries[1].id) and entries or { entries }
|
||||
|
||||
for _, entry in pairs(entries) do
|
||||
db.sqlite.files:remove { id = entry.id }
|
||||
end
|
||||
|
||||
if not vim.F.if_nil(silent, false) then
|
||||
vim.notify(("Telescope-frecency: removed %d missing entries."):format(#entries))
|
||||
end
|
||||
end
|
||||
|
||||
---Remove file entries that no longer exists.
|
||||
function db.validate(opts)
|
||||
opts = opts or {}
|
||||
|
||||
-- print "running validate"
|
||||
local threshold = const.db_remove_safety_threshold
|
||||
local unlinked = db.sqlite.files:map(function(entry)
|
||||
local invalid = (not util.path_exists(entry.path) or util.path_is_ignored(entry.path, db.ignore_patterns))
|
||||
return invalid and entry or nil
|
||||
end)
|
||||
|
||||
if #unlinked > 0 then
|
||||
if opts.force or not db.config.db_safe_mode or (#unlinked > threshold and util.confirm_deletion(#unlinked)) then
|
||||
db.remove(unlinked)
|
||||
elseif not opts.auto then
|
||||
util.abort_remove_unlinked_files()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return db
|
||||
336
lua/frecency/picker.lua
Normal file
336
lua/frecency/picker.lua
Normal file
@ -0,0 +1,336 @@
|
||||
local has_devicons, devicons = pcall(require, "nvim-web-devicons")
|
||||
local p = require "plenary.path"
|
||||
local util = require "frecency.util"
|
||||
local os_home = vim.loop.os_homedir()
|
||||
local os_path_sep = p.path.sep
|
||||
local actions = require "telescope.actions"
|
||||
local conf = require("telescope.config").values
|
||||
local entry_display = require "telescope.pickers.entry_display"
|
||||
local finders = require "telescope.finders"
|
||||
local pickers = require "telescope.pickers"
|
||||
local sorters = require "telescope.sorters"
|
||||
local ts_util = require "telescope.utils"
|
||||
local db = require "frecency.db"
|
||||
|
||||
---TODO: Describe FrecencyPicker fields
|
||||
|
||||
---@class FrecencyPicker
|
||||
---@field db FrecencyDB: where the files will be stored
|
||||
---@field results table
|
||||
---@field active_filter string
|
||||
---@field active_filter_tag string
|
||||
---@field previous_buffer string
|
||||
---@field cwd string
|
||||
---@field lsp_workspaces table
|
||||
---@field picker table
|
||||
---@field updated boolean: true if a new entry is added into DB
|
||||
local m = {
|
||||
results = {},
|
||||
active_filter = nil,
|
||||
active_filter_tag = nil,
|
||||
last_filter = nil,
|
||||
previous_buffer = nil,
|
||||
cwd = nil,
|
||||
lsp_workspaces = {},
|
||||
picker = {},
|
||||
updated = false,
|
||||
}
|
||||
|
||||
m.__index = m
|
||||
|
||||
---@class FrecencyConfig
|
||||
---@field show_unindexed boolean: default true
|
||||
---@field show_filter_column boolean|string[]: default true
|
||||
---@field workspaces table: default {}
|
||||
---@field disable_devicons boolean: default false
|
||||
---@field default_workspace string: default nil
|
||||
m.config = {
|
||||
show_scores = true,
|
||||
show_unindexed = true,
|
||||
show_filter_column = true,
|
||||
workspaces = {},
|
||||
disable_devicons = false,
|
||||
default_workspace = nil,
|
||||
}
|
||||
|
||||
---Setup frecency picker
|
||||
m.set_prompt_options = function(buffer)
|
||||
vim.bo[buffer].filetype = "frecency"
|
||||
vim.bo[buffer].completefunc = "v:lua.require'telescope'.extensions.frecency.complete"
|
||||
vim.keymap.set("i", "<Tab>", "pumvisible() ? '<C-n>' : '<C-x><C-u>'", { buffer = buffer, expr = true })
|
||||
vim.keymap.set("i", "<S-Tab>", "pumvisible() ? '<C-p>' : ''", { buffer = buffer, expr = true })
|
||||
end
|
||||
|
||||
---returns `true` if workspaces exit
|
||||
---@param bufnr integer
|
||||
---@param force boolean?
|
||||
---@return boolean workspaces_exist
|
||||
m.fetch_lsp_workspaces = function(bufnr, force)
|
||||
if not vim.tbl_isempty(m.lsp_workspaces) and not force then
|
||||
return true
|
||||
end
|
||||
|
||||
local lsp_workspaces = vim.api.nvim_buf_call(bufnr, vim.lsp.buf.list_workspace_folders)
|
||||
if not vim.tbl_isempty(lsp_workspaces) then
|
||||
m.lsp_workspaces = lsp_workspaces
|
||||
return true
|
||||
end
|
||||
|
||||
m.lsp_workspaces = {}
|
||||
return false
|
||||
end
|
||||
|
||||
---Update Frecency Picker result
|
||||
---@param filter string
|
||||
---@return boolean
|
||||
m.update = function(filter)
|
||||
local filter_updated = false
|
||||
local ws_dir = filter and m.config.workspaces[filter] or nil
|
||||
|
||||
if filter == "LSP" and not vim.tbl_isempty(m.lsp_workspaces) then
|
||||
ws_dir = m.lsp_workspaces[1]
|
||||
elseif filter == "CWD" then
|
||||
ws_dir = m.cwd
|
||||
end
|
||||
|
||||
if ws_dir ~= m.active_filter then
|
||||
filter_updated = true
|
||||
m.active_filter, m.active_filter_tag = ws_dir, filter
|
||||
end
|
||||
|
||||
m.results = (vim.tbl_isempty(m.results) or m.updated or filter_updated)
|
||||
and db.get_files { ws_path = ws_dir, show_unindexed = m.config.show_unindexed }
|
||||
or m.results
|
||||
|
||||
return filter_updated
|
||||
end
|
||||
|
||||
---@param opts table telescope picker table
|
||||
---@return fun(filename: string): string
|
||||
m.filepath_formatter = function(opts)
|
||||
local path_opts = {}
|
||||
for k, v in pairs(opts) do
|
||||
path_opts[k] = v
|
||||
end
|
||||
|
||||
return function(filename)
|
||||
path_opts.cwd = m.active_filter or m.cwd
|
||||
return ts_util.transform_path(path_opts, filename)
|
||||
end
|
||||
end
|
||||
|
||||
m.should_show_tail = function()
|
||||
local filters = type(m.config.show_filter_column) == "table" and m.config.show_filter_column or { "LSP", "CWD" }
|
||||
return vim.tbl_contains(filters, m.active_filter_tag)
|
||||
end
|
||||
|
||||
---Create entry maker function.
|
||||
---@param entry table
|
||||
---@return function
|
||||
m.maker = function(entry)
|
||||
local filter_column_width = (function()
|
||||
if m.active_filter then
|
||||
if m.should_show_tail() then
|
||||
-- TODO: Only add +1 if m.show_filter_thing is true, +1 is for the trailing slash
|
||||
return #(ts_util.path_tail(m.active_filter)) + 1
|
||||
end
|
||||
return #(p:new(m.active_filter):make_relative(os_home)) + 1
|
||||
end
|
||||
return 0
|
||||
end)()
|
||||
|
||||
local displayer = entry_display.create {
|
||||
separator = "",
|
||||
hl_chars = { [os_path_sep] = "TelescopePathSeparator" },
|
||||
items = (function()
|
||||
local i = m.config.show_scores and { { width = 8 } } or {}
|
||||
if has_devicons and not m.config.disable_devicons then
|
||||
table.insert(i, { width = 2 })
|
||||
end
|
||||
if m.config.show_filter_column then
|
||||
table.insert(i, { width = filter_column_width })
|
||||
end
|
||||
table.insert(i, { remaining = true })
|
||||
return i
|
||||
end)(),
|
||||
}
|
||||
|
||||
local filter_path = (function()
|
||||
if m.config.show_filter_column and m.active_filter then
|
||||
return m.should_show_tail() and ts_util.path_tail(m.active_filter) .. os_path_sep
|
||||
or p:new(m.active_filter):make_relative(os_home) .. os_path_sep
|
||||
end
|
||||
return ""
|
||||
end)()
|
||||
|
||||
local formatter = m.filepath_formatter(m.opts)
|
||||
|
||||
return {
|
||||
filename = entry.path,
|
||||
ordinal = entry.path,
|
||||
name = entry.path,
|
||||
score = entry.score,
|
||||
display = function(e)
|
||||
return displayer((function()
|
||||
local i = m.config.show_scores and { { entry.score, "TelescopeFrecencyScores" } } or {}
|
||||
if has_devicons and not m.config.disable_devicons then
|
||||
table.insert(i, { devicons.get_icon(e.name, string.match(e.name, "%a+$"), { default = true }) })
|
||||
end
|
||||
table.insert(i, { filter_path, "Directory" })
|
||||
table.insert(i, {
|
||||
formatter(e.name),
|
||||
util.buf_is_loaded(e.name) and "TelescopeBufferLoaded" or "",
|
||||
})
|
||||
return i
|
||||
end)())
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
---Find files
|
||||
---@param opts table: telescope picker opts
|
||||
m.fd = function(opts)
|
||||
opts = opts or {}
|
||||
|
||||
if not opts.path_display then
|
||||
opts.path_display = function(path_opts, filename)
|
||||
local original_filename = filename
|
||||
|
||||
filename = p:new(filename):make_relative(path_opts.cwd)
|
||||
if not m.active_filter then
|
||||
if vim.startswith(filename, os_home) then
|
||||
filename = "~/" .. p:new(filename):make_relative(os_home)
|
||||
elseif filename ~= original_filename then
|
||||
filename = "./" .. filename
|
||||
end
|
||||
end
|
||||
|
||||
return filename
|
||||
end
|
||||
end
|
||||
|
||||
m.previous_buffer, m.cwd, m.opts = vim.fn.bufnr "%", vim.fn.expand(opts.cwd or vim.loop.cwd()), opts
|
||||
-- TODO: should we update this every time it calls frecency on other buffers?
|
||||
m.fetch_lsp_workspaces(m.previous_buffer)
|
||||
m.update()
|
||||
|
||||
local picker_opts = {
|
||||
prompt_title = "Frecency",
|
||||
finder = finders.new_table { results = m.results, entry_maker = m.maker },
|
||||
previewer = conf.file_previewer(opts),
|
||||
sorter = sorters.get_substr_matcher(opts),
|
||||
}
|
||||
|
||||
picker_opts.on_input_filter_cb = function(query_text)
|
||||
local o = {}
|
||||
local delim = m.config.filter_delimiter or ":" -- check for :filter: in query text
|
||||
local matched, new_filter = query_text:match("^%s*(" .. delim .. "(%S+)" .. delim .. ")")
|
||||
new_filter = new_filter or opts.workspace or m.config.default_workspace
|
||||
|
||||
o.prompt = matched and query_text:sub(matched:len() + 1) or query_text
|
||||
if m.update(new_filter) then
|
||||
m.last_filter = new_filter
|
||||
o.updated_finder = finders.new_table { results = m.results, entry_maker = m.maker }
|
||||
end
|
||||
|
||||
return o
|
||||
end
|
||||
|
||||
picker_opts.attach_mappings = function(prompt_bufnr)
|
||||
actions.select_default:replace_if(function()
|
||||
return vim.fn.complete_info().pum_visible == 1
|
||||
end, function()
|
||||
local keys = vim.fn.complete_info().selected == -1 and "<C-e><Bs><Right>" or "<C-y><Right>:"
|
||||
local accept_completion = vim.api.nvim_replace_termcodes(keys, true, false, true)
|
||||
vim.api.nvim_feedkeys(accept_completion, "n", true)
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
m.picker = pickers.new(opts, picker_opts)
|
||||
m.picker:find()
|
||||
m.set_prompt_options(m.picker.prompt_bufnr)
|
||||
end
|
||||
|
||||
---TODO: this seems to be forgotten and just exported in old implementation.
|
||||
---@return table
|
||||
m.workspace_tags = function()
|
||||
-- Add user config workspaces.
|
||||
-- TODO: validate that workspaces are existing directories
|
||||
local tags = {}
|
||||
for k, _ in pairs(m.config.workspaces) do
|
||||
table.insert(tags, k)
|
||||
end
|
||||
|
||||
-- Add CWD filter
|
||||
-- NOTE: hmmm :cwd::lsp: is easier to write.
|
||||
table.insert(tags, "CWD")
|
||||
|
||||
-- Add LSP workpace(s)
|
||||
if m.fetch_lsp_workspaces(m.previous_buffer, true) then
|
||||
table.insert(tags, "LSP")
|
||||
end
|
||||
|
||||
-- TODO: sort tags - by collective frecency? (?????? is this still relevant)
|
||||
return tags
|
||||
end
|
||||
|
||||
m.complete = function(findstart, base)
|
||||
if findstart == 1 then
|
||||
local line = vim.api.nvim_get_current_line()
|
||||
local start = line:find ":"
|
||||
-- don't complete if there's already a completed `:tag:` in line
|
||||
if not start or line:find(":", start + 1) then
|
||||
return -3
|
||||
end
|
||||
return start
|
||||
else
|
||||
if vim.fn.pumvisible() == 1 and #vim.v.completed_item > 0 then
|
||||
return ""
|
||||
end
|
||||
|
||||
local matches = vim.tbl_filter(function(v)
|
||||
return vim.startswith(v, base)
|
||||
end, m.workspace_tags())
|
||||
|
||||
return #matches > 0 and matches or ""
|
||||
end
|
||||
end
|
||||
|
||||
---Setup Frecency Picker
|
||||
---@param db FrecencyDB
|
||||
---@param config FrecencyConfig
|
||||
m.setup = function(config)
|
||||
m.config = vim.tbl_extend("keep", config, m.config)
|
||||
db.set_config(config)
|
||||
|
||||
--- Seed files table with oldfiles when it's empty.
|
||||
if db.sqlite.files:count() == 0 then
|
||||
-- TODO: this needs to be scheduled for after shada load??
|
||||
for _, path in ipairs(vim.v.oldfiles) do
|
||||
db.sqlite.files:insert { path = path, count = 0 } -- TODO: remove when sql.nvim#97 is closed
|
||||
end
|
||||
vim.notify(("Telescope-Frecency: Imported %d entries from oldfiles."):format(#vim.v.oldfiles))
|
||||
end
|
||||
|
||||
-- TODO: perhaps ignore buffer without file path here?
|
||||
local group = vim.api.nvim_create_augroup("TelescopeFrecency", {})
|
||||
vim.api.nvim_create_autocmd({ "BufWinEnter", "BufWritePost" }, {
|
||||
group = group,
|
||||
callback = function(args)
|
||||
local path = vim.api.nvim_buf_get_name(args.buf)
|
||||
local has_added_entry = db.update(path)
|
||||
m.updated = m.updated or has_added_entry
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command("FrecencyValidate", function(cmd_info)
|
||||
db.validate { force = cmd_info.bang }
|
||||
end, { bang = true, desc = "Clean up DB for telescope-frecency" })
|
||||
|
||||
if db.config.auto_validate then
|
||||
db.validate { auto = true }
|
||||
end
|
||||
end
|
||||
|
||||
return m
|
||||
96
lua/frecency/util.lua
Normal file
96
lua/frecency/util.lua
Normal file
@ -0,0 +1,96 @@
|
||||
local uv = vim.loop
|
||||
local const = require "frecency.const"
|
||||
local Path = require "plenary.path"
|
||||
|
||||
local util = {}
|
||||
|
||||
-- stolen from penlight
|
||||
|
||||
---escape any Lua 'magic' characters in a string
|
||||
util.escape = function(str)
|
||||
return (str:gsub("[%-%.%+%[%]%(%)%$%^%%%?%*]", "%%%1"))
|
||||
end
|
||||
|
||||
util.string_isempty = function(str)
|
||||
return str == nil or str == ""
|
||||
end
|
||||
|
||||
util.filemask = function(mask)
|
||||
mask = util.escape(mask)
|
||||
return "^" .. mask:gsub("%%%*", ".*"):gsub("%%%?", ".") .. "$"
|
||||
end
|
||||
|
||||
util.path_is_ignored = function(filepath, ignore_patters)
|
||||
local i = ignore_patters and vim.tbl_flatten { ignore_patters, const.ignore_patterns } or const.ignore_patterns
|
||||
local is_ignored = false
|
||||
for _, pattern in ipairs(i) do
|
||||
if filepath:find(util.filemask(pattern)) ~= nil then
|
||||
is_ignored = true
|
||||
goto continue
|
||||
end
|
||||
end
|
||||
|
||||
::continue::
|
||||
return is_ignored
|
||||
end
|
||||
|
||||
util.path_exists = function(path)
|
||||
return Path:new(path):exists()
|
||||
end
|
||||
|
||||
util.path_invalid = function(path, ignore_patterns)
|
||||
local p = Path:new(path)
|
||||
if
|
||||
util.string_isempty(path)
|
||||
or (not p:is_file())
|
||||
or (not p:exists())
|
||||
or util.path_is_ignored(path, ignore_patterns)
|
||||
then
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
util.confirm_deletion = function(num_of_entries)
|
||||
local question = "Telescope-Frecency: remove %d entries from SQLite3 database?"
|
||||
return vim.fn.confirm(question:format(num_of_entries), "&Yes\n&No", 2) == 1
|
||||
end
|
||||
|
||||
util.abort_remove_unlinked_files = function()
|
||||
---TODO: refactor all messages to a lua file. alarts.lua?
|
||||
vim.notify "TelescopeFrecency: validation aborted."
|
||||
end
|
||||
|
||||
util.tbl_match = function(field, val, tbl)
|
||||
return vim.tbl_filter(function(t)
|
||||
return t[field] == val
|
||||
end, tbl)
|
||||
end
|
||||
|
||||
---Wrappe around Path:new():make_relative
|
||||
---@return string
|
||||
util.path_relative = function(path, cwd)
|
||||
return Path:new(path):make_relative(cwd)
|
||||
end
|
||||
|
||||
---Given a filename, check if there's a buffer with the given name.
|
||||
---@return boolean
|
||||
util.buf_is_loaded = function(filename)
|
||||
return vim.api.nvim_buf_is_loaded(vim.fn.bufnr(filename))
|
||||
end
|
||||
|
||||
util.include_unindexed = function(files, ws_path)
|
||||
local scan_opts = { respect_gitignore = true, depth = 100, hidden = true }
|
||||
|
||||
-- TODO: make sure scandir unindexed have opts.ignore_patterns applied
|
||||
-- TODO: make filters handle mulitple directories
|
||||
local unindexed_files = require("plenary.scandir").scan_dir(ws_path, scan_opts)
|
||||
for _, file in pairs(unindexed_files) do
|
||||
if not util.path_is_ignored(file) then -- this causes some slowdown on large dirs
|
||||
table.insert(files, { id = 0, path = file, count = 0, directory_id = 0, score = 0 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return util
|
||||
@ -1,353 +1,29 @@
|
||||
local has_telescope, telescope = pcall(require, "telescope")
|
||||
|
||||
-- TODO: make sure scandir unindexed have opts.ignore_patterns applied
|
||||
-- TODO: make filters handle mulitple directories
|
||||
|
||||
if not has_telescope then
|
||||
error "This plugin requires telescope.nvim (https://github.com/nvim-telescope/telescope.nvim)"
|
||||
end
|
||||
|
||||
local config = {}
|
||||
|
||||
local state = {
|
||||
results = {},
|
||||
active_filter = nil,
|
||||
active_filter_tag = nil,
|
||||
last_filter = nil,
|
||||
previous_buffer = nil,
|
||||
cwd = nil,
|
||||
show_scores = false,
|
||||
default_workspace = nil,
|
||||
user_workspaces = {},
|
||||
lsp_workspaces = {},
|
||||
picker = {},
|
||||
}
|
||||
|
||||
-- returns `true` if workspaces exist
|
||||
---@param bufnr number
|
||||
---@param force? boolean
|
||||
---@return boolean workspaces_exist
|
||||
local function fetch_lsp_workspaces(bufnr, force)
|
||||
if not vim.tbl_isempty(state.lsp_workspaces) and not force then
|
||||
return true
|
||||
local telescope = (function()
|
||||
local ok, m = pcall(require, "telescope")
|
||||
if not ok then
|
||||
error "telescope-frecency: couldn't find telescope.nvim, please install"
|
||||
end
|
||||
return m
|
||||
end)()
|
||||
|
||||
local lsp_workspaces = vim.api.nvim_buf_call(bufnr, vim.lsp.buf.list_workspace_folders)
|
||||
if not vim.tbl_isempty(lsp_workspaces) then
|
||||
state.lsp_workspaces = lsp_workspaces
|
||||
return true
|
||||
end
|
||||
|
||||
state.lsp_workspaces = {}
|
||||
return false
|
||||
end
|
||||
|
||||
local function get_workspace_tags()
|
||||
-- Add user config workspaces. TODO: validate that workspaces are existing directories
|
||||
local tags = {}
|
||||
for k, _ in pairs(state.user_workspaces) do
|
||||
table.insert(tags, k)
|
||||
end
|
||||
|
||||
-- Add CWD filter
|
||||
table.insert(tags, "CWD")
|
||||
|
||||
-- Add LSP workpace(s)
|
||||
if fetch_lsp_workspaces(state.previous_buffer, true) then
|
||||
table.insert(tags, "LSP")
|
||||
end
|
||||
|
||||
-- print(vim.inspect(tags))
|
||||
-- TODO: sort tags - by collective frecency?
|
||||
return tags
|
||||
end
|
||||
|
||||
local function complete(findstart, base)
|
||||
if findstart == 1 then
|
||||
local line = vim.api.nvim_get_current_line()
|
||||
local start = line:find ":"
|
||||
-- don't complete if there's already a completed `:tag:` in line
|
||||
if not start or line:find(":", start + 1) then
|
||||
return -3
|
||||
end
|
||||
return start
|
||||
else
|
||||
if vim.fn.pumvisible() == 1 and #vim.v.completed_item > 0 then
|
||||
return ""
|
||||
end
|
||||
|
||||
local tags = get_workspace_tags()
|
||||
local matches = vim.tbl_filter(function(v)
|
||||
return vim.startswith(v, base)
|
||||
end, tags)
|
||||
|
||||
return #matches > 0 and matches or ""
|
||||
end
|
||||
end
|
||||
|
||||
local frecency = function(opts)
|
||||
local has_devicons, devicons = pcall(require, "nvim-web-devicons")
|
||||
local entry_display = require "telescope.pickers.entry_display"
|
||||
local finders = require "telescope.finders"
|
||||
local Path = require "plenary.path"
|
||||
local pickers = require "telescope.pickers"
|
||||
local utils = require "telescope.utils"
|
||||
|
||||
local db_client = require "telescope._extensions.frecency.db_client"
|
||||
|
||||
-- start the database client
|
||||
db_client.init(
|
||||
config.db_root,
|
||||
config.ignore_patterns,
|
||||
vim.F.if_nil(config.db_safe_mode, true),
|
||||
vim.F.if_nil(config.auto_validate, true)
|
||||
)
|
||||
|
||||
opts = opts or {}
|
||||
|
||||
state.previous_buffer = vim.fn.bufnr "%"
|
||||
state.cwd = vim.fn.expand(opts.cwd or vim.fn.getcwd())
|
||||
|
||||
fetch_lsp_workspaces(state.previous_buffer)
|
||||
|
||||
local os_home = vim.loop.os_homedir()
|
||||
|
||||
local function should_show_tail()
|
||||
local filters = type(state.show_filter_column) == "table" and state.show_filter_column or { "LSP", "CWD" }
|
||||
return vim.tbl_contains(filters, state.active_filter_tag)
|
||||
end
|
||||
|
||||
local function get_display_cols()
|
||||
local directory_col_width = 0
|
||||
if state.active_filter then
|
||||
if should_show_tail() then
|
||||
-- TODO: Only add +1 if opts.show_filter_thing is true, +1 is for the trailing slash
|
||||
directory_col_width = #(utils.path_tail(state.active_filter)) + 1
|
||||
else
|
||||
directory_col_width = #(Path:new(state.active_filter):make_relative(os_home)) + 1
|
||||
end
|
||||
end
|
||||
local res = {}
|
||||
res[1] = state.show_scores and { width = 8 } or nil
|
||||
if has_devicons and not state.disable_devicons then
|
||||
table.insert(res, { width = 2 }) -- icon column
|
||||
end
|
||||
if state.show_filter_column then
|
||||
table.insert(res, { width = directory_col_width })
|
||||
end
|
||||
table.insert(res, { remaining = true })
|
||||
return res
|
||||
end
|
||||
local os_path_sep = utils.get_separator()
|
||||
|
||||
local displayer = entry_display.create {
|
||||
separator = "",
|
||||
hl_chars = { [os_path_sep] = "TelescopePathSeparator" },
|
||||
items = get_display_cols(),
|
||||
}
|
||||
|
||||
if not opts.path_display then
|
||||
opts.path_display = function (path_opts, filename)
|
||||
local original_filename = filename
|
||||
|
||||
filename = Path:new(filename):make_relative(path_opts.cwd)
|
||||
if not state.active_filter then
|
||||
if vim.startswith(filename, os_home) then
|
||||
filename = "~/" .. Path:new(filename):make_relative(os_home)
|
||||
elseif filename ~= original_filename then
|
||||
filename = "./" .. filename
|
||||
end
|
||||
end
|
||||
|
||||
return filename
|
||||
end
|
||||
end
|
||||
|
||||
local function filepath_formatter(path_opts0)
|
||||
local path_opts = {}
|
||||
for k, v in pairs(path_opts0) do
|
||||
path_opts[k] = v
|
||||
end
|
||||
|
||||
return function (filename)
|
||||
path_opts.cwd = state.active_filter or state.cwd
|
||||
return utils.transform_path(path_opts, filename)
|
||||
end
|
||||
end
|
||||
|
||||
local formatter = filepath_formatter(opts)
|
||||
|
||||
local bufnr, buf_is_loaded, display_filename, hl_filename, display_items, icon, icon_highlight
|
||||
local make_display = function(entry)
|
||||
bufnr = vim.fn.bufnr
|
||||
buf_is_loaded = vim.api.nvim_buf_is_loaded
|
||||
display_filename = entry.name
|
||||
hl_filename = buf_is_loaded(bufnr(display_filename)) and "TelescopeBufferLoaded" or ""
|
||||
display_filename = formatter(display_filename)
|
||||
|
||||
display_items = state.show_scores and { { entry.score, "TelescopeFrecencyScores" } } or {}
|
||||
|
||||
if has_devicons and not state.disable_devicons then
|
||||
icon, icon_highlight = devicons.get_icon(entry.name, string.match(entry.name, "%a+$"), { default = true })
|
||||
table.insert(display_items, { icon, icon_highlight })
|
||||
end
|
||||
|
||||
-- TODO: store the column lengths here, rather than recalculating in get_display_cols()
|
||||
if state.show_filter_column then
|
||||
local filter_path = ""
|
||||
if state.active_filter then
|
||||
if should_show_tail() then
|
||||
filter_path = utils.path_tail(state.active_filter) .. os_path_sep
|
||||
else
|
||||
filter_path = Path:new(state.active_filter):make_relative(os_home) .. os_path_sep
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(display_items, { filter_path, "Directory" })
|
||||
end
|
||||
|
||||
table.insert(display_items, { display_filename, hl_filename })
|
||||
|
||||
return displayer(display_items)
|
||||
end
|
||||
|
||||
local update_results = function(filter)
|
||||
local filter_updated = false
|
||||
|
||||
-- validate tag
|
||||
local ws_dir = filter and state.user_workspaces[filter]
|
||||
if filter == "LSP" and not vim.tbl_isempty(state.lsp_workspaces) then
|
||||
ws_dir = state.lsp_workspaces[1]
|
||||
end
|
||||
|
||||
if filter == "CWD" then
|
||||
ws_dir = state.cwd
|
||||
end
|
||||
|
||||
if ws_dir ~= state.active_filter then
|
||||
filter_updated = true
|
||||
state.active_filter = ws_dir
|
||||
state.active_filter_tag = filter
|
||||
end
|
||||
|
||||
if vim.tbl_isempty(state.results) or db_client.has_updated_results() or filter_updated then
|
||||
state.results = db_client.get_file_scores(state.show_unindexed, ws_dir)
|
||||
end
|
||||
return filter_updated
|
||||
end
|
||||
|
||||
-- populate initial results
|
||||
update_results()
|
||||
|
||||
local entry_maker = function(entry)
|
||||
return {
|
||||
filename = entry.filename,
|
||||
display = make_display,
|
||||
ordinal = entry.filename,
|
||||
name = entry.filename,
|
||||
score = entry.score,
|
||||
}
|
||||
end
|
||||
|
||||
local delim = opts.filter_delimiter or ":"
|
||||
local filter_re = "^%s*(" .. delim .. "(%S+)" .. delim .. ")"
|
||||
|
||||
state.picker = pickers.new(opts, {
|
||||
prompt_title = "Frecency",
|
||||
on_input_filter_cb = function(query_text)
|
||||
-- check for :filter: in query text
|
||||
local matched, new_filter = query_text:match(filter_re)
|
||||
if matched then
|
||||
query_text = query_text:sub(matched:len() + 1)
|
||||
end
|
||||
new_filter = new_filter or opts.workspace or state.default_workspace
|
||||
|
||||
local new_finder
|
||||
local results_updated = update_results(new_filter)
|
||||
if results_updated then
|
||||
displayer = entry_display.create {
|
||||
separator = "",
|
||||
hl_chars = { [os_path_sep] = "TelescopePathSeparator" },
|
||||
items = get_display_cols(),
|
||||
}
|
||||
|
||||
state.last_filter = new_filter
|
||||
new_finder = finders.new_table {
|
||||
results = state.results,
|
||||
entry_maker = entry_maker,
|
||||
}
|
||||
-- print(vim.inspect(new_finder))
|
||||
end
|
||||
|
||||
return { prompt = query_text, updated_finder = new_finder }
|
||||
end,
|
||||
attach_mappings = function(prompt_bufnr)
|
||||
require "telescope.actions".select_default:replace_if(function()
|
||||
local compinfo = vim.fn.complete_info()
|
||||
return compinfo.pum_visible == 1
|
||||
end, function()
|
||||
local compinfo = vim.fn.complete_info()
|
||||
local keys = compinfo.selected == -1 and "<C-e><Bs><Right>" or "<C-y><Right>:"
|
||||
local accept_completion = vim.api.nvim_replace_termcodes(keys, true, false, true)
|
||||
vim.api.nvim_feedkeys(accept_completion, "n", true)
|
||||
end)
|
||||
|
||||
return true
|
||||
end,
|
||||
finder = finders.new_table {
|
||||
results = state.results,
|
||||
entry_maker = entry_maker,
|
||||
},
|
||||
previewer = require("telescope.config").values.file_previewer(opts),
|
||||
sorter = require'telescope.sorters'.get_substr_matcher(opts),
|
||||
})
|
||||
state.picker:find()
|
||||
|
||||
local buffer = state.picker.prompt_bufnr
|
||||
vim.api.nvim_buf_set_option(buffer, "filetype", "frecency")
|
||||
vim.api.nvim_buf_set_option(buffer, "completefunc", "v:lua.require'telescope'.extensions.frecency.complete")
|
||||
vim.keymap.set("i", "<Tab>", "pumvisible() ? '<C-n>' : '<C-x><C-u>'", { buffer = buffer, expr = true })
|
||||
vim.keymap.set("i", "<S-Tab>", "pumvisible() ? '<C-p>' : ''", { buffer = buffer, expr = true })
|
||||
end
|
||||
|
||||
local function set_config_state(opt_name, value, default)
|
||||
state[opt_name] = value == nil and default or value
|
||||
end
|
||||
|
||||
local function checkhealth()
|
||||
local has_sql, _ = pcall(require, "sqlite")
|
||||
if has_sql then
|
||||
vim.health.report_ok "sql.nvim installed."
|
||||
-- return "MOOP"
|
||||
else
|
||||
vim.health.report_error "Need sql.nvim to be installed."
|
||||
end
|
||||
if pcall(require, "nvim-web-devicons") then
|
||||
vim.health.report_ok "nvim-web-devicons installed."
|
||||
else
|
||||
vim.health.report_info "nvim-web-devicons is not installed."
|
||||
end
|
||||
end
|
||||
local picker = require "frecency.picker"
|
||||
|
||||
return telescope.register_extension {
|
||||
setup = function(ext_config)
|
||||
set_config_state("db_root", ext_config.db_root, nil)
|
||||
set_config_state("show_scores", ext_config.show_scores, false)
|
||||
set_config_state("show_unindexed", ext_config.show_unindexed, true)
|
||||
set_config_state("show_filter_column", ext_config.show_filter_column, true)
|
||||
set_config_state("user_workspaces", ext_config.workspaces, {})
|
||||
set_config_state("disable_devicons", ext_config.disable_devicons, false)
|
||||
set_config_state("default_workspace", ext_config.default_workspace, nil)
|
||||
config = vim.deepcopy(ext_config)
|
||||
|
||||
vim.api.nvim_create_user_command("FrecencyValidate", function (cmd_info)
|
||||
local safe_mode = not cmd_info.bang
|
||||
require"telescope._extensions.frecency.db_client".validate(safe_mode)
|
||||
end, { bang = true, desc = "Clean up DB for telescope-frecency" })
|
||||
setup = picker.setup,
|
||||
health = function()
|
||||
if ({ pcall(require, "sqlite") })[1] then
|
||||
vim.health.report_ok "sql.nvim installed."
|
||||
else
|
||||
vim.health.report_error "sql.nvim is required for telescope-frecency.nvim to work."
|
||||
end
|
||||
if ({ pcall(require, "nvim-web-devicons") })[1] then
|
||||
vim.health.report_ok "nvim-web-devicons installed."
|
||||
else
|
||||
vim.health.report_info "nvim-web-devicons is not installed."
|
||||
end
|
||||
end,
|
||||
exports = {
|
||||
frecency = frecency,
|
||||
complete = complete,
|
||||
frecency = picker.fd,
|
||||
complete = picker.complete,
|
||||
},
|
||||
health = checkhealth,
|
||||
}
|
||||
|
||||
@ -1,239 +0,0 @@
|
||||
local sqlwrap = require("telescope._extensions.frecency.sql_wrapper")
|
||||
local scandir = require("plenary.scandir").scan_dir
|
||||
local util = require("telescope._extensions.frecency.util")
|
||||
|
||||
local MAX_TIMESTAMPS = 10
|
||||
local DB_REMOVE_SAFETY_THRESHOLD = 10
|
||||
|
||||
-- modifier used as a weight in the recency_score calculation:
|
||||
local recency_modifier = {
|
||||
[1] = { age = 240 , value = 100 }, -- past 4 hours
|
||||
[2] = { age = 1440 , value = 80 }, -- past day
|
||||
[3] = { age = 4320 , value = 60 }, -- past 3 days
|
||||
[4] = { age = 10080 , value = 40 }, -- past week
|
||||
[5] = { age = 43200 , value = 20 }, -- past month
|
||||
[6] = { age = 129600, value = 10 } -- past 90 days
|
||||
}
|
||||
|
||||
local default_ignore_patterns = {
|
||||
"*.git/*", "*/tmp/*"
|
||||
}
|
||||
|
||||
local sql_wrapper = nil
|
||||
local ignore_patterns = {}
|
||||
|
||||
local function import_oldfiles()
|
||||
local oldfiles = vim.api.nvim_get_vvar("oldfiles")
|
||||
for _, filepath in pairs(oldfiles) do
|
||||
sql_wrapper:update(filepath)
|
||||
end
|
||||
vim.notify(("Telescope-Frecency: Imported %d entries from oldfiles."):format(#oldfiles))
|
||||
end
|
||||
|
||||
local function file_is_ignored(filepath)
|
||||
local is_ignored = false
|
||||
for _, pattern in pairs(ignore_patterns) do
|
||||
if util.filename_match(filepath, pattern) then
|
||||
is_ignored = true
|
||||
goto continue
|
||||
end
|
||||
end
|
||||
|
||||
::continue::
|
||||
return is_ignored
|
||||
end
|
||||
|
||||
local function validate_db(safe_mode)
|
||||
if not sql_wrapper then return {} end
|
||||
|
||||
local queries = sql_wrapper.queries
|
||||
local files = sql_wrapper:do_transaction(queries.file_get_entries, {})
|
||||
local pending_remove = {}
|
||||
for _, entry in pairs(files) do
|
||||
if not util.fs_stat(entry.path).exists -- file no longer exists
|
||||
or file_is_ignored(entry.path) then -- cleanup entries that match the _current_ ignore list
|
||||
table.insert(pending_remove, entry)
|
||||
end
|
||||
end
|
||||
|
||||
local confirmed = false
|
||||
if not safe_mode then
|
||||
confirmed = true
|
||||
elseif #pending_remove > DB_REMOVE_SAFETY_THRESHOLD then
|
||||
-- don't allow removal of >N values from DB without confirmation
|
||||
local user_response = vim.fn.confirm("Telescope-Frecency: remove " .. #pending_remove .. " entries from SQLite3 database?", "&Yes\n&No", 2)
|
||||
if user_response == 1 then
|
||||
confirmed = true
|
||||
else
|
||||
vim.defer_fn(function() vim.notify("TelescopeFrecency: validation aborted.") end, 50)
|
||||
end
|
||||
else
|
||||
confirmed = true
|
||||
end
|
||||
|
||||
if #pending_remove > 0 then
|
||||
if confirmed == true then
|
||||
for _, entry in pairs(pending_remove) do
|
||||
-- remove entries from file and timestamp tables
|
||||
sql_wrapper:do_transaction(queries.file_delete_entry , {where = {id = entry.id }})
|
||||
sql_wrapper:do_transaction(queries.timestamp_delete_entry, {where = {file_id = entry.id}})
|
||||
end
|
||||
vim.notify(('Telescope-Frecency: removed %d missing entries.'):format(#pending_remove))
|
||||
else
|
||||
vim.notify("Telescope-Frecency: validation aborted.")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- TODO: make init params a keyed table
|
||||
local function init(db_root, config_ignore_patterns, safe_mode, auto_validate)
|
||||
if sql_wrapper then return end
|
||||
sql_wrapper = sqlwrap:new()
|
||||
local first_run = sql_wrapper:bootstrap(db_root)
|
||||
ignore_patterns = config_ignore_patterns or default_ignore_patterns
|
||||
|
||||
if auto_validate then
|
||||
validate_db(safe_mode)
|
||||
end
|
||||
|
||||
if first_run then
|
||||
-- TODO: this needs to be scheduled for after shada load
|
||||
vim.defer_fn(import_oldfiles, 100)
|
||||
end
|
||||
|
||||
-- setup autocommands
|
||||
local group = vim.api.nvim_create_augroup("TelescopeFrecency", {})
|
||||
vim.api.nvim_create_autocmd({ "BufWinEnter", "BufWritePost" }, {
|
||||
group = group,
|
||||
callback = function(args)
|
||||
local db_client = require "telescope._extensions.frecency.db_client"
|
||||
db_client.autocmd_handler(args.match)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local function calculate_file_score(frequency, timestamps)
|
||||
local recency_score = 0
|
||||
for _, ts in pairs(timestamps) do
|
||||
for _, rank in ipairs(recency_modifier) do
|
||||
if ts.age <= rank.age then
|
||||
recency_score = recency_score + rank.value
|
||||
goto continue
|
||||
end
|
||||
end
|
||||
::continue::
|
||||
end
|
||||
|
||||
return frequency * recency_score / MAX_TIMESTAMPS
|
||||
end
|
||||
|
||||
local function filter_timestamps(timestamps, file_id)
|
||||
local res = {}
|
||||
for _, entry in pairs(timestamps) do
|
||||
if entry.file_id == file_id then
|
||||
table.insert(res, entry)
|
||||
end
|
||||
end
|
||||
return res
|
||||
end
|
||||
|
||||
-- -- TODO: optimize this
|
||||
-- local function find_in_table(tbl, target)
|
||||
-- for _, entry in pairs(tbl) do
|
||||
-- if entry.path == target then return true end
|
||||
-- end
|
||||
-- return false
|
||||
-- end
|
||||
|
||||
-- local function async_callback(result)
|
||||
-- -- print(vim.inspect(result))
|
||||
-- end
|
||||
|
||||
local function filter_workspace(workspace_path, show_unindexed)
|
||||
local queries = sql_wrapper.queries
|
||||
show_unindexed = show_unindexed == nil and true or show_unindexed
|
||||
|
||||
local res = {}
|
||||
|
||||
res = sql_wrapper:do_transaction(queries.file_get_descendant_of, {path = workspace_path.."%"})
|
||||
if type(res) == "boolean" then res = {} end -- TODO: do this in sql_wrapper:transaction
|
||||
|
||||
local scan_opts = {
|
||||
respect_gitignore = true,
|
||||
depth = 100,
|
||||
hidden = true
|
||||
}
|
||||
|
||||
-- TODO: handle duplicate entries
|
||||
if show_unindexed then
|
||||
local unindexed_files = scandir(workspace_path, scan_opts)
|
||||
for _, file in pairs(unindexed_files) do
|
||||
if not file_is_ignored(file) then -- this causes some slowdown on large dirs
|
||||
table.insert(res, {
|
||||
id = 0,
|
||||
path = file,
|
||||
count = 0,
|
||||
directory_id = 0
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return res
|
||||
end
|
||||
|
||||
local updated = false
|
||||
|
||||
local function get_file_scores(show_unindexed, workspace_path)
|
||||
if not sql_wrapper then return {} end
|
||||
|
||||
local queries = sql_wrapper.queries
|
||||
local files = sql_wrapper:do_transaction(queries.file_get_entries, {})
|
||||
local timestamp_ages = sql_wrapper:do_transaction(queries.timestamp_get_all_entry_ages, {})
|
||||
|
||||
local scores = {}
|
||||
if vim.tbl_isempty(files) then return scores end
|
||||
files = workspace_path and filter_workspace(workspace_path, show_unindexed) or files
|
||||
|
||||
local score
|
||||
for _, file_entry in ipairs(files) do
|
||||
score = file_entry.count == 0 and 0 or calculate_file_score(file_entry.count, filter_timestamps(timestamp_ages, file_entry.id))
|
||||
table.insert(scores, {
|
||||
filename = file_entry.path,
|
||||
score = score
|
||||
})
|
||||
end
|
||||
|
||||
-- sort the table
|
||||
table.sort(scores, function(a, b) return a.score > b.score end)
|
||||
|
||||
updated = false
|
||||
|
||||
return scores
|
||||
end
|
||||
|
||||
local function autocmd_handler(filepath)
|
||||
if not sql_wrapper or util.string_isempty(filepath) then return end
|
||||
|
||||
-- check if file is registered as loaded
|
||||
if not vim.b.telescope_frecency_registered then
|
||||
-- allow [noname] files to go unregistered until BufWritePost
|
||||
if not util.fs_stat(filepath).exists then return end
|
||||
if file_is_ignored(filepath) then return end
|
||||
|
||||
vim.b.telescope_frecency_registered = 1
|
||||
updated = sql_wrapper:update(filepath)
|
||||
end
|
||||
end
|
||||
|
||||
local function has_updated_results()
|
||||
return updated
|
||||
end
|
||||
|
||||
return {
|
||||
init = init,
|
||||
get_file_scores = get_file_scores,
|
||||
autocmd_handler = autocmd_handler,
|
||||
validate = validate_db,
|
||||
has_updated_results = has_updated_results,
|
||||
}
|
||||
@ -1,180 +0,0 @@
|
||||
local util = require("telescope._extensions.frecency.util")
|
||||
local vim = vim
|
||||
|
||||
local has_sqlite, sqlite = pcall(require, "sqlite")
|
||||
if not has_sqlite then
|
||||
error("This plugin requires sqlite.lua (https://github.com/tami5/sqlite.lua) " .. tostring(sqlite))
|
||||
end
|
||||
|
||||
-- TODO: pass in max_timestamps from db.lua
|
||||
local MAX_TIMESTAMPS = 10
|
||||
|
||||
local db_table = {}
|
||||
db_table.files = "files"
|
||||
db_table.timestamps = "timestamps"
|
||||
db_table.workspaces = "workspaces"
|
||||
--
|
||||
|
||||
-- TODO: NEXT!
|
||||
-- extend substr sorter to have modes:
|
||||
-- when current string is prefixed by `:foo`, results are tag_names that come from tags/workspaces table. (if `:foo ` token is incomplete it is ignored)
|
||||
-- when a complete workspace tag is matched ':foobar:', results are indexed_files filtered by if their parent_dir is a descendant of the workspace_dir
|
||||
-- a recursive scan_dir() result is added to the :foobar: filter results; any non-indexed_files are given a score of zero, and are alphabetically sorted below the indexed_results
|
||||
|
||||
-- make tab completion for tab_names in insert mode`:foo|` state: cycles through available options
|
||||
|
||||
local M = {}
|
||||
|
||||
function M:new()
|
||||
local o = {}
|
||||
setmetatable(o, self)
|
||||
self.__index = self
|
||||
self.db = nil
|
||||
|
||||
return o
|
||||
end
|
||||
|
||||
function M:bootstrap(db_root)
|
||||
if self.db then return end
|
||||
|
||||
-- opts = opts or {}
|
||||
-- self.max_entries = opts.max_entries or 2000
|
||||
|
||||
-- create the db if it doesn't exist
|
||||
db_root = db_root or vim.fn.stdpath('data')
|
||||
local db_filename = db_root .. "/file_frecency.sqlite3"
|
||||
self.db = sqlite:open(db_filename)
|
||||
if not self.db then
|
||||
vim.notify("Telescope-Frecency: error in opening DB", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local first_run = false
|
||||
if not self.db:exists(db_table.files) then
|
||||
first_run = true
|
||||
-- create tables if they don't exist
|
||||
self.db:create(db_table.files, {
|
||||
id = {"INTEGER", "PRIMARY", "KEY"},
|
||||
count = "INTEGER",
|
||||
path = "TEXT"
|
||||
})
|
||||
self.db:create(db_table.timestamps, {
|
||||
id = {"INTEGER", "PRIMARY", "KEY"},
|
||||
file_id = "INTEGER",
|
||||
timestamp = "REAL"
|
||||
-- FOREIGN KEY(file_id) REFERENCES files(id)
|
||||
})
|
||||
end
|
||||
|
||||
self.db:close()
|
||||
return first_run
|
||||
end
|
||||
|
||||
--
|
||||
|
||||
function M:do_transaction(t, params)
|
||||
-- print(vim.inspect(t))
|
||||
-- print(vim.inspect(params))
|
||||
return self.db:with_open(function(db)
|
||||
local case = {
|
||||
[1] = function() return db:select(t.cmd_data, params) end,
|
||||
[2] = function() return db:insert(t.cmd_data, params) end,
|
||||
[3] = function() return db:delete(t.cmd_data, params) end,
|
||||
[4] = function() return db:eval(t.cmd_data, params) end,
|
||||
}
|
||||
return case[t.cmd]()
|
||||
end)
|
||||
end
|
||||
|
||||
local cmd = {
|
||||
select = 1,
|
||||
insert = 2,
|
||||
delete = 3,
|
||||
eval = 4,
|
||||
}
|
||||
|
||||
local queries = {
|
||||
file_get_descendant_of = {
|
||||
cmd = cmd.eval,
|
||||
cmd_data = "SELECT * FROM files WHERE path LIKE :path"
|
||||
},
|
||||
file_add_entry = {
|
||||
cmd = cmd.insert,
|
||||
cmd_data = db_table.files
|
||||
},
|
||||
file_delete_entry = {
|
||||
cmd = cmd.delete,
|
||||
cmd_data = db_table.files
|
||||
},
|
||||
file_get_entries = {
|
||||
cmd = cmd.select,
|
||||
cmd_data = db_table.files
|
||||
},
|
||||
file_update_counter = {
|
||||
cmd = cmd.eval,
|
||||
cmd_data = "UPDATE files SET count = count + 1 WHERE path == :path;"
|
||||
},
|
||||
timestamp_add_entry = {
|
||||
cmd = cmd.eval,
|
||||
cmd_data = "INSERT INTO timestamps (file_id, timestamp) values(:file_id, julianday('now'));"
|
||||
},
|
||||
timestamp_delete_entry = {
|
||||
cmd = cmd.delete,
|
||||
cmd_data = db_table.timestamps
|
||||
},
|
||||
timestamp_get_all_entries = {
|
||||
cmd = cmd.select,
|
||||
cmd_data = db_table.timestamps,
|
||||
},
|
||||
timestamp_get_all_entry_ages = {
|
||||
cmd = cmd.eval,
|
||||
cmd_data = "SELECT id, file_id, CAST((julianday('now') - julianday(timestamp)) * 24 * 60 AS INTEGER) AS age FROM timestamps;"
|
||||
},
|
||||
timestamp_delete_before_id = {
|
||||
cmd = cmd.eval,
|
||||
cmd_data = "DELETE FROM timestamps WHERE id < :id and file_id == :file_id;"
|
||||
},
|
||||
}
|
||||
|
||||
M.queries = queries
|
||||
|
||||
--
|
||||
|
||||
local function row_id(entry)
|
||||
return (not vim.tbl_isempty(entry)) and entry[1].id or nil
|
||||
end
|
||||
|
||||
function M:update(filepath)
|
||||
local filestat = util.fs_stat(filepath)
|
||||
if (vim.tbl_isempty(filestat) or
|
||||
filestat.exists == false or
|
||||
filestat.isdirectory == true) then
|
||||
return end
|
||||
|
||||
-- create entry if it doesn't exist
|
||||
local file_id
|
||||
file_id = row_id(self:do_transaction(queries.file_get_entries, {where = {path = filepath}}))
|
||||
local has_added_entry
|
||||
if not file_id then
|
||||
self:do_transaction(queries.file_add_entry, {path = filepath, count = 1})
|
||||
file_id = row_id(self:do_transaction(queries.file_get_entries, {where = {path = filepath}}))
|
||||
has_added_entry = true
|
||||
else
|
||||
-- ..or update existing entry
|
||||
self:do_transaction(queries.file_update_counter, {path = filepath})
|
||||
end
|
||||
|
||||
-- register timestamp for this update
|
||||
self:do_transaction(queries.timestamp_add_entry, {file_id = file_id})
|
||||
|
||||
-- trim timestamps to MAX_TIMESTAMPS per file (there should be up to MAX_TS + 1 at this point)
|
||||
local timestamps = self:do_transaction(queries.timestamp_get_all_entries, {where = {file_id = file_id}})
|
||||
local trim_at = timestamps[(#timestamps - MAX_TIMESTAMPS) + 1]
|
||||
if trim_at then
|
||||
self:do_transaction(queries.timestamp_delete_before_id, {id = trim_at.id, file_id = file_id})
|
||||
end
|
||||
|
||||
return has_added_entry
|
||||
end
|
||||
|
||||
return M
|
||||
@ -1,44 +0,0 @@
|
||||
local uv = vim.loop
|
||||
|
||||
local util = {}
|
||||
|
||||
-- stolen from penlight
|
||||
|
||||
-- escape any Lua 'magic' characters in a string
|
||||
util.escape = function(str)
|
||||
return (str:gsub('[%-%.%+%[%]%(%)%$%^%%%?%*]','%%%1'))
|
||||
end
|
||||
|
||||
util.filemask = function(mask)
|
||||
mask = util.escape(mask)
|
||||
return '^'..mask:gsub('%%%*','.*'):gsub('%%%?','.')..'$'
|
||||
end
|
||||
|
||||
util.filename_match = function(filename, pattern)
|
||||
return filename:find(util.filemask(pattern)) ~= nil
|
||||
end
|
||||
|
||||
--
|
||||
|
||||
util.string_isempty = function(str)
|
||||
return str == nil or str == ''
|
||||
end
|
||||
|
||||
util.split = function(str, delimiter)
|
||||
local result = {}
|
||||
for match in str:gmatch("[^" .. delimiter .. "]+") do
|
||||
table.insert(result, match)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
util.fs_stat = function(path)
|
||||
local stat = uv.fs_stat(path)
|
||||
local res = {}
|
||||
res.exists = stat and true or false -- TODO: this is silly
|
||||
res.isdirectory = (stat and stat.type == "directory") and true or false
|
||||
|
||||
return res
|
||||
end
|
||||
|
||||
return util
|
||||
Loading…
Reference in New Issue
Block a user