summaryrefslogtreecommitdiffstats
path: root/plugin/50-quit_guard.lua
blob: 7c72be343d057002abd95ee8ca604e197d4e321e (plain)
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)" })