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-18 05:23:34 +10:00
committed by GitHub
parent 9d3edf345f
commit 85e0559d46
11 changed files with 191 additions and 18 deletions

View File

@@ -3863,6 +3863,20 @@ nvim_ui_try_resize_grid({grid}, {width}, {height})
• {width} (`integer`) The new requested width.
• {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*

View File

@@ -59,15 +59,21 @@ Use cases:
------------------------------------------------------------------------------
Stop or detach the current UI
*:detach*
:detach
*:detach* *E5768*
:[range]detach
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
a background process, and you can reattach to it later.
Before detaching, you may want to note the server address:
>vim
: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
for any UI client. But if the client isn't expecting this, it
may (incorrectly) report an error.

View File

@@ -223,6 +223,7 @@ EDITOR
• |:restart| saves/restores the current session (window layout, buffers, …).
• |:restart!| (with a bang "!") does not save/restore the session.
• |ZR| restarts Nvim (|:restart|).
• |:detach| with range "%" detaches all UIs except the current one.
• |:uptime| displays uptime.
• |:packupdate| and |:packdel| for managing |vim.pack|.
• 'scrollback' is now also valid in |prompt-buffer| buffers to limit the

View File

@@ -219,4 +219,24 @@ function M.ex_session_restart(eap, extra)
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

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.
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.
---
--- @param str string

View File

@@ -17,6 +17,7 @@
#include "nvim/autocmd_defs.h"
#include "nvim/channel.h"
#include "nvim/channel_defs.h"
#include "nvim/errors.h"
#include "nvim/eval/typval.h"
#include "nvim/eval/vars.h"
#include "nvim/event/defs.h"
@@ -40,6 +41,10 @@
#include "nvim/types_defs.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))
#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);
}
/// 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
void remote_ui_free_all_mem(void)
{

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_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_floatexchange[] INIT(= N_("E5602: Cannot exchange or rotate float"));

View File

@@ -742,8 +742,8 @@ M.cmds = {
},
{
command = 'detach',
flags = TRLBAR,
addr_type = 'ADDR_NONE',
flags = bit.bor(RANGE, TRLBAR),
addr_type = 'ADDR_OTHER',
func = 'ex_detach',
},
{

View File

@@ -5961,22 +5961,36 @@ static void ex_tabs(exarg_T *eap)
///
/// 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)
{
// come on pooky let's burn this mf down
if (eap && eap->forceit) {
emsg("bang (!) not supported yet");
if (!current_ui) {
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 {
// 1. Send "error_exit" UI-event (notification only).
// 2. Perform server-side UI detach.
// 3. Close server-side channel without self-exit.
if (!current_ui) {
emsg("UI not attached");
return;
}
Channel *chan = find_channel(current_ui);
if (!chan) {
emsg(e_invchan);

View File

@@ -193,7 +193,7 @@ describe('vim._core', function()
-- All `vim._core.*` modules are builtin.
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]])
)
local expected = {

View File

@@ -149,14 +149,16 @@ describe('TUI', function()
end)
describe('TUI :detach', function()
it('does not stop server', function()
local child_server, screen
local function setup_detach_child()
n.clear()
finally(function()
n.check_close()
end)
local child_server = new_pipename()
local screen = tt.setup_child_nvim({
child_server = new_pipename()
screen = tt.setup_child_nvim({
'--listen',
child_server,
'-u',
@@ -169,6 +171,10 @@ describe('TUI :detach', function()
nvim_set .. ' laststatus=2 background=dark',
}, { env = env_notermguicolors })
tt.override_screen_expect_for_conpty(screen)
end
it('does not stop server', function()
setup_detach_child()
tt.feed_data('iHello, World')
screen:expect([[
@@ -193,8 +199,8 @@ describe('TUI :detach', function()
{ child_session:request('nvim_command', 'detach!') }
)
eq(
{ false, { 0, 'Vim(detach):E481: No range allowed: 1detach' } },
{ child_session:request('nvim_command', '1detach') }
{ false, { 0, 'Vim(detach):E16: Invalid range' } },
{ child_session:request('nvim_command', '2detach') }
)
eq(
{ false, { 0, 'Vim(detach):E488: Trailing characters: foo: detach foo' } },
@@ -234,6 +240,72 @@ describe('TUI :detach', function()
{5:-- TERMINAL --} |
]])
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)
describe('TUI :restart', function()