From 7818e4c27c9336d7c3a4a9458a1b58bf97d90830 Mon Sep 17 00:00:00 2001 From: Celso Benedetti Date: Wed, 11 Mar 2026 13:46:38 -0300 Subject: [PATCH 1/4] feat: agenda visual selection: toggle archive tag and archive headlines --- lua/orgmode/agenda/init.lua | 73 ++++++++++++++++++++++++++++ lua/orgmode/config/defaults.lua | 2 + lua/orgmode/config/mappings/init.lua | 11 +++++ 3 files changed, 86 insertions(+) diff --git a/lua/orgmode/agenda/init.lua b/lua/orgmode/agenda/init.lua index 9b42d73cc..9e1da218e 100644 --- a/lua/orgmode/agenda/init.lua +++ b/lua/orgmode/agenda/init.lua @@ -472,6 +472,79 @@ 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:archive_visual() + 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 _, line in ipairs(lines) do + 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('org_mappings.archive')) + end) + end) + end + return result:next(function() + return self:redo('agenda', true) + end) + end) + :next(function() + utils.echo_info(('Archived %d headlines'):format(#lines)) + end) +end + +function Agenda:toggle_archive_tag_visual() + 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 _, line in ipairs(lines) do + 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('org_mappings.toggle_archive_tag')) + end) + end) + end + return result + end) + :next(function() + return self:redo('agenda', true) + end) + :next(function() + utils.echo_info(('Toggled ARCHIVE tag on %d headlines'):format(#lines)) + end) +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' } } From 22a27f311da181067e5ac7219009e070ee584f7a Mon Sep 17 00:00:00 2001 From: Celso Benedetti Date: Wed, 11 Mar 2026 17:38:36 -0300 Subject: [PATCH 2/4] refactor: _bulk_action utility function for agenda --- lua/orgmode/agenda/init.lua | 51 +++++++++++++++---------------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/lua/orgmode/agenda/init.lua b/lua/orgmode/agenda/init.lua index 9e1da218e..699022023 100644 --- a/lua/orgmode/agenda/init.lua +++ b/lua/orgmode/agenda/init.lua @@ -490,7 +490,8 @@ function Agenda:_get_visual_selection_lines() return lines end -function Agenda:archive_visual() +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') @@ -504,45 +505,35 @@ function Agenda:archive_visual() result = result:next(function() return headline.file:update(function(_) vim.fn.cursor({ headline:get_range().start_line, 1 }) - return Promise.resolve(require('orgmode').action('org_mappings.archive')) + return Promise.resolve(require('orgmode').action(action)) end) end) end - return result:next(function() + return result + end) + :next(function() + if opts.redo ~= false then return self:redo('agenda', true) - end) + end end) :next(function() - utils.echo_info(('Archived %d headlines'):format(#lines)) + if opts.message then + utils.echo_info(opts.message:format(#lines)) + end end) end -function Agenda:toggle_archive_tag_visual() - local lines = self:_get_visual_selection_lines() - if #lines == 0 then - return utils.echo_warning('No headlines in visual selection') - end +function Agenda:archive_visual() + return self:_bulk_action('org_mappings.archive', { + redo = true, + message = 'Archived %d headlines', + }) +end - return Promise.resolve() - :next(function() - local result = Promise.resolve() - for _, line in ipairs(lines) do - 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('org_mappings.toggle_archive_tag')) - end) - end) - end - return result - end) - :next(function() - return self:redo('agenda', true) - end) - :next(function() - utils.echo_info(('Toggled ARCHIVE tag on %d headlines'):format(#lines)) - 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() From 0180acfb6c08628d17ece16f23341da4c474a7ea Mon Sep 17 00:00:00 2001 From: Celso Benedetti Date: Sun, 15 Mar 2026 21:35:11 -0300 Subject: [PATCH 3/4] test: agenda visual bulk actions --- .../agenda_visual_bulk_actions_spec.lua | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/plenary/agenda/agenda_visual_bulk_actions_spec.lua 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..a973b0549 --- /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(3, 1) + vim.cmd('normal! V') + vim.cmd('normal! jjj') + + 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(3, 1) + vim.cmd('normal! V') + vim.cmd('normal! jjj') + + 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) From 90d79a78d4fe702a02e0b0f4c058fd3edd6fae1e Mon Sep 17 00:00:00 2001 From: Celso Benedetti Date: Wed, 1 Apr 2026 18:33:08 -0300 Subject: [PATCH 4/4] fix: iterate bulk actions in reverse order this is done since actions may modify the file, and the precomputed `start_line` may be invalidated. --- lua/orgmode/agenda/init.lua | 3 ++- tests/plenary/agenda/agenda_visual_bulk_actions_spec.lua | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lua/orgmode/agenda/init.lua b/lua/orgmode/agenda/init.lua index 699022023..367934113 100644 --- a/lua/orgmode/agenda/init.lua +++ b/lua/orgmode/agenda/init.lua @@ -500,7 +500,8 @@ function Agenda:_bulk_action(action, opts) return Promise.resolve() :next(function() local result = Promise.resolve() - for _, line in ipairs(lines) do + for i = #lines, 1, -1 do + local line = lines[i] local headline = line.headline result = result:next(function() return headline.file:update(function(_) diff --git a/tests/plenary/agenda/agenda_visual_bulk_actions_spec.lua b/tests/plenary/agenda/agenda_visual_bulk_actions_spec.lua index a973b0549..8748b91ef 100644 --- a/tests/plenary/agenda/agenda_visual_bulk_actions_spec.lua +++ b/tests/plenary/agenda/agenda_visual_bulk_actions_spec.lua @@ -20,9 +20,9 @@ describe('Agenda visual archive', function() return vim.api.nvim_buf_get_name(0):match('agenda') end) - vim.fn.cursor(3, 1) + vim.fn.cursor(5, 1) vim.cmd('normal! V') - vim.cmd('normal! jjj') + vim.cmd('normal! jj') vim.wait(50) @@ -55,9 +55,9 @@ describe('Agenda visual archive', function() return vim.api.nvim_buf_get_name(0):match('agenda') end) - vim.fn.cursor(3, 1) + vim.fn.cursor(5, 1) vim.cmd('normal! V') - vim.cmd('normal! jjj') + vim.cmd('normal! jj') vim.wait(50)