diff --git a/lua/orgmode/agenda/init.lua b/lua/orgmode/agenda/init.lua index 9b42d73cc..367934113 100644 --- a/lua/orgmode/agenda/init.lua +++ b/lua/orgmode/agenda/init.lua @@ -472,6 +472,71 @@ function Agenda:toggle_archive_tag() }) end +function Agenda:_get_visual_selection_lines() + local start_line = vim.fn.line('v') + local end_line = vim.fn.line('.') + if start_line > end_line then + start_line, end_line = end_line, start_line + end + + local lines = {} + for _, view in ipairs(self.views) do + for _, line in ipairs(view:get_lines()) do + if line.line_nr >= start_line and line.line_nr <= end_line and line.headline then + table.insert(lines, line) + end + end + end + return lines +end + +function Agenda:_bulk_action(action, opts) + opts = opts or {} + local lines = self:_get_visual_selection_lines() + if #lines == 0 then + return utils.echo_warning('No headlines in visual selection') + end + + return Promise.resolve() + :next(function() + local result = Promise.resolve() + for i = #lines, 1, -1 do + local line = lines[i] + local headline = line.headline + result = result:next(function() + return headline.file:update(function(_) + vim.fn.cursor({ headline:get_range().start_line, 1 }) + return Promise.resolve(require('orgmode').action(action)) + end) + end) + end + return result + end) + :next(function() + if opts.redo ~= false then + return self:redo('agenda', true) + end + end) + :next(function() + if opts.message then + utils.echo_info(opts.message:format(#lines)) + end + end) +end + +function Agenda:archive_visual() + return self:_bulk_action('org_mappings.archive', { + redo = true, + message = 'Archived %d headlines', + }) +end + +function Agenda:toggle_archive_tag_visual() + return self:_bulk_action('org_mappings.toggle_archive_tag', { + message = 'Toggled ARCHIVE tag on %d headlines', + }) +end + function Agenda:set_tags() return self:_remote_edit({ action = 'org_mappings.set_tags', diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index 2a5ebd64f..11352608e 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -130,6 +130,8 @@ local DefaultConfig = { org_agenda_priority_down = '-', org_agenda_archive = '$', org_agenda_toggle_archive_tag = 'A', + org_agenda_archive_visual = '$', + org_agenda_toggle_archive_tag_visual = 'A', org_agenda_set_tags = 't', org_agenda_deadline = 'id', org_agenda_schedule = 'is', diff --git a/lua/orgmode/config/mappings/init.lua b/lua/orgmode/config/mappings/init.lua index 96bccd02a..de3596e1d 100644 --- a/lua/orgmode/config/mappings/init.lua +++ b/lua/orgmode/config/mappings/init.lua @@ -99,6 +99,17 @@ return { 'agenda.toggle_archive_tag', { opts = { desc = 'org toggle archive tag', help_desc = 'Toggle "ARCHIVE" tag on current headline' } } ), + org_agenda_archive_visual = m.action( + 'agenda.archive_visual', + { modes = { 'x' }, opts = { desc = 'org archive visual', help_desc = 'Archive selected headlines' } } + ), + org_agenda_toggle_archive_tag_visual = m.action('agenda.toggle_archive_tag_visual', { + modes = { 'x' }, + opts = { + desc = 'org toggle archive tag visual', + help_desc = 'Toggle "ARCHIVE" tag on selected headlines', + }, + }), org_agenda_set_tags = m.action( 'agenda.set_tags', { opts = { desc = 'org set tags', help_desc = 'Change tags of current headline' } } diff --git a/tests/plenary/agenda/agenda_visual_bulk_actions_spec.lua b/tests/plenary/agenda/agenda_visual_bulk_actions_spec.lua new file mode 100644 index 000000000..8748b91ef --- /dev/null +++ b/tests/plenary/agenda/agenda_visual_bulk_actions_spec.lua @@ -0,0 +1,74 @@ +local helpers = require('tests.plenary.helpers') +local Date = require('orgmode.objects.date') + +describe('Agenda visual archive', function() + it('should archive multiple selected headlines in visual mode', function() + local today = Date.now() + local file = helpers.create_agenda_file({ + '* TODO headline 1', + ' SCHEDULED: <2026-03-15 Sun>', + '* TODO headline 2', + ' SCHEDULED: <2026-03-15 Sun>', + '* TODO headline 3', + ' SCHEDULED: <2026-03-15 Sun>', + }) + + local org = require('orgmode') + org.agenda:open_view('agenda', { files = org.files }) + + vim.wait(100, function() + return vim.api.nvim_buf_get_name(0):match('agenda') + end) + + vim.fn.cursor(5, 1) + vim.cmd('normal! V') + vim.cmd('normal! jj') + + vim.wait(50) + + org.agenda:archive_visual() + + vim.wait(100) + + local archive_lines = vim.fn.readfile(file.filename .. '_archive') + local content = table.concat(archive_lines, '\n') + assert.is_not_nil(content:match('headline 1'), 'Archive should contain headline 1') + assert.is_not_nil(content:match('headline 2'), 'Archive should contain headline 2') + assert.is_not_nil(content:match('headline 3'), 'Archive should contain headline 3') + end) + + it('should toggle ARCHIVE tag on multiple selected headlines in visual mode', function() + local today = Date.now() + local file = helpers.create_agenda_file({ + '* TODO headline 1', + ' SCHEDULED: <2026-03-15 Sun>', + '* TODO headline 2', + ' SCHEDULED: <2026-03-15 Sun>', + '* TODO headline 3', + ' SCHEDULED: <2026-03-15 Sun>', + }) + + local org = require('orgmode') + org.agenda:open_view('agenda', { files = org.files }) + + vim.wait(100, function() + return vim.api.nvim_buf_get_name(0):match('agenda') + end) + + vim.fn.cursor(5, 1) + vim.cmd('normal! V') + vim.cmd('normal! jj') + + vim.wait(50) + + org.agenda:toggle_archive_tag_visual() + + vim.wait(100) + + local lines = vim.fn.readfile(file.filename) + local content = table.concat(lines, '\n') + assert.is_not_nil(content:match('headline 1.*:ARCHIVE:'), 'Headline 1 should have ARCHIVE tag') + assert.is_not_nil(content:match('headline 2.*:ARCHIVE:'), 'Headline 2 should have ARCHIVE tag') + assert.is_not_nil(content:match('headline 3.*:ARCHIVE:'), 'Headline 3 should have ARCHIVE tag') + end) +end)