local M = {} local git = require('diffs.git') local dbg = require('diffs.log').dbg ---@return integer? function M.find_diffs_window() local tabpage = vim.api.nvim_get_current_tabpage() for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do if vim.api.nvim_win_is_valid(win) then local buf = vim.api.nvim_win_get_buf(win) local name = vim.api.nvim_buf_get_name(buf) if name:match('^diffs://') then return win end end end return nil end ---@param bufnr integer function M.setup_diff_buf(bufnr) vim.diagnostic.enable(false, { bufnr = bufnr }) vim.keymap.set('n', 'q', 'close', { buffer = bufnr }) end ---@param diff_lines string[] ---@param hunk_position { hunk_header: string, offset: integer } ---@return integer? function M.find_hunk_line(diff_lines, hunk_position) for i, line in ipairs(diff_lines) do if line == hunk_position.hunk_header then return i + hunk_position.offset end end return nil end ---@param lines string[] ---@return string[] function M.filter_combined_diffs(lines) local result = {} local skip = false for _, line in ipairs(lines) do if line:match('^diff %-%-cc ') then skip = true elseif line:match('^diff %-%-git ') then skip = false end if not skip then table.insert(result, line) end end return result end ---@param old_lines string[] ---@param new_lines string[] ---@param old_name string ---@param new_name string ---@return string[] local function generate_unified_diff(old_lines, new_lines, old_name, new_name) local old_content = table.concat(old_lines, '\n') local new_content = table.concat(new_lines, '\n') local diff_fn = vim.text and vim.text.diff or vim.diff local diff_output = diff_fn(old_content, new_content, { result_type = 'unified', ctxlen = 3, }) if not diff_output or diff_output == '' then return {} end local diff_lines = vim.split(diff_output, '\n', { plain = true }) local result = { 'diff --git a/' .. old_name .. ' b/' .. new_name, '--- a/' .. old_name, '+++ b/' .. new_name, } for _, line in ipairs(diff_lines) do table.insert(result, line) end return result end ---@param revision? string ---@param vertical? boolean function M.gdiff(revision, vertical) revision = revision or 'HEAD' local bufnr = vim.api.nvim_get_current_buf() local filepath = vim.api.nvim_buf_get_name(bufnr) if filepath == '' then vim.notify('[diffs.nvim]: cannot diff unnamed buffer', vim.log.levels.ERROR) return end local rel_path = git.get_relative_path(filepath) if not rel_path then vim.notify('[diffs.nvim]: not in a git repository', vim.log.levels.ERROR) return end local old_lines, err = git.get_file_content(revision, filepath) if not old_lines then vim.notify('[diffs.nvim]: ' .. (err or 'unknown error'), vim.log.levels.ERROR) return end local new_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local diff_lines = generate_unified_diff(old_lines, new_lines, rel_path, rel_path) if #diff_lines == 0 then vim.notify('[diffs.nvim]: no diff against ' .. revision, vim.log.levels.INFO) return end local repo_root = git.get_repo_root(filepath) local diff_buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines) vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf }) vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf }) vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf }) vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf }) vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf }) vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. revision .. ':' .. rel_path) if repo_root then vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) end local existing_win = M.find_diffs_window() if existing_win then vim.api.nvim_set_current_win(existing_win) vim.api.nvim_win_set_buf(existing_win, diff_buf) else vim.cmd(vertical and 'vsplit' or 'split') vim.api.nvim_win_set_buf(0, diff_buf) end M.setup_diff_buf(diff_buf) dbg('opened diff buffer %d for %s against %s', diff_buf, rel_path, revision) vim.schedule(function() require('diffs').attach(diff_buf) end) end ---@class diffs.GdiffFileOpts ---@field vertical? boolean ---@field staged? boolean ---@field untracked? boolean ---@field unmerged? boolean ---@field old_filepath? string ---@field hunk_position? { hunk_header: string, offset: integer } ---@param filepath string ---@param opts? diffs.GdiffFileOpts function M.gdiff_file(filepath, opts) opts = opts or {} local rel_path = git.get_relative_path(filepath) if not rel_path then vim.notify('[diffs.nvim]: not in a git repository', vim.log.levels.ERROR) return end local old_rel_path = opts.old_filepath and git.get_relative_path(opts.old_filepath) or rel_path local old_lines, new_lines, err local diff_label if opts.unmerged then old_lines = git.get_file_content(':2', filepath) if not old_lines then old_lines = {} end new_lines = git.get_file_content(':3', filepath) if not new_lines then new_lines = {} end diff_label = 'unmerged' elseif opts.untracked then old_lines = {} new_lines, err = git.get_working_content(filepath) if not new_lines then vim.notify('[diffs.nvim]: ' .. (err or 'cannot read file'), vim.log.levels.ERROR) return end diff_label = 'untracked' elseif opts.staged then old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath) if not old_lines then old_lines = {} end new_lines, err = git.get_index_content(filepath) if not new_lines then new_lines = {} end diff_label = 'staged' else old_lines, err = git.get_index_content(opts.old_filepath or filepath) if not old_lines then old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath) if not old_lines then old_lines = {} diff_label = 'untracked' else diff_label = 'unstaged' end else diff_label = 'unstaged' end new_lines, err = git.get_working_content(filepath) if not new_lines then new_lines = {} end end local diff_lines = generate_unified_diff(old_lines, new_lines, old_rel_path, rel_path) if #diff_lines == 0 then vim.notify('[diffs.nvim]: no changes', vim.log.levels.INFO) return end local repo_root = git.get_repo_root(filepath) local diff_buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines) vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf }) vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf }) vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf }) vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf }) vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf }) vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':' .. rel_path) if repo_root then vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) end if old_rel_path ~= rel_path then vim.api.nvim_buf_set_var(diff_buf, 'diffs_old_filepath', old_rel_path) end local existing_win = M.find_diffs_window() if existing_win then vim.api.nvim_set_current_win(existing_win) vim.api.nvim_win_set_buf(existing_win, diff_buf) else vim.cmd(opts.vertical and 'vsplit' or 'split') vim.api.nvim_win_set_buf(0, diff_buf) end if opts.hunk_position then local target_line = M.find_hunk_line(diff_lines, opts.hunk_position) if target_line then vim.api.nvim_win_set_cursor(0, { target_line, 0 }) dbg('jumped to line %d for hunk', target_line) end end M.setup_diff_buf(diff_buf) if diff_label == 'unmerged' then vim.api.nvim_buf_set_var(diff_buf, 'diffs_unmerged', true) vim.api.nvim_buf_set_var(diff_buf, 'diffs_working_path', filepath) local conflict_config = require('diffs').get_conflict_config() require('diffs.merge').setup_keymaps(diff_buf, conflict_config) end dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) vim.schedule(function() require('diffs').attach(diff_buf) end) end ---@class diffs.GdiffSectionOpts ---@field vertical? boolean ---@field staged? boolean ---@param repo_root string ---@param opts? diffs.GdiffSectionOpts function M.gdiff_section(repo_root, opts) opts = opts or {} local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' } if opts.staged then table.insert(cmd, '--cached') end local result = vim.fn.systemlist(cmd) if vim.v.shell_error ~= 0 then vim.notify('[diffs.nvim]: git diff failed', vim.log.levels.ERROR) return end result = M.filter_combined_diffs(result) if #result == 0 then vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO) return end local diff_label = opts.staged and 'staged' or 'unstaged' local diff_buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, result) vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf }) vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf }) vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf }) vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf }) vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf }) vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':all') vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) local existing_win = M.find_diffs_window() if existing_win then vim.api.nvim_set_current_win(existing_win) vim.api.nvim_win_set_buf(existing_win, diff_buf) else vim.cmd(opts.vertical and 'vsplit' or 'split') vim.api.nvim_win_set_buf(0, diff_buf) end M.setup_diff_buf(diff_buf) dbg('opened section diff buffer %d (%s)', diff_buf, diff_label) vim.schedule(function() require('diffs').attach(diff_buf) end) end ---@param bufnr integer function M.read_buffer(bufnr) local name = vim.api.nvim_buf_get_name(bufnr) local url_body = name:match('^diffs://(.+)$') if not url_body then return end local label, path = url_body:match('^([^:]+):(.+)$') if not label or not path then return end local ok, repo_root = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_repo_root') if not ok or not repo_root then return end local diff_lines if path == 'all' then local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' } if label == 'staged' then table.insert(cmd, '--cached') end diff_lines = vim.fn.systemlist(cmd) if vim.v.shell_error ~= 0 then diff_lines = {} end diff_lines = M.filter_combined_diffs(diff_lines) else local abs_path = repo_root .. '/' .. path local old_ok, old_rel_path = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_old_filepath') local old_abs_path = old_ok and old_rel_path and (repo_root .. '/' .. old_rel_path) or abs_path local old_name = old_ok and old_rel_path or path local old_lines, new_lines if label == 'unmerged' then old_lines = git.get_file_content(':2', abs_path) or {} new_lines = git.get_file_content(':3', abs_path) or {} elseif label == 'untracked' then old_lines = {} new_lines = git.get_working_content(abs_path) or {} elseif label == 'staged' then old_lines = git.get_file_content('HEAD', old_abs_path) or {} new_lines = git.get_index_content(abs_path) or {} elseif label == 'unstaged' then old_lines = git.get_index_content(old_abs_path) if not old_lines then old_lines = git.get_file_content('HEAD', old_abs_path) or {} end new_lines = git.get_working_content(abs_path) or {} else old_lines = git.get_file_content(label, abs_path) or {} new_lines = git.get_working_content(abs_path) or {} end diff_lines = generate_unified_diff(old_lines, new_lines, old_name, path) end vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr }) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, diff_lines) vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr }) vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = bufnr }) vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr }) vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr }) dbg('reloaded diff buffer %d (%s:%s)', bufnr, label, path) require('diffs').attach(bufnr) end function M.setup() vim.api.nvim_create_user_command('Gdiff', function(opts) M.gdiff(opts.args ~= '' and opts.args or nil, false) end, { nargs = '?', desc = 'Show unified diff against git revision (default: HEAD)', }) vim.api.nvim_create_user_command('Gvdiff', function(opts) M.gdiff(opts.args ~= '' and opts.args or nil, true) end, { nargs = '?', desc = 'Show unified diff against git revision in vertical split', }) vim.api.nvim_create_user_command('Ghdiff', function(opts) M.gdiff(opts.args ~= '' and opts.args or nil, false) end, { nargs = '?', desc = 'Show unified diff against git revision in horizontal split', }) end return M