diff --git a/docs/addons.md b/docs/addons.md index a57ffdd..368f0b0 100644 --- a/docs/addons.md +++ b/docs/addons.md @@ -556,6 +556,10 @@ Clears the directory cache. Use this if you are modifying the contents of direct than the current one to ensure that their contents will be rescanned when next opened. An an array of directory strings is passed to the function only those directories will be cleared from the cache. +The cache is cleared asynchronously, it may not, and probably will not, have been cleared +when the function returns. This may change in the future. + +The cache is implemented as an [internal parser](../modules/parsers/cache.lua). #### `fb.coroutine.assert(err?: string): coroutine` @@ -648,7 +652,7 @@ any additional arguments. The (not yet started) coroutine is returned by the fun #### `fb.rescan(): coroutine` -Rescans the current directory. Equivalent to Ctrl+r without the cache refresh for any other directory. +Rescans the current directory. Equivalent to Ctrl+r. Returns the coroutine of the upcoming parse operation. The parse is queued and run when the script thread next goes idle, allowing one to store this value and use it to identify the triggered parse operation. diff --git a/docs/file_browser.conf b/docs/file_browser.conf index 55383e3..f3d104a 100644 --- a/docs/file_browser.conf +++ b/docs/file_browser.conf @@ -74,7 +74,8 @@ normalise_backslash=auto # enable if it takes a long time to load directories. # May cause 'ghost' files to be shown that no-longer exist or # fail to show files that have recently been created. -# Use Ctrl+r to forcibly clear the cache when enabled. +# Reloading the directory with Ctrl+r will never use the cache. +# Use Ctrl+Shift+r to forcibly clear the cache. cache=no # this option reverses the behaviour of the alt+ENTER keybind diff --git a/modules/apis/fb.lua b/modules/apis/fb.lua index 0c4d054..ed4a442 100644 --- a/modules/apis/fb.lua +++ b/modules/apis/fb.lua @@ -8,7 +8,6 @@ local fb_utils = require 'modules.utils' local ass = require 'modules.ass' local directory_movement = require 'modules.navigation.directory-movement' local scanning = require 'modules.navigation.scanning' -local cache = require 'modules.cache' local controls = require 'modules.controls' ---@class FbAPI: fb_utils @@ -22,21 +21,24 @@ fb.browse_directory = controls.browse_directory ---Clears the directory cache. ---@return thread function fb.rescan() - cache:clear({g.state.directory}) return scanning.rescan() end ---@async ---@return thread function fb.rescan_await() - cache:clear({g.state.directory}) local co = scanning.rescan(nil, fb_utils.coroutine.callback()) coroutine.yield() return co end +---@param directories? string[] function fb.clear_cache(directories) - cache:clear(directories) + if directories then + mp.commandv('script-message-to', mp.get_script_name(), 'cache/clear', utils.format_json(directories)) + else + mp.commandv('script-message-to', mp.get_script_name(), 'cache/clear') + end end ---A wrapper around scan_directory for addon API. diff --git a/modules/cache.lua b/modules/cache.lua deleted file mode 100644 index e79ede4..0000000 --- a/modules/cache.lua +++ /dev/null @@ -1,182 +0,0 @@ --------------------------------------------------------------------------------------------------------- ---------------------------------------Cache Implementation---------------------------------------------- --------------------------------------------------------------------------------------------------------- --------------------------------------------------------------------------------------------------------- - -local msg = require 'mp.msg' -local utils = require 'mp.utils' - -local o = require 'modules.options' -local g = require 'modules.globals' -local fb_utils = require 'modules.utils' - ----Returns an array of keys in a table. ----@generic T ----@param t table ----@return T[] -local function get_keys(t) - local keys = {} - for key in pairs(t --[[@as table]]) do - table.insert(keys, key) - end - return keys -end - ----@class CacheRef ----@field directory string ----@field ref State? - ----@class Cache ----@field traversal_stack CacheRef[] ----@field history CacheRef[] ----@field cache table ----@field dangling_refs Set -local cache = { - cache = setmetatable({}, {__mode = 'kv'}), - traversal_stack = {}, - history = {}, - cached_values = { - "directory", "directory_label", "list", "selected", "selection", "parser", "empty_text", "co" - }, - dangling_refs = {}, -} - -function cache:print_debug_info() - local cache_keys = get_keys(self.cache) - msg.verbose('Printing cache debug info') - msg.verbose('cache size:', #cache_keys) - msg.debug(utils.to_string(cache_keys)) - msg.trace(utils.to_string(self.cache[cache_keys[#cache_keys]])) - - msg.verbose('traversal_stack size:', #self.traversal_stack) - msg.debug(utils.to_string(fb_utils.list.map(self.traversal_stack, function(ref) return ref.directory end))) - - msg.verbose('history size:', #self.history) - msg.debug(utils.to_string(fb_utils.list.map(self.history, function(ref) return ref.directory end))) -end - ----@param directory string ----@param state State -function cache:replace_dangling_refs(directory, state) - for _, v in ipairs(self.traversal_stack) do - if v.directory == directory then - v.ref = state - self.dangling_refs[directory] = nil - end - end - for _, v in ipairs(self.history) do - if v.directory == directory then - v.ref = state - self.dangling_refs[directory] = nil - end - end -end - -function cache:add_current_state() - -- We won't actually store any cache details here if - -- the option is not enabled. - if not o.cache then return end - - local directory = g.state.directory - if directory == nil then return end - - local t = self.cache[directory] or {} - for _, value in ipairs(self.cached_values) do - t[value] = g.state[value] ---@diagnostic disable-line no-unknown - end - - self.cache[directory] = t - if self.dangling_refs[directory] then - self:replace_dangling_refs(directory, t) - end -end - ----Creates a reference to the cache of a particular directory to prevent it ----from being garbage collected. ----@param directory string ----@return CacheRef -function cache:get_cache_ref(directory) - return { - directory = directory, - ref = self.cache[directory], - } -end - -function cache:append_history() - self:add_current_state() - local history_size = #self.history - - -- We don't want to have the same directory in the history over and over again. - if history_size > 0 and self.history[history_size].directory == g.state.directory then return end - - table.insert(self.history, self:get_cache_ref(g.state.directory)) - if (history_size + 1) > 100 then table.remove(self.history, 1) end -end - ----@param directory string ----@return boolean -function cache:in_cache(directory) - return self.cache[directory] ~= nil -end - ----@param directory string ----@return boolean -function cache:apply(directory) - directory = directory or g.state.directory - local t = self.cache[directory] - if not t then return false end - - msg.verbose('applying cache for', directory) - - for _, value in ipairs(self.cached_values) do - msg.debug('setting', value, 'to', t[value]) - g.state[value] = t[value] ---@diagnostic disable-line no-unknown - end - - return true -end - -function cache:push() - local stack_size = #self.traversal_stack - if stack_size > 0 and self.traversal_stack[stack_size].directory == g.state.directory then return end - table.insert(self.traversal_stack, self:get_cache_ref(g.state.directory)) -end - -function cache:pop() - table.remove(self.traversal_stack) -end - -function cache:clear_traversal_stack() - self.traversal_stack = {} -end - ----@param directories? string[] -function cache:clear(directories) - if directories then - msg.verbose('clearing cache', utils.to_string(directories)) - for _, dir in ipairs(directories) do - self.cache[dir] = nil - for _, v in ipairs(self.traversal_stack) do - if v.directory == dir then v.ref = nil end - end - for _, v in ipairs(self.history) do - if v.directory == dir then v.ref = nil end - end - self.dangling_refs[dir] = nil - end - return - end - - msg.verbose('clearing cache') - self.cache = setmetatable({}, {__mode = 'kv'}) - for _, v in ipairs(self.traversal_stack) do - v.ref = nil - self.dangling_refs[v.directory] = true - end - for _, v in ipairs(self.history) do - v.ref = nil - self.dangling_refs[v.directory] = true - end -end - -return cache diff --git a/modules/defs/mp/defaults.lua b/modules/defs/mp/defaults.lua index 43acd63..2ba8ab8 100644 --- a/modules/defs/mp/defaults.lua +++ b/modules/defs/mp/defaults.lua @@ -101,6 +101,9 @@ function mp.get_property_native(name, def) end ---@return string|nil function mp.get_script_directory() end +---@return string +function mp.get_script_name() end + ---@param name string ---@param type 'native'|'bool'|'string'|'number' ---@param fn fun(name: string, v: unknown) @@ -112,7 +115,7 @@ function mp.observe_property(name, type, fn) end function mp.register_event(name, fn) end ---@param name string ----@param fn function +---@param fn fun(...: string) function mp.register_script_message(name, fn) end ---@param name string diff --git a/modules/defs/mp/utils.lua b/modules/defs/mp/utils.lua index 58d0b8c..59eae32 100644 --- a/modules/defs/mp/utils.lua +++ b/modules/defs/mp/utils.lua @@ -15,7 +15,7 @@ function utils.join_path(p1, p2) end ---@param str string ---@param trail? boolean ----@return table? t +---@return (table|unknown[])? t ---@return string? err # error ---@return string trail # trailing characters function utils.parse_json(str, trail) end diff --git a/modules/keybinds.lua b/modules/keybinds.lua index 5b0d8f5..22ce78e 100644 --- a/modules/keybinds.lua +++ b/modules/keybinds.lua @@ -16,7 +16,6 @@ local controls = require 'modules.controls' local movement = require 'modules.navigation.directory-movement' local scanning = require 'modules.navigation.scanning' local cursor = require 'modules.navigation.cursor' -local cache = require 'modules.cache' g.state.keybinds = { {'ENTER', 'play', function() playlist.add_files('replace', false) end}, @@ -33,7 +32,7 @@ g.state.keybinds = { {'Shift+PGUP', 'list_top', function() cursor.scroll(-math.huge) end}, {'HOME', 'goto_current', movement.goto_current_dir}, {'Shift+HOME', 'goto_root', movement.goto_root}, - {'Ctrl+r', 'reload', function() cache:clear(); scanning.rescan() end}, + {'Ctrl+r', 'reload', scanning.rescan}, {'s', 'select_mode', cursor.toggle_select_mode}, {'S', 'select_item', cursor.toggle_selection}, {'Ctrl+a', 'select_all', cursor.select_all} diff --git a/modules/navigation/directory-movement.lua b/modules/navigation/directory-movement.lua index ce2723c..c769101 100644 --- a/modules/navigation/directory-movement.lua +++ b/modules/navigation/directory-movement.lua @@ -6,7 +6,6 @@ local utils = require 'mp.utils' local o = require 'modules.options' local g = require 'modules.globals' local ass = require 'modules.ass' -local cache = require 'modules.cache' local scanning = require 'modules.navigation.scanning' local fb_utils = require 'modules.utils' @@ -35,9 +34,6 @@ end --the base function for moving to a directory function directory_movement.goto_directory(directory, moving_adjacent) - -- update cache to the lastest state values before changing the current directory - cache:add_current_state() - local current = g.state.list[g.state.selected] g.state.directory = directory diff --git a/modules/navigation/scanning.lua b/modules/navigation/scanning.lua index 46d3731..b1aea1e 100644 --- a/modules/navigation/scanning.lua +++ b/modules/navigation/scanning.lua @@ -4,7 +4,6 @@ local utils = require 'mp.utils' local g = require 'modules.globals' local fb_utils = require 'modules.utils' -local cache = require 'modules.cache' local cursor = require 'modules.navigation.cursor' local ass = require 'modules.ass' @@ -12,7 +11,6 @@ local parse_state_API = require 'modules.apis.parse-state' local function clear_non_adjacent_state() g.state.directory_label = nil - cache:clear_traversal_stack() end ---parses the given directory or defers to the next parser if nil is returned @@ -105,13 +103,6 @@ local function update_list(moving_adjacent) g.state.selected = 1 g.state.selection = {} - --loads the current directry from the cache to save loading time - if cache:in_cache(g.state.directory) then - msg.verbose('found directory in cache') - cache:apply(g.state.directory) - g.state.prev_directory = g.state.directory - return - end local directory = g.state.directory local list, opts = parse_directory(g.state.directory, { source = "browser" }) @@ -124,13 +115,7 @@ local function update_list(moving_adjacent) end --apply fallbacks if the scan failed - if not list and cache:in_cache(g.state.prev_directory) then - --switches settings back to the previously opened directory - --to the user it will be like the directory never changed - msg.warn("could not read directory", g.state.directory) - cache:apply(g.state.prev_directory) - return - elseif not list then + if not list then --opens the root instead msg.warn("could not read directory", g.state.directory, "redirecting to root") list, opts = parse_directory("", { source = "browser" }) @@ -189,11 +174,6 @@ local function rescan(moving_adjacent, cb) update_list(moving_adjacent) if g.state.empty_text == "~" then g.state.empty_text = "empty directory" end - cache:append_history() - if type(moving_adjacent) == 'number' and moving_adjacent < 0 then cache:pop() - else cache:push() end - if not cache.traversal_stack[1] then cache:push() end - ass.update_ass() if cb then fb_utils.coroutine.run(cb) end end) diff --git a/modules/options.lua b/modules/options.lua index 9ba5a69..6307fce 100644 --- a/modules/options.lua +++ b/modules/options.lua @@ -61,8 +61,7 @@ local o = { --a directory cache to improve directory reading time, --enable if it takes a long time to load directories. --may cause 'ghost' files to be shown that no-longer exist or - --fail to show files that have recently been created. Use Ctrl+r to - --forcibly clear the cache when enabled. + --fail to show files that have recently been created. cache = false, --this option reverses the behaviour of the alt+ENTER keybind diff --git a/modules/parsers/cache.lua b/modules/parsers/cache.lua new file mode 100644 index 0000000..87ff54b --- /dev/null +++ b/modules/parsers/cache.lua @@ -0,0 +1,131 @@ +local mp = require 'mp' +local msg = require 'mp.msg' +local utils = require 'mp.utils' + +local o = require 'modules.options' +local fb = require 'file-browser' + +---@type ParserConfig +local cacheParser = { + name = 'cache', + priority = 0, + api_version = '1.6', +} + +---@class CacheEntry +---@field list List +---@field opts Opts? +---@field timeout MPTimer + +---@type table +local cache = {} + +---@type table +local pending_parses = {} + +---@param directories? string[] +local function clear_cache(directories) + if directories then + msg.debug('clearing cache', table.concat(directories, '\n')) + for _, dir in ipairs(directories) do + if cache[dir] then + cache[dir].timeout:kill() + cache[dir] = nil + end + end + else + msg.debug('clearing cache') + for _, entry in pairs(cache) do + entry.timeout:kill() + end + cache = {} + end +end + +---@type string +local prev_directory = '' + +function cacheParser:can_parse(directory, parse_state) + -- the script message is guaranteed to always bypass the cache + if parse_state.source == 'script-message' then return false end + if not o.cache or directory == '' then return false end + + -- clear the cache if reloading the current directory in the browser + -- this means that fb.rescan() should maintain expected behaviour + if parse_state.source == 'browser' then + prev_directory = directory + if prev_directory == directory then clear_cache({directory}) end + end + + return true +end + +---@async +function cacheParser:parse(directory) + if cache[directory] then + msg.verbose('fetching', directory, 'contents from cache') + cache[directory].timeout:kill() + cache[directory].timeout:resume() + return cache[directory].list, cache[directory].opts + end + + ---@type List?, Opts? + local list, opts + + -- if another parse is already running on the same directory, then wait and use the same result + if not pending_parses[directory] then + pending_parses[directory] = {} + list, opts = self:defer(directory) + else + msg.debug('parse for', directory, 'already running - waiting for other parse to finish...') + table.insert(pending_parses[directory], fb.coroutine.callback(30)) + list, opts = coroutine.yield() + end + + local pending = pending_parses[directory] + -- need to clear the pending parses before resuming them or they will also attempt to resume the parses + pending_parses[directory] = nil + if pending and #pending > 0 then + msg.debug('resuming', #pending, 'pending parses for', directory) + for _, cb in ipairs(pending) do + cb(list, opts) + end + end + + if not list then return end + + -- pending will be truthy for the original parse and falsy for any parses that were pending + if pending then + msg.debug('storing', directory, 'contents in cache') + cache[directory] = { + list = list, + opts = opts, + timeout = mp.add_timeout(120, function() cache[directory] = nil end), + } + end + + return list, opts +end + +cacheParser.keybinds = { + { + key = 'Ctrl+Shift+r', + name = 'clear_cache', + command = function() clear_cache() ; fb.rescan() end, + } +} + +-- provide method of clearing the cache through script messages +mp.register_script_message('cache/clear', function(dirs) + if not dirs then + return clear_cache() + end + + ---@type string[]? + local directories = utils.parse_json(dirs) + if not directories then msg.error('unable to parse', dirs) end + + clear_cache(directories) +end) + +return cacheParser diff --git a/modules/utils.lua b/modules/utils.lua index 24afd05..ed522ec 100644 --- a/modules/utils.lua +++ b/modules/utils.lua @@ -143,7 +143,7 @@ end ---If the time limit expires the coroutine will be resumed. The first return value will be true ---if the callback was resumed within the time limit and false otherwise. ---If time_limit is falsy then there will be no time limit and there will be no additional return value. ----@param time_limit? number +---@param time_limit? number seconds ---@return fun(...) function fb_utils.coroutine.callback(time_limit) local co = fb_utils.coroutine.assert("cannot create a coroutine callback for the main thread")