Skip to content

Commit 846d593

Browse files
committed
feat(attach): add ability to open attachments
This adds functions that correspond to these Emacs functions: - `org-attach-reveal` - `org-attach-reveal-in-emacs` - `org-attach-open` - `org-attach-open-in-emacs`
1 parent 19c9367 commit 846d593

File tree

6 files changed

+253
-0
lines changed

6 files changed

+253
-0
lines changed

docs/configuration.org

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,16 @@ is copied due to the [[https://orgmode.org/manual/Attachment-defaults-and-dispat
11881188
symlink itself. The default is to treat the symlink transparently as
11891189
a directory.
11901190

1191+
*** org_attach_visit_command
1192+
:PROPERTIES:
1193+
:CUSTOM_ID: org_attach_visit_command
1194+
:END:
1195+
- Type: =string|fun(path: string)=
1196+
- Default: ='edit'=
1197+
1198+
Command or function used to open a directory. The default opens NetRW if it
1199+
is available.
1200+
11911201
*** org_attach_use_inheritance
11921202
:PROPERTIES:
11931203
:CUSTOM_ID: org_attach_use_inheritance

lua/orgmode/attach/core.lua

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,4 +401,47 @@ function AttachCore:attach_new(node, name, opts)
401401
end)
402402
end
403403

404+
---Open the attachments directory via `vim.ui.open()`.
405+
---
406+
---@param attach_dir string the directory to open
407+
---@return vim.SystemObj
408+
function AttachCore:reveal(attach_dir)
409+
return assert(vim.ui.open(attach_dir))
410+
end
411+
412+
---Open the attachments directory via `org_attach_visit_command`.
413+
---
414+
---@param attach_dir string the directory to open
415+
---@return nil
416+
function AttachCore:reveal_nvim(attach_dir)
417+
local command = config.org_attach_visit_command or 'edit'
418+
if type(command) == 'string' then
419+
vim.cmd[command](attach_dir)
420+
else
421+
command(attach_dir)
422+
end
423+
end
424+
425+
---Open an attached file via `vim.ui.open()`.
426+
---
427+
---@param node OrgAttachNode
428+
---@param name string name of the file to open
429+
---@return vim.SystemObj
430+
function AttachCore:open(name, node)
431+
local attach_dir = self:get_dir(node)
432+
local path = vim.fs.joinpath(attach_dir, name)
433+
return assert(vim.ui.open(path))
434+
end
435+
436+
---Open an attached file via `:edit`.
437+
---
438+
---@param node OrgAttachNode
439+
---@param name string name of the file to open
440+
---@return nil
441+
function AttachCore:open_in_vim(name, node)
442+
local attach_dir = self:get_dir(node)
443+
local path = vim.fs.joinpath(attach_dir, name)
444+
vim.cmd.edit(path)
445+
end
446+
404447
return AttachCore

lua/orgmode/attach/init.lua

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,35 @@ function Attach:prompt()
7878
return self:attach_new()
7979
end,
8080
})
81+
menu:add_separator({ length = #menu.title })
82+
menu:add_option({
83+
label = 'Open an attachment externally.',
84+
key = 'o',
85+
action = function()
86+
return self:open()
87+
end,
88+
})
89+
menu:add_option({
90+
label = 'Open an attachment in vim.',
91+
key = 'O',
92+
action = function()
93+
return self:open_in_vim()
94+
end,
95+
})
96+
menu:add_option({
97+
label = 'Open the attachment directory externally.',
98+
key = 'f',
99+
action = function()
100+
return self:reveal()
101+
end,
102+
})
103+
menu:add_option({
104+
label = 'Open the attachment directory in vim.',
105+
key = 'F',
106+
action = function()
107+
return self:reveal_nvim()
108+
end,
109+
})
81110
menu:add_option({
82111
label = 'Set specific attachment directory for this task.',
83112
key = 's',
@@ -243,6 +272,8 @@ end
243272

244273
---@class orgmode.attach.attach.Options
245274
---@inlinedoc
275+
---@field visit_dir? boolean if true, visit the directory subsequently using
276+
--- `org_attach_visit_command`
246277
---@field method? OrgAttachMethod The method via which to attach `file`;
247278
--- default is taken from `org_attach_method`
248279
---@field node? OrgAttachNode
@@ -254,6 +285,7 @@ end
254285
---@return string|nil attachment_name
255286
function Attach:attach(file, opts)
256287
local node = opts and opts.node or self.core:get_current_node()
288+
local visit_dir = opts and opts.visit_dir or false
257289
local method = opts and opts.method or config.org_attach_method
258290
return Promise
259291
.resolve(file or Input.open('File to keep as an attachment: ', '', 'file'))
@@ -271,6 +303,10 @@ function Attach:attach(file, opts)
271303
:next(function(attachment_name)
272304
if attachment_name then
273305
utils.echo_info(('File %s is now an attachment'):format(attachment_name))
306+
if visit_dir then
307+
local attach_dir = self.core:get_dir(node)
308+
self.core:reveal_nvim(attach_dir)
309+
end
274310
end
275311
return attachment_name
276312
end)
@@ -279,6 +315,8 @@ end
279315

280316
---@class orgmode.attach.attach_buffer.Options
281317
---@inlinedoc
318+
---@field visit_dir? boolean if true, visit the directory subsequently using
319+
--- `org_attach_visit_command`
282320
---@field node? OrgAttachNode
283321

284322
---Attach buffer's contents to current outline node.
@@ -290,6 +328,7 @@ end
290328
---@return string|nil attachment_name
291329
function Attach:attach_buffer(buffer, opts)
292330
local node = opts and opts.node or self.core:get_current_node()
331+
local visit_dir = opts and opts.visit_dir or false
293332
return Promise
294333
.resolve(buffer and ui.get_bufnr_verbose(buffer) or ui.select_buffer())
295334
---@param bufnr? integer
@@ -318,6 +357,7 @@ end
318357
---@return string|nil attachment_name
319358
function Attach:attach_many(files, opts)
320359
local node = opts and opts.node or self.core:get_current_node()
360+
local visit_dir = opts and opts.visit_dir or false
321361
local method = opts and opts.method or config.org_attach_method
322362

323363
return self.core
@@ -336,6 +376,10 @@ function Attach:attach_many(files, opts)
336376
and { { ('failed to attach %d file%s'):format(res.failures, plural(res.failures)), 'ErrorMsg' } }
337377
or nil
338378
utils.echo_info(msg, extra)
379+
if res.successes > 0 and visit_dir then
380+
local attach_dir = self.core:get_dir(node)
381+
self.core:reveal_nvim(attach_dir)
382+
end
339383
end
340384
return nil
341385
end)
@@ -419,4 +463,68 @@ function Attach:attach_lns(node)
419463
return self:attach(nil, { method = 'lns', node = node })
420464
end
421465

466+
---Open the attachments directory via `vim.ui.open()`.
467+
---
468+
---@param attach_dir? string the directory to open
469+
---@return nil
470+
function Attach:reveal(attach_dir)
471+
attach_dir = attach_dir or self:get_dir_or_create()
472+
local res = self.core:reveal(attach_dir):wait()
473+
if res.code ~= 0 then
474+
error(('exit code %d for opening: %s'):format(res.code, attach_dir))
475+
end
476+
end
477+
478+
---Open the attachments directory via `org_attach_visit_command`.
479+
---
480+
---@param attach_dir? string the directory to open
481+
---@return nil
482+
function Attach:reveal_nvim(attach_dir)
483+
attach_dir = attach_dir or self:get_dir_or_create()
484+
return self.core:reveal_nvim(attach_dir)
485+
end
486+
487+
---Open an attached file via `vim.ui.open()`.
488+
---
489+
---@param name? string name of the file to open
490+
---@param node? OrgAttachNode
491+
---@return nil
492+
function Attach:open(name, node)
493+
node = node or self.core:get_current_node()
494+
local attach_dir = self.core:get_dir(node)
495+
---@type vim.SystemObj?
496+
local obj = Promise.resolve(name or ui.select_attachment('Open', attach_dir))
497+
:next(function(chosen_name)
498+
if not chosen_name then
499+
return
500+
end
501+
return self.core:open(chosen_name, node)
502+
end)
503+
:wait(MAX_TIMEOUT)
504+
if obj then
505+
local res = obj:wait()
506+
if res.code ~= 0 then
507+
error(('exit code %d for command: %s'):format(res.code, obj.cmd))
508+
end
509+
end
510+
end
511+
512+
---Open an attached file via `:edit`.
513+
---
514+
---@param name? string name of the file to open
515+
---@param node? OrgAttachNode
516+
---@return nil
517+
function Attach:open_in_vim(name, node)
518+
node = node or self.core:get_current_node()
519+
local attach_dir = self.core:get_dir(node)
520+
return Promise.resolve(name or ui.select_attachment('Open', attach_dir))
521+
:next(function(chosen_name)
522+
if not chosen_name then
523+
return
524+
end
525+
self.core:open_in_vim(chosen_name, node)
526+
end)
527+
:wait(MAX_TIMEOUT)
528+
end
529+
422530
return Attach

lua/orgmode/attach/ui.lua

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
local Input = require('orgmode.ui.input')
22
local Promise = require('orgmode.utils.promise')
3+
local fileops = require('orgmode.attach.fileops')
34
local utils = require('orgmode.utils')
45

56
local M = {}
@@ -65,6 +66,95 @@ function M.ask_new_method()
6566
end)
6667
end
6768

69+
---Helper for `make_completion()`.
70+
---
71+
---@param directory string
72+
---@param show_hidden? boolean
73+
---@return string[] file_names
74+
local function list_files(directory, show_hidden)
75+
---@param path string
76+
---@return string ftype
77+
local function resolve_links(path)
78+
local target = vim.uv.fs_realpath(path)
79+
local stat = target and vim.uv.fs_stat(target)
80+
return stat and stat.type or 'file'
81+
end
82+
local filter = show_hidden and function()
83+
return true
84+
end or function(name)
85+
return not vim.startswith(name, '.') and not vim.endswith(name, '~')
86+
end
87+
local dirs = {}
88+
local files = {}
89+
fileops
90+
.iterdir(directory)
91+
:filter(filter)
92+
:map(
93+
---@param name string
94+
---@param ftype string
95+
---@return string name
96+
---@return string ftype
97+
function(name, ftype)
98+
if ftype == 'link' then
99+
ftype = resolve_links(vim.fs.joinpath(directory, name))
100+
end
101+
---@diagnostic disable-next-line: redundant-return-value
102+
return name, ftype
103+
end
104+
)
105+
---@param name string
106+
---@param ftype string
107+
:each(function(name, ftype)
108+
if ftype == 'directory' then
109+
dirs[#dirs + 1] = name .. '/'
110+
else
111+
files[#files + 1] = name
112+
end
113+
end)
114+
-- Ensure that directories are sorted before files.
115+
table.sort(dirs)
116+
table.sort(files)
117+
return vim.list_extend(dirs, files)
118+
end
119+
120+
---Return a completion function for attachments.
121+
---
122+
---@param root string the attachment directory
123+
---@return fun(arglead: string): string[]
124+
local function make_completion(root)
125+
---@param arglead string
126+
---@return string[]
127+
return function(arglead)
128+
local dirname = vim.fs.dirname(arglead)
129+
local searchdir = vim.fs.normalize(vim.fs.joinpath(root, dirname))
130+
local basename = vim.fs.basename(arglead)
131+
local show_hidden = vim.startswith(basename, '.')
132+
local candidates = list_files(searchdir, show_hidden)
133+
-- Only call matchfuzzy() if it won't break.
134+
if basename ~= '' and basename:len() <= 256 then
135+
candidates = vim.fn.matchfuzzy(candidates, basename)
136+
end
137+
-- Don't prefix `./` to the paths.
138+
if dirname ~= '.' then
139+
candidates = vim.tbl_map(function(name)
140+
return vim.fs.joinpath(dirname, name)
141+
end, candidates)
142+
end
143+
return candidates
144+
end
145+
end
146+
147+
---Dialog that has user select an existing attachment file.
148+
---
149+
---Returns nil if the user cancels with `<Esc>`.
150+
---
151+
---@param action string
152+
---@param attach_dir string
153+
---@return OrgPromise<string> attach_file
154+
function M.select_attachment(action, attach_dir)
155+
return Input.open(action .. ' attachment: ', '', make_completion(attach_dir))
156+
end
157+
68158
---Like `vim.fn.bufnr()`, but error instead of returning `-1`.
69159
---
70160
---@param buf integer | string

lua/orgmode/config/_meta.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@
244244
---@field org_babel_default_header_args? table<string, string> Default header args for org-babel blocks: Default: { [':tangle'] = 'no', [':noweb'] = 'no' }
245245
---@field org_attach_preferred_new_method 'id' | 'dir' | 'ask' | false If true, create attachments directory when necessary according to the given method. Default: 'id'
246246
---@field org_attach_method 'mv' | 'cp' | 'ln' | 'lns' Default method of attacahing files. Default: 'cp'
247+
---@field org_attach_visit_command string | fun(dir: string) Command or Lua function used to open a directory. Default: 'edit'
247248
---@field org_attach_use_inheritance 'always' | 'selective' | 'never' Determines whether headlines inherit the attachments directory of their parents. Default: 'selective'
248249
---@field org_attach_id_to_path_function_list (string | fun(id: string): (string|nil))[] List of functions used to derive the attachments directory from an ID property.
249250
---@field org_attach_sync_delete_empty_dir 'always' | 'ask' | 'never' Determines whether to delete empty directories when using `org.attach.sync()`. Default: 'ask'

lua/orgmode/config/defaults.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ local DefaultConfig = {
7676
org_attach_preferred_new_method = 'id',
7777
org_attach_method = 'cp',
7878
org_attach_copy_directory_create_symlink = false,
79+
org_attach_visit_command = 'edit',
7980
org_attach_use_inheritance = 'selective',
8081
org_attach_id_to_path_function_list = {
8182
'uuid_folder_format',

0 commit comments

Comments
 (0)