From 855f71c0bb7d06203d38fa6cd411e3e90a728614 Mon Sep 17 00:00:00 2001 From: Thomas Vanbesien Date: Mon, 29 Jun 2026 21:47:33 +0200 Subject: feat: confirm before quitting with a running terminal job Remove conflicting `ZZ`, `ZQ` and `ZR` keymaps. --- plugin/50-quit_guard.lua | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 plugin/50-quit_guard.lua (limited to 'plugin') diff --git a/plugin/50-quit_guard.lua b/plugin/50-quit_guard.lua new file mode 100644 index 0000000..7c72be3 --- /dev/null +++ b/plugin/50-quit_guard.lua @@ -0,0 +1,88 @@ +--[[ 50-quit_guard.lua — confirm before quitting Neovim while a terminal job is still running. + +Neovim only warns about *visible* terminals and unsaved files, so a job left running in a hidden +terminal buffer (e.g. a backgrounded `claude`) is killed without warning when Neovim exits. Autocmds +can't veto a quit (`QuitPre`/`ExitPre` are notify-only), so the exit commands are wrapped to prompt +first. The quit-all commands always exit, so they always prompt; `:q`/`:wq`/`:x`/`ZZ` only prompt when +they would close the last window. A `!` (e.g. `:qa!`), `ZQ`, and closing a split all skip the prompt. +]] + +-- Display names of every terminal buffer whose job is still alive. +local function running_jobs() + local names = {} + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.bo[buf].buftype == "terminal" then + local job = vim.b[buf].terminal_job_id + -- `jobwait` with a 0 timeout returns -1 while the job is still running. + if job and vim.fn.jobwait({ job }, 0)[1] == -1 then + table.insert(names, vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf), ":t")) + end + end + end + return names +end + +-- True when it's safe to quit: no running jobs, or the user confirms at the prompt. +local function may_quit() + local jobs = running_jobs() + if #jobs == 0 then + return true + end + local msg = ("Terminal job%s still running: %s\nQuit anyway?"):format( + #jobs > 1 and "s" or "", + table.concat(jobs, ", ") + ) + return vim.fn.confirm(msg, "&Quit\n&Cancel", 2) == 1 +end + +-- Whether quitting the current window now would exit Neovim (last window of the last tabpage). +local function is_last_window() + return vim.fn.tabpagenr("$") == 1 and vim.fn.winnr("$") == 1 +end + +-- Run `real` (a quit ex-command) unless a terminal job is running and the quit would actually exit +-- Neovim, in which case prompt first. `always_exits` is true for the quit-all family; otherwise the +-- quit only exits when it's the last window. A `!` always skips the prompt. +local function run_guarded(real, always_exits, bang) + if bang or not (always_exits or is_last_window()) or may_quit() then + vim.cmd({ cmd = real, bang = bang }) + end +end + +-- Define an uppercase user command `name` wrapping `real`, then alias the lowercase `spellings` to it +-- via command-line abbreviations that only fire when the spelling is the whole `:`-command typed (so a +-- trailing `!` and arguments elsewhere are untouched). +local function guard(name, real, always_exits, spellings) + vim.api.nvim_create_user_command(name, function(o) + run_guarded(real, always_exits, o.bang) + end, { bang = true, desc = "Quit (guarded against running terminal jobs)" }) + for _, s in ipairs(spellings) do + vim.cmd( + ("cnoreabbrev %s (getcmdtype() ==# ':' && getcmdline() ==# '%s') ? '%s' : '%s'"):format( + s, + s, + name, + s + ) + ) + end +end + +-- Quit-all family: always exits Neovim, so always guard. +guard("Quitall", "quitall", true, { "qa", "qall", "quita", "quitall" }) +guard("Wqall", "wqall", true, { "wqa", "wqall" }) +guard("Xall", "xall", true, { "xa", "xall" }) +-- Single-window quits: only exit (and so only prompt) when it's the last window. +guard("Quit", "quit", false, { "q", "quit" }) +guard("Wq", "wq", false, { "wq", "wquit" }) +guard("Xit", "x", false, { "x", "xit" }) + +-- Normal-mode quits bypass the command-line abbreviations, so guard them directly. `ZZ` writes the +-- buffer if modified and closes the window (like `:x`); `q` quits the window (like `:quit`). +-- (`ZQ` is a deliberate force-quit, so it is left to skip the prompt.) +vim.keymap.set("n", "ZZ", function() + run_guarded("x", false, false) +end, { desc = "Write & quit (guarded against running terminal jobs)" }) +vim.keymap.set("n", "q", function() + run_guarded("quit", false, false) +end, { desc = "Quit window (guarded against running terminal jobs)" }) -- cgit v1.3.1