aboutsummaryrefslogtreecommitdiffstats
path: root/plugin
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-06-29 21:47:33 +0200
committerThomas Vanbesien <tvanbesi@proton.me>2026-06-30 14:56:54 +0200
commit855f71c0bb7d06203d38fa6cd411e3e90a728614 (patch)
tree7b219f5472483e86fb4c3e9b5db568a804b34462 /plugin
parent8835c32c8693edacb503141cb845b9ad837505cb (diff)
downloadnvim-config-855f71c0bb7d06203d38fa6cd411e3e90a728614.tar.gz
nvim-config-855f71c0bb7d06203d38fa6cd411e3e90a728614.zip
feat: confirm before quitting with a running terminal job
Remove conflicting `ZZ`, `ZQ` and `ZR` keymaps.
Diffstat (limited to 'plugin')
-rw-r--r--plugin/50-quit_guard.lua88
1 files changed, 88 insertions, 0 deletions
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 <expr> %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`); `<C-w>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", "<C-w>q", function()
+ run_guarded("quit", false, false)
+end, { desc = "Quit window (guarded against running terminal jobs)" })