feat(terminal): add support for copying with OSC 52 in embedded terminal (#29117)

When libvterm receives the OSC 52 escape sequence it ignores it because
Nvim does not set any selection callbacks. Install selection callbacks
that forward to the clipboard provider, so that setting the clipboard
with OSC 52 in the embedded terminal writes to the system clipboard
using the configured clipboard provider.
This commit is contained in:
Gregory Anders 2024-06-11 13:18:06 -05:00 committed by GitHub
parent 39d8651283
commit 3ad977f01d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 152 additions and 3 deletions

View File

@ -138,7 +138,8 @@ STARTUP
TERMINAL
• TODO
• The |terminal| now understands the OSC 52 escape sequence to write to the
system clipboard (copy). Querying with OSC 52 (paste) is not supported.
TREESITTER

View File

@ -166,6 +166,21 @@ this command in a :terminal buffer: >
printf "\033]7;file://./foo/bar\033\\"
OSC 52: write to system clipboard *terminal-osc52*
Applications in the :terminal buffer can write to the system clipboard by
emitting an OSC 52 sequence. Example: >
printf '\033]52;;%s\033\\' "$(echo -n 'Hello world' | base64)"
Nvim uses the configured |clipboard| provider to write to the system
clipboard. Reading from the system clipboard with OSC 52 is not supported, as
this would allow any arbitrary program in the :terminal to read the user's
clipboard.
OSC 52 sequences sent from the :terminal buffer do not emit a |TermRequest|
event. The event is handled directly by Nvim and is not forwarded to plugins.
==============================================================================
Status Variables *terminal-status*

View File

@ -142,6 +142,7 @@ char *base64_encode(const char *src, size_t src_len)
/// @param [out] out_lenp Returns the length of the decoded string
/// @return Decoded string
char *base64_decode(const char *src, size_t src_len, size_t *out_lenp)
FUNC_ATTR_NONNULL_ALL
{
assert(src != NULL);
assert(out_lenp != NULL);

View File

@ -112,6 +112,9 @@ typedef struct {
// libvterm. Improves performance when receiving large bursts of data.
#define REFRESH_DELAY 10
#define TEXTBUF_SIZE 0x1fff
#define SELECTIONBUF_SIZE 0x0400
static TimeWatcher refresh_timer;
static bool refresh_pending = false;
@ -127,7 +130,7 @@ struct terminal {
// buffer used to:
// - convert VTermScreen cell arrays into utf8 strings
// - receive data from libvterm as a result of key presses.
char textbuf[0x1fff];
char textbuf[TEXTBUF_SIZE];
ScrollbackLine **sb_buffer; // Scrollback storage.
size_t sb_current; // Lines stored in sb_buffer.
@ -166,6 +169,9 @@ struct terminal {
// When there is a pending TermRequest autocommand, block and store input.
StringBuilder *pending_send;
char *selection_buffer; /// libvterm selection buffer
StringBuilder selection; /// Growable array containing full selection data
size_t refcount; // reference count
};
@ -179,6 +185,12 @@ static VTermScreenCallbacks vterm_screen_callbacks = {
.sb_popline = term_sb_pop,
};
static VTermSelectionCallbacks vterm_selection_callbacks = {
.set = term_selection_set,
// For security reasons we don't support querying the system clipboard from the embedded terminal
.query = NULL,
};
static Set(ptr_t) invalidated_terminals = SET_INIT;
static void emit_termrequest(void **argv)
@ -315,6 +327,11 @@ void terminal_open(Terminal **termpp, buf_T *buf, TerminalOptions opts)
vterm_screen_set_damage_merge(term->vts, VTERM_DAMAGE_SCROLL);
vterm_screen_reset(term->vts, 1);
vterm_output_set_callback(term->vt, term_output_callback, term);
term->selection_buffer = xcalloc(SELECTIONBUF_SIZE, 1);
vterm_state_set_selection_callbacks(state, &vterm_selection_callbacks, term,
term->selection_buffer, SELECTIONBUF_SIZE);
// force a initial refresh of the screen to ensure the buffer will always
// have as many lines as screen rows when refresh_scrollback is called
term->invalid_start = 0;
@ -769,6 +786,8 @@ void terminal_destroy(Terminal **termpp)
}
xfree(term->sb_buffer);
xfree(term->title);
xfree(term->selection_buffer);
kv_destroy(term->selection);
vterm_free(term->vt);
xfree(term);
*termpp = NULL; // coverity[dead-store]
@ -1198,6 +1217,54 @@ static int term_sb_pop(int cols, VTermScreenCell *cells, void *data)
return 1;
}
static void term_clipboard_set(void **argv)
{
VTermSelectionMask mask = (VTermSelectionMask)(long)argv[0];
char *data = argv[1];
char regname;
switch (mask) {
case VTERM_SELECTION_CLIPBOARD:
regname = '+';
break;
case VTERM_SELECTION_PRIMARY:
regname = '*';
break;
default:
regname = '+';
break;
}
list_T *lines = tv_list_alloc(1);
tv_list_append_allocated_string(lines, data);
list_T *args = tv_list_alloc(3);
tv_list_append_list(args, lines);
const char regtype = 'v';
tv_list_append_string(args, &regtype, 1);
tv_list_append_string(args, &regname, 1);
eval_call_provider("clipboard", "set", args, true);
}
static int term_selection_set(VTermSelectionMask mask, VTermStringFragment frag, void *user)
{
Terminal *term = user;
if (frag.initial) {
kv_size(term->selection) = 0;
}
kv_concat_len(term->selection, frag.str, frag.len);
if (frag.final) {
char *data = xmemdupz(term->selection.items, kv_size(term->selection));
multiqueue_put(main_loop.events, term_clipboard_set, (void *)mask, data);
}
return 1;
}
// }}}
// input handling {{{

View File

@ -0,0 +1,65 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local eq = t.eq
local retry = t.retry
local clear = n.clear
local fn = n.fn
local testprg = n.testprg
local exec_lua = n.exec_lua
local eval = n.eval
describe(':terminal', function()
before_each(function()
clear()
exec_lua([[
local function clipboard(reg, type)
if type == 'copy' then
return function(lines)
local data = table.concat(lines, '\n')
vim.g.clipboard_data = data
end
end
if type == 'paste' then
return function()
error()
end
end
error('invalid type: ' .. type)
end
vim.g.clipboard = {
name = 'Test',
copy = {
['+'] = clipboard('+', 'copy'),
['*'] = clipboard('*', 'copy'),
},
paste = {
['+'] = clipboard('+', 'paste'),
['*'] = clipboard('*', 'paste'),
},
}
]])
end)
it('can write to the system clipboard', function()
eq('Test', eval('g:clipboard.name'))
local text = 'Hello, world! This is some\nexample text\nthat spans multiple\nlines'
local encoded = exec_lua('return vim.base64.encode(...)', text)
local function osc52(arg)
return string.format('\027]52;;%s\027\\', arg)
end
fn.termopen({ testprg('shell-test'), '-t', osc52(encoded) })
retry(nil, 1000, function()
eq(text, exec_lua([[ return vim.g.clipboard_data ]]))
end)
end)
end)