feat(ui): ":%detach!" detaches all other UIs #39216

Problem:
Not easy for one UI to kick the others off a shared Nvim server.

Solution:
Treat `:%detach` as detach-other-UIs.
This commit is contained in:
Jason Woodland
2026-07-17 15:23:34 -04:00
committed by GitHub
parent 9d3edf345f
commit 85e0559d46
11 changed files with 191 additions and 18 deletions
+14
View File
@@ -3863,6 +3863,20 @@ nvim_ui_try_resize_grid({grid}, {width}, {height})
• {width} (`integer`) The new requested width. • {width} (`integer`) The new requested width.
• {height} (`integer`) The new requested height. • {height} (`integer`) The new requested height.
nvim__ui_detach({chan}) *nvim__ui_detach()*
WARNING: This feature is experimental/unstable.
Detaches the UI on channel `chan`.
Sets the channel's detach flag (so the server doesn't exit on stdio UIs),
sends an "error_exit" UI event, and closes the channel.
Attributes: ~
Since: 0.12.0
Parameters: ~
• {chan} (`integer`) UI channel to disconnect.
============================================================================== ==============================================================================
Win_config Functions *api-win_config* Win_config Functions *api-win_config*
+8 -2
View File
@@ -59,15 +59,21 @@ Use cases:
------------------------------------------------------------------------------ ------------------------------------------------------------------------------
Stop or detach the current UI Stop or detach the current UI
*:detach* *:detach* *E5768*
:detach :[range]detach
Detaches the current UI. Other UIs (if any) remain attached. Detaches the current UI. Other UIs (if any) remain attached.
Fails with |E5768| if no UI is attached.
The server (typically `nvim --embed`) continues running as The server (typically `nvim --embed`) continues running as
a background process, and you can reattach to it later. a background process, and you can reattach to it later.
Before detaching, you may want to note the server address: Before detaching, you may want to note the server address:
>vim >vim
:echo v:servername :echo v:servername
< <
When [range] covers the entire buffer (`%`, as in `:%detach`),
all other UIs are detached, keeping only the current one. The
command fails with |E16| for any other range. No-op if only
one UI is attached.
Note: The server closes the UI RPC channel, so :detach works Note: The server closes the UI RPC channel, so :detach works
for any UI client. But if the client isn't expecting this, it for any UI client. But if the client isn't expecting this, it
may (incorrectly) report an error. may (incorrectly) report an error.
+1
View File
@@ -223,6 +223,7 @@ EDITOR
• |:restart| saves/restores the current session (window layout, buffers, …). • |:restart| saves/restores the current session (window layout, buffers, …).
• |:restart!| (with a bang "!") does not save/restore the session. • |:restart!| (with a bang "!") does not save/restore the session.
• |ZR| restarts Nvim (|:restart|). • |ZR| restarts Nvim (|:restart|).
• |:detach| with range "%" detaches all UIs except the current one.
• |:uptime| displays uptime. • |:uptime| displays uptime.
• |:packupdate| and |:packdel| for managing |vim.pack|. • |:packupdate| and |:packdel| for managing |vim.pack|.
• 'scrollback' is now also valid in |prompt-buffer| buffers to limit the • 'scrollback' is now also valid in |prompt-buffer| buffers to limit the
+20
View File
@@ -219,4 +219,24 @@ function M.ex_session_restart(eap, extra)
end end
end end
--- Disconnects every UI except `keep_chan`, keeping the server running.
---
--- @param keep_chan integer Channel ID of the UI to preserve.
--- @return integer # Number of UIs detached.
function M.detach_others(keep_chan)
local uicount = #vim.api.nvim_list_uis()
for _, ui in ipairs(vim.api.nvim_list_uis()) do
if ui.chan and ui.chan ~= keep_chan then
vim.api.nvim__ui_detach(ui.chan)
end
end
local n = uicount - #vim.api.nvim_list_uis()
if n == 0 then
vim.api.nvim_echo({ { 'No other UIs are attached' } }, true, {})
else
vim.api.nvim_echo({ { ('Detached %d non-current UIs'):format(n) } }, true, {})
end
return n
end
return M return M
+10
View File
@@ -186,6 +186,16 @@ function vim.api.nvim__set_restart_on_crash(progpath, argv) end
--- @return table<string,any> # Map of various internal stats. --- @return table<string,any> # Map of various internal stats.
function vim.api.nvim__stats() end function vim.api.nvim__stats() end
--- WARNING: This feature is experimental/unstable.
---
--- Detaches the UI on channel `chan`.
---
--- Sets the channel's detach flag (so the server doesn't exit on stdio UIs),
--- sends an "error_exit" UI event, and closes the channel.
---
--- @param chan integer UI channel to disconnect.
function vim.api.nvim__ui_detach(chan) end
--- WARNING: This feature is experimental/unstable. --- WARNING: This feature is experimental/unstable.
--- ---
--- @param str string --- @param str string
+35
View File
@@ -17,6 +17,7 @@
#include "nvim/autocmd_defs.h" #include "nvim/autocmd_defs.h"
#include "nvim/channel.h" #include "nvim/channel.h"
#include "nvim/channel_defs.h" #include "nvim/channel_defs.h"
#include "nvim/errors.h"
#include "nvim/eval/typval.h" #include "nvim/eval/typval.h"
#include "nvim/eval/vars.h" #include "nvim/eval/vars.h"
#include "nvim/event/defs.h" #include "nvim/event/defs.h"
@@ -40,6 +41,10 @@
#include "nvim/types_defs.h" #include "nvim/types_defs.h"
#include "nvim/ui.h" #include "nvim/ui.h"
#ifdef MSWIN
# include "nvim/os/os_win_console.h"
#endif
#define BUF_POS(ui) ((size_t)((ui)->packer.ptr - (ui)->packer.startptr)) #define BUF_POS(ui) ((size_t)((ui)->packer.ptr - (ui)->packer.startptr))
#include "api/ui.c.generated.h" #include "api/ui.c.generated.h"
@@ -108,6 +113,36 @@ void remote_ui_disconnect(uint64_t channel_id, Error *err, bool send_error_exit)
remote_ui_destroy(ui); remote_ui_destroy(ui);
} }
/// Detaches the UI on channel `chan`.
///
/// Sets the channel's detach flag (so the server doesn't exit on stdio UIs),
/// sends an "error_exit" UI event, and closes the channel.
///
/// @param chan UI channel to disconnect.
/// @param[out] err Error details, if any.
void nvim__ui_detach(Integer chan, Error *err)
FUNC_API_SINCE(14)
{
Channel *c = find_channel((uint64_t)chan);
VALIDATE(c != NULL, "%s", e_invchan, {
return;
});
#ifdef MSWIN
bool detach_stdio = c->streamtype == kChannelStreamStdio;
#endif
c->detach = true;
remote_ui_disconnect((uint64_t)chan, err, true);
if (ERROR_SET(err)) {
return;
}
channel_close((uint64_t)chan, kChannelPartAll, NULL);
#ifdef MSWIN
if (detach_stdio) {
os_swap_to_hidden_console();
}
#endif
}
#ifdef EXITFREE #ifdef EXITFREE
void remote_ui_free_all_mem(void) void remote_ui_free_all_mem(void)
{ {
+1
View File
@@ -164,6 +164,7 @@ EXTERN const char e_auabort[] INIT(= N_("E855: Autocommands caused command to ab
EXTERN const char e_api_error[] INIT(= N_("E5555: API call: %s")); EXTERN const char e_api_error[] INIT(= N_("E5555: API call: %s"));
EXTERN const char e_fast_api_disabled[] INIT(= N_("E5560: %s must not be called in a fast event context")); EXTERN const char e_fast_api_disabled[] INIT(= N_("E5560: %s must not be called in a fast event context"));
EXTERN const char e_noui[] INIT(= N_("E5768: No UI attached"));
EXTERN const char e_floatonly[] INIT(= N_("E5601: Cannot close window, only floating window would remain")); EXTERN const char e_floatonly[] INIT(= N_("E5601: Cannot close window, only floating window would remain"));
EXTERN const char e_floatexchange[] INIT(= N_("E5602: Cannot exchange or rotate float")); EXTERN const char e_floatexchange[] INIT(= N_("E5602: Cannot exchange or rotate float"));
+2 -2
View File
@@ -742,8 +742,8 @@ M.cmds = {
}, },
{ {
command = 'detach', command = 'detach',
flags = TRLBAR, flags = bit.bor(RANGE, TRLBAR),
addr_type = 'ADDR_NONE', addr_type = 'ADDR_OTHER',
func = 'ex_detach', func = 'ex_detach',
}, },
{ {
+22 -8
View File
@@ -5961,22 +5961,36 @@ static void ex_tabs(exarg_T *eap)
/// ///
/// Detaches the current UI. /// Detaches the current UI.
/// ///
/// ":detach!" with bang (!) detaches all UIs _except_ the current UI. /// ":%detach" detaches all UIs _except_ the current UI.
static void ex_detach(exarg_T *eap) static void ex_detach(exarg_T *eap)
{ {
// come on pooky let's burn this mf down // come on pooky let's burn this mf down
if (eap && eap->forceit) { if (!current_ui) {
emsg("bang (!) not supported yet"); emsg(_(e_noui));
return;
}
if (eap && eap->addr_count > 0) {
if (eap->line1 != 1 || eap->line2 != curbuf->b_ml.ml_line_count) {
emsg(_(e_invrange));
return;
}
typval_T lua_args[] = {
{ .v_type = VAR_NUMBER, .vval.v_number = (varnumber_T)current_ui },
{ .v_type = VAR_UNKNOWN },
};
typval_T rv = TV_INITIAL_VALUE;
nlua_call_typval("vim._core.server", "detach_others", lua_args, &rv);
ILOG("%%detach current_ui=%" PRIu64 " detached=%zu", current_ui,
(rv.v_type == VAR_NUMBER && rv.vval.v_number > 0)
? (size_t)rv.vval.v_number : (size_t)0);
tv_clear(&rv);
} else { } else {
// 1. Send "error_exit" UI-event (notification only). // 1. Send "error_exit" UI-event (notification only).
// 2. Perform server-side UI detach. // 2. Perform server-side UI detach.
// 3. Close server-side channel without self-exit. // 3. Close server-side channel without self-exit.
if (!current_ui) {
emsg("UI not attached");
return;
}
Channel *chan = find_channel(current_ui); Channel *chan = find_channel(current_ui);
if (!chan) { if (!chan) {
emsg(e_invchan); emsg(e_invchan);
+1 -1
View File
@@ -193,7 +193,7 @@ describe('vim._core', function()
-- All `vim._core.*` modules are builtin. -- All `vim._core.*` modules are builtin.
t.eq( t.eq(
{ 'ex_session_restart', 'rebind_after_restart', 'serverlist' }, { 'detach_others', 'ex_session_restart', 'rebind_after_restart', 'serverlist' },
n.exec_lua([[local k = vim.tbl_keys(require('vim._core.server')); table.sort(k); return k]]) n.exec_lua([[local k = vim.tbl_keys(require('vim._core.server')); table.sort(k); return k]])
) )
local expected = { local expected = {
+77 -5
View File
@@ -149,14 +149,16 @@ describe('TUI', function()
end) end)
describe('TUI :detach', function() describe('TUI :detach', function()
it('does not stop server', function() local child_server, screen
local function setup_detach_child()
n.clear() n.clear()
finally(function() finally(function()
n.check_close() n.check_close()
end) end)
local child_server = new_pipename() child_server = new_pipename()
local screen = tt.setup_child_nvim({ screen = tt.setup_child_nvim({
'--listen', '--listen',
child_server, child_server,
'-u', '-u',
@@ -169,6 +171,10 @@ describe('TUI :detach', function()
nvim_set .. ' laststatus=2 background=dark', nvim_set .. ' laststatus=2 background=dark',
}, { env = env_notermguicolors }) }, { env = env_notermguicolors })
tt.override_screen_expect_for_conpty(screen) tt.override_screen_expect_for_conpty(screen)
end
it('does not stop server', function()
setup_detach_child()
tt.feed_data('iHello, World') tt.feed_data('iHello, World')
screen:expect([[ screen:expect([[
@@ -193,8 +199,8 @@ describe('TUI :detach', function()
{ child_session:request('nvim_command', 'detach!') } { child_session:request('nvim_command', 'detach!') }
) )
eq( eq(
{ false, { 0, 'Vim(detach):E481: No range allowed: 1detach' } }, { false, { 0, 'Vim(detach):E16: Invalid range' } },
{ child_session:request('nvim_command', '1detach') } { child_session:request('nvim_command', '2detach') }
) )
eq( eq(
{ false, { 0, 'Vim(detach):E488: Trailing characters: foo: detach foo' } }, { false, { 0, 'Vim(detach):E488: Trailing characters: foo: detach foo' } },
@@ -234,6 +240,72 @@ describe('TUI :detach', function()
{5:-- TERMINAL --} | {5:-- TERMINAL --} |
]]) ]])
end) end)
it('% detaches other UIs', function()
setup_detach_child()
finally(function()
pcall(function()
local s = n.connect(child_server)
s:request('nvim_command', 'qall!')
end)
end)
tt.feed_data('iHello from detach!')
screen:expect({ any = vim.pesc('Hello from detach!^') })
tt.feed_data('\027')
local child_session = n.connect(child_server)
local _, uis_before = child_session:request('nvim_list_uis')
eq(1, #uis_before)
local tui_chan_id = uis_before[1].chan
-- :%detach with only 1 UI is a no-op.
tt.feed_data(':%detach\013')
screen:expect({ any = vim.pesc('No other UIs are attached') })
local _, uis_noop = child_session:request('nvim_list_uis')
eq(1, #uis_noop)
child_session:request('nvim_ui_attach', 50, 7, {})
local _, uis_attached = child_session:request('nvim_list_uis')
eq(2, #uis_attached)
tt.feed_data(':%detach\013')
screen:expect([[
Hello from detach^! |
{100:~ }|*3
{3:[No Name] [+] }|
|
{5:-- TERMINAL --} |
]])
local verify_session = n.connect(child_server)
local _, uis_after = verify_session:request('nvim_list_uis')
eq(1, #uis_after)
eq(tui_chan_id, uis_after[1].chan)
end)
it('% detaches the original UI when invoked from a later UI', function()
setup_detach_child()
finally(function()
pcall(function()
local s = n.connect(child_server)
s:request('nvim_command', 'qall!')
end)
end)
screen:expect({ any = vim.pesc('[No Name]') })
local child_session = n.connect(child_server)
child_session:request('nvim_ui_attach', 50, 7, {})
local _, uis_before = child_session:request('nvim_list_uis')
eq(2, #uis_before)
eq({ true, vim.NIL }, { child_session:request('nvim_command', '%detach') })
screen:expect({ any = [[Process exited 0]] })
local status, uis_after = child_session:request('nvim_list_uis')
ok(status)
eq(1, #uis_after)
end)
end) end)
describe('TUI :restart', function() describe('TUI :restart', function()