Skip to content

Commit 83c9c00

Browse files
David Villafañadtvillafana
authored andcommitted
fix(notifications): optimise org notification
do not block main ui thread (use async) implement caching for files (typically files do not change frequently)
1 parent 1ab7b45 commit 83c9c00

1 file changed

Lines changed: 141 additions & 43 deletions

File tree

lua/orgmode/notifications/init.lua

Lines changed: 141 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,24 @@ local NotificationPopup = require('orgmode.notifications.notification_popup')
55
local current_file_path = string.sub(debug.getinfo(1, 'S').source, 2)
66
local root_path = vim.fn.fnamemodify(current_file_path, ':p:h:h:h:h')
77

8+
---@class OrgNotificationsCacheEntry
9+
---@field mtime number
10+
---@field mtime_sec number
11+
---@field changedtick number
12+
---@field headlines { headline: OrgHeadline, dates: OrgDate[] }[]
13+
814
---@class OrgNotifications
915
---@field timer table
1016
---@field files OrgFiles
17+
---@field _file_cache table<string, OrgNotificationsCacheEntry>
1118
local Notifications = {}
1219

1320
---@param opts { files: OrgFiles }
1421
function Notifications:new(opts)
1522
local data = {
1623
timer = nil,
1724
files = opts.files,
25+
_file_cache = {},
1826
}
1927
setmetatable(data, self)
2028
self.__index = self
@@ -41,36 +49,62 @@ function Notifications:stop_timer()
4149
end
4250
end
4351

52+
---@private
53+
---Run get_tasks as a coroutine, processing file batches across event loop iterations
4454
---@param time OrgDate
45-
function Notifications:notify(time)
46-
local tasks = self:get_tasks(time)
55+
---@param callback fun(tasks: table[])
56+
function Notifications:_get_tasks_async(time, callback)
57+
local co = coroutine.create(function()
58+
return self:get_tasks(time)
59+
end)
4760

48-
if type(config.notifications.notifier) == 'function' then
49-
return config.notifications.notifier(tasks)
61+
local function step()
62+
local ok, result = coroutine.resume(co)
63+
if not ok then
64+
vim.notify('[orgmode] notification error: ' .. tostring(result), vim.log.levels.ERROR)
65+
return
66+
end
67+
if coroutine.status(co) == 'dead' then
68+
callback(result)
69+
else
70+
vim.schedule(step)
71+
end
5072
end
5173

52-
local result = {}
53-
for _, task in ipairs(tasks) do
54-
utils.concat(result, {
55-
string.format('# %s (%s)', task.category, task.humanized_duration),
56-
string.format('%s %s %s', string.rep('*', task.level), task.todo or '', task.title),
57-
string.format('%s: <%s>', task.type, task.time:to_string()),
58-
})
59-
end
74+
step()
75+
end
6076

61-
if not vim.tbl_isempty(result) then
62-
NotificationPopup:new({ content = result, border = config.win_border })
63-
end
77+
---@param time OrgDate
78+
function Notifications:notify(time)
79+
self:_get_tasks_async(time, function(tasks)
80+
if type(config.notifications.notifier) == 'function' then
81+
return config.notifications.notifier(tasks)
82+
end
83+
84+
local result = {}
85+
for _, task in ipairs(tasks) do
86+
utils.concat(result, {
87+
string.format('# %s (%s)', task.category, task.humanized_duration),
88+
string.format('%s %s %s', string.rep('*', task.level), task.todo or '', task.title),
89+
string.format('%s: <%s>', task.type, task.time:to_string()),
90+
})
91+
end
92+
93+
if not vim.tbl_isempty(result) then
94+
NotificationPopup:new({ content = result, border = config.win_border })
95+
end
96+
end)
6497
end
6598

6699
function Notifications:cron()
67-
local tasks = self:get_tasks(Date.now())
68-
if type(config.notifications.cron_notifier) == 'function' then
69-
config.notifications.cron_notifier(tasks)
70-
else
71-
self:_cron_notifier(tasks)
72-
end
73-
vim.cmd([[qall!]])
100+
self:_get_tasks_async(Date.now(), function(tasks)
101+
if type(config.notifications.cron_notifier) == 'function' then
102+
config.notifications.cron_notifier(tasks)
103+
else
104+
self:_cron_notifier(tasks)
105+
end
106+
vim.cmd([[qall!]])
107+
end)
74108
end
75109

76110
---@param tasks table[]
@@ -96,33 +130,97 @@ function Notifications:_cron_notifier(tasks)
96130
end
97131
end
98132

