Neovim sign gutter, designed to be mostly VCS agnostic
1local M = {}
2
3local diff = require "vcsigns.diff"
4local fold = require "vcsigns.fold"
5local hunkops = require "vcsigns.hunkops"
6local repo = require "vcsigns.repo"
7local sign = require "vcsigns.sign"
8local state = require "vcsigns.state"
9local util = require "vcsigns.util"
10local updates = require "vcsigns.updates"
11
12local function _set_buflocal_autocmds(bufnr)
13 local group = vim.api.nvim_create_augroup("VCSigns", { clear = false })
14
15 -- Clear existing autocommands in this buffer only.
16 vim.api.nvim_clear_autocmds { buffer = bufnr, group = group }
17
18 -- Expensive update on certain events.
19 local events = {
20 "BufEnter",
21 "WinEnter",
22 "BufWritePost",
23 "CursorHold",
24 "CursorHoldI",
25 "FocusGained",
26 "ShellCmdPost",
27 "VimResume",
28 }
29 vim.api.nvim_create_autocmd(events, {
30 group = group,
31 buffer = bufnr,
32 callback = function()
33 if not state.get(bufnr).vcs.detecting then
34 updates.deep_update(bufnr)
35 end
36 end,
37 desc = "VCSigns refresh and update hunks",
38 })
39
40 -- Cheaper update on some frequent events.
41 local frequent_events = {
42 "TextChanged",
43 "TextChangedI",
44 "BufModifiedSet",
45 "InsertLeave",
46 }
47 vim.api.nvim_create_autocmd(frequent_events, {
48 group = group,
49 buffer = bufnr,
50 callback = function()
51 if not state.get(bufnr).vcs.detecting then
52 updates.shallow_update(bufnr)
53 end
54 end,
55 desc = "VCSigns refresh and update hunks",
56 })
57end
58
59--- Start VCSigns for the given buffer, forcing a VCS detection.
60---@param bufnr integer The buffer number.
61function M.start(bufnr)
62 -- Clear existing state.
63 local s = state.get(bufnr)
64 s.vcs.detecting = nil
65 s.vcs.vcs = nil
66
67 local vcs = repo.detect_vcs(bufnr)
68 s.vcs.detecting = false
69 if not vcs then
70 util.verbose "No VCS detected"
71 return
72 end
73 util.verbose("Detected VCS " .. vcs.name)
74 s.vcs.vcs = vcs
75
76 _set_buflocal_autocmds(bufnr)
77 updates.deep_update(bufnr, true)
78end
79
80--- Start VCSigns for the given buffer, but skip if detection was already done.
81---@param bufnr integer The buffer number.
82function M.start_if_needed(bufnr)
83 if state.get(bufnr).vcs.vcs == nil then
84 M.start(bufnr)
85 end
86end
87
88---@param bufnr integer The buffer number.
89function M.stop(bufnr)
90 -- Clear autocommands.
91 local group = vim.api.nvim_create_augroup("VCSigns", { clear = false })
92 vim.api.nvim_clear_autocmds { buffer = bufnr, group = group }
93
94 -- Clear signs.
95 sign.clear_signs(bufnr)
96
97 -- Clear state.
98 state.clear(bufnr)
99end
100
101local last_target_notification = nil
102
103local function _target_change_message()
104 local msg =
105 string.format("Now diffing against HEAD~%d", vim.g.vcsigns_target_commit)
106 last_target_notification = vim.notify(
107 msg,
108 vim.log.levels.INFO,
109 { title = "VCSigns", replace = last_target_notification }
110 )
111end
112
113---@param bufnr integer The buffer number.
114---@param steps integer Number of steps to go back in time.
115function M.target_older_commit(bufnr, steps)
116 vim.g.vcsigns_target_commit = vim.g.vcsigns_target_commit + steps
117 _target_change_message()
118 -- Target has changed, trigger a full update.
119 updates.deep_update(bufnr, true)
120end
121
122---@param bufnr integer The buffer number.
123---@param steps integer Number of steps to go forward in time.
124function M.target_newer_commit(bufnr, steps)
125 local new_target = vim.g.vcsigns_target_commit - steps
126 if new_target >= 0 then
127 vim.g.vcsigns_target_commit = new_target
128 _target_change_message()
129 -- Target has changed, trigger a full update.
130 updates.deep_update(bufnr, true)
131 else
132 last_target_notification = vim.notify(
133 "No timetravel! Cannot diff against HEAD~" .. new_target,
134 vim.log.levels.WARN,
135 {
136 title = "VCSigns",
137 replace = last_target_notification,
138 }
139 )
140 end
141end
142
143local function _hunk_navigation(bufnr, count, forward)
144 if vim.o.diff then
145 vim.cmd("normal! " .. (forward and "]c" or "[c"))
146 return
147 end
148 local lnum = vim.fn.line "."
149 local hunks = state.get(bufnr).diff.hunks
150 local hunk
151 if forward then
152 hunk = hunkops.next_hunk(lnum, hunks, count)
153 else
154 hunk = hunkops.prev_hunk(lnum, hunks, count)
155 end
156 if hunk then
157 vim.cmd "normal! m`"
158 vim.api.nvim_win_set_cursor(0, { hunk.plus_start, 0 })
159 end
160end
161
162---@param bufnr integer The buffer number.
163---@param count integer The number of hunks ahead.
164function M.hunk_next(bufnr, count)
165 return _hunk_navigation(bufnr, count, true)
166end
167
168---@param bufnr integer The buffer number.
169---@param count integer The number of hunks ahead.
170function M.hunk_prev(bufnr, count)
171 return _hunk_navigation(bufnr, count, false)
172end
173
174---@param bufnr integer The buffer number.
175---@param range integer[] The range of lines to consider for the hunks.
176---@return Hunk[] Hunks in the specified range.
177local function _hunks_in_range(bufnr, range)
178 local hunks = state.get(bufnr).diff.hunks
179 ---@type Hunk[]
180 local hunks_in_range = {}
181 for _, hunk in ipairs(hunks) do
182 local l = hunkops.hunk_visual_start(hunk)
183 local r = l + hunkops.hunk_visual_size(hunk) - 1
184 if l <= range[2] and r >= range[1] then
185 table.insert(hunks_in_range, hunk)
186 end
187 end
188 return hunks_in_range
189end
190
191---@param bufnr integer The buffer number.
192---@param range integer[]|nil The range of lines to undo hunks in.
193function M.hunk_undo(bufnr, range)
194 if not range then
195 range = { vim.fn.line ".", vim.fn.line "v" }
196 end
197 table.sort(range)
198 local hunks_in_range = _hunks_in_range(bufnr, range)
199
200 if #hunks_in_range == 0 then
201 vim.notify(
202 "No hunks found in range " .. range[1] .. "-" .. range[2],
203 vim.log.levels.WARN,
204 { title = "VCSigns" }
205 )
206 return
207 end
208
209 -- Undo the hunks in reverse order to make sure numbering is correct.
210 table.sort(hunks_in_range, function(a, b)
211 return a.plus_start > b.plus_start
212 end)
213 for _, hunk in ipairs(hunks_in_range) do
214 local start = hunk.plus_start - 1
215 if hunk.plus_count == 0 then
216 -- Special case of undoing a pure deletion.
217 -- To append after `start` we insert before `start + 1`.
218 start = start + 1
219 end
220 vim.api.nvim_buf_set_lines(
221 bufnr,
222 start,
223 start + hunk.plus_count,
224 true,
225 hunk.minus_lines
226 )
227 end
228 updates.shallow_update(bufnr)
229end
230
231---@param bufnr integer The buffer number.
232function M.toggle_hunk_diff(bufnr)
233 vim.b[bufnr].vcsigns_show_hunk_diffs =
234 not vim.b[bufnr].vcsigns_show_hunk_diffs
235 updates.shallow_update(bufnr)
236end
237
238---@param bufnr integer The buffer number.
239function M.toggle_fold(bufnr)
240 fold.toggle(bufnr)
241end
242
243function M.diffthis(bufnr)
244 local diff_win = vim.b[bufnr].vcsigns_diff_win
245 if diff_win then
246 vim.api.nvim_win_close(diff_win, true)
247 vim.b[bufnr].vcsigns_diff_win = nil
248 vim.cmd "diffoff"
249 return
250 end
251
252 local s = state.get(bufnr)
253 local base_lines = s.diff.old_lines
254
255 -- Open a diff buffer with the base text.
256 if not base_lines or #base_lines == 0 then
257 vim.notify(
258 "Could not get base text from VCS",
259 vim.log.levels.ERROR,
260 { title = "VCSigns" }
261 )
262 return
263 end
264 local diff_buf = vim.api.nvim_create_buf(false, true)
265 vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, base_lines)
266
267 -- Open the diff buffer in a new window.
268 local win = vim.api.nvim_open_win(diff_buf, false, {
269 split = "right",
270 win = 0,
271 })
272 -- TODO(algmyr): Store this in the state module.
273 vim.b[bufnr].vcsigns_diff_win = win
274
275 -- Sync the filetype and syntax.
276 vim.bo[diff_buf].filetype = vim.bo[bufnr].filetype
277
278 vim.bo[diff_buf].buftype = "nofile"
279 vim.bo[diff_buf].bufhidden = "wipe"
280 vim.bo[diff_buf].swapfile = false
281 vim.bo[diff_buf].modifiable = false
282 vim.wo[win].foldcolumn = "0"
283
284 -- Run diffthis in both windows.
285 local current = vim.api.nvim_get_current_win()
286 vim.api.nvim_set_current_win(win)
287 vim.cmd "diffthis"
288 vim.api.nvim_set_current_win(current)
289 vim.cmd "diffthis"
290end
291
292return M