diff --git a/runtime/doc/api.txt b/runtime/doc/api.txt index bcdb12fafd..d34bbb2617 100644 --- a/runtime/doc/api.txt +++ b/runtime/doc/api.txt @@ -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* diff --git a/runtime/doc/gui.txt b/runtime/doc/gui.txt index fe550acb56..2789cd5849 100644 --- a/runtime/doc/gui.txt +++ b/runtime/doc/gui.txt @@ -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. diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index ef9a9ba945..81c5fc580d 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -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 diff --git a/runtime/lua/vim/_core/server.lua b/runtime/lua/vim/_core/server.lua index e5e2737a7f..1649582067 100644 --- a/runtime/lua/vim/_core/server.lua +++ b/runtime/lua/vim/_core/server.lua @@ -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 diff --git a/runtime/lua/vim/_meta/api.gen.lua b/runtime/lua/vim/_meta/api.gen.lua index 88ffde519a..4f8b292d6d 100644 --- a/runtime/lua/vim/_meta/api.gen.lua +++ b/runtime/lua/vim/_meta/api.gen.lua @@ -186,6 +186,16 @@ function vim.api.nvim__set_restart_on_crash(progpath, argv) end --- @return table # 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 diff --git a/src/nvim/api/ui.c b/src/nvim/api/ui.c index 7c13cf682d..a2f4aaa7db 100644 --- a/src/nvim/api/ui.c +++ b/src/nvim/api/ui.c @@ -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) { diff --git a/src/nvim/errors.h b/src/nvim/errors.h index 7b0489e3da..89a6e0d0fa 100644 --- a/src/nvim/errors.h +++ b/src/nvim/errors.h @@ -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")); diff --git a/src/nvim/ex_cmds.lua b/src/nvim/ex_cmds.lua index 0ea7642d08..5654206351 100644 --- a/src/nvim/ex_cmds.lua +++ b/src/nvim/ex_cmds.lua @@ -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', }, { diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index 9bc62d6159..0cbe4d70aa 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -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); diff --git a/test/functional/core/main_spec.lua b/test/functional/core/main_spec.lua index d185156d7d..8419184c6d 100644 --- a/test/functional/core/main_spec.lua +++ b/test/functional/core/main_spec.lua @@ -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 = { diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua index fd762476ef..b7672e3330 100644 --- a/test/functional/terminal/tui_spec.lua +++ b/test/functional/terminal/tui_spec.lua @@ -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()