133+
---@private
134+
---Check if the cache entry for a file is still valid
135+
---@param orgfile OrgFile
136+
---@return boolean
137+
function Notifications:_is_cache_valid(orgfile)
138+
local cached = self._file_cache[orgfile.filename]
139+
if not cached then
140+
return false
141+
end
142+
return cached.mtime == orgfile.metadata.mtime
143+
and cached.mtime_sec == orgfile.metadata.mtime_sec
144+
and cached.changedtick == orgfile.metadata.changedtick
145+
end
146+
147+
---@private
148+
---Get cached headline data for a file, rebuilding the cache if the file has changed
149+
---@param orgfile OrgFile
150+
---@return { headline: OrgHeadline, dates: OrgDate[] }[]
151+
function Notifications:_get_cached_headlines(orgfile)
152+
if self:_is_cache_valid(orgfile) then
153+
return self._file_cache[orgfile.filename].headlines
154+
end
155+
156+
local headline_data = {}
157+
for _, headline in ipairs(orgfile:get_opened_unfinished_headlines()) do
158+
local dates = headline:get_deadline_and_scheduled_dates()
159+
if #dates > 0 then
160+
table.insert(headline_data, { headline = headline, dates = dates })
161+
end
162+
end
163+
164+
self._file_cache[orgfile.filename] = {
165+
mtime = orgfile.metadata.mtime,
166+
mtime_sec = orgfile.metadata.mtime_sec,
167+
changedtick = orgfile.metadata.changedtick,
168+
headlines = headline_data,
169+
}
170+
171+
return headline_data
172+
end
173+
99174
---@param time OrgDate
100175
function Notifications:get_tasks(time)
101176
local tasks = {}
102-
for _, orgfile in ipairs(self.files:all()) do
103-
for _, headline in ipairs(orgfile:get_opened_unfinished_headlines()) do
104-
for _, date in ipairs(headline:get_deadline_and_scheduled_dates()) do
105-
local reminders = self:_check_reminders(date, time)
106-
for _, reminder in ipairs(reminders) do
107-
table.insert(tasks, {
108-
file = orgfile.filename,
109-
todo = headline:get_todo(),
110-
category = headline:get_category(),
111-
priority = headline:get_priority(),
112-
title = headline:get_title(),
113-
level = headline:get_level(),
114-
tags = headline:get_tags(),
115-
original_time = date,
116-
time = reminder.time,
117-
reminder_type = reminder.reminder_type,
118-
minutes = reminder.minutes,
119-
humanized_duration = utils.humanize_minutes(reminder.minutes),
120-
type = date.type,
121-
range = headline:get_range(),
122-
})
177+
local orgfiles = self.files:all()
178+
local file_idx = 1
179+
local batch_size = 3
180+
181+
local function process_batch()
182+
local batch_end = math.min(file_idx + batch_size - 1, #orgfiles)
183+
for i = file_idx, batch_end do
184+
local orgfile = orgfiles[i]
185+
local headline_data = self:_get_cached_headlines(orgfile)
186+
for _, entry in ipairs(headline_data) do
187+
for _, date in ipairs(entry.dates) do
188+
local reminders = self:_check_reminders(date, time)
189+
-- only build task objects when reminders match
190+
if #reminders > 0 then
191+
local headline = entry.headline
192+
for _, reminder in ipairs(reminders) do
193+
table.insert(tasks, {
194+
file = orgfile.filename,
195+
todo = headline:get_todo(),
196+
category = headline:get_category(),
197+
priority = headline:get_priority(),
198+
title = headline:get_title(),
199+
level = headline:get_level(),
200+
tags = headline:get_tags(),
201+
original_time = date,
202+
time = reminder.time,
203+
reminder_type = reminder.reminder_type,
204+
minutes = reminder.minutes,
205+
humanized_duration = utils.humanize_minutes(reminder.minutes),
206+
type = date.type,
207+
range = headline:get_range(),
208+
})
209+
end
210+
end
123211
end
124212
end
125213
end
214+
file_idx = batch_end + 1
215+
end
216+
217+
-- process files in batches, yielding between batches to avoid blocking the editor
218+
local _, is_main = coroutine.running()
219+
while file_idx <= #orgfiles do
220+
process_batch()
221+
if not is_main and file_idx <= #orgfiles then
222+
coroutine.yield()
223+
end
126224
end
127225

128226
return tasks

0 commit comments

Comments
 (0)