Skip to content

Commit d83e85f

Browse files
author
troiganto
committed
feat(attach): add OrgAttach:attach_to_other_buffer()
This function has no direct Emacs equivalent. However, it tries to replicate the functionality provided by `org-attach-dired-to-subtree`, which attaches files selected in a dired window (roughly equivalent to Vim's NetRW) to the last-seen Orgmode task. Our implementation tries to be extremely general because the Neovim ecosystem has a plethora of file browser plugins.
1 parent 636d33e commit d83e85f

File tree

4 files changed

+340
-0
lines changed

4 files changed

+340
-0
lines changed

docs/configuration.org

+12
Original file line numberDiff line numberDiff line change
@@ -3015,6 +3015,18 @@ looks up =file.txt= in the current node's attachments directory and opens
30153015
it. Attaching a file stores a link to the attachment. See
30163016
[[#org_attach_store_link_p][org_attach_store_link_p]] on how to configure this behavior.
30173017

3018+
You can also attach files from a different buffer. The following
3019+
mapping attaches the path under the cursor to the current headline of the
3020+
most recently open org file:
3021+
3022+
#+begin_src lua
3023+
vim.keymap.set('n', '<Leader>o+', function()
3024+
local file = vim.fn.expand('<cfile>')
3025+
local org = require('orgmode')
3026+
org.attach:attach_to_other_buffer(file)
3027+
end)
3028+
#+end_src
3029+
30183030
The only missing feature is expansion of attachment links before exporting
30193031
a file with [[#org_export][org_exporting]].
30203032

lua/orgmode/attach/core.lua

+139
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,145 @@ function AttachCore:get_current_node()
2727
return AttachNode.at_cursor(self.files:get_current_file())
2828
end
2929

30+
---Get an attachment node for an arbitrary window.
31+
---
32+
---An error occurs if the given window doesn't point at a loaded org file.
33+
---
34+
---@param winid integer window-ID or 0 for the current window
35+
---@return OrgAttachNode
36+
function AttachCore:get_node_by_winid(winid)
37+
local bufnr = vim.api.nvim_win_get_buf(winid)
38+
local path = vim.api.nvim_buf_get_name(bufnr)
39+
local file = self.files:get(path)
40+
local cursor = vim.api.nvim_win_get_cursor(winid)
41+
return AttachNode.at_cursor(file, cursor)
42+
end
43+
44+
---@param self OrgAttachCore
45+
---@param bufnr integer
46+
---@return OrgFile | nil
47+
local function get_file_by_bufnr(self, bufnr)
48+
if not vim.api.nvim_buf_is_loaded(bufnr) then
49+
return
50+
end
51+
local path = vim.api.nvim_buf_get_name(bufnr)
52+
return self.files:load_file_sync(path) or nil
53+
end
54+
55+
---Get all attachment nodes that are pointed at in a given buffer.
56+
---
57+
---If the buffer is not loaded, or if it's not an org file, this returns an
58+
---empty list.
59+
---
60+
---If the buffer is loaded but hidden, this returns a table mapping from 0 to
61+
---the only attachment node pointed at by the mark `"` (position at last exit
62+
---from the buffer).
63+
---
64+
---If the buffer is active, this returns a table mapping from window-ID to
65+
---attachment node containing the curser in that window. Note that two windows
66+
---may point at the same attachment node.
67+
---
68+
---See `:help windows-intro` for terminology.
69+
---
70+
---@param bufnr integer
71+
---@return OrgAttachNode[]
72+
function AttachCore:get_nodes_by_buffer(bufnr)
73+
local file = get_file_by_bufnr(self, bufnr)
74+
if not file then
75+
return {}
76+
end
77+
local windows = vim.fn.win_findbuf(bufnr)
78+
if #windows == 0 then
79+
-- Org file is loaded but hidden.
80+
local cursor = vim.api.nvim_buf_get_mark(bufnr, '"')
81+
return { AttachNode.at_cursor(file, cursor) }
82+
end
83+
-- Org file is active, collect all windows.
84+
-- Because all nodes are in the same buffer, we use the fact that their
85+
-- starting-line numbers are unique. This lets us deduplicate multiple
86+
-- windows that show the same node.
87+
local nodes = {} ---@type table<integer, OrgAttachNode>
88+
for _, winid in ipairs(windows) do
89+
local cursor = vim.api.nvim_win_get_cursor(winid)
90+
local node = AttachNode.at_cursor(file, cursor)
91+
nodes[node:get_start_line()] = node
92+
end
93+
return vim.tbl_values(nodes)
94+
end
95+
96+
---Like `get_nodes_by_buffer()`, but only accept an unambiguous result.
97+
---
98+
---If the buffer is displayed in multiple windows, *and* those windows have
99+
---their cursors at different attachment nodes, return nil.
100+
---
101+
---@param bufnr integer
102+
---@return OrgAttachNode|nil
103+
function AttachCore:get_single_node_by_buffer(bufnr)
104+
local file = get_file_by_bufnr(self, bufnr)
105+
if not file then
106+
return {}
107+
end
108+
local windows = vim.fn.win_findbuf(bufnr)
109+
if #windows == 0 then
110+
-- Org file is loaded but hidden.
111+
local cursor = vim.api.nvim_buf_get_mark(bufnr, '"')
112+
return AttachNode.at_cursor(file, cursor)
113+
end
114+
-- Org file is active. Check that all cursors are on the same node.
115+
-- (This is a very cold loop, so it being a bit awkward is acceptable.)
116+
local node
117+
for _, winid in ipairs(windows) do
118+
local cursor = vim.api.nvim_win_get_cursor(winid)
119+
local next_node = AttachNode.at_cursor(file, cursor)
120+
-- Because all nodes are in the same buffer, we use the fact that their
121+
-- starting-line numbers are unique. This lets us detect when two windows
122+
-- point at different nodes.
123+
if node and node:get_start_line() ~= next_node:get_start_line() then
124+
return
125+
end
126+
node = AttachNode.at_cursor(file, cursor)
127+
end
128+
return node
129+
end
130+
131+
---List attachment nodes across buffers.
132+
---
133+
---By default, the result includes all nodes pointed at by a cursor in
134+
---a window. If `include_hidden` is true, the result also includes buffers that
135+
---are loaded but hidden. In their case, the node that contains the `"` mark is
136+
---used.
137+
---
138+
---@param opts? { include_hidden?: boolean }
139+
---@return OrgAttachNode[]
140+
function AttachCore:list_current_nodes(opts)
141+
local nodes = {} ---@type OrgAttachNode[]
142+
local seen_bufs = {} ---@type table<integer, true>
143+
for _, winid in vim.api.nvim_list_wins() do
144+
local bufnr = vim.api.nvim_win_get_buf(winid)
145+
local path = vim.api.nvim_buf_get_name(bufnr)
146+
local file = self.files:load_file_sync(path)
147+
if file then
148+
local cursor = vim.api.nvim_win_get_cursor(winid)
149+
nodes[#nodes + 1] = AttachNode.at_cursor(file, cursor)
150+
end
151+
seen_bufs[bufnr] = true
152+
end
153+
if opts and opts.include_hidden or false then
154+
for _, bufnr in vim.api.nvim_list_bufs() do
155+
if not seen_bufs[bufnr] then
156+
local file = get_file_by_bufnr(self, bufnr)
157+
if file then
158+
-- Hidden buffers don't have cursors, only windows do; instead, we
159+
-- use the mark where the buffer was last exited.
160+
local cursor = vim.api.nvim_buf_get_mark(bufnr, '"')
161+
nodes[#nodes + 1] = AttachNode.at_cursor(file, cursor)
162+
end
163+
end
164+
end
165+
end
166+
return nodes
167+
end
168+
30169
---Return the directory associated with the current outline node.
31170
---
32171
---First check for DIR property, then ID property.

lua/orgmode/attach/init.lua

+158
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,29 @@ function Attach:get_node(file, cursor)
191191
return AttachNode.at_cursor(file, cursor)
192192
end
193193

194+
---Get attachment node pointed at in a window
195+
---
196+
---@param window? integer | string window-ID, window number or any argument
197+
--- accepted by `winnr()`; if 0 or nil, use the
198+
--- current window
199+
---@return OrgAttachNode
200+
function Attach:get_node_by_window(window)
201+
local winid
202+
if not window or window == 0 then
203+
winid = vim.api.nvim_get_current_win()
204+
elseif type(window) == 'string' then
205+
winid = vim.fn.win_getid(vim.fn.winnr(window))
206+
elseif vim.fn.win_id2win(window) ~= 0 then
207+
winid = window
208+
else
209+
winid = vim.fn.win_getid(window)
210+
end
211+
if winid == 0 then
212+
error(('invalid window: %s'):format(window))
213+
end
214+
return self.core:get_node_by_winid(winid)
215+
end
216+
194217
---Return the directory associated with the current outline node.
195218
---
196219
---First check for DIR property, then ID property.
@@ -556,6 +579,141 @@ function Attach:attach_lns(node)
556579
return self:attach(nil, { method = 'lns', node = node })
557580
end
558581

582+
---@class orgmode.attach.attach_to_other_buffer.Options
583+
---@inlinedoc
584+
---@field window? integer | string if passed, attach to the node pointed at in
585+
--- the given window; you can pass a window-ID, window number, or
586+
--- `winnr()`-style strings, e.g. `#` to use the previously
587+
--- active window. Pass 0 for the current window. It's an error
588+
--- if the window doesn't display an org file.
589+
---@field ask? 'always'|'multiple' determines what to do if `window` is nil;
590+
--- if 'always', collect all nodes displayed in a window and ask the
591+
--- user to select one. If 'multiple', only ask if more than one
592+
--- node is displayed. If false or nil, never ask the user; accept
593+
--- the unambiguous choice or abort.
594+
---@field prefer_recent? 'ask'|'buffer'|'window'|boolean if not nil but
595+
--- `window` is nil, and more than one node is displayed,
596+
--- and one of them is more preferable than the others,
597+
--- this one is used without asking the user.
598+
--- Preferred nodes are those displayed in the current
599+
--- window's current buffer and alternate buffer, as well
600+
--- as the previous window's current buffer. Pass 'buffer'
601+
--- to prefer the alternate buffer over the previous
602+
--- window. Pass 'window' for the same vice versa. Pass
603+
--- 'ask' to ask the user in case of conflict. Pass 'true'
604+
--- to prefer only an unambiguous recent node over
605+
--- non-recent ones.
606+
---@field include_hidden? boolean If not nil, include not only displayed nodes,
607+
--- but also those in hidden buffers; for those, the node
608+
--- pointed at by the `"` mark (position when last
609+
--- exiting the buffer) is chosen.
610+
---@field visit_dir? boolean if not nil, open the relevant attachment directory
611+
--- after attaching the file.
612+
---@field method? 'cp' | 'mv' | 'ln' | 'lns' The attachment method, same values
613+
--- as in `org_attach_method`.
614+
615+
---@param file_or_files string | string[]
616+
---@param opts? orgmode.attach.attach_to_other_buffer.Options
617+
---@return string|nil attachment_name
618+
function Attach:attach_to_other_buffer(file_or_files, opts)
619+
local files = utils.ensure_array(file_or_files) ---@type string[]
620+
return self
621+
:find_other_node(opts)
622+
:next(function(node)
623+
if not node then
624+
return nil
625+
end
626+
return self:attach_many(files, {
627+
node = node,
628+
method = opts and opts.method,
629+
visit_dir = opts and opts.visit_dir,
630+
})
631+
end)
632+
:wait(MAX_TIMEOUT)
633+
end
634+
635+
---Helper to `Attach:attach_to_other_buffer`, unfortunately really complicated.
636+
---@param opts? orgmode.attach.attach_to_other_buffer.Options
637+
---@return OrgPromise<OrgAttachNode | nil>
638+
function Attach:find_other_node(opts)
639+
local window = opts and opts.window
640+
local ask = opts and opts.ask
641+
local prefer_recent = opts and opts.prefer_recent
642+
local include_hidden = opts and opts.include_hidden or false
643+
if window then
644+
return Promise.resolve(self:get_node_by_window(window))
645+
end
646+
if prefer_recent then
647+
local ok, node = pcall(self.core.get_current_node, self.core)
648+
if ok then
649+
return Promise.resolve(node)
650+
end
651+
local altbuf_nodes, altwin_node
652+
if prefer_recent == 'buffer' then
653+
altbuf_nodes = self.core:get_single_node_by_buffer(vim.fn.bufnr('#'))
654+
if altbuf_nodes then
655+
return Promise.resolve(altbuf_nodes)
656+
end
657+
ok, altwin_node = pcall(self.get_node_by_window, self, '#')
658+
if ok then
659+
return Promise.resolve(altwin_node)
660+
end
661+
elseif prefer_recent == 'window' then
662+
ok, altwin_node = pcall(self.get_node_by_window, self, '#')
663+
if ok then
664+
return Promise.resolve(altwin_node)
665+
end
666+
altbuf_nodes = self.core:get_single_node_by_buffer(vim.fn.bufnr('#'))
667+
if altbuf_nodes then
668+
return Promise.resolve(altbuf_nodes)
669+
end
670+
else
671+
local altbuf = vim.fn.bufnr('#')
672+
local altwin = vim.fn.win_getid(vim.fn.winnr('#'))
673+
-- altwin falls back to current window if previous window doesn't exist;
674+
-- that's fine, we've handled it earlier.
675+
ok, altwin_node = pcall(self.core.get_node_by_winid, self.core, altwin)
676+
altwin_node = ok and altwin_node or nil
677+
altbuf_nodes = self.core:get_nodes_by_buffer(altbuf)
678+
if altwin_node and (#altbuf_nodes == 0 or vim.api.nvim_win_get_buf(altwin) == altbuf) then
679+
return Promise.resolve(altwin_node)
680+
end
681+
if #altbuf_nodes == 1 and not altwin_node then
682+
return Promise.resolve(altbuf_nodes[1])
683+
end
684+
if prefer_recent == 'ask' then
685+
local candidates = altbuf_nodes
686+
if altwin_node then
687+
table.insert(candidates, 1, altwin_node)
688+
end
689+
return ui.select_node(candidates)
690+
end
691+
-- More than one possible attachment location and not asking; fall back
692+
-- to regular behavior.
693+
end
694+
end
695+
local candidates = self.core:list_current_nodes({ include_hidden = include_hidden })
696+
if #candidates == 0 then
697+
return Promise.reject('nowhere to attach to')
698+
end
699+
if ask == 'always' then
700+
return ui.select_node(candidates)
701+
end
702+
if ask == 'multiple' then
703+
if #candidates == 1 then
704+
return Promise.resolve(candidates[1])
705+
end
706+
return ui.select_node(candidates)
707+
end
708+
if ask then
709+
return Promise.reject(('invalid value for ask: %s'):format(ask))
710+
end
711+
if #candidates == 1 then
712+
return Promise.resolve(candidates[1])
713+
end
714+
return Promise.reject('more than one possible attachment location')
715+
end
716+
559717
---Open the attachments directory via `vim.ui.open()`.
560718
---
561719
---@param attach_dir? string the directory to open

lua/orgmode/attach/ui.lua

+31
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
local AttachNode = require('orgmode.attach.node')
12
local Input = require('orgmode.ui.input')
23
local Promise = require('orgmode.utils.promise')
34
local fileops = require('orgmode.attach.fileops')
@@ -66,6 +67,36 @@ function M.ask_new_method()
6667
end)
6768
end
6869

70+
---Dialog that has user select one among a given number of attachment nodes.
71+
---
72+
---Returns nil if the user cancels with `<Esc>`.
73+
---
74+
---Errors if the user's selection doesn't match a single node.
75+
---
76+
---@param nodes OrgAttachNode[]
77+
---@return OrgPromise<OrgAttachNode | nil> selection
78+
function M.select_node(nodes)
79+
---@param arglead string
80+
---@return OrgAttachNode[]
81+
local function get_matches(arglead)
82+
return vim.fn.matchfuzzy(nodes, arglead, { matchseq = true, text_cb = AttachNode.get_title })
83+
end
84+
return Input.open('Select an attachment node: ', '', get_matches):next(function(choice)
85+
if not choice then
86+
return nil
87+
end
88+
local matches = get_matches(choice)
89+
if #matches == 1 then
90+
return matches[1]
91+
end
92+
if #matches > 1 then
93+
error('more than one match for ' .. tostring(choice))
94+
else
95+
error('no matching buffer for ' .. tostring(choice))
96+
end
97+
end)
98+
end
99+
69100
---Helper for `make_completion()`.
70101
---
71102
---@param directory string

0 commit comments

Comments
 (0)