1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
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)" })
|