From 68886d74f90d75131408e60cc37f7e93b1c259d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Villafa=C3=B1a?= Date: Thu, 19 Mar 2026 15:17:27 -0500 Subject: [PATCH] fix(notifications): optimise org notification do not block main ui thread (use async) implement caching for files (typically files do not change frequently) --- lua/orgmode/notifications/init.lua | 184 ++++++++++++++++++++++------- 1 file changed, 141 insertions(+), 43 deletions(-) diff --git a/lua/orgmode/notifications/init.lua b/lua/orgmode/notifications/init.lua index 273284b1f..7e11f0bfb 100644 --- a/lua/orgmode/notifications/init.lua +++ b/lua/orgmode/notifications/init.lua @@ -5,9 +5,16 @@ local NotificationPopup = require('orgmode.notifications.notification_popup') local current_file_path = string.sub(debug.getinfo(1, 'S').source, 2) local root_path = vim.fn.fnamemodify(current_file_path, ':p:h:h:h:h') +---@class OrgNotificationsCacheEntry +---@field mtime number +---@field mtime_sec number +---@field changedtick number +---@field headlines { headline: OrgHeadline, dates: OrgDate[] }[] + ---@class OrgNotifications ---@field timer table ---@field files OrgFiles +---@field _file_cache table local Notifications = {} ---@param opts { files: OrgFiles } @@ -15,6 +22,7 @@ function Notifications:new(opts) local data = { timer = nil, files = opts.files, + _file_cache = {}, } setmetatable(data, self) self.__index = self @@ -41,36 +49,62 @@ function Notifications:stop_timer() end end +---@private +---Run get_tasks as a coroutine, processing file batches across event loop iterations ---@param time OrgDate -function Notifications:notify(time) - local tasks = self:get_tasks(time) +---@param callback fun(tasks: table[]) +function Notifications:_get_tasks_async(time, callback) + local co = coroutine.create(function() + return self:get_tasks(time) + end) - if type(config.notifications.notifier) == 'function' then - return config.notifications.notifier(tasks) + local function step() + local ok, result = coroutine.resume(co) + if not ok then + vim.notify('[orgmode] notification error: ' .. tostring(result), vim.log.levels.ERROR) + return + end + if coroutine.status(co) == 'dead' then + callback(result) + else + vim.schedule(step) + end end - local result = {} - for _, task in ipairs(tasks) do - utils.concat(result, { - string.format('# %s (%s)', task.category, task.humanized_duration), - string.format('%s %s %s', string.rep('*', task.level), task.todo or '', task.title), - string.format('%s: <%s>', task.type, task.time:to_string()), - }) - end + step() +end - if not vim.tbl_isempty(result) then - NotificationPopup:new({ content = result, border = config.win_border }) - end +---@param time OrgDate +function Notifications:notify(time) + self:_get_tasks_async(time, function(tasks) + if type(config.notifications.notifier) == 'function' then + return config.notifications.notifier(tasks) + end + + local result = {} + for _, task in ipairs(tasks) do + utils.concat(result, { + string.format('# %s (%s)', task.category, task.humanized_duration), + string.format('%s %s %s', string.rep('*', task.level), task.todo or '', task.title), + string.format('%s: <%s>', task.type, task.time:to_string()), + }) + end + + if not vim.tbl_isempty(result) then + NotificationPopup:new({ content = result, border = config.win_border }) + end + end) end function Notifications:cron() - local tasks = self:get_tasks(Date.now()) - if type(config.notifications.cron_notifier) == 'function' then - config.notifications.cron_notifier(tasks) - else - self:_cron_notifier(tasks) - end - vim.cmd([[qall!]]) + self:_get_tasks_async(Date.now(), function(tasks) + if type(config.notifications.cron_notifier) == 'function' then + config.notifications.cron_notifier(tasks) + else + self:_cron_notifier(tasks) + end + vim.cmd([[qall!]]) + end) end ---@param tasks table[] @@ -96,33 +130,97 @@ function Notifications:_cron_notifier(tasks) end end +---@private +---Check if the cache entry for a file is still valid +---@param orgfile OrgFile +---@return boolean +function Notifications:_is_cache_valid(orgfile) + local cached = self._file_cache[orgfile.filename] + if not cached then + return false + end + return cached.mtime == orgfile.metadata.mtime + and cached.mtime_sec == orgfile.metadata.mtime_sec + and cached.changedtick == orgfile.metadata.changedtick +end + +---@private +---Get cached headline data for a file, rebuilding the cache if the file has changed +---@param orgfile OrgFile +---@return { headline: OrgHeadline, dates: OrgDate[] }[] +function Notifications:_get_cached_headlines(orgfile) + if self:_is_cache_valid(orgfile) then + return self._file_cache[orgfile.filename].headlines + end + + local headline_data = {} + for _, headline in ipairs(orgfile:get_opened_unfinished_headlines()) do + local dates = headline:get_deadline_and_scheduled_dates() + if #dates > 0 then + table.insert(headline_data, { headline = headline, dates = dates }) + end + end + + self._file_cache[orgfile.filename] = { + mtime = orgfile.metadata.mtime, + mtime_sec = orgfile.metadata.mtime_sec, + changedtick = orgfile.metadata.changedtick, + headlines = headline_data, + } + + return headline_data +end + ---@param time OrgDate function Notifications:get_tasks(time) local tasks = {} - for _, orgfile in ipairs(self.files:all()) do - for _, headline in ipairs(orgfile:get_opened_unfinished_headlines()) do - for _, date in ipairs(headline:get_deadline_and_scheduled_dates()) do - local reminders = self:_check_reminders(date, time) - for _, reminder in ipairs(reminders) do - table.insert(tasks, { - file = orgfile.filename, - todo = headline:get_todo(), - category = headline:get_category(), - priority = headline:get_priority(), - title = headline:get_title(), - level = headline:get_level(), - tags = headline:get_tags(), - original_time = date, - time = reminder.time, - reminder_type = reminder.reminder_type, - minutes = reminder.minutes, - humanized_duration = utils.humanize_minutes(reminder.minutes), - type = date.type, - range = headline:get_range(), - }) + local orgfiles = self.files:all() + local file_idx = 1 + local batch_size = 3 + + local function process_batch() + local batch_end = math.min(file_idx + batch_size - 1, #orgfiles) + for i = file_idx, batch_end do + local orgfile = orgfiles[i] + local headline_data = self:_get_cached_headlines(orgfile) + for _, entry in ipairs(headline_data) do + for _, date in ipairs(entry.dates) do + local reminders = self:_check_reminders(date, time) + -- only build task objects when reminders match + if #reminders > 0 then + local headline = entry.headline + for _, reminder in ipairs(reminders) do + table.insert(tasks, { + file = orgfile.filename, + todo = headline:get_todo(), + category = headline:get_category(), + priority = headline:get_priority(), + title = headline:get_title(), + level = headline:get_level(), + tags = headline:get_tags(), + original_time = date, + time = reminder.time, + reminder_type = reminder.reminder_type, + minutes = reminder.minutes, + humanized_duration = utils.humanize_minutes(reminder.minutes), + type = date.type, + range = headline:get_range(), + }) + end + end end end end + file_idx = batch_end + 1 + end + + -- process files in batches, yielding between batches to avoid blocking the editor + local _, is_main = coroutine.running() + while file_idx <= #orgfiles do + process_batch() + if not is_main and file_idx <= #orgfiles then + coroutine.yield() + end end return tasks