1

Update generated nvim config

This commit is contained in:
2024-06-05 22:05:42 +02:00
parent 859ee3a2ba
commit 075fe5f587
1292 changed files with 152601 additions and 0 deletions

View File

@ -0,0 +1,652 @@
require("diffview.bootstrap")
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local DiffView = lazy.access("diffview.scene.views.diff.diff_view", "DiffView") ---@type DiffView|LazyModule
local FileHistoryView = lazy.access("diffview.scene.views.file_history.file_history_view", "FileHistoryView") ---@type FileHistoryView|LazyModule
local HelpPanel = lazy.access("diffview.ui.panels.help_panel", "HelpPanel") ---@type HelpPanel|LazyModule
local StandardView = lazy.access("diffview.scene.views.standard.standard_view", "StandardView") ---@type StandardView|LazyModule
local lib = lazy.require("diffview.lib") ---@module "diffview.lib"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.utils"
local Diff1 = lazy.access("diffview.scene.layouts.diff_1", "Diff1") ---@type Diff1|LazyModule
local Diff2Hor = lazy.access("diffview.scene.layouts.diff_2_hor", "Diff2Hor") ---@type Diff2Hor|LazyModule
local Diff2Ver = lazy.access("diffview.scene.layouts.diff_2_ver", "Diff2Ver") ---@type Diff2Ver|LazyModule
local Diff3 = lazy.access("diffview.scene.layouts.diff_3", "Diff3") ---@type Diff3|LazyModule
local Diff3Hor = lazy.access("diffview.scene.layouts.diff_3_hor", "Diff3Hor") ---@type Diff3Hor|LazyModule
local Diff3Ver = lazy.access("diffview.scene.layouts.diff_3_ver", "Diff3Ver") ---@type Diff3Hor|LazyModule
local Diff3Mixed = lazy.access("diffview.scene.layouts.diff_3_mixed", "Diff3Mixed") ---@type Diff3Mixed|LazyModule
local Diff4 = lazy.access("diffview.scene.layouts.diff_4", "Diff4") ---@type Diff4|LazyModule
local Diff4Mixed = lazy.access("diffview.scene.layouts.diff_4_mixed", "Diff4Mixed") ---@type Diff4Mixed|LazyModule
local api = vim.api
local await = async.await
local pl = lazy.access(utils, "path") ---@type PathLib
local M = setmetatable({}, {
__index = function(_, k)
utils.err((
"The action '%s' does not exist! "
.. "See ':h diffview-available-actions' for an overview of available actions."
):format(k))
end
})
M.compat = {}
---@return FileEntry?
---@return integer[]? cursor
local function prepare_goto_file()
local view = lib.get_current_view()
if view and not (view:instanceof(DiffView.__get()) or view:instanceof(FileHistoryView.__get())) then
return
end
---@cast view DiffView|FileHistoryView
local file = view:infer_cur_file()
if file then
---@cast file FileEntry
-- Ensure file exists
if not pl:readable(file.absolute_path) then
utils.err(
string.format(
"File does not exist on disk: '%s'",
pl:relative(file.absolute_path, ".")
)
)
return
end
local cursor
local cur_file = view.cur_entry
if file == cur_file then
local win = view.cur_layout:get_main_win()
cursor = api.nvim_win_get_cursor(win.id)
end
return file, cursor
end
end
function M.goto_file()
local file, cursor = prepare_goto_file()
if file then
local target_tab = lib.get_prev_non_view_tabpage()
if target_tab then
api.nvim_set_current_tabpage(target_tab)
file.layout:restore_winopts()
vim.cmd("sp " .. vim.fn.fnameescape(file.absolute_path))
else
vim.cmd("tabnew")
local temp_bufnr = api.nvim_get_current_buf()
file.layout:restore_winopts()
vim.cmd("keepalt edit " .. vim.fn.fnameescape(file.absolute_path))
if temp_bufnr ~= api.nvim_get_current_buf() then
api.nvim_buf_delete(temp_bufnr, { force = true })
end
end
if cursor then
utils.set_cursor(0, unpack(cursor))
end
end
end
function M.goto_file_edit()
local file, cursor = prepare_goto_file()
if file then
local target_tab = lib.get_prev_non_view_tabpage()
if target_tab then
api.nvim_set_current_tabpage(target_tab)
file.layout:restore_winopts()
vim.cmd("edit " .. vim.fn.fnameescape(file.absolute_path))
else
vim.cmd("tabnew")
local temp_bufnr = api.nvim_get_current_buf()
file.layout:restore_winopts()
vim.cmd("keepalt edit " .. vim.fn.fnameescape(file.absolute_path))
if temp_bufnr ~= api.nvim_get_current_buf() then
api.nvim_buf_delete(temp_bufnr, { force = true })
end
end
if cursor then
utils.set_cursor(0, unpack(cursor))
end
end
end
function M.goto_file_split()
local file, cursor = prepare_goto_file()
if file then
vim.cmd("new")
local temp_bufnr = api.nvim_get_current_buf()
file.layout:restore_winopts()
vim.cmd("keepalt edit " .. vim.fn.fnameescape(file.absolute_path))
if temp_bufnr ~= api.nvim_get_current_buf() then
api.nvim_buf_delete(temp_bufnr, { force = true })
end
if cursor then
utils.set_cursor(0, unpack(cursor))
end
end
end
function M.goto_file_tab()
local file, cursor = prepare_goto_file()
if file then
vim.cmd("tabnew")
local temp_bufnr = api.nvim_get_current_buf()
file.layout:restore_winopts()
vim.cmd("keepalt edit " .. vim.fn.fnameescape(file.absolute_path))
if temp_bufnr ~= api.nvim_get_current_buf() then
api.nvim_buf_delete(temp_bufnr, { force = true })
end
if cursor then
utils.set_cursor(0, unpack(cursor))
end
end
end
---@class diffview.ConflictCount
---@field total integer
---@field current integer
---@field cur_conflict? ConflictRegion
---@field conflicts ConflictRegion[]
---@param num integer
---@param use_delta? boolean
---@return diffview.ConflictCount?
function M.jumpto_conflict(num, use_delta)
local view = lib.get_current_view()
if view and view:instanceof(StandardView.__get()) then
---@cast view StandardView
local main = view.cur_layout:get_main_win()
local curfile = main.file
if main:is_valid() and curfile:is_valid() then
local next_idx
local conflicts, cur, cur_idx = vcs_utils.parse_conflicts(
api.nvim_buf_get_lines(curfile.bufnr, 0, -1, false),
main.id
)
if #conflicts > 0 then
if not use_delta then
next_idx = utils.clamp(num, 1, #conflicts)
else
local delta = num
if not cur and delta < 0 and cur_idx <= #conflicts then
delta = delta + 1
end
if (delta < 0 and cur_idx < 1) or (delta > 0 and cur_idx > #conflicts) then
cur_idx = utils.clamp(cur_idx, 1, #conflicts)
end
next_idx = (cur_idx + delta - 1) % #conflicts + 1
end
local next_conflict = conflicts[next_idx]
local curwin = api.nvim_get_current_win()
api.nvim_win_call(main.id, function()
api.nvim_win_set_cursor(main.id, { next_conflict.first, 0 })
if curwin ~= main.id then view.cur_layout:sync_scroll() end
end)
api.nvim_echo({{ ("Conflict [%d/%d]"):format(next_idx, #conflicts) }}, false, {})
return {
total = #conflicts,
current = next_idx,
cur_conflict = next_conflict,
conflicts = conflicts,
}
end
end
end
end
---Jump to the next merge conflict marker.
---@return diffview.ConflictCount?
function M.next_conflict()
return M.jumpto_conflict(1, true)
end
---Jump to the previous merge conflict marker.
---@return diffview.ConflictCount?
function M.prev_conflict()
return M.jumpto_conflict(-1, true)
end
---Execute `cmd` for each target window in the current view. If no targets
---are given, all windows are targeted.
---@param cmd string|function The vim cmd to execute, or a function.
---@return function action
function M.view_windo(cmd)
local fun
if type(cmd) == "string" then
fun = function(_, _) vim.cmd(cmd) end
else
fun = cmd
end
return function()
local view = lib.get_current_view()
if view and view:instanceof(StandardView.__get()) then
---@cast view StandardView
for _, symbol in ipairs({ "a", "b", "c", "d" }) do
local win = view.cur_layout[symbol] --[[@as Window? ]]
if win then
api.nvim_win_call(win.id, function()
fun(view.cur_layout.name, symbol)
end)
end
end
end
end
end
---@param distance number Either an exact number of lines, or a fraction of the window height.
---@return function
function M.scroll_view(distance)
local scroll_opr = distance < 0 and [[\<c-y>]] or [[\<c-e>]]
local scroll_cmd
if distance % 1 == 0 then
scroll_cmd = ([[exe "norm! %d%s"]]):format(distance, scroll_opr)
else
scroll_cmd = ([[exe "norm! " . float2nr(winheight(0) * %f) . "%s"]])
:format(math.abs(distance), scroll_opr)
end
return function()
local view = lib.get_current_view()
if view and view:instanceof(StandardView.__get()) then
---@cast view StandardView
local max = -1
local target
for _, win in ipairs(view.cur_layout.windows) do
local height = utils.win_content_height(win.id)
if height > max then
max = height
target = win.id
end
end
if target then
api.nvim_win_call(target, function()
vim.cmd(scroll_cmd)
end)
end
end
end
end
---@param kind "ours"|"theirs"|"base"|"local"
local function diff_copy_target(kind)
local view = lib.get_current_view() --[[@as DiffView|FileHistoryView ]]
local file = view.cur_entry
if file then
local layout = file.layout
local bufnr
if layout:instanceof(Diff3.__get()) then
---@cast layout Diff3
if kind == "ours" then
bufnr = layout.a.file.bufnr
elseif kind == "theirs" then
bufnr = layout.c.file.bufnr
elseif kind == "local" then
bufnr = layout.b.file.bufnr
end
elseif layout:instanceof(Diff4.__get()) then
---@cast layout Diff4
if kind == "ours" then
bufnr = layout.a.file.bufnr
elseif kind == "theirs" then
bufnr = layout.c.file.bufnr
elseif kind == "base" then
bufnr = layout.d.file.bufnr
elseif kind == "local" then
bufnr = layout.b.file.bufnr
end
end
if bufnr then return bufnr end
end
end
---@param view DiffView
---@param target "ours"|"theirs"|"base"|"all"|"none"
local function resolve_all_conflicts(view, target)
local main = view.cur_layout:get_main_win()
local curfile = main.file
if main:is_valid() and curfile:is_valid() then
local lines = api.nvim_buf_get_lines(curfile.bufnr, 0, -1, false)
local conflicts = vcs_utils.parse_conflicts(lines, main.id)
if next(conflicts) then
local content
local offset = 0
local first, last
for _, cur_conflict in ipairs(conflicts) do
-- add offset to line numbers
first = cur_conflict.first + offset
last = cur_conflict.last + offset
if target == "ours" then content = cur_conflict.ours.content
elseif target == "theirs" then content = cur_conflict.theirs.content
elseif target == "base" then content = cur_conflict.base.content
elseif target == "all" then
content = utils.vec_join(
cur_conflict.ours.content,
cur_conflict.base.content,
cur_conflict.theirs.content
)
end
content = content or {}
api.nvim_buf_set_lines(curfile.bufnr, first - 1, last, false, content)
offset = offset + (#content - (last - first) - 1)
end
utils.set_cursor(main.id, unpack({
(content and #content or 0) + first - 1,
content and content[1] and #content[#content] or 0
}))
view.cur_layout:sync_scroll()
end
end
end
---@param target "ours"|"theirs"|"base"|"all"|"none"
function M.conflict_choose_all(target)
return async.void(function()
local view = lib.get_current_view() --[[@as DiffView ]]
if (view and view:instanceof(DiffView.__get())) then
---@cast view DiffView
if view.panel:is_focused() then
local item = view:infer_cur_file(false) ---@cast item -DirData
if not item then return end
if not item.active then
-- Open the entry
await(view:set_file(item))
end
end
resolve_all_conflicts(view, target)
end
end)
end
---@param target "ours"|"theirs"|"base"|"all"|"none"
function M.conflict_choose(target)
return function()
local view = lib.get_current_view()
if view and view:instanceof(StandardView.__get()) then
---@cast view StandardView
local main = view.cur_layout:get_main_win()
local curfile = main.file
if main:is_valid() and curfile:is_valid() then
local _, cur = vcs_utils.parse_conflicts(
api.nvim_buf_get_lines(curfile.bufnr, 0, -1, false),
main.id
)
if cur then
local content
if target == "ours" then content = cur.ours.content
elseif target == "theirs" then content = cur.theirs.content
elseif target == "base" then content = cur.base.content
elseif target == "all" then
content = utils.vec_join(
cur.ours.content,
cur.base.content,
cur.theirs.content
)
end
api.nvim_buf_set_lines(curfile.bufnr, cur.first - 1, cur.last, false, content or {})
utils.set_cursor(main.id, unpack({
(content and #content or 0) + cur.first - 1,
content and content[1] and #content[#content] or 0
}))
end
end
end
end
end
---@param target "ours"|"theirs"|"base"|"local"
function M.diffget(target)
return function()
local bufnr = diff_copy_target(target)
if bufnr and api.nvim_buf_is_valid(bufnr) then
local range
if api.nvim_get_mode().mode:match("^[vV]") then
range = ("%d,%d"):format(unpack(utils.vec_sort({
vim.fn.line("."),
vim.fn.line("v")
})))
end
vim.cmd(("%sdiffget %d"):format(range or "", bufnr))
if range then
api.nvim_feedkeys(utils.t("<esc>"), "n", false)
end
end
end
end
---@param target "ours"|"theirs"|"base"|"local"
function M.diffput(target)
return function()
local bufnr = diff_copy_target(target)
if bufnr and api.nvim_buf_is_valid(bufnr) then
vim.cmd("diffput " .. bufnr)
end
end
end
function M.cycle_layout()
local layout_cycles = {
standard = {
Diff2Hor.__get(),
Diff2Ver.__get(),
},
merge_tool = {
Diff3Hor.__get(),
Diff3Ver.__get(),
Diff3Mixed.__get(),
Diff4Mixed.__get(),
Diff1.__get(),
}
}
local view = lib.get_current_view()
if not view then return end
local layouts, files, cur_file
if view:instanceof(FileHistoryView.__get()) then
---@cast view FileHistoryView
layouts = layout_cycles.standard
files = view.panel:list_files()
cur_file = view:cur_file()
elseif view:instanceof(DiffView.__get()) then
---@cast view DiffView
cur_file = view.cur_entry
if cur_file then
layouts = cur_file.kind == "conflicting"
and layout_cycles.merge_tool
or layout_cycles.standard
files = cur_file.kind == "conflicting"
and view.files.conflicting
or utils.vec_join(view.panel.files.working, view.panel.files.staged)
end
else
return
end
for _, entry in ipairs(files) do
local cur_layout = entry.layout
local next_layout = layouts[utils.vec_indexof(layouts, cur_layout.class) % #layouts + 1]
entry:convert_layout(next_layout)
end
if cur_file then
local main = view.cur_layout:get_main_win()
local pos = api.nvim_win_get_cursor(main.id)
local was_focused = view.cur_layout:is_focused()
cur_file.layout.emitter:once("files_opened", function()
utils.set_cursor(main.id, unpack(pos))
if not was_focused then view.cur_layout:sync_scroll() end
end)
view:set_file(cur_file, false)
main = view.cur_layout:get_main_win()
if was_focused then main:focus() end
end
end
---@param keymap_groups string|string[]
function M.help(keymap_groups)
keymap_groups = type(keymap_groups) == "table" and keymap_groups or { keymap_groups }
return function()
local view = lib.get_current_view()
if view then
local help_panel = HelpPanel(view, keymap_groups) --[[@as HelpPanel ]]
help_panel:focus()
end
end
end
do
M.compat.fold_cmds = {}
-- For file entries that use custom folds with `foldmethod=manual` we need to
-- replicate fold commands in all diff windows, as folds are only
-- synchronized between diff windows when `foldmethod=diff`.
local function compat_fold(fold_cmd)
return function()
if vim.wo.foldmethod ~= "manual" then
local ok, msg = pcall(vim.cmd, "norm! " .. fold_cmd)
if not ok and msg then
api.nvim_err_writeln(msg)
end
return
end
local view = lib.get_current_view()
if view and view:instanceof(StandardView.__get()) then
---@cast view StandardView
local err
for _, win in ipairs(view.cur_layout.windows) do
api.nvim_win_call(win.id, function()
local ok, msg = pcall(vim.cmd, "norm! " .. fold_cmd)
if not ok then err = msg end
end)
end
if err then api.nvim_err_writeln(err) end
end
end
end
for _, fold_cmd in ipairs({
"za", "zA", "ze", "zE", "zo", "zc", "zO", "zC", "zr", "zm", "zR", "zM",
"zv", "zx", "zX", "zn", "zN", "zi",
}) do
table.insert(M.compat.fold_cmds, {
"n",
fold_cmd,
compat_fold(fold_cmd),
{ desc = "diffview_ignore" },
})
end
end
local action_names = {
"close",
"close_all_folds",
"close_fold",
"copy_hash",
"focus_entry",
"focus_files",
"listing_style",
"next_entry",
"open_all_folds",
"open_commit_log",
"open_fold",
"open_in_diffview",
"options",
"prev_entry",
"refresh_files",
"restore_entry",
"select_entry",
"select_next_entry",
"select_prev_entry",
"stage_all",
"toggle_files",
"toggle_flatten_dirs",
"toggle_fold",
"toggle_stage_entry",
"unstage_all",
}
for _, name in ipairs(action_names) do
M[name] = function()
require("diffview").emit(name)
end
end
return M

View File

@ -0,0 +1,179 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local DiffView = lazy.access("diffview.scene.views.diff.diff_view", "DiffView") ---@type DiffView|LazyModule
local FileEntry = lazy.access("diffview.scene.file_entry", "FileEntry") ---@type FileEntry|LazyModule
local FilePanel = lazy.access("diffview.scene.views.diff.file_panel", "FilePanel") ---@type FilePanel|LazyModule
local Rev = lazy.access("diffview.vcs.adapters.git.rev", "GitRev") ---@type GitRev|LazyModule
local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule
local vcs_utils = lazy.require("diffview.vcs") ---@module "diffview.vcs"
local oop = lazy.require("diffview.oop") ---@module "diffview.oop"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local logger = DiffviewGlobal.logger
local M = {}
---@class FileData
---@field path string Path relative to git root.
---@field oldpath string|nil If the file has been renamed, this should be the old path, oterhwise nil.
---@field status string Git status symbol.
---@field stats GitStats|nil
---@field left_null boolean Indicates that the left buffer should be represented by the null buffer.
---@field right_null boolean Indicates that the right buffer should be represented by the null buffer.
---@field selected boolean|nil Indicates that this should be the initially selected file.
---@class CDiffView : DiffView
---@field files any
---@field fetch_files function A function that should return an updated list of files.
---@field get_file_data function A function that is called with parameters `path: string` and `split: string`, and should return a list of lines that should make up the buffer.
local CDiffView = oop.create_class("CDiffView", DiffView.__get())
---CDiffView constructor.
---@param opt any
function CDiffView:init(opt)
logger:info("[api] Creating a new Custom DiffView.")
self.valid = false
local err, adapter = vcs_utils.get_adapter({ top_indicators = { opt.git_root } })
if err then
utils.err(
("Failed to create an adapter for the repository: %s")
:format(utils.str_quote(opt.git_root))
)
return
end
---@cast adapter -?
-- Fix malformed revs
for _, v in ipairs({ "left", "right" }) do
local rev = opt[v]
if not rev or not rev.type then
opt[v] = Rev(RevType.STAGE, 0)
end
end
self.fetch_files = opt.update_files
self.get_file_data = opt.get_file_data
self:super(vim.tbl_extend("force", opt, {
adapter = adapter,
panel = FilePanel(
adapter,
self.files,
self.path_args,
self.rev_arg or adapter:rev_to_pretty_string(opt.left, opt.right)
),
}))
if type(opt.files) == "table" and not vim.tbl_isempty(opt.files) then
local files = self:create_file_entries(opt.files)
for kind, entries in pairs(files) do
for _, entry in ipairs(entries) do
table.insert(self.files[kind], entry)
end
end
self.files:update_file_trees()
if self.panel.cur_file then
vim.schedule(function()
self:set_file(self.panel.cur_file, false, true)
end)
end
end
self.valid = true
end
---@override
CDiffView.get_updated_files = async.wrap(function(self, callback)
local err
repeat
local ok, new_files = pcall(self.fetch_files, self)
if not ok or type(new_files) ~= "table" then
err = { "Integrating plugin failed to provide file data!" }
break
end
---@diagnostic disable-next-line: redefined-local
local ok, entries = pcall(self.create_file_entries, self, new_files)
if not ok then
err = { "Integrating plugin provided malformed file data!" }
break
end
callback(nil, entries)
return
until true
utils.err(err, true)
logger:error(table.concat(err, "\n"))
callback(err, nil)
end)
function CDiffView:create_file_entries(files)
local entries = {}
local sections = {
{ kind = "conflicting", files = files.conflicting or {} },
{ kind = "working", files = files.working or {}, left = self.left, right = self.right },
{
kind = "staged",
files = files.staged or {},
left = self.adapter:head_rev(),
right = Rev(RevType.STAGE, 0),
},
}
for _, v in ipairs(sections) do
entries[v.kind] = {}
for _, file_data in ipairs(v.files) do
if v.kind == "conflicting" then
table.insert(entries[v.kind], FileEntry.with_layout(CDiffView.get_default_merge_layout(), {
adapter = self.adapter,
path = file_data.path,
oldpath = file_data.oldpath,
status = "U",
kind = "conflicting",
revs = {
a = Rev(RevType.STAGE, 2),
b = Rev(RevType.LOCAL),
c = Rev(RevType.STAGE, 3),
d = Rev(RevType.STAGE, 1),
},
}))
else
table.insert(entries[v.kind], FileEntry.with_layout(CDiffView.get_default_layout(), {
adapter = self.adapter,
path = file_data.path,
oldpath = file_data.oldpath,
status = file_data.status,
stats = file_data.stats,
kind = v.kind,
revs = {
a = v.left,
b = v.right,
},
get_data = self.get_file_data,
--FIXME: left_null, right_null
}))
end
if file_data.selected then
self.panel:set_cur_file(entries[v.kind][#entries[v.kind]])
end
end
end
return entries
end
M.CDiffView = CDiffView
return M

View File

@ -0,0 +1,432 @@
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local M = {}
local short_flag_pat = { "^[-+](%a)=?(.*)" }
local long_flag_pat = { "^%-%-(%a[%a%d-]*)=?(.*)", "^%+%+(%a[%a%d-]*)=?(.*)" }
---@class ArgObject : diffview.Object
---@field flags table<string, string[]>
---@field args string[]
---@field post_args string[]
local ArgObject = oop.create_class("ArgObject")
---ArgObject constructor.
---@param flags table<string, string>
---@param args string[]
function ArgObject:init(flags, args, post_args)
self.flags = flags
self.args = args
self.post_args = post_args
end
---@class ArgObject.GetFlagSpec
---@field plain boolean Never cast string values to booleans.
---@field expect_list boolean Return a list of all defined values for the given flag.
---@field expect_string boolean Inferred boolean values are changed to be empty strings.
---@field no_empty boolean Return nil if the value is an empty string. Implies `expect_string`.
---@field expand boolean Expand wildcards and special keywords (`:h expand()`).
---Get a flag value.
---@param names string|string[] Flag synonyms
---@param opt? ArgObject.GetFlagSpec
---@return string[]|string|boolean
function ArgObject:get_flag(names, opt)
opt = opt or {}
if opt.no_empty then
opt.expect_string = true
end
if type(names) ~= "table" then
names = { names }
end
local values = {}
for _, name in ipairs(names) do
if self.flags[name] then
utils.vec_push(values, unpack(self.flags[name]))
end
end
values = utils.tbl_fmap(values, function(v)
if opt.expect_string and v == "true" then
-- Undo inferred boolean values
if opt.no_empty then
return nil
end
v = ""
elseif not opt.plain and (v == "true" or v == "false") then
-- Cast to boolean
v = v == "true"
end
if opt.expand then
v = vim.fn.expand(v)
end
return v
end)
-- If a list isn't expected: return the last defined value for this flag.
return opt.expect_list and values or values[#values]
end
---@class FlagValueMap : diffview.Object
---@field map table<string, string[]>
local FlagValueMap = oop.create_class("FlagValueMap")
---FlagValueMap constructor
function FlagValueMap:init()
self.map = {}
end
---@param flag_synonyms string[]
---@param producer? string[]|fun(name_lead: string, arg_lead: string): string[]
function FlagValueMap:put(flag_synonyms, producer)
for _, flag in ipairs(flag_synonyms) do
local char = flag:sub(1, 1)
if char ~= "-" and char ~= "+" then
if #flag > 1 then
flag = "--" .. flag
else
flag = "-" .. flag
end
end
self.map[flag] = producer or { "true", "false" }
self.map[#self.map + 1] = flag
end
end
---Get list of possible values for a given flag.
---@param flag_name string
---@return string[]
function FlagValueMap:get(flag_name)
local char = flag_name:sub(1, 1)
if char ~= "-" and char ~= "+" then
if #flag_name > 1 then
flag_name = "--" .. flag_name
else
flag_name = "-" .. flag_name
end
end
if type(self.map[flag_name]) == "function" then
local is_short = utils.str_match(flag_name, short_flag_pat) ~= nil
return self.map[flag_name](flag_name .. (not is_short and "=" or ""), "")
end
return self.map[flag_name]
end
---Get a list of all flag names.
---@return string[]
function FlagValueMap:get_all_names()
return utils.vec_slice(self.map)
end
---@param arg_lead string
---@return string[]?
function FlagValueMap:get_completion(arg_lead)
arg_lead = arg_lead or ""
local name
local is_short = utils.str_match(arg_lead, short_flag_pat) ~= nil
if is_short then
name = arg_lead:sub(1, 2)
arg_lead = arg_lead:match("..=?(.*)") or ""
else
name = arg_lead:gsub("=.*", "")
arg_lead = arg_lead:match("=(.*)") or ""
end
local name_lead = name .. (not is_short and "=" or "")
local values = self.map[name]
if type(values) == "function" then
values = values(name_lead, arg_lead)
end
if not values then
return nil
end
local items = {}
for _, v in ipairs(values) do
local e_lead, _ = vim.pesc(arg_lead)
if v:match(e_lead) then
items[#items + 1] = name_lead .. v
end
end
return items
end
---Parse args and create an ArgObject.
---@param args string[]
---@return ArgObject
function M.parse(args)
local flags = {}
local pre_args = {}
local post_args = {}
for i, arg in ipairs(args) do
if arg == "--" then
for j = i + 1, #args do
table.insert(post_args, args[j])
end
break
end
local flag, value
flag, value = utils.str_match(arg, short_flag_pat)
if flag then
value = (value == "") and "true" or value
if not flags[flag] then
flags[flag] = {}
end
table.insert(flags[flag], value)
goto continue
end
flag, value = utils.str_match(arg, long_flag_pat)
if flag then
value = (value == "") and "true" or value
if not flags[flag] then
flags[flag] = {}
end
table.insert(flags[flag], value)
goto continue
end
table.insert(pre_args, arg)
::continue::
end
return ArgObject(flags, pre_args, post_args)
end
---Split the line range from an EX command arg.
---@param arg string
---@return string range, string command
function M.split_ex_range(arg)
local idx = arg:match(".*()%A")
if not idx then
return "", arg
end
local slice = arg:sub(idx or 1)
idx = slice:match("[^']()%a")
if idx then
return arg:sub(1, (#arg - #slice) + idx - 1), slice:sub(idx)
end
return arg, ""
end
---@class CmdLineContext
---@field cmd_line string
---@field args string[] # The tokenized list of arguments.
---@field raw_args string[] # The unprocessed list of arguments. Contains syntax characters, such as quotes.
---@field arg_lead string # The leading part of the current argument.
---@field lead_quote string? # If present: the quote character used for the current argument.
---@field cur_pos integer # The cursor position in the command line.
---@field argidx integer # Index of the current argument.
---@field divideridx integer # The index of the end-of-options token. (default: math.huge)
---@field range string? # Ex command range.
---@field between boolean # The current position is between two arguments.
---@class arg_parser.scan.Opt
---@field cur_pos integer # The current cursor position in the command line.
---@field allow_quoted boolean # Everything between a pair of quotes should be treated as part of a single argument. (default: true)
---@field allow_ex_range boolean # The command line may contain an EX command range. (default: false)
---Tokenize a command line string.
---@param cmd_line string
---@param opt? arg_parser.scan.Opt
---@return CmdLineContext
function M.scan(cmd_line, opt)
opt = vim.tbl_extend("keep", opt or {}, {
cur_pos = #cmd_line + 1,
allow_quoted = true,
allow_ex_range = false,
}) --[[@as arg_parser.scan.Opt ]]
local args = {}
local raw_args = {}
local arg_lead
local divideridx = math.huge
local argidx
local between = false
local cur_quote, lead_quote
local arg, raw_arg = "", ""
local h, i = -1, 1
while i <= #cmd_line do
local char = cmd_line:sub(i, i)
if not argidx and i > opt.cur_pos then
argidx = #args + 1
arg_lead = arg
lead_quote = cur_quote
if h < opt.cur_pos then between = true end
end
if char == "\\" then
arg = arg .. char
raw_arg = raw_arg .. char
if i < #cmd_line then
i = i + 1
arg = arg .. cmd_line:sub(i, i)
raw_arg = raw_arg .. cmd_line:sub(i, i)
end
h = i
elseif cur_quote then
if char == cur_quote then
cur_quote = nil
else
arg = arg .. char
end
raw_arg = raw_arg .. char
h = i
elseif opt.allow_quoted and (char == [[']] or char == [["]]) then
cur_quote = char
raw_arg = raw_arg .. char
h = i
elseif char:match("%s") then
if arg ~= "" then
table.insert(args, arg)
if arg == "--" and i - 1 < #cmd_line then
divideridx = #args
end
end
if raw_arg ~= "" then
table.insert(raw_args, raw_arg)
end
arg = ""
raw_arg = ""
-- Skip whitespace
i = i + cmd_line:sub(i, -1):match("^%s+()") - 2
else
arg = arg .. char
raw_arg = raw_arg .. char
h = i
end
i = i + 1
end
if #arg > 0 then
table.insert(args, arg)
table.insert(raw_args, raw_arg)
if not arg_lead then
arg_lead = arg
lead_quote = cur_quote
end
if arg == "--" and cmd_line:sub(#cmd_line, #cmd_line) ~= "-" then
divideridx = #args
end
end
if not argidx then
argidx = #args
if cmd_line:sub(#cmd_line, #cmd_line):match("%s") then
argidx = argidx + 1
end
end
local range
if #args > 0 then
if opt.allow_ex_range then
range, args[1] = M.split_ex_range(args[1])
_, raw_args[1] = M.split_ex_range(raw_args[1])
end
if args[1] == "" then
table.remove(args, 1)
table.remove(raw_args, 1)
argidx = math.max(argidx - 1, 1)
divideridx = math.max(divideridx - 1, 1)
end
end
return {
cmd_line = cmd_line,
args = args,
raw_args = raw_args,
arg_lead = arg_lead or "",
lead_quote = lead_quote,
cur_pos = opt.cur_pos,
argidx = argidx,
divideridx = divideridx,
range = range ~= "" and range or nil,
between = between,
}
end
---Filter completion candidates.
---@param arg_lead string
---@param candidates string[]
---@return string[]
function M.filter_candidates(arg_lead, candidates)
arg_lead, _ = vim.pesc(arg_lead)
return vim.tbl_filter(function(item)
return item:match(arg_lead)
end, candidates)
end
---Process completion candidates.
---@param candidates string[]
---@param ctx CmdLineContext
---@param input_cmp boolean? Completion for |input()|.
---@return string[]
function M.process_candidates(candidates, ctx, input_cmp)
if not candidates then return {} end
local cmd_lead = ""
local ex_lead = (ctx.lead_quote or "") .. ctx.arg_lead
if ctx.arg_lead and ctx.arg_lead:find("[^\\]%s") then
ex_lead = (ctx.lead_quote or "") .. ctx.arg_lead:match(".*[^\\]%s(.*)")
end
if input_cmp then
cmd_lead = ctx.cmd_line:sub(1, ctx.cur_pos - #ex_lead)
end
local ret = vim.tbl_map(function(v)
if v:match("^" .. vim.pesc(ctx.arg_lead)) then
return cmd_lead .. ex_lead .. v:sub(#ctx.arg_lead + 1)
elseif input_cmp then
return cmd_lead .. v
end
return (ctx.lead_quote or "") .. v
end, candidates)
return M.filter_candidates(cmd_lead .. ex_lead, ret)
end
function M.ambiguous_bool(value, default, truthy, falsy)
if vim.tbl_contains(truthy, value) then
return true
end
if vim.tbl_contains(falsy, value) then
return false
end
return default
end
M.ArgObject = ArgObject
M.FlagValueMap = FlagValueMap
return M

View File

@ -0,0 +1,577 @@
local ffi = require("diffview.ffi")
local oop = require("diffview.oop")
local fmt = string.format
local uv = vim.loop
local DEFAULT_ERROR = "Unkown error."
local M = {}
---@package
---@type { [Future]: boolean }
M._watching = setmetatable({}, { __mode = "k" })
---@package
---@type { [thread]: Future }
M._handles = {}
---@alias AsyncFunc (fun(...): Future)
---@alias AsyncKind "callback"|"void"
local function dstring(object)
if not DiffviewGlobal.logger then return "" end
dstring = DiffviewGlobal.logger.dstring
return dstring(object)
end
---@param ... any
---@return table
local function tbl_pack(...)
return { n = select("#", ...), ... }
end
---@param t table
---@param i? integer
---@param j? integer
---@return any ...
local function tbl_unpack(t, i, j)
return unpack(t, i or 1, j or t.n or table.maxn(t))
end
---Returns the current thread or `nil` if it's the main thread.
---
---NOTE: coroutine.running() was changed between Lua 5.1 and 5.2:
--- • 5.1: Returns the running coroutine, or `nil` when called by the main
--- thread.
--- • 5.2: Returns the running coroutine plus a boolean, true when the running
--- coroutine is the main one.
---
---For LuaJIT, 5.2 behaviour is enabled with LUAJIT_ENABLE_LUA52COMPAT
---
---We need to handle both.
---
---Source: https://github.com/lewis6991/async.nvim/blob/bad4edbb2917324cd11662dc0209ce53f6c8bc23/lua/async.lua#L10
---@return thread?
local function current_thread()
local current, ismain = coroutine.running()
if type(ismain) == "boolean" then
return not ismain and current or nil
else
return current
end
end
---@class Waitable : diffview.Object
local Waitable = oop.create_class("Waitable")
M.Waitable = Waitable
---@abstract
---@return any ... # Any values returned by the waitable
function Waitable:await() oop.abstract_stub() end
---Schedule a callback to be invoked when this waitable has settled.
---@param callback function
function Waitable:finally(callback)
(M.void(function()
local ret = tbl_pack(M.await(self))
callback(tbl_unpack(ret))
end))()
end
---@class Future : Waitable
---@operator call : Future
---@field package thread thread
---@field package listeners Future[]
---@field package parent? Future
---@field package func? function
---@field package return_values? any[]
---@field package err? string
---@field package kind AsyncKind
---@field package started boolean
---@field package awaiting_cb boolean
---@field package done boolean
---@field package has_raised boolean # `true` if this future has raised an error.
local Future = oop.create_class("Future", Waitable)
function Future:init(opt)
opt = opt or {}
if opt.thread then
self.thread = opt.thread
elseif opt.func then
self.thread = coroutine.create(opt.func)
else
error("Either 'thread' or 'func' must be specified!")
end
M._handles[self.thread] = self
self.listeners = {}
self.kind = opt.kind
self.started = false
self.awaiting_cb = false
self.done = false
self.has_raised = false
end
---@package
---@return string
function Future:__tostring()
return dstring(self.thread)
end
---@package
function Future:destroy()
M._handles[self.thread] = nil
end
---@package
---@param value boolean
function Future:set_done(value)
self.done = value
if self:is_watching() then
self:dprint("done was set:", self.done)
end
end
---@return boolean
function Future:is_done()
return not not self.done
end
---@return any ... # If the future has completed, this returns any returned values.
function Future:get_returned()
if not self.return_values then return end
return unpack(self.return_values, 2, table.maxn(self.return_values))
end
---@package
---@param ... any
function Future:dprint(...)
if not DiffviewGlobal.logger then return end
if DiffviewGlobal.debug_level >= 10 or M._watching[self] then
local t = { self, "::", ... }
for i = 1, table.maxn(t) do t[i] = dstring(t[i]) end
DiffviewGlobal.logger:debug(table.concat(t, " "))
end
end
---@package
---@param ... any
function Future:dprintf(...)
self:dprint(fmt(...))
end
---Start logging debug info about this future.
function Future:watch()
M._watching[self] = true
end
---Stop logging debug info about this future.
function Future:unwatch()
M._watching[self] = nil
end
---@package
---@return boolean
function Future:is_watching()
return not not M._watching[self]
end
---@package
---@param force? boolean
function Future:raise(force)
if self.has_raised and not force then return end
self.has_raised = true
error(self.err)
end
---@package
function Future:step(...)
self:dprint("step")
local ret = { coroutine.resume(self.thread, ...) }
local ok = ret[1]
if not ok then
local err = ret[2] or DEFAULT_ERROR
local func_info
if self.func then
func_info = debug.getinfo(self.func, "uS")
end
local msg = fmt(
"The coroutine failed with this message: \n"
.. "\tcontext: cur_thread=%s co_thread=%s %s\n%s",
dstring(current_thread() or "main"),
dstring(self.thread),
func_info and fmt("co_func=%s:%d", func_info.short_src, func_info.linedefined) or "",
debug.traceback(self.thread, err)
)
self:set_done(true)
self:notify_all(false, msg)
self:destroy()
self:raise()
return
end
if coroutine.status(self.thread) == "dead" then
self:dprint("handle dead")
self:set_done(true)
self:notify_all(true, unpack(ret, 2, table.maxn(ret)))
self:destroy()
return
end
end
---@package
---@param ok boolean
---@param ... any
function Future:notify_all(ok, ...)
local ret_values = tbl_pack(ok, ...)
if not ok then
self.err = ret_values[2] or DEFAULT_ERROR
end
local seen = {}
while next(self.listeners) do
local handle = table.remove(self.listeners, #self.listeners) --[[@as Future ]]
-- We don't want to trigger multiple steps for a single thread
if handle and not seen[handle.thread] then
self:dprint("notifying:", handle)
seen[handle.thread] = true
handle:step(ret_values)
end
end
end
---@override
---@return any ... # Return values
function Future:await()
if self.err then
self:raise(true)
return
end
if self:is_done() then
return self:get_returned()
end
local current = current_thread()
if not current then
-- Await called from main thread
return self:toplevel_await()
end
local parent_handle = M._handles[current]
if not parent_handle then
-- We're on a thread not managed by us: create a Future wrap around the
-- thread
self:dprint("creating a wrapper around unmanaged thread")
self.parent = Future({
thread = current,
kind = "void",
})
else
self.parent = parent_handle
end
if current ~= self.thread then
-- We want the current thread to be notified when this future is done /
-- terminated
table.insert(self.listeners, self.parent)
end
self:dprintf("awaiting: yielding=%s listeners=%s", dstring(current), dstring(self.listeners))
coroutine.yield()
local ok
if not self.return_values then
ok = self.err == nil
else
ok = self.return_values[1]
if not ok then
self.err = self.return_values[2] or DEFAULT_ERROR
end
end
if not ok then
self:raise(true)
return
end
return self:get_returned()
end
---@package
---@return any ...
function Future:toplevel_await()
local ok, status
while true do
ok, status = vim.wait(1000 * 60, function()
return coroutine.status(self.thread) == "dead"
end, 1)
-- Respect interrupts
if status ~= -1 then break end
end
if not ok then
if status == -1 then
error("Async task timed out!")
elseif status == -2 then
error("Async task got interrupted!")
end
end
if self.err then
self:raise(true)
return
end
return self:get_returned()
end
---@class async._run.Opt
---@field kind AsyncKind
---@field nparams? integer
---@field args any[]
---@package
---@param func function
---@param opt async._run.Opt
function M._run(func, opt)
opt = opt or {}
local handle ---@type Future
local use_err_handler = not not current_thread()
local function wrapped_func(...)
if use_err_handler then
-- We are not on the main thread: use custom err handler
local ok = xpcall(func, function(err)
handle.err = debug.traceback(err, 2)
end, ...)
if not ok then
handle:dprint("an error was raised: terminating")
handle:set_done(true)
handle:destroy()
error(handle.err, 0)
return
end
else
func(...)
end
-- Check if we need to yield until cb. We might not need to if the cb was
-- called in a synchronous way.
if opt.kind == "callback" and not handle:is_done() then
handle.awaiting_cb = true
handle:dprintf("yielding for cb: current=%s", dstring(current_thread()))
coroutine.yield()
handle:dprintf("resuming after cb: current=%s", dstring(current_thread()))
end
handle:set_done(true)
end
if opt.kind == "callback" then
local cur_cb = opt.args[opt.nparams]
local function wrapped_cb(...)
handle:set_done(true)
handle.return_values = { true, ... }
if cur_cb then cur_cb(...) end
if handle.awaiting_cb then
-- The thread was yielding for the callback: resume
handle.awaiting_cb = false
handle:step()
end
handle:notify_all(true, ...)
end
opt.args[opt.nparams] = wrapped_cb
end
handle = Future({ func = wrapped_func, kind = opt.kind })
handle:dprint("created thread")
handle.func = func
handle.started = true
handle:step(tbl_unpack(opt.args))
return handle
end
---Create an async task for a function with no return values.
---@param func function
---@return AsyncFunc
function M.void(func)
return function(...)
return M._run(func, {
kind = "void",
args = { ... },
})
end
end
---Create an async task for a callback style function.
---@param func function
---@param nparams? integer # The number of parameters.
---The last parameter in `func` must be the callback. For Lua functions this
---can be derived through reflection. If `func` is an FFI procedure then
---`nparams` is required.
---@return AsyncFunc
function M.wrap(func, nparams)
if not nparams then
local info = debug.getinfo(func, "uS")
assert(info.what == "Lua", "Parameter count can only be derived for Lua functions!")
nparams = info.nparams
end
return function(...)
return M._run(func, {
nparams = nparams,
kind = "callback",
args = { ... },
})
end
end
---@param waitable Waitable
---@return any ... # Any values returned by the waitable
function M.await(waitable)
return waitable:await()
end
---Await the async function `x` with the given arguments in protected mode. `x`
---may also be a waitable, in which case the subsequent parameters are ignored.
---@param x AsyncFunc|Waitable # The async function or waitable.
---@param ... any # Arguments to be applied to the `x` if it's a function.
---@return boolean ok # `false` if the execution of `x` failed.
---@return any result # Either the first returned value from `x` or an error message.
---@return any ... # Any subsequent values returned from `x`.
function M.pawait(x, ...)
local args = tbl_pack(...)
return pcall(function()
if type(x) == "function" then
return M.await(x(tbl_unpack(args)))
else
return x:await()
end
end)
end
-- ###############################
-- ### VARIOUS ASYNC UTILITIES ###
-- ###############################
local await = M.await
---Create a synchronous version of an async `void` task. Calling the resulting
---function will block until the async task is done.
---@param func function
function M.sync_void(func)
local afunc = M.void(func)
return function(...)
return await(afunc(...))
end
end
---Create a synchronous version of an async `wrap` task. Calling the resulting
---function will block until the async task is done. Any values that were
---passed to the callback will be returned.
---@param func function
---@param nparams? integer
---@return (fun(...): ...)
function M.sync_wrap(func, nparams)
local afunc = M.wrap(func, nparams)
return function(...)
return await(afunc(...))
end
end
---Run the given async tasks concurrently, and then wait for them all to
---terminate.
---@param tasks (AsyncFunc|Waitable)[]
M.join = M.void(function(tasks)
---@type Waitable[]
local futures = {}
-- Ensure all async tasks are started
for _, cur in ipairs(tasks) do
if cur then
if type(cur) == "function" then
futures[#futures+1] = cur()
else
---@cast cur Waitable
futures[#futures+1] = cur
end
end
end
-- Await all futures
for _, future in ipairs(futures) do
await(future)
end
end)
---Run, and await the given async tasks in sequence.
---@param tasks (AsyncFunc|Waitable)[]
M.chain = M.void(function(tasks)
for _, task in ipairs(tasks) do
if type(task) == "function" then
---@cast task AsyncFunc
await(task())
else
---@cast task Waitable
await(task)
end
end
end)
---Async task that resolves after the given `timeout` ms passes.
---@param timeout integer # Duration of the timeout (ms)
M.timeout = M.wrap(function(timeout, callback)
local timer = assert(uv.new_timer())
timer:start(
timeout,
0,
function()
if not timer:is_closing() then timer:close() end
callback()
end
)
end)
---Yield until the Neovim API is available.
---@param fast_only? boolean # Only schedule if in an |api-fast| event.
--- When this is `true`, the scheduler will resume immediately unless the
--- editor is in an |api-fast| event. This means that the API might still be
--- limited by other mechanisms (i.e. |textlock|).
M.scheduler = M.wrap(function(fast_only, callback)
if (fast_only and not vim.in_fast_event()) or not ffi.nvim_is_locked() then
callback()
return
end
vim.schedule(callback)
end)
M.schedule_now = M.wrap(vim.schedule, 1)
return M

View File

@ -0,0 +1,56 @@
if DiffviewGlobal and DiffviewGlobal.bootstrap_done then
return DiffviewGlobal.bootstrap_ok
end
local lazy = require("diffview.lazy")
local EventEmitter = lazy.access("diffview.events", "EventEmitter") ---@type EventEmitter|LazyModule
local Logger = lazy.access("diffview.logger", "Logger") ---@type Logger|LazyModule
local config = lazy.require("diffview.config") ---@module "diffview.config"
local diffview = lazy.require("diffview") ---@module "diffview"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local uv = vim.loop
local function err(msg)
msg = msg:gsub("'", "''")
vim.cmd("echohl Error")
vim.cmd(string.format("echom '[diffview.nvim] %s'", msg))
vim.cmd("echohl NONE")
end
_G.DiffviewGlobal = {
bootstrap_done = true,
bootstrap_ok = false,
}
if vim.fn.has("nvim-0.7") ~= 1 then
err(
"Minimum required version is Neovim 0.7.0! Cannot continue."
.. " (See ':h diffview.changelog-137')"
)
return false
end
_G.DiffviewGlobal = {
---Debug Levels:
---0: NOTHING
---1: NORMAL
---5: LOADING
---10: RENDERING & ASYNC
---@diagnostic disable-next-line: missing-parameter
debug_level = tonumber((uv.os_getenv("DEBUG_DIFFVIEW"))) or 0,
state = {},
bootstrap_done = true,
bootstrap_ok = true,
}
DiffviewGlobal.logger = Logger()
DiffviewGlobal.emitter = EventEmitter()
DiffviewGlobal.emitter:on_any(function(e, args)
diffview.nore_emit(e.id, utils.tbl_unpack(args))
config.user_emitter:nore_emit(e.id, utils.tbl_unpack(args))
end)
return true

View File

@ -0,0 +1,660 @@
require("diffview.bootstrap")
---@diagnostic disable: deprecated
local EventEmitter = require("diffview.events").EventEmitter
local actions = require("diffview.actions")
local lazy = require("diffview.lazy")
local Diff1 = lazy.access("diffview.scene.layouts.diff_1", "Diff1") ---@type Diff1|LazyModule
local Diff2 = lazy.access("diffview.scene.layouts.diff_2", "Diff2") ---@type Diff2|LazyModule
local Diff2Hor = lazy.access("diffview.scene.layouts.diff_2_hor", "Diff2Hor") ---@type Diff2Hor|LazyModule
local Diff2Ver = lazy.access("diffview.scene.layouts.diff_2_ver", "Diff2Ver") ---@type Diff2Ver|LazyModule
local Diff3 = lazy.access("diffview.scene.layouts.diff_3", "Diff3") ---@type Diff3|LazyModule
local Diff3Hor = lazy.access("diffview.scene.layouts.diff_3_hor", "Diff3Hor") ---@type Diff3Hor|LazyModule
local Diff3Mixed = lazy.access("diffview.scene.layouts.diff_3_mixed", "Diff3Mixed") ---@type Diff3Mixed|LazyModule
local Diff3Ver = lazy.access("diffview.scene.layouts.diff_3_ver", "Diff3Ver") ---@type Diff3Hor|LazyModule
local Diff4 = lazy.access("diffview.scene.layouts.diff_4", "Diff4") ---@type Diff4|LazyModule
local Diff4Mixed = lazy.access("diffview.scene.layouts.diff_4_mixed", "Diff4Mixed") ---@type Diff4Mixed|LazyModule
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local M = {}
local setup_done = false
---@deprecated
function M.diffview_callback(cb_name)
if cb_name == "select" then
-- Reroute deprecated action
return actions.select_entry
end
return actions[cb_name]
end
---@class ConfigLogOptions
---@field single_file LogOptions
---@field multi_file LogOptions
-- stylua: ignore start
---@class DiffviewConfig
M.defaults = {
diff_binaries = false,
enhanced_diff_hl = false,
git_cmd = { "git" },
hg_cmd = { "hg" },
use_icons = true,
show_help_hints = true,
watch_index = true,
icons = {
folder_closed = "",
folder_open = "",
},
signs = {
fold_closed = "",
fold_open = "",
done = "",
},
view = {
default = {
layout = "diff2_horizontal",
disable_diagnostics = false,
winbar_info = false,
},
merge_tool = {
layout = "diff3_horizontal",
disable_diagnostics = true,
winbar_info = true,
},
file_history = {
layout = "diff2_horizontal",
disable_diagnostics = false,
winbar_info = false,
},
},
file_panel = {
listing_style = "tree",
tree_options = {
flatten_dirs = true,
folder_statuses = "only_folded"
},
win_config = {
position = "left",
width = 35,
win_opts = {}
},
},
file_history_panel = {
log_options = {
---@type ConfigLogOptions
git = {
single_file = {
diff_merges = "first-parent",
follow = true,
},
multi_file = {
diff_merges = "first-parent",
},
},
---@type ConfigLogOptions
hg = {
single_file = {},
multi_file = {},
},
},
win_config = {
position = "bottom",
height = 16,
win_opts = {}
},
},
commit_log_panel = {
win_config = {
win_opts = {}
},
},
default_args = {
DiffviewOpen = {},
DiffviewFileHistory = {},
},
hooks = {},
-- Tabularize formatting pattern: `\v(\"[^"]{-}\",\ze(\s*)actions)|actions\.\w+(\(.{-}\))?,?|\{\ desc\ \=`
keymaps = {
disable_defaults = false, -- Disable the default keymaps
view = {
-- The `view` bindings are active in the diff buffers, only when the current
-- tabpage is a Diffview.
{ "n", "<tab>", actions.select_next_entry, { desc = "Open the diff for the next file" } },
{ "n", "<s-tab>", actions.select_prev_entry, { desc = "Open the diff for the previous file" } },
{ "n", "gf", actions.goto_file_edit, { desc = "Open the file in the previous tabpage" } },
{ "n", "<C-w><C-f>", actions.goto_file_split, { desc = "Open the file in a new split" } },
{ "n", "<C-w>gf", actions.goto_file_tab, { desc = "Open the file in a new tabpage" } },
{ "n", "<leader>e", actions.focus_files, { desc = "Bring focus to the file panel" } },
{ "n", "<leader>b", actions.toggle_files, { desc = "Toggle the file panel." } },
{ "n", "g<C-x>", actions.cycle_layout, { desc = "Cycle through available layouts." } },
{ "n", "[x", actions.prev_conflict, { desc = "In the merge-tool: jump to the previous conflict" } },
{ "n", "]x", actions.next_conflict, { desc = "In the merge-tool: jump to the next conflict" } },
{ "n", "<leader>co", actions.conflict_choose("ours"), { desc = "Choose the OURS version of a conflict" } },
{ "n", "<leader>ct", actions.conflict_choose("theirs"), { desc = "Choose the THEIRS version of a conflict" } },
{ "n", "<leader>cb", actions.conflict_choose("base"), { desc = "Choose the BASE version of a conflict" } },
{ "n", "<leader>ca", actions.conflict_choose("all"), { desc = "Choose all the versions of a conflict" } },
{ "n", "dx", actions.conflict_choose("none"), { desc = "Delete the conflict region" } },
{ "n", "<leader>cO", actions.conflict_choose_all("ours"), { desc = "Choose the OURS version of a conflict for the whole file" } },
{ "n", "<leader>cT", actions.conflict_choose_all("theirs"), { desc = "Choose the THEIRS version of a conflict for the whole file" } },
{ "n", "<leader>cB", actions.conflict_choose_all("base"), { desc = "Choose the BASE version of a conflict for the whole file" } },
{ "n", "<leader>cA", actions.conflict_choose_all("all"), { desc = "Choose all the versions of a conflict for the whole file" } },
{ "n", "dX", actions.conflict_choose_all("none"), { desc = "Delete the conflict region for the whole file" } },
unpack(actions.compat.fold_cmds),
},
diff1 = {
-- Mappings in single window diff layouts
{ "n", "g?", actions.help({ "view", "diff1" }), { desc = "Open the help panel" } },
},
diff2 = {
-- Mappings in 2-way diff layouts
{ "n", "g?", actions.help({ "view", "diff2" }), { desc = "Open the help panel" } },
},
diff3 = {
-- Mappings in 3-way diff layouts
{ { "n", "x" }, "2do", actions.diffget("ours"), { desc = "Obtain the diff hunk from the OURS version of the file" } },
{ { "n", "x" }, "3do", actions.diffget("theirs"), { desc = "Obtain the diff hunk from the THEIRS version of the file" } },
{ "n", "g?", actions.help({ "view", "diff3" }), { desc = "Open the help panel" } },
},
diff4 = {
-- Mappings in 4-way diff layouts
{ { "n", "x" }, "1do", actions.diffget("base"), { desc = "Obtain the diff hunk from the BASE version of the file" } },
{ { "n", "x" }, "2do", actions.diffget("ours"), { desc = "Obtain the diff hunk from the OURS version of the file" } },
{ { "n", "x" }, "3do", actions.diffget("theirs"), { desc = "Obtain the diff hunk from the THEIRS version of the file" } },
{ "n", "g?", actions.help({ "view", "diff4" }), { desc = "Open the help panel" } },
},
file_panel = {
{ "n", "j", actions.next_entry, { desc = "Bring the cursor to the next file entry" } },
{ "n", "<down>", actions.next_entry, { desc = "Bring the cursor to the next file entry" } },
{ "n", "k", actions.prev_entry, { desc = "Bring the cursor to the previous file entry" } },
{ "n", "<up>", actions.prev_entry, { desc = "Bring the cursor to the previous file entry" } },
{ "n", "<cr>", actions.select_entry, { desc = "Open the diff for the selected entry" } },
{ "n", "o", actions.select_entry, { desc = "Open the diff for the selected entry" } },
{ "n", "l", actions.select_entry, { desc = "Open the diff for the selected entry" } },
{ "n", "<2-LeftMouse>", actions.select_entry, { desc = "Open the diff for the selected entry" } },
{ "n", "-", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry" } },
{ "n", "s", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry" } },
{ "n", "S", actions.stage_all, { desc = "Stage all entries" } },
{ "n", "U", actions.unstage_all, { desc = "Unstage all entries" } },
{ "n", "X", actions.restore_entry, { desc = "Restore entry to the state on the left side" } },
{ "n", "L", actions.open_commit_log, { desc = "Open the commit log panel" } },
{ "n", "zo", actions.open_fold, { desc = "Expand fold" } },
{ "n", "h", actions.close_fold, { desc = "Collapse fold" } },
{ "n", "zc", actions.close_fold, { desc = "Collapse fold" } },
{ "n", "za", actions.toggle_fold, { desc = "Toggle fold" } },
{ "n", "zR", actions.open_all_folds, { desc = "Expand all folds" } },
{ "n", "zM", actions.close_all_folds, { desc = "Collapse all folds" } },
{ "n", "<c-b>", actions.scroll_view(-0.25), { desc = "Scroll the view up" } },
{ "n", "<c-f>", actions.scroll_view(0.25), { desc = "Scroll the view down" } },
{ "n", "<tab>", actions.select_next_entry, { desc = "Open the diff for the next file" } },
{ "n", "<s-tab>", actions.select_prev_entry, { desc = "Open the diff for the previous file" } },
{ "n", "gf", actions.goto_file_edit, { desc = "Open the file in the previous tabpage" } },
{ "n", "<C-w><C-f>", actions.goto_file_split, { desc = "Open the file in a new split" } },
{ "n", "<C-w>gf", actions.goto_file_tab, { desc = "Open the file in a new tabpage" } },
{ "n", "i", actions.listing_style, { desc = "Toggle between 'list' and 'tree' views" } },
{ "n", "f", actions.toggle_flatten_dirs, { desc = "Flatten empty subdirectories in tree listing style" } },
{ "n", "R", actions.refresh_files, { desc = "Update stats and entries in the file list" } },
{ "n", "<leader>e", actions.focus_files, { desc = "Bring focus to the file panel" } },
{ "n", "<leader>b", actions.toggle_files, { desc = "Toggle the file panel" } },
{ "n", "g<C-x>", actions.cycle_layout, { desc = "Cycle available layouts" } },
{ "n", "[x", actions.prev_conflict, { desc = "Go to the previous conflict" } },
{ "n", "]x", actions.next_conflict, { desc = "Go to the next conflict" } },
{ "n", "g?", actions.help("file_panel"), { desc = "Open the help panel" } },
{ "n", "<leader>cO", actions.conflict_choose_all("ours"), { desc = "Choose the OURS version of a conflict for the whole file" } },
{ "n", "<leader>cT", actions.conflict_choose_all("theirs"), { desc = "Choose the THEIRS version of a conflict for the whole file" } },
{ "n", "<leader>cB", actions.conflict_choose_all("base"), { desc = "Choose the BASE version of a conflict for the whole file" } },
{ "n", "<leader>cA", actions.conflict_choose_all("all"), { desc = "Choose all the versions of a conflict for the whole file" } },
{ "n", "dX", actions.conflict_choose_all("none"), { desc = "Delete the conflict region for the whole file" } },
},
file_history_panel = {
{ "n", "g!", actions.options, { desc = "Open the option panel" } },
{ "n", "<C-A-d>", actions.open_in_diffview, { desc = "Open the entry under the cursor in a diffview" } },
{ "n", "y", actions.copy_hash, { desc = "Copy the commit hash of the entry under the cursor" } },
{ "n", "L", actions.open_commit_log, { desc = "Show commit details" } },
{ "n", "X", actions.restore_entry, { desc = "Restore file to the state from the selected entry" } },
{ "n", "zo", actions.open_fold, { desc = "Expand fold" } },
{ "n", "zc", actions.close_fold, { desc = "Collapse fold" } },
{ "n", "h", actions.close_fold, { desc = "Collapse fold" } },
{ "n", "za", actions.toggle_fold, { desc = "Toggle fold" } },
{ "n", "zR", actions.open_all_folds, { desc = "Expand all folds" } },
{ "n", "zM", actions.close_all_folds, { desc = "Collapse all folds" } },
{ "n", "j", actions.next_entry, { desc = "Bring the cursor to the next file entry" } },
{ "n", "<down>", actions.next_entry, { desc = "Bring the cursor to the next file entry" } },
{ "n", "k", actions.prev_entry, { desc = "Bring the cursor to the previous file entry" } },
{ "n", "<up>", actions.prev_entry, { desc = "Bring the cursor to the previous file entry" } },
{ "n", "<cr>", actions.select_entry, { desc = "Open the diff for the selected entry" } },
{ "n", "o", actions.select_entry, { desc = "Open the diff for the selected entry" } },
{ "n", "l", actions.select_entry, { desc = "Open the diff for the selected entry" } },
{ "n", "<2-LeftMouse>", actions.select_entry, { desc = "Open the diff for the selected entry" } },
{ "n", "<c-b>", actions.scroll_view(-0.25), { desc = "Scroll the view up" } },
{ "n", "<c-f>", actions.scroll_view(0.25), { desc = "Scroll the view down" } },
{ "n", "<tab>", actions.select_next_entry, { desc = "Open the diff for the next file" } },
{ "n", "<s-tab>", actions.select_prev_entry, { desc = "Open the diff for the previous file" } },
{ "n", "gf", actions.goto_file_edit, { desc = "Open the file in the previous tabpage" } },
{ "n", "<C-w><C-f>", actions.goto_file_split, { desc = "Open the file in a new split" } },
{ "n", "<C-w>gf", actions.goto_file_tab, { desc = "Open the file in a new tabpage" } },
{ "n", "<leader>e", actions.focus_files, { desc = "Bring focus to the file panel" } },
{ "n", "<leader>b", actions.toggle_files, { desc = "Toggle the file panel" } },
{ "n", "g<C-x>", actions.cycle_layout, { desc = "Cycle available layouts" } },
{ "n", "g?", actions.help("file_history_panel"), { desc = "Open the help panel" } },
},
option_panel = {
{ "n", "<tab>", actions.select_entry, { desc = "Change the current option" } },
{ "n", "q", actions.close, { desc = "Close the panel" } },
{ "n", "g?", actions.help("option_panel"), { desc = "Open the help panel" } },
},
help_panel = {
{ "n", "q", actions.close, { desc = "Close help menu" } },
{ "n", "<esc>", actions.close, { desc = "Close help menu" } },
},
},
}
-- stylua: ignore end
---@type EventEmitter
M.user_emitter = EventEmitter()
M._config = M.defaults
---@class GitLogOptions
---@field follow boolean
---@field first_parent boolean
---@field show_pulls boolean
---@field reflog boolean
---@field walk_reflogs boolean
---@field all boolean
---@field merges boolean
---@field no_merges boolean
---@field reverse boolean
---@field cherry_pick boolean
---@field left_only boolean
---@field right_only boolean
---@field max_count integer
---@field L string[]
---@field author string
---@field grep string
---@field G string
---@field S string
---@field diff_merges string
---@field rev_range string
---@field base string
---@field path_args string[]
---@field after string
---@field before string
---@class HgLogOptions
---@field follow string
---@field limit integer
---@field user string
---@field no_merges boolean
---@field rev string
---@field keyword string
---@field branch string
---@field bookmark string
---@field include string
---@field exclude string
---@field path_args string[]
---@alias LogOptions GitLogOptions|HgLogOptions
M.log_option_defaults = {
---@type GitLogOptions
git = {
follow = false,
first_parent = false,
show_pulls = false,
reflog = false,
walk_reflogs = false,
all = false,
merges = false,
no_merges = false,
reverse = false,
cherry_pick = false,
left_only = false,
right_only = false,
rev_range = nil,
base = nil,
max_count = 256,
L = {},
diff_merges = nil,
author = nil,
grep = nil,
G = nil,
S = nil,
path_args = {},
},
---@type HgLogOptions
hg = {
limit = 256,
user = nil,
no_merges = false,
rev = nil,
keyword = nil,
include = nil,
exclude = nil,
},
}
---@return DiffviewConfig
function M.get_config()
if not setup_done then
M.setup()
end
return M._config
end
---@param single_file boolean
---@param t GitLogOptions|HgLogOptions
---@param vcs "git"|"hg"
---@return GitLogOptions|HgLogOptions
function M.get_log_options(single_file, t, vcs)
local log_options
if single_file then
log_options = M._config.file_history_panel.log_options[vcs].single_file
else
log_options = M._config.file_history_panel.log_options[vcs].multi_file
end
if t then
log_options = vim.tbl_extend("force", log_options, t)
for k, _ in pairs(log_options) do
if t[k] == "" then
log_options[k] = nil
end
end
end
return log_options
end
---@alias LayoutName "diff1_plain"
--- | "diff2_horizontal"
--- | "diff2_vertical"
--- | "diff3_horizontal"
--- | "diff3_vertical"
--- | "diff3_mixed"
--- | "diff4_mixed"
local layout_map = {
diff1_plain = Diff1,
diff2_horizontal = Diff2Hor,
diff2_vertical = Diff2Ver,
diff3_horizontal = Diff3Hor,
diff3_vertical = Diff3Ver,
diff3_mixed = Diff3Mixed,
diff4_mixed = Diff4Mixed,
}
---@param layout_name LayoutName
---@return Layout
function M.name_to_layout(layout_name)
assert(layout_map[layout_name], "Invalid layout name: " .. layout_name)
return layout_map[layout_name].__get()
end
---@param layout Layout
---@return table?
function M.get_layout_keymaps(layout)
if layout:instanceof(Diff1.__get()) then
return M._config.keymaps.diff1
elseif layout:instanceof(Diff2.__get()) then
return M._config.keymaps.diff2
elseif layout:instanceof(Diff3.__get()) then
return M._config.keymaps.diff3
elseif layout:instanceof(Diff4.__get()) then
return M._config.keymaps.diff4
end
end
function M.find_option_keymap(t)
for _, mapping in ipairs(t) do
if mapping[3] and mapping[3] == actions.options then
return mapping
end
end
end
function M.find_help_keymap(t)
for _, mapping in ipairs(t) do
if type(mapping[4]) == "table" and mapping[4].desc == "Open the help panel" then
return mapping
end
end
end
---@param values vector
---@param no_quote? boolean
---@return string
local function fmt_enum(values, no_quote)
return table.concat(vim.tbl_map(function(v)
return (not no_quote and type(v) == "string") and ("'" .. v .. "'") or v
end, values), "|")
end
---@param ... table
---@return table
function M.extend_keymaps(...)
local argc = select("#", ...)
local argv = { ... }
local contexts = {}
for i = 1, argc do
local cur = argv[i]
if type(cur) == "table" then
contexts[#contexts + 1] = { subject = cur, expanded = {} }
end
end
for _, ctx in ipairs(contexts) do
-- Expand the normal mode maps
for lhs, rhs in pairs(ctx.subject) do
if type(lhs) == "string" then
ctx.expanded["n " .. lhs] = {
"n",
lhs,
rhs,
{ silent = true, nowait = true },
}
end
end
for _, map in ipairs(ctx.subject) do
for _, mode in ipairs(type(map[1]) == "table" and map[1] or { map[1] }) do
ctx.expanded[mode .. " " .. map[2]] = utils.vec_join(
mode,
map[2],
utils.vec_slice(map, 3)
)
end
end
end
local merged = vim.tbl_extend("force", unpack(
vim.tbl_map(function(v)
return v.expanded
end, contexts)
))
return vim.tbl_values(merged)
end
function M.setup(user_config)
user_config = user_config or {}
M._config = vim.tbl_deep_extend(
"force",
utils.tbl_deep_clone(M.defaults),
user_config
)
---@type EventEmitter
M.user_emitter = EventEmitter()
--#region DEPRECATION NOTICES
if type(M._config.file_panel.use_icons) ~= "nil" then
utils.warn("'file_panel.use_icons' has been deprecated. See ':h diffview.changelog-64'.")
end
-- Move old panel preoperties to win_config
local old_win_config_spec = { "position", "width", "height" }
for _, panel_name in ipairs({ "file_panel", "file_history_panel" }) do
local panel_config = M._config[panel_name]
---@cast panel_config table
local notified = false
for _, option in ipairs(old_win_config_spec) do
if panel_config[option] ~= nil then
if not notified then
utils.warn(
("'%s.{%s}' has been deprecated. See ':h diffview.changelog-136'.")
:format(panel_name, fmt_enum(old_win_config_spec, true))
)
notified = true
end
panel_config.win_config[option] = panel_config[option]
panel_config[option] = nil
end
end
end
-- Move old keymaps
if user_config.key_bindings then
M._config.keymaps = vim.tbl_deep_extend("force", M._config.keymaps, user_config.key_bindings)
user_config.keymaps = user_config.key_bindings
M._config.key_bindings = nil
end
local user_log_options = utils.tbl_access(user_config, "file_history_panel.log_options")
if user_log_options then
local top_options = {
"single_file",
"multi_file",
}
for _, name in ipairs(top_options) do
if user_log_options[name] ~= nil then
utils.warn("Global config of 'file_panel.log_options' has been deprecated. See ':h diffview.changelog-271'.")
end
break
end
local option_names = {
"max_count",
"follow",
"all",
"merges",
"no_merges",
"reverse",
}
for _, name in ipairs(option_names) do
if user_log_options[name] ~= nil then
utils.warn(
("'file_history_panel.log_options.{%s}' has been deprecated. See ':h diffview.changelog-151'.")
:format(fmt_enum(option_names, true))
)
break
end
end
end
--#endregion
if #M._config.git_cmd == 0 then
M._config.git_cmd = M.defaults.git_cmd
end
do
-- Validate layouts
local view = M._config.view
local standard_layouts = { "diff2_horizontal", "diff2_vertical", -1 }
local merge_layuots = {
"diff1_plain",
"diff3_horizontal",
"diff3_vertical",
"diff3_mixed",
"diff4_mixed",
-1
}
local valid_layouts = {
default = standard_layouts,
merge_tool = merge_layuots,
file_history = standard_layouts,
}
for _, kind in ipairs(vim.tbl_keys(valid_layouts)) do
if not vim.tbl_contains(valid_layouts[kind], view[kind].layout) then
utils.err(("Invalid layout name '%s' for 'view.%s'! Must be one of (%s)."):format(
view[kind].layout,
kind,
fmt_enum(valid_layouts[kind])
))
view[kind].layout = M.defaults.view[kind].layout
end
end
end
for _, name in ipairs({ "single_file", "multi_file" }) do
for _, vcs in ipairs({ "git", "hg" }) do
local t = M._config.file_history_panel.log_options[vcs]
t[name] = vim.tbl_extend(
"force",
M.log_option_defaults[vcs],
t[name]
)
for k, _ in pairs(t[name]) do
if t[name][k] == "" then
t[name][k] = nil
end
end
end
end
for event, callback in pairs(M._config.hooks) do
if type(callback) == "function" then
M.user_emitter:on(event, function (_, ...)
callback(...)
end)
end
end
if M._config.keymaps.disable_defaults then
for name, _ in pairs(M._config.keymaps) do
if name ~= "disable_defaults" then
M._config.keymaps[name] = utils.tbl_access(user_config, { "keymaps", name }) or {}
end
end
else
M._config.keymaps = utils.tbl_clone(M.defaults.keymaps)
end
-- Merge default and user keymaps
for name, keymap in pairs(M._config.keymaps) do
if type(name) == "string" and type(keymap) == "table" then
M._config.keymaps[name] = M.extend_keymaps(
keymap,
utils.tbl_access(user_config, { "keymaps", name }) or {}
)
end
end
-- Disable keymaps set to `false`
for name, keymaps in pairs(M._config.keymaps) do
if type(name) == "string" and type(keymaps) == "table" then
for i = #keymaps, 1, -1 do
local v = keymaps[i]
if type(v) == "table" and not v[3] then
table.remove(keymaps, i)
end
end
end
end
setup_done = true
end
M.actions = actions
return M

View File

@ -0,0 +1,294 @@
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local async = require("diffview.async")
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local await = async.await
local M = {}
---@class Condvar : Waitable
---@operator call : Condvar
local Condvar = oop.create_class("Condvar", async.Waitable)
M.Condvar = Condvar
function Condvar:init()
self.handles = {}
end
---@override
Condvar.await = async.sync_wrap(function(self, callback)
table.insert(self.handles, callback)
end, 2)
function Condvar:notify_all()
local len = #self.handles
for i, cb in ipairs(self.handles) do
if i > len then break end
cb()
end
if #self.handles > len then
self.handles = utils.vec_slice(self.handles, len + 1)
else
self.handles = {}
end
end
---@class SignalConsumer : Waitable
---@operator call : SignalConsumer
---@field package parent Signal
local SignalConsumer = oop.create_class("SignalConsumer", async.Waitable)
function SignalConsumer:init(parent)
self.parent = parent
end
---@override
---@param self SignalConsumer
SignalConsumer.await = async.sync_void(function(self)
await(self.parent)
end)
---Check if the signal has been emitted.
---@return boolean
function SignalConsumer:check()
return self.parent:check()
end
---Listen for the signal to be emitted. If the signal has already been emitted,
---the callback is invoked immediately. The callback can potentially be called
---multiple times if the signal is reset between emissions.
---@see Signal.reset
---@param callback fun(signal: Signal)
function SignalConsumer:listen(callback)
self.parent:listen(callback)
end
function SignalConsumer:get_name()
return self.parent:get_name()
end
---@class Signal : SignalConsumer
---@operator call : Signal
---@field package name string
---@field package emitted boolean
---@field package cond Condvar
---@field package listeners (fun(signal: Signal))[]
local Signal = oop.create_class("Signal", async.Waitable)
M.Signal = Signal
function Signal:init(name)
self.name = name or "UNNAMED_SIGNAL"
self.emitted = false
self.cond = Condvar()
self.listeners = {}
end
---@override
---@param self Signal
Signal.await = async.sync_void(function(self)
if self.emitted then return end
await(self.cond)
end)
---Send the signal.
function Signal:send()
if self.emitted then return end
self.emitted = true
for _, listener in ipairs(self.listeners) do
listener(self)
end
self.cond:notify_all()
end
---Listen for the signal to be emitted. If the signal has already been emitted,
---the callback is invoked immediately. The callback can potentially be called
---multiple times if the signal is reset between emissions.
---@see Signal.reset
---@param callback fun(signal: Signal)
function Signal:listen(callback)
self.listeners[#self.listeners + 1] = callback
if self.emitted then callback(self) end
end
---@return SignalConsumer
function Signal:new_consumer()
return SignalConsumer(self)
end
---Check if the signal has been emitted.
---@return boolean
function Signal:check()
return self.emitted
end
---Reset the signal such that it can be sent again.
function Signal:reset()
self.emitted = false
end
function Signal:get_name()
return self.name
end
---@class WorkPool : Waitable
---@operator call : WorkPool
---@field package workers table<Signal, boolean>
local WorkPool = oop.create_class("WorkPool", async.Waitable)
M.WorkPool = WorkPool
function WorkPool:init()
self.workers = {}
end
---Check in a worker. Returns a "checkout" signal that must be used to resolve
---the work.
---@return Signal checkout
function WorkPool:check_in()
local signal = Signal()
self.workers[signal] = true
signal:listen(function()
self.workers[signal] = nil
end)
return signal
end
function WorkPool:size()
return #vim.tbl_keys(self.workers)
end
---Wait for all workers to resolve and check out.
---@override
---@param self WorkPool
WorkPool.await = async.sync_void(function(self)
local cur = next(self.workers)
while cur do
self.workers[cur] = nil
await(cur)
cur = next(self.workers)
end
end)
---@class Permit : diffview.Object
---@operator call : Permit
---@field parent Semaphore
local Permit = oop.create_class("Permit")
function Permit:init(opt)
self.parent = opt.parent
end
function Permit:destroy()
self.parent = nil
end
---@param self Permit
function Permit:forget()
if self.parent then
local parent = self.parent
self:destroy()
parent:forget_one()
end
end
---@class Semaphore : diffview.Object
---@operator call : Semaphore
---@field initial_count integer
---@field permit_count integer
---@field queue fun(p: Permit)[]
local Semaphore = oop.create_class("Semaphore")
M.Semaphore = Semaphore
function Semaphore:init(permit_count)
assert(permit_count)
self.initial_count = permit_count
self.permit_count = permit_count
self.queue = {}
end
function Semaphore:forget_one()
if self.permit_count == self.initial_count then return end
if next(self.queue) then
local next_contractee = table.remove(self.queue, 1)
next_contractee(Permit({ parent = self }))
else
self.permit_count = self.permit_count + 1
end
end
---@param self Semaphore
---@param callback fun(permit: Permit)
Semaphore.acquire = async.wrap(function(self, callback)
if self.permit_count <= 0 then
table.insert(self.queue, callback)
return
end
self.permit_count = self.permit_count - 1
return callback(Permit({ parent = self }))
end)
---@class CountDownLatch : Waitable
---@operator call : CountDownLatch
---@field initial_count integer
---@field counter integer
---@field sem Semaphore
---@field condvar Condvar
---@field count_down fun(self: CountDownLatch)
local CountDownLatch = oop.create_class("CountDownLatch", async.Waitable)
M.CountDownLatch = CountDownLatch
function CountDownLatch:init(count)
self.initial_count = count
self.counter = count
self.sem = Semaphore(1)
self.condvar = Condvar()
end
function CountDownLatch:count_down()
local permit = await(self.sem:acquire()) --[[@as Permit ]]
if self.counter == 0 then
-- The counter reached 0 while we were waiting for the permit
permit:forget()
return
end
self.counter = self.counter - 1
permit:forget()
if self.counter == 0 then
self.condvar:notify_all()
end
end
---@override
function CountDownLatch:await()
if self.counter == 0 then return end
await(self.condvar)
end
function CountDownLatch:reset()
local permit = await(self.sem:acquire()) --[[@as Permit ]]
self.counter = self.initial_count
permit:forget()
self.condvar:notify_all()
end
return M

View File

@ -0,0 +1,246 @@
local async = require("diffview.async")
local utils = require("diffview.utils")
local await = async.await
local uv = vim.loop
local M = {}
---@class Closeable
---@field close fun() # Perform cleanup and release the associated handle.
---@class ManagedFn : Closeable
---@operator call : unknown ...
---@param ... uv_handle_t
function M.try_close(...)
local args = { ... }
for i = 1, select("#", ...) do
local handle = args[i]
if handle and not handle:is_closing() then
handle:close()
end
end
end
---@return ManagedFn
local function wrap(timer, fn)
return setmetatable({}, {
__call = function(_, ...)
fn(...)
end,
__index = {
close = function()
timer:stop()
M.try_close(timer)
end,
},
})
end
---Debounces a function on the leading edge.
---@param ms integer Timeout in ms
---@param fn function Function to debounce
---@return ManagedFn # Debounced function.
function M.debounce_leading(ms, fn)
local timer = assert(uv.new_timer())
local lock = false
return wrap(timer, function(...)
timer:start(ms, 0, function()
timer:stop()
lock = false
end)
if not lock then
lock = true
fn(...)
end
end)
end
---Debounces a function on the trailing edge.
---@param ms integer Timeout in ms
---@param rush_first boolean If the managed fn is called and it's not recovering from a debounce: call the fn immediately.
---@param fn function Function to debounce
---@return ManagedFn # Debounced function.
function M.debounce_trailing(ms, rush_first, fn)
local timer = assert(uv.new_timer())
local lock = false
local debounced_fn, args
debounced_fn = wrap(timer, function(...)
if not lock and rush_first and args == nil then
lock = true
fn(...)
else
args = utils.tbl_pack(...)
end
timer:start(ms, 0, function()
lock = false
timer:stop()
if args then
local a = args
args = nil
fn(utils.tbl_unpack(a))
end
end)
end)
return debounced_fn
end
---Throttles a function on the leading edge.
---@param ms integer Timeout in ms
---@param fn function Function to throttle
---@return ManagedFn # throttled function.
function M.throttle_leading(ms, fn)
local timer = assert(uv.new_timer())
local lock = false
return wrap(timer, function(...)
if not lock then
timer:start(ms, 0, function()
lock = false
timer:stop()
end)
lock = true
fn(...)
end
end)
end
---Throttles a function on the trailing edge.
---@param ms integer Timeout in ms
---@param rush_first boolean If the managed fn is called and it's not recovering from a throttle: call the fn immediately.
---@param fn function Function to throttle
---@return ManagedFn # throttled function.
function M.throttle_trailing(ms, rush_first, fn)
local timer = assert(uv.new_timer())
local lock = false
local throttled_fn, args
throttled_fn = wrap(timer, function(...)
if lock or (not rush_first and args == nil) then
args = utils.tbl_pack(...)
end
if lock then return end
lock = true
if rush_first then
fn(...)
end
timer:start(ms, 0, function()
lock = false
if args then
local a = args
args = nil
if rush_first then
throttled_fn(utils.tbl_unpack(a))
else
fn(utils.tbl_unpack(a))
end
end
end)
end)
return throttled_fn
end
---Throttle a function against a target framerate. The function will always be
---called when the editor is unlocked and writing to buffers is possible.
---@param framerate integer # Target framerate. Set to <= 0 to render whenever the scheduler is ready.
---@param fn function
function M.throttle_render(framerate, fn)
local lock = false
local use_framerate = framerate > 0
local period = use_framerate and (1000 / framerate) * 1E6 or 0
local throttled_fn
local args, last
throttled_fn = async.void(function(...)
args = utils.tbl_pack(...)
if lock then return end
lock = true
await(async.schedule_now())
fn(utils.tbl_unpack(args))
args = nil
if use_framerate then
local now = uv.hrtime()
if last and now - last < period then
local wait = period - (now - last)
await(async.timeout(wait / 1E6))
last = last + period
else
last = now
end
end
lock = false
if args ~= nil then
throttled_fn(utils.tbl_unpack(args))
end
end)
return throttled_fn
end
---Repeatedly call `func` with a fixed time delay.
---@param func function
---@param delay integer # Delay between executions (ms)
---@return Closeable
function M.set_interval(func, delay)
local timer = assert(uv.new_timer())
local ret = {
close = function()
timer:stop()
M.try_close(timer)
end,
}
timer:start(delay, delay, function()
local should_close = func()
if type(should_close) == "boolean" and should_close then
ret.close()
end
end)
return ret
end
---Call `func` after a fixed time delay.
---@param func function
---@param delay integer # Delay until execution (ms)
---@return Closeable
function M.set_timeout(func, delay)
local timer = assert(uv.new_timer())
local ret = {
close = function()
timer:stop()
M.try_close(timer)
end,
}
timer:start(delay, 0, function()
func()
ret.close()
end)
return ret
end
return M

View File

@ -0,0 +1,225 @@
--[[
An implementation of Myers' diff algorithm
Derived from: https://github.com/Swatinem/diff
]]
local oop = require("diffview.oop")
local M = {}
---@enum EditToken
local EditToken = oop.enum({
NOOP = 1,
DELETE = 2,
INSERT = 3,
REPLACE = 4,
})
---@class Diff : diffview.Object
---@operator call : Diff
---@field a any[]
---@field b any[]
---@field moda boolean[]
---@field modb boolean[]
---@field up table<integer, integer>
---@field down table<integer, integer>
---@field eql_fn function
local Diff = oop.create_class("Diff")
---Diff constructor.
---@param a any[]
---@param b any[]
---@param eql_fn function|nil
function Diff:init(a, b, eql_fn)
self.a = a
self.b = b
self.moda = {}
self.modb = {}
self.up = {}
self.down = {}
self.eql_fn = eql_fn or function(aa, bb)
return aa == bb
end
for i = 1, #a do
self.moda[i] = false
end
for i = 1, #b do
self.modb[i] = false
end
self:lcs(1, #self.a + 1, 1, #self.b + 1)
end
---@return EditToken[]
function Diff:create_edit_script()
local astart = 1
local bstart = 1
local aend = #self.moda
local bend = #self.modb
local script = {}
while astart <= aend or bstart <= bend do
if astart <= aend and bstart <= bend then
if not self.moda[astart] and not self.modb[bstart] then
table.insert(script, EditToken.NOOP)
astart = astart + 1
bstart = bstart + 1
goto continue
elseif self.moda[astart] and self.modb[bstart] then
table.insert(script, EditToken.REPLACE)
astart = astart + 1
bstart = bstart + 1
goto continue
end
end
if astart <= aend and (bstart > bend or self.moda[astart]) then
table.insert(script, EditToken.DELETE)
astart = astart + 1
end
if bstart <= bend and (astart > aend or self.modb[bstart]) then
table.insert(script, EditToken.INSERT)
bstart = bstart + 1
end
::continue::
end
return script
end
---@private
---@param astart integer
---@param aend integer
---@param bstart integer
---@param bend integer
function Diff:lcs(astart, aend, bstart, bend)
-- separate common head
while astart < aend and bstart < bend and self.eql_fn(self.a[astart], self.b[bstart]) do
astart = astart + 1
bstart = bstart + 1
end
-- separate common tail
while astart < aend and bstart < bend and self.eql_fn(self.a[aend - 1], self.b[bend - 1]) do
aend = aend - 1
bend = bend - 1
end
if astart == aend then
-- only insertions
while bstart < bend do
self.modb[bstart] = true
bstart = bstart + 1
end
elseif bend == bstart then
-- only deletions
while astart < aend do
self.moda[astart] = true
astart = astart + 1
end
else
local snake = self:snake(astart, aend, bstart, bend)
self:lcs(astart, snake.x, bstart, snake.y)
self:lcs(snake.u, aend, snake.v, bend)
end
end
---@class Diff.Snake
---@field x integer
---@field y integer
---@field u integer
---@field v integer
---@private
---@param astart integer
---@param aend integer
---@param bstart integer
---@param bend integer
---@return Diff.Snake
function Diff:snake(astart, aend, bstart, bend)
local N = aend - astart
local MM = bend - bstart
local kdown = astart - bstart
local kup = aend - bend
local delta = N - MM
local deltaOdd = delta % 2 ~= 0
self.down[kdown + 1] = astart
self.up[kup - 1] = aend
local Dmax = (N + MM) / 2 + 1
for D = 0, Dmax do
local x, y
-- Forward path
for k = kdown - D, kdown + D, 2 do
if k == kdown - D then
x = self.down[k + 1] -- down
else
x = self.down[k - 1] + 1 -- right
if k < kdown + D and self.down[k + 1] >= x then
x = self.down[k + 1] -- down
end
end
y = x - k
while x < aend and y < bend and self.eql_fn(self.a[x], self.b[y]) do
x = x + 1
y = y + 1 -- diagonal
end
self.down[k] = x
if deltaOdd and kup - D < k and k < kup + D and self.up[k] <= self.down[k] then
return {
x = self.down[k],
y = self.down[k] - k,
u = self.up[k],
v = self.up[k] - k,
}
end
end
-- Reverse path
for k = kup - D, kup + D, 2 do
if k == kup + D then
x = self.up[k - 1] -- up
else
x = self.up[k + 1] - 1 -- left
if k > kup - D and self.up[k - 1] < x then
x = self.up[k - 1] -- up
end
end
y = x - k
while x > astart and y > bstart and self.eql_fn(self.a[x - 1], self.b[y - 1]) do
x = x - 1
y = y - 1 -- diagonal
end
self.up[k] = x
if not deltaOdd and kdown - D <= k and k <= kdown + D and self.up[k] <= self.down[k] then
return {
x = self.down[k],
y = self.down[k] - k,
u = self.up[k],
v = self.up[k] - k,
}
end
end
end
error("Unexpected state!")
end
M.EditToken = EditToken
M.Diff = Diff
return M

View File

@ -0,0 +1,237 @@
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local M = {}
---@enum EventName
local EventName = oop.enum({
FILES_STAGED = 1,
})
---@alias ListenerType "normal"|"once"|"any"|"any_once"
---@alias ListenerCallback (fun(e: Event, ...): boolean?)
---@class Listener
---@field type ListenerType
---@field callback ListenerCallback The original callback
---@field call function
---@class Event : diffview.Object
---@operator call : Event
---@field id any
---@field propagate boolean
local Event = oop.create_class("Event")
function Event:init(opt)
self.id = opt.id
self.propagate = true
end
function Event:stop_propagation()
self.propagate = false
end
---@class EventEmitter : diffview.Object
---@operator call : EventEmitter
---@field event_map table<any, Listener[]> # Registered events mapped to subscribed listeners.
---@field any_listeners Listener[] # Listeners subscribed to all events.
---@field emit_lock table<any, boolean>
local EventEmitter = oop.create_class("EventEmitter")
---EventEmitter constructor.
function EventEmitter:init()
self.event_map = {}
self.any_listeners = {}
self.emit_lock = {}
end
---Subscribe to a given event.
---@param event_id any Event identifier.
---@param callback ListenerCallback
function EventEmitter:on(event_id, callback)
if not self.event_map[event_id] then
self.event_map[event_id] = {}
end
table.insert(self.event_map[event_id], 1, {
type = "normal",
callback = callback,
call = function(event, args)
return callback(event, utils.tbl_unpack(args))
end,
})
end
---Subscribe a one-shot listener to a given event.
---@param event_id any Event identifier.
---@param callback ListenerCallback
function EventEmitter:once(event_id, callback)
if not self.event_map[event_id] then
self.event_map[event_id] = {}
end
local emitted = false
table.insert(self.event_map[event_id], 1, {
type = "once",
callback = callback,
call = function(event, args)
if not emitted then
emitted = true
return callback(event, utils.tbl_unpack(args))
end
end,
})
end
---Add a new any-listener, subscribed to all events.
---@param callback ListenerCallback
function EventEmitter:on_any(callback)
table.insert(self.any_listeners, 1, {
type = "any",
callback = callback,
call = function(event, args)
return callback(event, args)
end,
})
end
---Add a new one-shot any-listener, subscribed to all events.
---@param callback ListenerCallback
function EventEmitter:once_any(callback)
local emitted = false
table.insert(self.any_listeners, 1, {
type = "any_once",
callback = callback,
call = function(event, args)
if not emitted then
emitted = true
return callback(event, utils.tbl_unpack(args))
end
end,
})
end
---Unsubscribe a listener. If no event is given, the listener is unsubscribed
---from all events.
---@param callback function
---@param event_id? any Only unsubscribe listeners from this event.
function EventEmitter:off(callback, event_id)
---@type Listener[][]
local all
if event_id then
all = { self.event_map[event_id] }
else
all = utils.vec_join(
vim.tbl_values(self.event_map),
{ self.any_listeners }
)
end
for _, listeners in ipairs(all) do
local remove = {}
for i, listener in ipairs(listeners) do
if listener.callback == callback then
remove[#remove + 1] = i
end
end
for i = #remove, 1, -1 do
table.remove(listeners, remove[i])
end
end
end
---Clear all listeners for a given event. If no event is given: clear all listeners.
---@param event_id any?
function EventEmitter:clear(event_id)
for e, _ in pairs(self.event_map) do
if event_id == nil or event_id == e then
self.event_map[e] = nil
end
end
end
---@param listeners Listener[]
---@param event Event
---@param args table
---@return Listener[]
local function filter_call(listeners, event, args)
listeners = utils.vec_slice(listeners) --[[@as Listener[] ]]
local result = {}
for i = 1, #listeners do
local cur = listeners[i]
local ret = cur.call(event, args)
local discard = (type(ret) == "boolean" and ret)
or cur.type == "once"
or cur.type == "any_once"
if not discard then result[#result + 1] = cur end
if not event.propagate then
for j = i + 1, #listeners do result[j] = listeners[j] end
break
end
end
return result
end
---Notify all listeners subscribed to a given event.
---@param event_id any Event identifier.
---@param ... any Event callback args.
function EventEmitter:emit(event_id, ...)
if not self.emit_lock[event_id] then
local args = utils.tbl_pack(...)
local e = Event({ id = event_id })
if type(self.event_map[event_id]) == "table" then
self.event_map[event_id] = filter_call(self.event_map[event_id], e, args)
end
if e.propagate then
self.any_listeners = filter_call(self.any_listeners, e, args)
end
end
end
---Non-recursively notify all listeners subscribed to a given event.
---@param event_id any Event identifier.
---@param ... any Event callback args.
function EventEmitter:nore_emit(event_id, ...)
if not self.emit_lock[event_id] then
self.emit_lock[event_id] = true
local args = utils.tbl_pack(...)
local e = Event({ id = event_id })
if type(self.event_map[event_id]) == "table" then
self.event_map[event_id] = filter_call(self.event_map[event_id], e, args)
end
if e.propagate then
self.any_listeners = filter_call(self.any_listeners, e, args)
end
self.emit_lock[event_id] = false
end
end
---Get all listeners subscribed to the given event.
---@param event_id any Event identifier.
---@return Listener[]?
function EventEmitter:get(event_id)
return self.event_map[event_id]
end
M.EventName = EventName
M.Event = Event
M.EventEmitter = EventEmitter
return M

View File

@ -0,0 +1,50 @@
local ffi = require("ffi")
local C = ffi.C
local M = setmetatable({}, { __index = ffi })
local HAS_NVIM_0_9 = vim.fn.has("nvim-0.9") == 1
---Check if the |textlock| is active.
---@return boolean
function M.nvim_is_textlocked()
return C.textlock > 0
end
---Check if the nvim API is locked for any reason.
---See: |api-fast|, |textlock|
---@return boolean
function M.nvim_is_locked()
if vim.in_fast_event() then return true end
if HAS_NVIM_0_9 then
return C.textlock > 0 or C.allbuf_lock > 0 or C.expr_map_lock > 0
end
return C.textlock > 0 or C.allbuf_lock > 0 or C.ex_normal_lock > 0
end
ffi.cdef([[
/// Non-zero when changing text and jumping to another window or editing another buffer is not
/// allowed.
extern int textlock;
/// Non-zero when no buffer name can be changed, no buffer can be deleted and
/// current directory can't be changed. Used for SwapExists et al.
extern int allbuf_lock;
]])
if HAS_NVIM_0_9 then
ffi.cdef([[
/// Running expr mapping, prevent use of ex_normal() and text changes
extern int expr_map_lock;
]])
else
ffi.cdef([[
/// prevent use of ex_normal()
extern int ex_normal_lock;
]])
end
return M

View File

@ -0,0 +1,102 @@
local health = vim.health or require("health")
local fmt = string.format
-- Polyfill deprecated health api
if vim.fn.has("nvim-0.10") ~= 1 then
health = {
start = health.report_start,
ok = health.report_ok,
info = health.report_info,
warn = health.report_warn,
error = health.report_error,
}
end
local M = {}
M.plugin_deps = {
{
name = "nvim-web-devicons",
optional = true,
},
}
---@param cmd string|string[]
---@return string[] stdout
---@return integer code
local function system_list(cmd)
local out = vim.fn.systemlist(cmd)
return out or {}, vim.v.shell_error
end
local function lualib_available(name)
local ok, _ = pcall(require, name)
return ok
end
function M.check()
if vim.fn.has("nvim-0.7") == 0 then
health.error("Diffview.nvim requires Neovim 0.7.0+")
end
-- LuaJIT
if not _G.jit then
health.error("Not running on LuaJIT! Non-JIT Lua runtimes are not officially supported by the plugin. Mileage may vary.")
end
health.start("Checking plugin dependencies")
local missing_essential = false
for _, plugin in ipairs(M.plugin_deps) do
if lualib_available(plugin.name) then
health.ok(plugin.name .. " installed.")
else
if plugin.optional then
health.warn(fmt("Optional dependency '%s' not found.", plugin.name))
else
missing_essential = true
health.error(fmt("Dependency '%s' not found!", plugin.name))
end
end
end
health.start("Checking VCS tools")
;(function()
if missing_essential then
health.warn("Cannot perform checks on external dependencies without all essential plugin dependencies installed!")
return
end
health.info("The plugin requires at least one of the supported VCS tools to be valid.")
local has_valid_adapter = false
local adapter_kinds = {
{ class = require("diffview.vcs.adapters.git").GitAdapter, name = "Git" },
{ class = require("diffview.vcs.adapters.hg").HgAdapter, name = "Mercurial" },
}
for _, kind in ipairs(adapter_kinds) do
local bs = kind.class.bootstrap
if not bs.done then kind.class.run_bootstrap() end
if bs.version_string then
health.ok(fmt("%s found.", kind.name))
end
if bs.ok then
health.ok(fmt("%s is up-to-date. (%s)", kind.name, bs.version_string))
has_valid_adapter = true
else
health.warn(bs.err or (kind.name .. ": Unknown error"))
end
end
if not has_valid_adapter then
health.error("No valid VCS tool was found!")
end
end)()
end
return M

View File

@ -0,0 +1,497 @@
local lazy = require("diffview.lazy")
local config = lazy.require("diffview.config") ---@module "diffview.config"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local api = vim.api
local web_devicons
local icon_cache = {}
local M = {}
---@alias hl.HiValue<T> T|"NONE"
---@class hl.HiSpec
---@field fg hl.HiValue<string>
---@field bg hl.HiValue<string>
---@field sp hl.HiValue<string>
---@field style hl.HiValue<string>
---@field ctermfg hl.HiValue<integer>
---@field ctermbg hl.HiValue<integer>
---@field cterm hl.HiValue<string>
---@field blend hl.HiValue<integer>
---@field default hl.HiValue<boolean> Only set values if the hl group is cleared.
---@field link string|-1
---@field explicit boolean All undefined fields will be cleared from the hl group.
---@class hl.HiLinkSpec
---@field force boolean
---@field default boolean
---@field clear boolean
---@class hl.HlData
---@field link string|integer
---@field fg integer Foreground color integer
---@field bg integer Background color integer
---@field sp integer Special color integer
---@field x_fg string Foreground color hex string
---@field x_bg string Bakground color hex string
---@field x_sp string Special color hex string
---@field bold boolean
---@field italic boolean
---@field underline boolean
---@field underlineline boolean
---@field undercurl boolean
---@field underdash boolean
---@field underdot boolean
---@field strikethrough boolean
---@field standout boolean
---@field reverse boolean
---@field blend integer
---@field default boolean
---@alias hl.HlAttrValue integer|boolean
local HAS_NVIM_0_8 = vim.fn.has("nvim-0.8") == 1
local HAS_NVIM_0_9 = vim.fn.has("nvim-0.9") == 1
---@enum HlAttribute
M.HlAttribute = {
fg = 1,
bg = 2,
sp = 3,
x_fg = 4,
x_bg = 5,
x_sp = 6,
bold = 7,
italic = 8,
underline = 9,
underlineline = 10,
undercurl = 11,
underdash = 12,
underdot = 13,
strikethrough = 14,
standout = 15,
reverse = 16,
blend = 17,
}
local style_attrs = {
"bold",
"italic",
"underline",
"underlineline",
"undercurl",
"underdash",
"underdot",
"strikethrough",
"standout",
"reverse",
}
-- NOTE: Some atrtibutes have been renamed in v0.8.0
if HAS_NVIM_0_8 then
M.HlAttribute.underdashed = M.HlAttribute.underdash
M.HlAttribute.underdash = nil
M.HlAttribute.underdotted = M.HlAttribute.underdot
M.HlAttribute.underdot = nil
M.HlAttribute.underdouble = M.HlAttribute.underlineline
M.HlAttribute.underlineline = nil
style_attrs = {
"bold",
"italic",
"underline",
"underdouble",
"undercurl",
"underdashed",
"underdotted",
"strikethrough",
"standout",
"reverse",
}
end
utils.add_reverse_lookup(M.HlAttribute)
utils.add_reverse_lookup(style_attrs)
local hlattr = M.HlAttribute
---@param name string Syntax group name.
---@param no_trans? boolean Don't translate the syntax group (follow links).
---@return hl.HlData?
function M.get_hl(name, no_trans)
local hl
if no_trans then
if HAS_NVIM_0_9 then
hl = api.nvim_get_hl(0, { name = name, link = true })
else
hl = api.nvim__get_hl_defs(0)[name]
end
else
local id = api.nvim_get_hl_id_by_name(name)
if id then
if HAS_NVIM_0_9 then
hl = api.nvim_get_hl(0, { id = id, link = false })
else
hl = api.nvim_get_hl_by_id(id, true)
end
end
end
if hl then
if not HAS_NVIM_0_9 then
-- Handle renames
if hl.foreground then hl.fg = hl.foreground; hl.foreground = nil end
if hl.background then hl.bg = hl.background; hl.background = nil end
if hl.special then hl.sp = hl.special; hl.special = nil end
end
if hl.fg then hl.x_fg = string.format("#%06x", hl.fg) end
if hl.bg then hl.x_bg = string.format("#%06x", hl.bg) end
if hl.sp then hl.x_sp = string.format("#%06x", hl.sp) end
return hl
end
end
---@param name string Syntax group name.
---@param attr HlAttribute|string Attribute kind.
---@param no_trans? boolean Don't translate the syntax group (follow links).
---@return hl.HlAttrValue?
function M.get_hl_attr(name, attr, no_trans)
local hl = M.get_hl(name, no_trans)
if type(attr) == "string" then attr = hlattr[attr] end
if not (hl and attr) then return end
return hl[hlattr[attr]]
end
---@param groups string|string[] Syntax group name, or an ordered list of
---groups where the first found value will be returned.
---@param no_trans? boolean Don't translate the syntax group (follow links).
---@return string?
function M.get_fg(groups, no_trans)
no_trans = not not no_trans
if type(groups) ~= "table" then groups = { groups } end
for _, group in ipairs(groups) do
local v = M.get_hl_attr(group, hlattr.x_fg, no_trans) --[[@as string? ]]
if v then return v end
end
end
---@param groups string|string[] Syntax group name, or an ordered list of
---groups where the first found value will be returned.
---@param no_trans? boolean Don't translate the syntax group (follow links).
---@return string?
function M.get_bg(groups, no_trans)
no_trans = not not no_trans
if type(groups) ~= "table" then groups = { groups } end
for _, group in ipairs(groups) do
local v = M.get_hl_attr(group, hlattr.x_bg, no_trans) --[[@as string? ]]
if v then return v end
end
end
---@param groups string|string[] Syntax group name, or an ordered list of
---groups where the first found value will be returned.
---@param no_trans? boolean Don't translate the syntax group (follow links).
---@return string?
function M.get_style(groups, no_trans)
no_trans = not not no_trans
if type(groups) ~= "table" then groups = { groups } end
for _, group in ipairs(groups) do
local hl = M.get_hl(group, no_trans)
if hl then
local res = {}
for _, attr in ipairs(style_attrs) do
if hl[attr] then table.insert(res, attr)
end
if #res > 0 then
return table.concat(res, ",")
end
end
end
end
end
---@param spec hl.HiSpec
---@return hl.HlData
function M.hi_spec_to_def_map(spec)
---@type hl.HlData
local res = {}
local fields = { "fg", "bg", "sp", "ctermfg", "ctermbg", "default", "link" }
for _, field in ipairs(fields) do
res[field] = spec[field]
end
if spec.style then
local spec_attrs = utils.add_reverse_lookup(vim.split(spec.style, ","))
for _, attr in ipairs(style_attrs) do
res[attr] = spec_attrs[attr] ~= nil
end
end
return res
end
---@param groups string|string[] Syntax group name or a list of group names.
---@param opt hl.HiSpec
function M.hi(groups, opt)
if type(groups) ~= "table" then groups = { groups } end
for _, group in ipairs(groups) do
local def_spec
if opt.explicit then
def_spec = M.hi_spec_to_def_map(opt)
else
def_spec = M.hi_spec_to_def_map(
vim.tbl_extend("force", M.get_hl(group, true) or {}, opt)
)
end
for k, v in pairs(def_spec) do
if v == "NONE" then
def_spec[k] = nil
end
end
if not HAS_NVIM_0_9 and def_spec.link then
-- Pre 0.9 `nvim_set_hl()` could not set other attributes in combination
-- with `link`. Furthermore, setting non-link attributes would clear the
-- link, but this does *not* happen if you set the other attributes first
-- (???). However, if the value of `link` is `-1`, the group will be
-- cleared regardless (?????).
local link = def_spec.link
def_spec.link = nil
if not def_spec.default then
api.nvim_set_hl(0, group, def_spec)
end
if link ~= -1 then
api.nvim_set_hl(0, group, { link = link, default = def_spec.default })
end
else
api.nvim_set_hl(0, group, def_spec)
end
end
end
---@param from string|string[] Syntax group name or a list of group names.
---@param to? string Syntax group name. (default: `"NONE"`)
---@param opt? hl.HiLinkSpec
function M.hi_link(from, to, opt)
if to and tostring(to):upper() == "NONE" then
---@diagnostic disable-next-line: cast-local-type
to = -1
end
opt = vim.tbl_extend("keep", opt or {}, {
force = true,
}) --[[@as hl.HiLinkSpec ]]
if type(from) ~= "table" then from = { from } end
for _, f in ipairs(from) do
if opt.clear then
if not HAS_NVIM_0_9 then
-- Pre 0.9 `nvim_set_hl()` did not clear other attributes when `link` was set.
api.nvim_set_hl(0, f, {})
end
api.nvim_set_hl(0, f, { default = opt.default, link = to })
else
-- When `clear` is not set; use our `hi()` function such that other
-- attributes are not affected.
M.hi(f, { default = opt.default, link = to })
end
end
end
---Clear highlighting for a given syntax group, or all groups if no group is
---given.
---@param groups? string|string[]
function M.hi_clear(groups)
if not groups then
vim.cmd("hi clear")
return
end
if type(groups) ~= "table" then
groups = { groups }
end
for _, g in ipairs(groups) do
api.nvim_set_hl(0, g, {})
end
end
function M.get_file_icon(name, ext, render_data, line_idx, offset)
if not config.get_config().use_icons then return "" end
if not web_devicons then
local ok
ok, web_devicons = pcall(require, "nvim-web-devicons")
if not ok then
config.get_config().use_icons = false
utils.warn(
"nvim-web-devicons is required to use file icons! "
.. "Set `use_icons = false` in your config to stop seeing this message."
)
return ""
end
end
local icon, hl
local icon_key = (name or "") .. "|&|" .. (ext or "")
if icon_cache[icon_key] then
icon, hl = unpack(icon_cache[icon_key])
else
icon, hl = web_devicons.get_icon(name, ext, { default = true })
icon_cache[icon_key] = { icon, hl }
end
if icon then
if hl and render_data then
render_data:add_hl(hl, line_idx, offset, offset + string.len(icon) + 1)
end
return icon .. " ", hl
end
return ""
end
local git_status_hl_map = {
["A"] = "DiffviewStatusAdded",
["?"] = "DiffviewStatusUntracked",
["M"] = "DiffviewStatusModified",
["R"] = "DiffviewStatusRenamed",
["C"] = "DiffviewStatusCopied",
["T"] = "DiffviewStatusTypeChanged",
["U"] = "DiffviewStatusUnmerged",
["X"] = "DiffviewStatusUnknown",
["D"] = "DiffviewStatusDeleted",
["B"] = "DiffviewStatusBroken",
["!"] = "DiffviewStatusIgnored",
}
function M.get_git_hl(status)
return git_status_hl_map[status]
end
function M.get_colors()
return {
white = M.get_fg("Normal") or "White",
red = M.get_fg("Keyword") or "Red",
green = M.get_fg("Character") or "Green",
yellow = M.get_fg("PreProc") or "Yellow",
blue = M.get_fg("Include") or "Blue",
purple = M.get_fg("Define") or "Purple",
cyan = M.get_fg("Conditional") or "Cyan",
dark_red = M.get_fg("Keyword") or "DarkRed",
orange = M.get_fg("Number") or "Orange",
}
end
function M.get_hl_groups()
local colors = M.get_colors()
return {
FilePanelTitle = { fg = M.get_fg("Label") or colors.blue, style = "bold" },
FilePanelCounter = { fg = M.get_fg("Identifier") or colors.purple, style = "bold" },
FilePanelFileName = { fg = M.get_fg("Normal") or colors.white },
Dim1 = { fg = M.get_fg("Comment") or colors.white },
Primary = { fg = M.get_fg("Function") or "Purple" },
Secondary = { fg = M.get_fg("String") or "Orange" },
}
end
M.hl_links = {
Normal = "Normal",
NonText = "NonText",
CursorLine = "CursorLine",
WinSeparator = "WinSeparator",
SignColumn = "Normal",
StatusLine = "StatusLine",
StatusLineNC = "StatusLineNC",
EndOfBuffer = "EndOfBuffer",
FilePanelRootPath = "DiffviewFilePanelTitle",
FilePanelFileName = "Normal",
FilePanelSelected = "Type",
FilePanelPath = "Comment",
FilePanelInsertions = "diffAdded",
FilePanelDeletions = "diffRemoved",
FilePanelConflicts = "DiagnosticSignWarn",
FolderName = "Directory",
FolderSign = "PreProc",
Hash = "Identifier",
Reference = "Function",
ReflogSelector = "Special",
StatusAdded = "diffAdded",
StatusUntracked = "diffAdded",
StatusModified = "diffChanged",
StatusRenamed = "diffChanged",
StatusCopied = "diffChanged",
StatusTypeChange = "diffChanged",
StatusUnmerged = "diffChanged",
StatusUnknown = "diffRemoved",
StatusDeleted = "diffRemoved",
StatusBroken = "diffRemoved",
StatusIgnored = "Comment",
DiffAdd = "DiffAdd",
DiffDelete = "DiffDelete",
DiffChange = "DiffChange",
DiffText = "DiffText",
}
function M.update_diff_hl()
local fg = M.get_fg("DiffDelete", true) or "NONE"
local bg = M.get_bg("DiffDelete", true) or "NONE"
local style = M.get_style("DiffDelete", true) or "NONE"
M.hi("DiffviewDiffAddAsDelete", { fg = fg, bg = bg, style = style })
M.hi_link("DiffviewDiffDeleteDim", "Comment", { default = true })
if config.get_config().enhanced_diff_hl then
M.hi_link("DiffviewDiffDelete", "DiffviewDiffDeleteDim")
end
end
function M.setup()
for name, v in pairs(M.get_hl_groups()) do
v = vim.tbl_extend("force", v, { default = true })
M.hi("Diffview" .. name, v)
end
for from, to in pairs(M.hl_links) do
M.hi_link("Diffview" .. from, to, { default = true })
end
M.update_diff_hl()
end
return M

View File

@ -0,0 +1,279 @@
if not require("diffview.bootstrap") then
return
end
local hl = require("diffview.hl")
local lazy = require("diffview.lazy")
local arg_parser = lazy.require("diffview.arg_parser") ---@module "diffview.arg_parser"
local config = lazy.require("diffview.config") ---@module "diffview.config"
local lib = lazy.require("diffview.lib") ---@module "diffview.lib"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local vcs = lazy.require("diffview.vcs") ---@module "diffview.vcs"
local api = vim.api
local logger = DiffviewGlobal.logger
local pl = lazy.access(utils, "path") ---@type PathLib
local M = {}
function M.setup(user_config)
config.setup(user_config or {})
end
function M.init()
-- Fix the strange behavior that "<afile>" expands non-files
-- as file name in some cases.
--
-- Ref:
-- * sindrets/diffview.nvim#369
-- * neovim/neovim#23943
local function get_tabnr(state)
if vim.fn.has("nvim-0.9.2") ~= 1 then
return tonumber(state.match)
else
return tonumber(state.file)
end
end
local au = api.nvim_create_autocmd
-- Set up highlighting
hl.setup()
-- Set up autocommands
M.augroup = api.nvim_create_augroup("diffview_nvim", {})
au("TabEnter", {
group = M.augroup,
pattern = "*",
callback = function(_)
M.emit("tab_enter")
end,
})
au("TabLeave", {
group = M.augroup,
pattern = "*",
callback = function(_)
M.emit("tab_leave")
end,
})
au("TabClosed", {
group = M.augroup,
pattern = "*",
callback = function(state)
M.close(get_tabnr(state))
end,
})
au("BufWritePost", {
group = M.augroup,
pattern = "*",
callback = function(_)
M.emit("buf_write_post")
end,
})
au("WinClosed", {
group = M.augroup,
pattern = "*",
callback = function(state)
M.emit("win_closed", get_tabnr(state))
end,
})
au("ColorScheme", {
group = M.augroup,
pattern = "*",
callback = function(_)
M.update_colors()
end,
})
au("User", {
group = M.augroup,
pattern = "FugitiveChanged",
callback = function(_)
M.emit("refresh_files")
end,
})
-- Set up user autocommand emitters
DiffviewGlobal.emitter:on("view_opened", function(_)
api.nvim_exec_autocmds("User", { pattern = "DiffviewViewOpened", modeline = false })
end)
DiffviewGlobal.emitter:on("view_closed", function(_)
api.nvim_exec_autocmds("User", { pattern = "DiffviewViewClosed", modeline = false })
end)
DiffviewGlobal.emitter:on("view_enter", function(_)
api.nvim_exec_autocmds("User", { pattern = "DiffviewViewEnter", modeline = false })
end)
DiffviewGlobal.emitter:on("view_leave", function(_)
api.nvim_exec_autocmds("User", { pattern = "DiffviewViewLeave", modeline = false })
end)
DiffviewGlobal.emitter:on("view_post_layout", function(_)
api.nvim_exec_autocmds("User", { pattern = "DiffviewViewPostLayout", modeline = false })
end)
DiffviewGlobal.emitter:on("diff_buf_read", function(_)
api.nvim_exec_autocmds("User", { pattern = "DiffviewDiffBufRead", modeline = false })
end)
DiffviewGlobal.emitter:on("diff_buf_win_enter", function(_)
api.nvim_exec_autocmds("User", { pattern = "DiffviewDiffBufWinEnter", modeline = false })
end)
-- Set up completion wrapper used by `vim.ui.input()`
vim.cmd([[
function! Diffview__ui_input_completion(...) abort
return luaeval("DiffviewGlobal.state.current_completer(
\ unpack(vim.fn.eval('a:000')))")
endfunction
]])
end
---@param args string[]
function M.open(args)
local view = lib.diffview_open(args)
if view then
view:open()
end
end
---@param range? { [1]: integer, [2]: integer }
---@param args string[]
function M.file_history(range, args)
local view = lib.file_history(range, args)
if view then
view:open()
end
end
function M.close(tabpage)
if tabpage then
vim.schedule(function()
lib.dispose_stray_views()
end)
return
end
local view = lib.get_current_view()
if view then
view:close()
lib.dispose_view(view)
end
end
function M.completion(_, cmd_line, cur_pos)
local ctx = arg_parser.scan(cmd_line, { cur_pos = cur_pos, allow_ex_range = true })
local cmd = ctx.args[1]
if cmd and M.completers[cmd] then
return arg_parser.process_candidates(M.completers[cmd](ctx), ctx)
end
end
---Create a temporary adapter to get relevant completions
---@return VCSAdapter?
function M.get_adapter()
local cfile = pl:vim_expand("%")
local top_indicators = utils.vec_join(
vim.bo.buftype == ""
and pl:absolute(cfile)
or nil,
pl:realpath(".")
)
local err, adapter = vcs.get_adapter({ top_indicators = top_indicators })
if err then
logger:warn("[completion] Failed to create adapter: " .. err)
end
return adapter
end
M.completers = {
---@param ctx CmdLineContext
DiffviewOpen = function(ctx)
local has_rev_arg = false
local adapter = M.get_adapter()
for i = 2, math.min(#ctx.args, ctx.divideridx) do
if ctx.args[i]:sub(1, 1) ~= "-" and i ~= ctx.argidx then
has_rev_arg = true
break
end
end
local candidates = {}
if ctx.argidx > ctx.divideridx then
if adapter then
utils.vec_push(candidates, unpack(adapter:path_candidates(ctx.arg_lead)))
else
utils.vec_push(candidates, unpack(vim.fn.getcompletion(ctx.arg_lead, "file", 0)))
end
elseif adapter then
if not has_rev_arg and ctx.arg_lead:sub(1, 1) ~= "-" then
utils.vec_push(candidates, unpack(adapter.comp.open:get_all_names()))
utils.vec_push(candidates, unpack(adapter:rev_candidates(ctx.arg_lead, {
accept_range = true,
})))
else
utils.vec_push(candidates, unpack(
adapter.comp.open:get_completion(ctx.arg_lead)
or adapter.comp.open:get_all_names()
))
end
end
return candidates
end,
---@param ctx CmdLineContext
DiffviewFileHistory = function(ctx)
local adapter = M.get_adapter()
local candidates = {}
if adapter then
utils.vec_push(candidates, unpack(
adapter.comp.file_history:get_completion(ctx.arg_lead)
or adapter.comp.file_history:get_all_names()
))
utils.vec_push(candidates, unpack(adapter:path_candidates(ctx.arg_lead)))
else
utils.vec_push(candidates, unpack(vim.fn.getcompletion(ctx.arg_lead, "file", 0)))
end
return candidates
end,
}
function M.update_colors()
hl.setup()
lib.update_colors()
end
local function _emit(no_recursion, event_name, ...)
local view = lib.get_current_view()
if view and not view.closing:check() then
local that = view.emitter
local fn = no_recursion and that.nore_emit or that.emit
fn(that, event_name, ...)
that = DiffviewGlobal.emitter
fn = no_recursion and that.nore_emit or that.emit
if event_name == "tab_enter" then
fn(that, "view_enter", view)
elseif event_name == "tab_leave" then
fn(that, "view_leave", view)
end
end
end
function M.emit(event_name, ...)
_emit(false, event_name, ...)
end
function M.nore_emit(event_name, ...)
_emit(true, event_name, ...)
end
M.init()
return M

View File

@ -0,0 +1,546 @@
---@diagnostic disable: invisible
local oop = require("diffview.oop")
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local await = async.await
local fmt = string.format
local logger = DiffviewGlobal.logger
local uv = vim.loop
local M = {}
---@alias diffview.Job.OnOutCallback fun(err?: string, line: string, j: diffview.Job)
---@alias diffview.Job.OnExitCallback fun(j: diffview.Job, success: boolean, err?: string)
---@alias diffview.Job.OnRetryCallback fun(j: diffview.Job)
---@alias diffview.Job.FailCond fun(j: diffview.Job): boolean, string
---@alias StdioKind "in"|"out"|"err"
---@class diffview.Job : Waitable
---@operator call: diffview.Job
---@field command string
---@field args string[]
---@field cwd string
---@field retry integer
---@field check_status diffview.Job.FailCond
---@field log_opt Logger.log_job.Opt
---@field writer string|string[]
---@field env string[]
---@field stdout string[]
---@field stderr string[]
---@field handle uv_process_t
---@field pid integer
---@field code integer
---@field signal integer
---@field p_out uv_pipe_t
---@field p_err uv_pipe_t
---@field p_in? uv_pipe_t
---@field buffered_std boolean
---@field on_stdout_listeners diffview.Job.OnOutCallback[]
---@field on_stderr_listeners diffview.Job.OnOutCallback[]
---@field on_exit_listeners diffview.Job.OnExitCallback[]
---@field on_retry_listeners diffview.Job.OnRetryCallback[]
---@field _started boolean
---@field _done boolean
---@field _retry_count integer
local Job = oop.create_class("Job", async.Waitable)
local function prepare_env(env)
local ret = {}
for k, v in pairs(env) do
table.insert(ret, k .. "=" .. v)
end
return ret
end
---Predefined fail conditions.
Job.FAIL_COND = {
---Fail on all non-zero exit codes.
---@param j diffview.Job
non_zero = function(j)
return j.code == 0, fmt("Job exited with a non-zero exit code: %d", j.code)
end,
---Fail if there's no data in stdout.
---@param j diffview.Job
on_empty = function(j)
local msg = fmt("Job expected output, but returned nothing! Code: %d", j.code)
local n = #j.stdout
if n == 0 or (n == 1 and j.stdout[1] == "") then return false, msg end
return true
end,
}
function Job:init(opt)
self.command = opt.command
self.args = opt.args
self.cwd = opt.cwd
self.env = opt.env and prepare_env(opt.env) or prepare_env(uv.os_environ())
self.retry = opt.retry or 0
self.writer = opt.writer
self.buffered_std = utils.sate(opt.buffered_std, true)
self.on_stdout_listeners = {}
self.on_stderr_listeners = {}
self.on_exit_listeners = {}
self.on_retry_listeners = {}
self._started = false
self._done = false
self._retry_count = 0
self.log_opt = vim.tbl_extend("keep", opt.log_opt or {}, {
func = "debug",
no_stdout = true,
debuginfo = debug.getinfo(3, "Sl"),
})
if opt.fail_cond then
if type(opt.fail_cond) == "string" then
self.check_status = Job.FAIL_COND[opt.fail_cond]
assert(self.check_status, fmt("Unknown fail condition: '%s'", opt.fail_cond))
elseif type(opt.fail_cond) == "function" then
self.check_status = opt.fail_cond
else
error("Invalid fail condition: " .. vim.inspect(opt.fail_cond))
end
else
self.check_status = Job.FAIL_COND.non_zero
end
if opt.on_stdout then self:on_stdout(opt.on_stdout) end
if opt.on_stderr then self:on_stderr(opt.on_stderr) end
if opt.on_exit then self:on_exit(opt.on_exit) end
if opt.on_retry then self:on_retry(opt.on_retry) end
end
---@param ... uv_handle_t
local function try_close(...)
local args = { ... }
for i = 1, select("#", ...) do
local handle = args[i]
if handle and not handle:is_closing() then
handle:close()
end
end
end
---@param chunks string[]
---@return string[] lines
local function process_chunks(chunks)
local data = table.concat(chunks)
if data == "" then
return {}
end
local has_eof = data:sub(-1) == "\n"
local ret = vim.split(data, "\r?\n")
if has_eof then ret[#ret] = nil end
return ret
end
---@private
---@param pipe uv_pipe_t
---@param out string[]
---@param err? string
---@param data? string
function Job:buffered_reader(pipe, out, err, data)
if err then
logger:error("[Job:buffered_reader()] " .. err)
end
if data then
out[#out + 1] = data
else
try_close(pipe)
end
end
---@private
---@param pipe uv_pipe_t
---@param out string[]
---@param line_listeners? diffview.Job.OnOutCallback[]
function Job:line_reader(pipe, out, line_listeners)
local line_buffer
---@param err? string
---@param data? string
return function (err, data)
if err then
logger:error("[Job:line_reader()] " .. err)
end
if data then
local has_eol = data:sub(-1) == "\n"
local lines = vim.split(data, "\r?\n")
lines[1] = (line_buffer or "") .. lines[1]
line_buffer = nil
for i, line in ipairs(lines) do
if not has_eol and i == #lines then
line_buffer = line
else
out[#out+1] = line
if line_listeners then
for _, listener in ipairs(line_listeners) do
listener(nil, line, self)
end
end
end
end
else
if line_buffer then
out[#out+1] = line_buffer
if line_listeners then
for _, listener in ipairs(line_listeners) do
listener(nil, line_buffer, self)
end
end
end
try_close(pipe)
end
end
end
---@private
---@param pipe uv_pipe_t
---@param out string[]
---@param kind StdioKind
function Job:handle_reader(pipe, out, kind)
if self.buffered_std then
pipe:read_start(utils.bind(self.buffered_reader, self, pipe, out))
else
local listeners = ({
out = self.on_stdout_listeners,
err = self.on_stderr_listeners,
})[kind] or {}
pipe:read_start(self:line_reader(pipe, out, listeners))
end
end
---@private
---@param pipe uv_pipe_t
---@param data string|string[]
function Job:handle_writer(pipe, data)
if type(data) == "string" then
if data:sub(-1) ~= "\n" then data = data .. "\n" end
pipe:write(data, function(err)
if err then
logger:error("[Job:handle_writer()] " .. err)
end
try_close(pipe)
end)
else
---@cast data string[]
local c = #data
for i, s in ipairs(data) do
if i ~= c then
pipe:write(s .. "\n")
else
pipe:write(s .. "\n", function(err)
if err then
logger:error("[Job:handle_writer()] " .. err)
end
try_close(pipe)
end)
end
end
end
end
---@private
function Job:reset()
try_close(self.handle, self.p_out, self.p_err, self.p_in)
self.handle = nil
self.p_out = nil
self.p_err = nil
self.p_in = nil
self.stdout = {}
self.stderr = {}
self.pid = nil
self.code = nil
self.signal = nil
self._started = false
self._done = false
end
---@param self diffview.Job
---@param callback fun(success: boolean, err?: string)
Job.start = async.wrap(function(self, callback)
self:reset()
self.p_out = uv.new_pipe(false)
self.p_err = uv.new_pipe(false)
assert(self.p_out and self.p_err, "Failed to create pipes!")
if self.writer then
self.p_in = uv.new_pipe(false)
assert(self.p_in, "Failed to create pipes!")
end
self._started = true
local handle, pid
handle, pid = uv.spawn(self.command, {
args = self.args,
stdio = { self.p_in, self.p_out, self.p_err },
cwd = self.cwd,
env = self.env,
hide = true,
},
function(code, signal)
---@cast handle -?
handle:close()
self.p_out:read_stop()
self.p_err:read_stop()
if not self.code then self.code = code end
if not self.signal then self.signal = signal end
try_close(self.p_out, self.p_err, self.p_in)
if self.buffered_std then
self.stdout = process_chunks(self.stdout)
self.stderr = process_chunks(self.stderr)
end
---@type boolean, string?
local ok, err = self:is_success()
local log = not self.log_opt.silent and logger or logger.mock --[[@as Logger ]]
if not ok then
log:error(err)
log:log_job(self, { func = "error", no_stdout = true, debuginfo = self.log_opt.debuginfo })
if self.retry > 0 then
if self._retry_count < self.retry then
self:do_retry(callback)
return
else
log:error("All retries failed!")
end
end
else
if self._retry_count > 0 then
log:info("Retry was successful!")
end
log:log_job(self, self.log_opt)
end
self._retry_count = 0
self._done = true
for _, listener in ipairs(self.on_exit_listeners) do
listener(self, ok, err)
end
callback(ok, err)
end)
if not handle then
try_close(self.p_out, self.p_err, self.p_in)
error("Failed to spawn job!")
end
self.handle = handle
self.pid = pid
self:handle_reader(self.p_out, self.stdout, "out")
self:handle_reader(self.p_err, self.stderr, "err")
if self.p_in then
self:handle_writer(self.p_in, self.writer)
end
end)
---@private
---@param self diffview.Job
---@param callback function
Job.do_retry = async.void(function(self, callback)
self._retry_count = self._retry_count + 1
if not self.log_opt.silent then
logger:fmt_warn("(%d/%d) Retrying job...", self._retry_count, self.retry)
end
await(async.timeout(1))
for _, listener in ipairs(self.on_retry_listeners) do
listener(self)
end
self:start(callback)
end)
---@param self diffview.Job
---@param timeout? integer # Max duration (ms) (default: 30_000)
---@return boolean success
---@return string? err
function Job:sync(timeout)
if not self:is_started() then
self:start()
end
await(async.scheduler())
if self:is_done() then
return self:is_success()
end
local ok, status = vim.wait(timeout or (30 * 1000), function()
return self:is_done()
end, 1)
await(async.scheduler())
if not ok then
if status == -1 then
error("Synchronous job timed out!")
elseif status == -2 then
error("Synchronous job got interrupted!")
end
return false, "Unexpected state"
end
return self:is_success()
end
---@param code integer
---@param signal? integer|uv.aliases.signals # (default: "sigterm")
---@return 0? success
---@return string? err_name
---@return string? err_msg
function Job:kill(code, signal)
if not self.handle then return 0 end
if not self.handle:is_closing() then
self.code = code
self.signal = signal
return self.handle:kill(signal or "sigterm")
end
return 0
end
---@override
---@param self diffview.Job
---@param callback fun(success: boolean, err?: string)
Job.await = async.sync_wrap(function(self, callback)
if self:is_done() then
callback(self:is_success())
elseif self:is_running() then
self:on_exit(function(_, ...) callback(...) end)
else
callback(await(self:start()))
end
end)
---@param jobs diffview.Job[]
function Job.start_all(jobs)
for _, job in ipairs(jobs) do
job:start()
end
end
---@param jobs diffview.Job[]
---@param callback fun(success: boolean, errors?: string[])
Job.join = async.wrap(function(jobs, callback)
-- Start by ensuring all jobs are running
for _, job in ipairs(jobs) do
if not job:is_started() then
job:start()
end
end
local success, errors = true, {}
for _, job in ipairs(jobs) do
local ok, err = await(job)
if not ok then
success = false
errors[#errors + 1] = err
end
end
callback(success, not success and errors or nil)
end)
---@param jobs diffview.Job[]
Job.chain = async.void(function(jobs)
for _, job in ipairs(jobs) do
await(job)
end
end)
---Subscribe to stdout data. Only used if `buffered_std=false`.
---@param callback diffview.Job.OnOutCallback
function Job:on_stdout(callback)
table.insert(self.on_stdout_listeners, callback)
if not self:is_started() then
self.buffered_std = false
end
end
---Subscribe to stderr data. Only used if `buffered_std=false`.
---@param callback diffview.Job.OnOutCallback
function Job:on_stderr(callback)
table.insert(self.on_stderr_listeners, callback)
if not self:is_running() then
self.buffered_std = false
end
end
---@param callback diffview.Job.OnExitCallback
function Job:on_exit(callback)
table.insert(self.on_exit_listeners, callback)
end
---@param callback diffview.Job.OnRetryCallback
function Job:on_retry(callback)
table.insert(self.on_retry_listeners, callback)
end
---@return boolean success
---@return string? err
function Job:is_success()
local ok, err = self:check_status()
if not ok then return false, err end
return true
end
function Job:is_done()
return self._done
end
function Job:is_started()
return self._started
end
function Job:is_running()
return self:is_started() and not self:is_done()
end
M.Job = Job
return M

View File

@ -0,0 +1,125 @@
local fmt = string.format
local lazy = {}
---@class LazyModule : { [string] : unknown }
---@field __get fun(): unknown Load the module if needed, and return it.
---@field __loaded boolean Indicates that the module has been loaded.
---Create a table the triggers a given handler every time it's accessed or
---called, until the handler returns a table. Once the handler has returned a
---table, any subsequent accessing of the wrapper will instead access the table
---returned from the handler.
---@param t any
---@param handler fun(t: any): table?
---@return LazyModule
function lazy.wrap(t, handler)
local export
local ret = {
__get = function()
if export == nil then
---@cast handler function
export = handler(t)
end
return export
end,
__loaded = function()
return export ~= nil
end,
}
return setmetatable(ret, {
__index = function(_, key)
if export == nil then ret.__get() end
---@cast export table
return export[key]
end,
__newindex = function(_, key, value)
if export == nil then ret.__get() end
export[key] = value
end,
__call = function(_, ...)
if export == nil then ret.__get() end
---@cast export table
return export(...)
end,
})
end
---Will only require the module after first either indexing, or calling it.
---
---You can pass a handler function to process the module in some way before
---returning it. This is useful i.e. if you're trying to require the result of
---an exported function.
---
---Example:
---
---```lua
--- local foo = require("bar")
--- local foo = lazy.require("bar")
---
--- local foo = require("bar").baz({ qux = true })
--- local foo = lazy.require("bar", function(module)
--- return module.baz({ qux = true })
--- end)
---```
---@param require_path string
---@param handler? fun(module: any): any
---@return LazyModule
function lazy.require(require_path, handler)
local use_handler = type(handler) == "function"
return lazy.wrap(require_path, function(s)
if use_handler then
---@cast handler function
return handler(require(s))
end
return require(s)
end)
end
---Lazily access a table value. If `x` is a string, it's treated as a lazy
---require.
---
---Example:
---
---```lua
--- -- table:
--- local foo = bar.baz.qux.quux
--- local foo = lazy.access(bar, "baz.qux.quux")
--- local foo = lazy.access(bar, { "baz", "qux", "quux" })
---
--- -- require:
--- local foo = require("bar").baz.qux.quux
--- local foo = lazy.access("bar", "baz.qux.quux")
--- local foo = lazy.access("bar", { "baz", "qux", "quux" })
---```
---@param x table|string Either the table to be accessed, or a module require path.
---@param access_path string|string[] Either a `.` separated string of table keys, or a list.
---@return LazyModule
function lazy.access(x, access_path)
local keys = type(access_path) == "table"
and access_path
or vim.split(access_path --[[@as string ]], ".", { plain = true })
local handler = function(module)
local export = module
for _, key in ipairs(keys) do
export = export[key]
assert(export ~= nil, fmt("Failed to lazy-access! No key '%s' in table!", key))
end
return export
end
if type(x) == "string" then
return lazy.require(x, handler)
else
return lazy.wrap(x, handler)
end
end
return lazy

View File

@ -0,0 +1,233 @@
local lazy = require("diffview.lazy")
local DiffView = lazy.access("diffview.scene.views.diff.diff_view", "DiffView") ---@type DiffView|LazyModule
local FileHistoryView = lazy.access("diffview.scene.views.file_history.file_history_view", "FileHistoryView") ---@type FileHistoryView|LazyModule
local StandardView = lazy.access("diffview.scene.views.standard.standard_view", "StandardView") ---@type StandardView|LazyModule
local arg_parser = lazy.require("diffview.arg_parser") ---@module "diffview.arg_parser"
local config = lazy.require("diffview.config") ---@module "diffview.config"
local vcs = lazy.require("diffview.vcs") ---@module "diffview.vcs"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local api = vim.api
local logger = DiffviewGlobal.logger
local M = {}
---@type View[]
M.views = {}
function M.diffview_open(args)
local default_args = config.get_config().default_args.DiffviewOpen
local argo = arg_parser.parse(utils.flatten({ default_args, args }))
local rev_arg = argo.args[1]
logger:info("[command call] :DiffviewOpen " .. table.concat(utils.flatten({
default_args,
args,
}), " "))
local err, adapter = vcs.get_adapter({
cmd_ctx = {
path_args = argo.post_args,
cpath = argo:get_flag("C", { no_empty = true, expand = true }),
},
})
if err then
utils.err(err)
return
end
---@cast adapter -?
local opts = adapter:diffview_options(argo)
if opts == nil then
return
end
local v = DiffView({
adapter = adapter,
rev_arg = rev_arg,
path_args = adapter.ctx.path_args,
left = opts.left,
right = opts.right,
options = opts.options,
})
if not v:is_valid() then
return
end
table.insert(M.views, v)
logger:debug("DiffView instantiation successful!")
return v
end
---@param range? { [1]: integer, [2]: integer }
---@param args string[]
function M.file_history(range, args)
local default_args = config.get_config().default_args.DiffviewFileHistory
local argo = arg_parser.parse(utils.flatten({ default_args, args }))
logger:info("[command call] :DiffviewFileHistory " .. table.concat(utils.flatten({
default_args,
args,
}), " "))
local err, adapter = vcs.get_adapter({
cmd_ctx = {
path_args = argo.args,
cpath = argo:get_flag("C", { no_empty = true, expand = true }),
},
})
if err then
utils.err(err)
return
end
---@cast adapter -?
local log_options = adapter:file_history_options(range, adapter.ctx.path_args, argo)
if log_options == nil then
return
end
local v = FileHistoryView({
adapter = adapter,
log_options = log_options,
})
if not v:is_valid() then
return
end
table.insert(M.views, v)
logger:debug("FileHistoryView instantiation successful!")
return v
end
---@param view View
function M.add_view(view)
table.insert(M.views, view)
end
---@param view View
function M.dispose_view(view)
for j, v in ipairs(M.views) do
if v == view then
table.remove(M.views, j)
return
end
end
end
---Close and dispose of views that have no tabpage.
function M.dispose_stray_views()
local tabpage_map = {}
for _, id in ipairs(api.nvim_list_tabpages()) do
tabpage_map[id] = true
end
local dispose = {}
for _, view in ipairs(M.views) do
if not tabpage_map[view.tabpage] then
-- Need to schedule here because the tabnr's don't update fast enough.
vim.schedule(function()
view:close()
end)
table.insert(dispose, view)
end
end
for _, view in ipairs(dispose) do
M.dispose_view(view)
end
end
---Get the currently open Diffview.
---@return View?
function M.get_current_view()
local tabpage = api.nvim_get_current_tabpage()
for _, view in ipairs(M.views) do
if view.tabpage == tabpage then
return view
end
end
return nil
end
function M.tabpage_to_view(tabpage)
for _, view in ipairs(M.views) do
if view.tabpage == tabpage then
return view
end
end
end
---Get the first tabpage that is not a view. Tries the previous tabpage first.
---If there are no non-view tabpages: returns nil.
---@return number|nil
function M.get_prev_non_view_tabpage()
local tabs = api.nvim_list_tabpages()
if #tabs > 1 then
local seen = {}
for _, view in ipairs(M.views) do
seen[view.tabpage] = true
end
local prev_tab = utils.tabnr_to_id(vim.fn.tabpagenr("#")) or -1
if api.nvim_tabpage_is_valid(prev_tab) and not seen[prev_tab] then
return prev_tab
else
for _, id in ipairs(tabs) do
if not seen[id] then
return id
end
end
end
end
end
---@param bufnr integer
---@param ignore? vcs.File[]
---@return boolean
function M.is_buf_in_use(bufnr, ignore)
local ignore_map = ignore and utils.vec_slice(ignore) or {}
utils.add_reverse_lookup(ignore_map)
for _, view in ipairs(M.views) do
if view:instanceof(StandardView.__get()) then
---@cast view StandardView
for _, file in ipairs(view.cur_entry and view.cur_entry.layout:files() or {}) do
if file:is_valid() and file.bufnr == bufnr then
if not ignore_map[file] then
return true
end
end
end
end
end
return false
end
function M.update_colors()
for _, view in ipairs(M.views) do
if view:instanceof(StandardView.__get()) then
---@cast view StandardView
if view.panel:buf_loaded() then
view.panel:render()
view.panel:redraw()
end
end
end
end
return M

View File

@ -0,0 +1,411 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local Mock = lazy.access("diffview.mock", "Mock") ---@type Mock|LazyModule
local Semaphore = lazy.access("diffview.control", "Semaphore") ---@type Semaphore|LazyModule
local loop = lazy.require("diffview.debounce") ---@module "diffview.debounce"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local api = vim.api
local await, pawait = async.await, async.pawait
local fmt = string.format
local pl = lazy.access(utils, "path") ---@type PathLib
local uv = vim.loop
local M = {}
---@class Logger.TimeOfDay
---@field hours integer
---@field mins integer
---@field secs integer
---@field micros integer
---@field tz string
---@field timestamp integer
---@class Logger.Time
---@field timestamp integer # Unix time stamp
---@field micros integer # Microsecond offset
---Get high resolution time of day
---@param time? Logger.Time
---@return Logger.TimeOfDay
local function time_of_day(time)
local secs, micros
if time then
secs, micros = time.timestamp, (time.micros or 0)
else
secs, micros = uv.gettimeofday()
assert(secs, micros)
end
local tzs = os.date("%z", secs) --[[@as string ]]
local sign = tzs:match("[+-]") == "-" and -1 or 1
local tz_h, tz_m = tzs:match("[+-]?(%d%d)(%d%d)")
tz_h = tz_h * sign
tz_m = tz_m * sign
local ret = {}
ret.hours = math.floor(((secs / (60 * 60)) % 24) + tz_h)
ret.mins = math.floor(((secs / 60) % 60) + tz_m)
ret.secs = (secs % 60)
ret.micros = micros
ret.tz = tzs
ret.timestamp = secs
return ret
end
---@alias Logger.LogFunc fun(self: Logger, ...)
---@alias Logger.FmtLogFunc fun(self: Logger, formatstring: string, ...)
---@alias Logger.LazyLogFunc fun(self: Logger, work: (fun(): ...))
---@class Logger.Context
---@field debuginfo debuginfo
---@field time Logger.Time
---@field label string
---@class Logger : diffview.Object
---@operator call : Logger
---@field private outfile_status Logger.OutfileStatus
---@field private level integer # Max level. Messages of higher level will be ignored. NOTE: Higher level -> lower severity.
---@field private msg_buffer (string|function)[]
---@field private msg_sem Semaphore
---@field private batch_interval integer # Minimum time (ms) between each time batched messages are written to the output file.
---@field private batch_handle? Closeable
---@field private ctx? Logger.Context
---@field plugin string
---@field outfile string
---@field trace Logger.LogFunc
---@field debug Logger.LogFunc
---@field info Logger.LogFunc
---@field warn Logger.LogFunc
---@field error Logger.LogFunc
---@field fatal Logger.LogFunc
---@field fmt_trace Logger.FmtLogFunc
---@field fmt_debug Logger.FmtLogFunc
---@field fmt_info Logger.FmtLogFunc
---@field fmt_warn Logger.FmtLogFunc
---@field fmt_error Logger.FmtLogFunc
---@field fmt_fatal Logger.FmtLogFunc
---@field lazy_trace Logger.LazyLogFunc
---@field lazy_debug Logger.LazyLogFunc
---@field lazy_info Logger.LazyLogFunc
---@field lazy_warn Logger.LazyLogFunc
---@field lazy_error Logger.LazyLogFunc
---@field lazy_fatal Logger.LazyLogFunc
local Logger = oop.create_class("Logger")
---@enum Logger.OutfileStatus
Logger.OutfileStatus = oop.enum({
UNKNOWN = 1,
READY = 2,
ERROR = 3,
})
---@enum Logger.LogLevels
Logger.LogLevels = oop.enum({
fatal = 1,
error = 2,
warn = 3,
info = 4,
debug = 5,
trace = 6,
})
Logger.mock = Mock()
function Logger:init(opt)
opt = opt or {}
self.plugin = opt.plugin or "diffview"
self.outfile = opt.outfile or fmt("%s/%s.log", vim.fn.stdpath("cache"), self.plugin)
self.outfile_status = Logger.OutfileStatus.UNKNOWN
self.level = DiffviewGlobal.debug_level > 0 and Logger.LogLevels.debug or Logger.LogLevels.info
self.msg_buffer = {}
self.msg_sem = Semaphore(1)
self.batch_interval = opt.batch_interval or 3000
-- Flush msg buffer before exiting
api.nvim_create_autocmd("VimLeavePre", {
callback = function()
if self.batch_handle then
self.batch_handle.close()
await(self:flush())
end
end,
})
end
---@return Logger.Time
function Logger.time_now()
local secs, micros = uv.gettimeofday()
assert(secs, micros)
return {
timestamp = secs,
micros = micros,
}
end
---@param num number
---@param precision number
---@return number
local function to_precision(num, precision)
if num % 1 == 0 then return num end
local pow = math.pow(10, precision)
return math.floor(num * pow) / pow
end
---@param object any
---@return string
function Logger.dstring(object)
local tp = type(object)
if tp == "thread"
or tp == "function"
or tp == "userdata"
then
return fmt("<%s %p>", tp, object)
elseif tp == "number" then
return tostring(to_precision(object, 3))
elseif tp == "table" then
local mt = getmetatable(object)
if mt and mt.__tostring then
return tostring(object)
elseif utils.islist(object) then
if #object == 0 then return "[]" end
local s = ""
for i = 1, table.maxn(object) do
if i > 1 then s = s .. ", " end
s = s .. Logger.dstring(object[i])
end
return "[ " .. s .. " ]"
end
return vim.inspect(object)
end
return tostring(object)
end
local dstring = Logger.dstring
local function dvalues(...)
local args = { ... }
local ret = {}
for i = 1, select("#", ...) do
ret[i] = dstring(args[i])
end
return ret
end
---@private
---@param level_name string
---@param lazy_eval boolean
---@param x function|any
---@param ... any
function Logger:_log(level_name, lazy_eval, x, ...)
local ctx = self.ctx or {}
local info = ctx.debuginfo or debug.getinfo(3, "Sl")
local lineinfo = info.short_src .. ":" .. info.currentline
local time = ctx.time or Logger.time_now()
local tod = time_of_day(time)
local date = fmt(
"%s %02d:%02d:%02d.%03d %s",
os.date("%F", time.timestamp),
tod.hours,
tod.mins,
tod.secs,
math.floor(tod.micros / 1000),
tod.tz
)
if lazy_eval then
self:queue_msg(function()
return fmt(
"[%-6s%s] %s: %s%s\n",
level_name:upper(),
date,
lineinfo,
ctx.label and fmt("[%s] ", ctx.label) or "",
table.concat(dvalues(x()), " ")
)
end)
else
self:queue_msg(
fmt(
"[%-6s%s] %s: %s%s\n",
level_name:upper(),
date,
lineinfo,
ctx.label and fmt("[%s] ", ctx.label) or "",
table.concat(dvalues(x, ...), " ")
)
)
end
end
---@diagnostic disable: invisible
---@private
---@param self Logger
---@param msg string
Logger.queue_msg = async.void(function(self, msg)
if self.outfile_status == Logger.OutfileStatus.ERROR then
-- We already failed to prepare the log file
return
elseif self.outfile_status == Logger.OutfileStatus.UNKNOWN then
local ok, err = pawait(pl.touch, pl, self.outfile, { parents = true })
if not ok then
error("Failed to prepare log file! Details:\n" .. err)
end
self.outfile_status = Logger.OutfileStatus.READY
end
local permit = await(self.msg_sem:acquire()) --[[@as Permit ]]
table.insert(self.msg_buffer, msg)
permit:forget()
if self.batch_handle then return end
self.batch_handle = loop.set_timeout(
async.void(function()
await(self:flush())
self.batch_handle = nil
end),
self.batch_interval
)
end)
---@private
---@param self Logger
Logger.flush = async.void(function(self)
if next(self.msg_buffer) then
local permit = await(self.msg_sem:acquire()) --[[@as Permit ]]
-- Eval lazy messages
for i = 1, #self.msg_buffer do
if type(self.msg_buffer[i]) == "function" then
self.msg_buffer[i] = self.msg_buffer[i]()
end
end
local fd, err = uv.fs_open(self.outfile, "a", tonumber("0644", 8))
assert(fd, err)
uv.fs_write(fd, table.concat(self.msg_buffer))
uv.fs_close(fd)
self.msg_buffer = {}
permit:forget()
end
end)
---@param min_level integer
---@return Logger
function Logger:lvl(min_level)
if DiffviewGlobal.debug_level >= min_level then
return self
end
return Logger.mock --[[@as Logger ]]
end
---@param ctx Logger.Context
function Logger:set_context(ctx)
self.ctx = ctx
end
function Logger:clear_context()
self.ctx = nil
end
do
-- Create methods
for level, name in ipairs(Logger.LogLevels --[[@as string[] ]]) do
---@param self Logger
Logger[name] = function(self, ...)
if self.level < level then return end
self:_log(name, false, ...)
end
---@param self Logger
Logger["fmt_" .. name] = function(self, formatstring, ...)
if self.level < level then return end
self:_log(name, false, fmt(formatstring, ...))
end
---@param self Logger
Logger["lazy_" .. name] = function(self, func)
if self.level < level then return end
self:_log(name, true, func)
end
end
end
---@diagnostic enable: invisible
---@class Logger.log_job.Opt
---@field func function|string
---@field label string
---@field no_stdout boolean
---@field no_stderr boolean
---@field silent boolean
---@field debug_level integer
---@field debuginfo debuginfo
---@param job diffview.Job
---@param opt? Logger.log_job.Opt
function Logger:log_job(job, opt)
opt = opt or {}
if opt.silent then return end
if opt.debug_level and DiffviewGlobal.debug_level < opt.debug_level then
return
end
self:set_context({
debuginfo = opt.debuginfo or debug.getinfo(2, "Sl"),
time = Logger.time_now(),
label = opt.label,
})
local args = vim.tbl_map(function(arg)
-- Simple shell escape. NOTE: not valid for windows shell.
return fmt("'%s'", arg:gsub("'", [['"'"']]))
end, job.args) --[[@as vector ]]
local log_func = self.debug
if type(opt.func) == "string" then
log_func = self[opt.func]
elseif type(opt.func) == "function" then
log_func = opt.func --[[@as function ]]
end
log_func(self, fmt("[job-info] Exit code: %s", job.code))
log_func(self, fmt(" [cmd] %s %s", job.command, table.concat(args, " ")))
if job.cwd then
log_func(self, fmt(" [cwd] %s", job.cwd))
end
if not opt.no_stdout and job.stdout[1] then
log_func(self, " [stdout] " .. table.concat(job.stdout, "\n"))
end
if not opt.no_stderr and job.stderr[1] then
log_func(self, " [stderr] " .. table.concat(job.stderr, "\n"))
end
self:clear_context()
end
M.Logger = Logger
return M

View File

@ -0,0 +1,41 @@
--[[
A class for creating mock objects. Accessing any key in the object returns
itself. Calling the object does nothing.
--]]
local M = {}
local mock_mt = {}
local function tbl_clone(t)
local ret = {}
for k, v in pairs(t) do ret[k] = v end
return ret
end
---@class Mock
---@operator call : Mock
local Mock = setmetatable({}, mock_mt)
function mock_mt.__index(_, key)
return mock_mt[key]
end
function mock_mt.__call(_, internals)
local mt = {
__index = function(self, k)
if Mock[k] then
return Mock[k]
else
return self
end
end,
__call = function()
return nil
end,
}
local this = setmetatable(tbl_clone(internals or {}), mt)
return this
end
M.Mock = Mock
return M

View File

@ -0,0 +1,257 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local Job = lazy.access("diffview.job", "Job") ---@type diffview.Job|LazyModule
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local await = async.await
local fmt = string.format
local logger = DiffviewGlobal.logger
local M = {}
---@alias MultiJob.OnExitCallback fun(mj: MultiJob, success: boolean, err?: string)
---@alias MultiJob.OnRetryCallback fun(mj: MultiJob, jobs: diffview.Job[])
---@alias MultiJob.FailCond fun(mj: MultiJob): boolean, diffview.Job[]?, string?
---@class MultiJob : Waitable
---@operator call : MultiJob
---@field jobs diffview.Job[]
---@field retry integer
---@field check_status MultiJob.FailCond
---@field on_exit_listeners MultiJob.OnExitCallback[]
---@field _started boolean
---@field _done boolean
local MultiJob = oop.create_class("MultiJob")
---Predefined fail conditions.
MultiJob.FAIL_COND = {
---Fail if any of the jobs termintated with a non-zero exit code.
---@param mj MultiJob
non_zero = function(mj)
local failed = {}
for _, job in ipairs(mj.jobs) do
if job.code ~= 0 then
failed[#failed + 1] = job
end
end
if next(failed) then
return false, failed, "Job(s) exited with a non-zero exit code!"
end
return true
end,
---Fail if any of the jobs had no data in stdout.
---@param mj MultiJob
on_empty = function(mj)
local failed = {}
for _, job in ipairs(mj.jobs) do
if #job.stdout == 1 and job.stdout[1] == ""
or #job.stdout == 0
then
failed[#failed + 1] = job
end
end
if next(failed) then
return false, failed, "Job(s) expected output, but returned nothing!"
end
return true
end,
}
function MultiJob:init(jobs, opt)
self.jobs = jobs
self.retry = opt.retry or 0
self.on_exit_listeners = {}
self.on_retry_listeners = {}
self._started = false
self._done = false
self.log_opt = vim.tbl_extend("keep", opt.log_opt or {}, {
func = "debug",
no_stdout = true,
debuginfo = debug.getinfo(3, "Sl"),
})
if opt.fail_cond then
if type(opt.fail_cond) == "string" then
self.check_status = MultiJob.FAIL_COND[opt.fail_cond]
assert(self.check_status, fmt("Unknown fail condition: '%s'", opt.fail_cond))
elseif type(opt.fail_cond) == "function" then
self.check_status = opt.fail_cond
else
error("Invalid fail condition: " .. vim.inspect(opt.fail_cond))
end
else
self.check_status = MultiJob.FAIL_COND.non_zero
end
if opt.on_exit then self:on_exit(opt.on_exit) end
if opt.on_retry then self:on_retry(opt.on_retry) end
end
---@private
function MultiJob:reset()
self._started = false
self._done = false
end
---@param self MultiJob
MultiJob.start = async.wrap(function(self, callback)
---@diagnostic disable: invisible
for _, job in ipairs(self.jobs) do
if job:is_running() then
error("A job is still running!")
end
end
self:reset()
self._started = true
local jobs = self.jobs
local retry_status
for i = 1, self.retry + 1 do
if i > 1 then
for _, listener in ipairs(self.on_retry_listeners) do
listener(self, jobs)
end
end
Job.start_all(jobs)
await(Job.join(jobs))
local ok, err
ok, jobs, err = self:check_status()
if ok then break end
---@cast jobs -?
if i == self.retry + 1 then
retry_status = 1
else
retry_status = 0
if not self.log_opt.silent then
logger:error(err)
for _, job in ipairs(jobs) do
logger:log_job(job, { func = "error", no_stdout = true })
end
logger:fmt_error("(%d/%d) Retrying failed jobs...", i, self.retry)
end
await(async.timeout(1))
end
end
if not self.log_opt.silent then
if retry_status == 0 then
logger:info("Retry was successful!")
elseif retry_status == 1 then
logger:error("All retries failed!")
end
end
self._done = true
local ok, err = self:is_success()
for _, listener in ipairs(self.on_exit_listeners) do
listener(self, ok, err)
end
callback(ok, err)
---@diagnostic enable: invisible
end)
---@override
---@param self MultiJob
---@param callback fun(success: boolean, err?: string)
MultiJob.await = async.sync_wrap(function(self, callback)
if self:is_done() then
callback(self:is_success())
elseif self:is_running() then
self:on_exit(function(_, ...) callback(...) end)
else
callback(await(self:start()))
end
end)
---@return boolean success
---@return string? err
function MultiJob:is_success()
local ok, _, err = self:check_status()
if not ok then return false, err end
return true
end
---@param callback MultiJob.OnExitCallback
function MultiJob:on_exit(callback)
table.insert(self.on_exit_listeners, callback)
end
---@param callback MultiJob.OnRetryCallback
function MultiJob:on_retry(callback)
table.insert(self.on_retry_listeners, callback)
end
function MultiJob:is_done()
return self._done
end
function MultiJob:is_started()
return self._started
end
function MultiJob:is_running()
return self:is_started() and not self:is_done()
end
---@return string[]
function MultiJob:stdout()
return utils.flatten(
---@param value diffview.Job
vim.tbl_map(function(value)
return value.stdout
end, self.jobs)
)
end
---@return string[]
function MultiJob:stderr()
return utils.flatten(
---@param value diffview.Job
vim.tbl_map(function(value)
return value.stderr
end, self.jobs)
)
end
---@param code integer
---@param signal? integer|uv.aliases.signals # (default: "sigterm")
---@return 0|nil success
function MultiJob:kill(code, signal)
---@type 0?
local ret = 0
for _, job in ipairs(self.jobs) do
if job:is_running() then
local success = job:kill(code, signal)
if not success then ret = nil end
end
end
return ret
end
M.MultiJob = MultiJob
return M

View File

@ -0,0 +1,227 @@
local lazy = require("diffview.lazy")
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local fmt = string.format
local M = {}
function M.abstract_stub()
error("Unimplemented abstract method!")
end
---@generic T
---@param t T
---@return T
function M.enum(t)
utils.add_reverse_lookup(t)
return t
end
---Wrap metatable methods to ensure they're called with the instance as `self`.
---@param func function
---@param instance table
---@return function
local function wrap_mt_func(func, instance)
return function(_, k)
return func(instance, k)
end
end
local mt_func_names = {
"__index",
"__tostring",
"__eq",
"__add",
"__sub",
"__mul",
"__div",
"__mod",
"__pow",
"__unm",
"__len",
"__lt",
"__le",
"__concat",
"__newindex",
"__call",
}
local function new_instance(class, ...)
local inst = { class = class }
local mt = { __index = class }
for _, mt_name in ipairs(mt_func_names) do
local class_mt_func = class[mt_name]
if type(class_mt_func) == "function" then
mt[mt_name] = wrap_mt_func(class_mt_func, inst)
elseif class_mt_func ~= nil then
mt[mt_name] = class_mt_func
end
end
local self = setmetatable(inst, mt)
self:init(...)
return self
end
local function tostring(class)
return fmt("<class %s>", class.__name)
end
---@generic T : diffview.Object
---@generic U : diffview.Object
---@param name string
---@param super_class? T
---@return U new_class
function M.create_class(name, super_class)
super_class = super_class or M.Object
return setmetatable(
{
__name = name,
super_class = super_class,
},
{
__index = super_class,
__call = new_instance,
__tostring = tostring,
}
)
end
local function classm_safeguard(x)
assert(x.class == nil, "Class method should not be invoked from an instance!")
end
local function instancem_safeguard(x)
assert(type(x.class) == "table", "Instance method must be called from a class instance!")
end
---@class diffview.Object
---@field protected __name string
---@field private __init_caller? table
---@field class table|diffview.Object
---@field super_class table|diffview.Object
local Object = M.create_class("Object")
M.Object = Object
function Object:__tostring()
return fmt("<a %s>", self.class.__name)
end
-- ### CLASS METHODS ###
---@return string
function Object:name()
classm_safeguard(self)
return self.__name
end
---Check if this class is an ancestor of the given instance. `A` is an ancestor
---of `b` if - and only if - `b` is an instance of a subclass of `A`.
---@param other any
---@return boolean
function Object:ancestorof(other)
classm_safeguard(self)
if not M.is_instance(other) then return false end
return other:instanceof(self)
end
---@return string
function Object:classpath()
classm_safeguard(self)
local ret = self.__name
local cur = self.super_class
while cur do
ret = cur.__name .. "." .. ret
cur = cur.super_class
end
return ret
end
-- ### INSTANCE METHODS ###
---Call constructor.
function Object:init(...) end
---Call super constructor.
---@param ... any
function Object:super(...)
instancem_safeguard(self)
local next_super
-- Keep track of what class is currently calling the constructor such that we
-- can avoid loops.
if self.__init_caller then
next_super = self.__init_caller.super_class
else
next_super = self.super_class
end
if not next_super then return end
self.__init_caller = next_super
next_super.init(self, ...)
self.__init_caller = nil
end
---@param other diffview.Object
---@return boolean
function Object:instanceof(other)
instancem_safeguard(self)
local cur = self.class
while cur do
if cur == other then return true end
cur = cur.super_class
end
return false
end
---@param x any
---@return boolean
function M.is_class(x)
if type(x) ~= "table" then return false end
return type(rawget(x, "__name")) == "string" and x.instanceof == Object.instanceof
end
---@param x any
---@return boolean
function M.is_instance(x)
if type(x) ~= "table" then return false end
return M.is_class(x.class)
end
---@class Symbol
---@operator call : Symbol
---@field public name? string
---@field public id integer
---@field private _id_counter integer
local Symbol = M.create_class("Symbol")
M.Symbol = Symbol
---@private
Symbol._id_counter = 1
---@param name? string
function Symbol:init(name)
self.name = name
self.id = Symbol._id_counter
Symbol._id_counter = Symbol._id_counter + 1
end
function Symbol:__tostring()
if self.name then
return fmt("<Symbol('%s)>", self.name)
else
return fmt("<Symbol(#%d)>", self.id)
end
end
return M

View File

@ -0,0 +1,662 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local await = async.await
local fmt = string.format
local uv = vim.loop
local M = {}
local is_windows = uv.os_uname().version:match("Windows")
local function handle_uv_err(x, err, err_msg)
if not x then
error(err .. " " .. err_msg, 2)
end
return x
end
-- Ref: https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats
local WINDOWS_PATH_SPECIFIER = {
dos_dev = "^[\\/][\\/][.?][\\/]", -- DOS Device path
unc = "^[\\/][\\/]", -- UNC path
rel_drive = "^[\\/]", -- Relative drive
drive = [[^[a-zA-Z]:]],
}
table.insert(WINDOWS_PATH_SPECIFIER, WINDOWS_PATH_SPECIFIER.dos_dev)
table.insert(WINDOWS_PATH_SPECIFIER, WINDOWS_PATH_SPECIFIER.unc)
table.insert(WINDOWS_PATH_SPECIFIER, WINDOWS_PATH_SPECIFIER.rel_drive)
table.insert(WINDOWS_PATH_SPECIFIER, WINDOWS_PATH_SPECIFIER.drive)
---@class PathLib
---@operator call : PathLib
---@field sep "/"|"\\"
---@field os "unix"|"windows" Determines the type of paths we're dealing with.
---@field cwd string Leave as `nil` to always use current cwd.
local PathLib = oop.create_class("PathLib")
function PathLib:init(o)
self.os = o.os or (is_windows and "windows" or "unix")
assert(vim.tbl_contains({ "unix", "windows" }, self.os), "Invalid OS type!")
self._is_windows = self.os == "windows"
self.sep = o.separator or (self._is_windows and "\\" or "/")
self.cwd = o.cwd and self:convert(o.cwd) or nil
end
---@private
function PathLib:_cwd()
return self.cwd or self:convert(uv.cwd())
end
---@private
---@return ...
function PathLib:_clean(...)
local argc = select("#", ...)
if argc == 1 and select(1, ...) ~= nil then
return self:convert(...)
end
local paths = { ... }
for i = 1, argc do
if paths[i] ~= nil then
paths[i] = self:convert(paths[i])
end
end
return unpack(paths, 1, argc)
end
---@private
---@param path string
function PathLib:_split_root(path)
local root = self:root(path)
if not root then return "", path end
return root, path:sub(#root + 1)
end
---Check if a given path is a URI.
---@param path string
---@return boolean
function PathLib:is_uri(path)
return string.match(path, "^%w+://") ~= nil
end
---Get the URI scheme of a given URI.
---@param path string
---@return string
function PathLib:get_uri_scheme(path)
return string.match(path, "^(%w+://)")
end
---Change the path separators in a path. Removes duplicate separators.
---@param path string
---@param sep? "/"|"\\"
---@return string
function PathLib:convert(path, sep)
sep = sep or self.sep
local prefix
local p = tostring(path)
if self:is_uri(path) then
sep = "/"
prefix, p = path:match("^(%w+://)(.*)")
elseif self._is_windows then
for _, pat in ipairs(WINDOWS_PATH_SPECIFIER) do
prefix = path:match(pat)
if prefix then
prefix = prefix:gsub("[\\/]", sep)
p = path:sub(#prefix + 1)
break
end
end
end
p, _ = p:gsub("[\\/]+", sep)
return (prefix or "") .. p
end
---Convert a path to use the appropriate path separators for the current OS.
---@param path string
---@return string
function PathLib:to_os(path)
return self:convert(path, self._is_windows and "\\" or "/")
end
---Check if a given path is absolute.
---@param path string
---@return boolean
function PathLib:is_abs(path)
path = self:_clean(path)
if self._is_windows then
for _, pat in ipairs(WINDOWS_PATH_SPECIFIER) do
if path:match(pat) ~= nil then return true end
end
return false
else
return path:sub(1, 1) == self.sep
end
end
---Get the absolute path of a given path. This is resolved using either the
---`cwd` field if it's defined. Otherwise the current cwd is used instead.
---@param path string
---@param cwd? string
---@return string
function PathLib:absolute(path, cwd)
path, cwd = self:_clean(path, cwd)
path = self:expand(path)
cwd = cwd or self:_cwd()
if self:is_uri(path) then
return path
end
if self:is_abs(path) then
return self:normalize(path, { cwd = cwd, absolute = true })
end
return self:normalize(self:join(cwd, path), { cwd = cwd, absolute = true })
end
---Check if the given path is the root.
---@param path string
---@return boolean
function PathLib:is_root(path)
path = self:remove_trailing(self:_clean(path))
if self:is_abs(path) then
if self._is_windows then
for _, pat in ipairs(WINDOWS_PATH_SPECIFIER) do
local prefix = path:match(pat)
if prefix and #path == #prefix then return true end
end
return false
else
return path == self.sep
end
end
return false
end
---Get the root of an absolute path. Returns nil if the path is not absolute.
---@param path string
---@return string|nil
function PathLib:root(path)
path = tostring(path)
if self:is_abs(path) then
if self._is_windows then
for _, pat in ipairs(WINDOWS_PATH_SPECIFIER) do
local root = path:match(pat)
if root then return root end
end
else
return self.sep
end
end
end
---@class PathLibNormalizeSpec
---@field cwd string
---@field absolute boolean
---Normalize a given path, resolving relative segments.
---@param path string
---@param opt? PathLibNormalizeSpec
---@return string
function PathLib:normalize(path, opt)
path = self:_clean(path)
if self:is_uri(path) then
return path
end
opt = opt or {}
local cwd = opt.cwd and self:_clean(opt.cwd) or self:_cwd()
local absolute = vim.F.if_nil(opt.absolute, false)
local root = self:root(path)
if root and self:is_root(path) then
return path
end
if not self:is_abs(path) then
local relpath = self:relative(path, cwd, true)
path = self:add_trailing(cwd) .. relpath
end
local parts = self:explode(path)
if root then
table.remove(parts, 1)
if self._is_windows and root == root:match(WINDOWS_PATH_SPECIFIER.rel_drive) then
-- Resolve relative drive
-- path="/foo/bar/baz", cwd="D:/lorem/ipsum" -> "D:/foo/bar/baz"
root = self:root(cwd)
end
end
local normal = path
if #parts > 1 then
local i = 2
local upc = 0
repeat
if parts[i] == "." then
table.remove(parts, i)
i = i - 1
elseif parts[i] == ".." then
if i == 1 then
upc = upc + 1
end
table.remove(parts, i)
if i > 1 then
table.remove(parts, i - 1)
i = i - 2
else
i = i - 1
end
end
i = i + 1
until i > #parts
normal = self:join(root, unpack(parts))
if not absolute and upc == 0 then
normal = self:relative(normal, cwd, true)
end
end
return normal == "" and "." or normal
end
---Expand environment variables and `~`.
---@param path string
---@return string
function PathLib:expand(path)
local segments = self:explode(path)
local idx = 1
if segments[1] == "~" then
segments[1] = uv.os_homedir()
idx = 2
end
for i = idx, #segments do
local env_var = segments[i]:match("^%$(%S+)$")
if env_var then
segments[i] = uv.os_getenv(env_var) or env_var
end
end
return self:join(unpack(segments))
end
---Joins an ordered list of path segments into a path string.
---@vararg ... string|string[] Paths
---@return string
function PathLib:join(...)
local segments = { ... }
if type(segments[1]) == "table" then
segments = segments[1]
end
local ret = ""
for i = 1, table.maxn(segments) do
local cur = segments[i]
if cur and cur ~= "" then
if #ret > 0 and not ret:sub(-1, -1):match("[\\/]") then
ret = ret .. self.sep
end
ret = ret .. cur
end
end
return self:_clean(ret)
end
---Explodes the path into an ordered list of path segments.
---@param path string
---@return string[]
function PathLib:explode(path)
path = self:_clean(path)
local parts = {}
local i = 1
if self:is_uri(path) then
local scheme, p = path:match("^(%w+://)(.*)")
parts[i] = scheme
path = p
i = i + 1
end
local root
root, path = self:_split_root(path)
if root ~= "" then
parts[i] = root
if path:sub(1, 1) == self.sep then
path = path:sub(2)
end
end
for part in path:gmatch(string.format("([^%s]+)%s?", self.sep, self.sep)) do
parts[#parts+1] = part
end
return parts
end
---Add a trailing separator, unless already present.
---@param path string
---@return string
function PathLib:add_trailing(path)
local root
root, path = self:_split_root(path)
if #path == 0 then return root .. path end
if path:sub(-1) == self.sep then
return root .. path
end
return root .. path .. self.sep
end
---Remove any trailing separator, if present.
---@param path string
---@return string
function PathLib:remove_trailing(path)
local root
root, path = self:_split_root(path)
local p, _ = path:gsub(self.sep .. "$", "")
return root .. p
end
---Get the basename of the given path.
---@param path string
---@return string
function PathLib:basename(path)
path = self:remove_trailing(self:_clean(path))
local i = path:match("^.*()" .. self.sep)
if not i then
return path
end
return path:sub(i + 1, #path)
end
---Get the extension of the given path.
---@param path string
---@return string|nil
function PathLib:extension(path)
path = self:basename(path)
return path:match(".+%.(.*)")
end
---Get the path to the parent directory of the given path. Returns `nil` if the
---path has no parent.
---@param path string
---@param n? integer Nth parent. (default: 1)
---@return string?
function PathLib:parent(path, n)
if type(n) ~= "number" or n < 1 then
n = 1
end
local parts = self:explode(path)
local root = self:root(path)
if root and n == #parts then
return root
elseif n >= #parts then
return
end
return self:join(unpack(parts, 1, #parts - n))
end
---Get a path relative to another path.
---@param path string
---@param relative_to string
---@param no_resolve? boolean Don't normalize paths first.
---@return string
function PathLib:relative(path, relative_to, no_resolve)
path, relative_to = self:_clean(path, relative_to)
if not no_resolve then
local abs = self:is_abs(path)
path = self:normalize(path, { absolute = abs })
relative_to = self:normalize(relative_to, { absolute = abs })
end
if relative_to == "" then
return path
elseif relative_to == path then
return ""
end
local p, _ = path:gsub("^" .. vim.pesc(self:add_trailing(relative_to)), "")
return p
end
---Shorten a path by truncating the head.
---@param path string
---@param max_length integer
---@return string
function PathLib:truncate(path, max_length)
path = self:_clean(path)
if #path > max_length - 1 then
path = path:sub(#path - max_length + 1, #path)
local i = path:match("()" .. self.sep)
if not i then
return "" .. path
end
return "" .. path:sub(i, -1)
else
return path
end
end
---@param path string
---@return string|nil
function PathLib:realpath(path)
local p = uv.fs_realpath(path)
if p then
return self:convert(p)
end
end
---@param path string
---@return string|nil
function PathLib:readlink(path)
local p = uv.fs_readlink(path)
if p then
return self:convert(p)
end
end
---@param path string
---@param nosuf? boolean
---@param list falsy
---@return string
---@overload fun(self: PathLib, path: string, nosuf: boolean, list: true): string[]
function PathLib:vim_expand(path, nosuf, list)
if list then
return vim.tbl_map(function(v)
return self:convert(v)
end, vim.fn.expand(path, nosuf, list))
end
return self:convert(vim.fn.expand(path, nosuf, list) --[[@as string ]])
end
---@param path string
---@return string
function PathLib:vim_fnamemodify(path, mods)
return self:convert(vim.fn.fnamemodify(path, mods))
end
---@param path string
---@return table?
function PathLib:stat(path)
return uv.fs_stat(path)
end
---@param path string
---@return string?
function PathLib:type(path)
local p = uv.fs_realpath(path)
if p then
local stat = uv.fs_stat(p)
if stat then
return stat.type
end
end
end
---@param path string
---@return boolean
function PathLib:is_dir(path)
return self:type(path) == "directory"
end
---Check for read access to a given path.
---@param path string
---@return boolean
function PathLib:readable(path)
local p = uv.fs_realpath(path)
if p then
return not not uv.fs_access(p, "R")
end
return false
end
---@class PathLib.touch.Opt
---@field mode? integer
---@field parents? boolean
---@param self PathLib
---@param path string
---@param opt PathLib.touch.Opt
PathLib.touch = async.void(function(self, path, opt)
opt = opt or {}
local mode = opt.mode or tonumber("0644", 8)
path = self:_clean(path)
local stat = self:stat(path)
if stat then
-- Path exists: just update utime
local time = os.time()
handle_uv_err(uv.fs_utime(path, time, time))
return
end
if opt.parents then
local parent = self:parent(path)
if parent then
await(self:mkdir(self:parent(path), { parents = true }))
end
end
local fd = handle_uv_err(uv.fs_open(path, "w", mode))
handle_uv_err(uv.fs_close(fd))
end)
---@class PathLib.mkdir.Opt
---@field mode? integer
---@field parents? boolean
---@param self PathLib
---@param path string
---@param opt? table
PathLib.mkdir = async.void(function(self, path, opt)
opt = opt or {}
local mode = opt.mode or tonumber("0700", 8)
path = self:absolute(path)
if not opt.parents then
handle_uv_err(uv.fs_mkdir(path, mode))
return
end
local cur_path
for _, part in ipairs(self:explode(path)) do
cur_path = cur_path and self:join(cur_path, part) or part
local stat = self:stat(cur_path)
if not stat then
handle_uv_err(uv.fs_mkdir(cur_path, mode))
else
if stat.type ~= "directory" then
error(fmt("Cannot create directory '%s': Not a directory", cur_path))
end
end
end
end)
---Delete a name and possibly the file it refers to.
---@param self PathLib
---@param path string
---@param callback? function
---@diagnostic disable-next-line: unused-local
PathLib.unlink = async.wrap(function(self, path, callback)
---@cast callback -?
uv.fs_unlink(path, function(err, ok)
if not ok then
error(err)
end
callback()
end)
end)
function PathLib:chain(...)
local t = {
__result = utils.tbl_pack(...)
}
return setmetatable(t, {
__index = function(chain, k)
if k == "get" then
return function(_)
return utils.tbl_unpack(t.__result)
end
else
return function(_, ...)
t.__result = utils.tbl_pack(self[k](self, utils.tbl_unpack(t.__result), ...))
return chain
end
end
end
})
end
M.PathLib = PathLib
return M

View File

@ -0,0 +1,92 @@
local oop = require("diffview.oop")
local utils = require("diffview.utils")
local uv = vim.loop
local M = {}
---@class PerfTimer : diffview.Object
---@operator call : PerfTimer
---@field subject string|nil
---@field first integer Start time (ns)
---@field last integer Stop time (ns)
---@field final_time number Final time (ms)
---@field laps number[] List of lap times (ms)
local PerfTimer = oop.create_class("PerfTimer")
---PerfTimer constructor.
---@param subject string|nil
function PerfTimer:init(subject)
self.subject = subject
self.laps = {}
self.first = uv.hrtime()
end
function PerfTimer:reset()
self.laps = {}
self.first = uv.hrtime()
self.final_time = nil
end
---Record a lap time.
---@param subject string|nil
function PerfTimer:lap(subject)
self.laps[#self.laps + 1] = {
subject or #self.laps + 1,
(uv.hrtime() - self.first) / 1000000,
}
end
---Set final time.
---@return number
function PerfTimer:time()
self.last = uv.hrtime() - self.first
self.final_time = self.last / 1000000
return self.final_time
end
function PerfTimer:__tostring()
if not self.final_time then
self:time()
end
if #self.laps == 0 then
return string.format(
"%s %.3f ms",
utils.str_right_pad((self.subject or "TIME") .. ":", 24),
self.final_time
)
else
local s = (self.subject or "LAPS") .. ":\n"
local last = 0
for _, lap in ipairs(self.laps) do
s = s
.. string.format(
">> %s %.3f ms\t(%.3f ms)\n",
utils.str_right_pad(lap[1], 36),
lap[2],
lap[2] - last
)
last = lap[2]
end
return s .. string.format("== %s %.3f ms", utils.str_right_pad("FINAL TIME", 36), self.final_time)
end
end
---Get the relative performance difference in percent.
---@static
---@param a PerfTimer
---@param b PerfTimer
---@return string
function PerfTimer.difference(a, b)
local delta = (b.final_time - a.final_time) / a.final_time
local negative = delta < 0
return string.format("%s%.2f%%", not negative and "+" or "", delta * 100)
end
M.PerfTimer = PerfTimer
return M

View File

@ -0,0 +1,532 @@
local oop = require("diffview.oop")
local utils = require("diffview.utils")
local api = vim.api
local M = {}
local uid_counter = 0
---Duration of the last redraw in ms.
M.last_draw_time = 0
---@class renderer.HlData
---@field group string
---@field line_idx integer
---@field first integer 0 indexed, inclusive
---@field last integer Exclusive
---@class renderer.HlList
---@field offset integer
---@field [integer] renderer.HlData
---@class CompStruct
---@field _name string
---@field comp RenderComponent
---@field [integer|string] CompStruct
---@class CompSchema
---@field name? string
---@field context? table
---@field [integer] CompSchema
---@class RenderComponent : diffview.Object
---@field name string
---@field context? table
---@field parent RenderComponent
---@field lines string[]
---@field hl renderer.HlList
---@field line_buffer string
---@field components RenderComponent[]
---@field lstart integer 0 indexed, Inclusive
---@field lend integer Exclusive
---@field height integer
---@field data_root RenderData
local RenderComponent = oop.create_class("RenderComponent")
---RenderComponent constructor.
function RenderComponent:init(name)
self.name = name or RenderComponent.next_uid()
self.lines = {}
self.hl = {}
self.line_buffer = ""
self.components = {}
self.lstart = -1
self.lend = -1
self.height = 0
end
---@param parent RenderComponent
---@param comp_struct CompStruct
---@param schema CompSchema
local function create_subcomponents(parent, comp_struct, schema)
for i, v in ipairs(schema) do
v.name = v.name or RenderComponent.next_uid()
local sub_comp = parent:create_component()
---@cast sub_comp RenderComponent
sub_comp.name = v.name
sub_comp.context = v.context
sub_comp.parent = parent
comp_struct[i] = {
_name = v.name,
comp = sub_comp,
}
comp_struct[v.name] = comp_struct[i]
if #v > 0 then
create_subcomponents(sub_comp, comp_struct[i], v)
end
end
end
function RenderComponent.next_uid()
local uid = "comp_" .. uid_counter
uid_counter = uid_counter + 1
return uid
end
---Create a new compoenent
---@param schema? CompSchema
---@return RenderComponent, CompStruct
function RenderComponent.create_static_component(schema)
local comp_struct
---@diagnostic disable-next-line: need-check-nil
local new_comp = RenderComponent(schema and schema.name or nil)
if schema then
new_comp.context = schema.context
comp_struct = { _name = new_comp.name, comp = new_comp }
create_subcomponents(new_comp, comp_struct, schema)
end
return new_comp, comp_struct
end
---Create and add a new component.
---@param schema? CompSchema
---@overload fun(): RenderComponent
---@overload fun(schema: CompSchema): CompStruct
function RenderComponent:create_component(schema)
local new_comp, comp_struct = RenderComponent.create_static_component(schema)
new_comp.data_root = self.data_root
self:add_component(new_comp)
if comp_struct then
return comp_struct
end
return new_comp
end
---@param component RenderComponent
function RenderComponent:add_component(component)
component.parent = self
self.components[#self.components + 1] = component
end
---@param component RenderComponent
function RenderComponent:remove_component(component)
for i, c in ipairs(self.components) do
if c == component then
table.remove(self.components, i)
return true
end
end
return false
end
---@param line string?
---@param hl_group string?
function RenderComponent:add_line(line, hl_group)
if line and hl_group then
local first = #self.line_buffer
self:add_hl(hl_group, #self.lines, first, first + #line)
end
self.lines[#self.lines + 1] = self.line_buffer .. (line or "")
self.line_buffer = ""
end
---@param group string
---@param line_idx integer
---@param first integer
---@param last integer
function RenderComponent:add_hl(group, line_idx, first, last)
self.hl[#self.hl + 1] = {
group = group,
line_idx = line_idx,
first = first,
last = last,
}
end
---@param text string
---@param hl_group string?
function RenderComponent:add_text(text, hl_group)
if hl_group then
local first = #self.line_buffer
self:add_hl(hl_group, #self.lines, first, first + #text)
end
self.line_buffer = self.line_buffer .. text
end
---Finalize current line
function RenderComponent:ln()
self.lines[#self.lines + 1] = self.line_buffer
self.line_buffer = ""
end
function RenderComponent:clear()
self.lines = {}
self.hl = {}
self.lstart = -1
self.lend = -1
self.height = 0
for _, c in ipairs(self.components) do
c:clear()
end
end
function RenderComponent:destroy()
self.lines = nil
self.hl = nil
self.parent = nil
self.context = nil
self.data_root = nil
for _, c in ipairs(self.components) do
c:destroy()
end
self.components = nil
end
function RenderComponent:isleaf()
return (not next(self.components))
end
---@param line integer
---@return RenderComponent?
function RenderComponent:get_comp_on_line(line)
line = line - 1
local ret
self:deep_some(function(child)
if line >= child.lstart and line < child.lend and child:isleaf() then
ret = child
return true
end
end)
return ret
end
---@param callback fun(comp: RenderComponent, i: integer, parent: RenderComponent): boolean?
function RenderComponent:some(callback)
for i, child in ipairs(self.components) do
if callback(child, i, self) then
return
end
end
end
---@param callback fun(comp: RenderComponent, i: integer, parent: RenderComponent): boolean?
function RenderComponent:deep_some(callback)
local function wrap(comp, i, parent)
if callback(comp, i, parent) then
return true
else
return comp:some(wrap)
end
end
self:some(wrap)
end
function RenderComponent:leaves()
local leaves = {}
self:deep_some(function(comp)
if #comp.components == 0 then
leaves[#leaves + 1] = comp
end
return false
end)
return leaves
end
function RenderComponent:pretty_print()
local keys = { "name", "lstart", "lend" }
local function recurse(depth, comp)
local outer_padding = string.rep(" ", depth * 2)
print(outer_padding .. "{")
local inner_padding = outer_padding .. " "
for _, k in ipairs(keys) do
print(string.format("%s%s = %s,", inner_padding, k, vim.inspect(comp[k])))
end
if #comp.lines > 0 then
print(string.format("%slines = {", inner_padding))
for _, line in ipairs(comp.lines) do
print(string.format("%s %s,", inner_padding, vim.inspect(line)))
end
print(string.format("%s},", inner_padding))
end
for _, child in ipairs(comp.components) do
recurse(depth + 1, child)
end
print(outer_padding .. "},")
end
recurse(0, self)
end
---@class RenderData : diffview.Object
---@field lines string[]
---@field hl renderer.HlList
---@field components RenderComponent[]
---@field namespace integer
local RenderData = oop.create_class("RenderData")
---RenderData constructor.
function RenderData:init(ns_name)
self.lines = {}
self.hl = {}
self.components = {}
self.namespace = api.nvim_create_namespace(ns_name)
end
---Create and add a new component.
---@param schema table
---@return RenderComponent|CompStruct
function RenderData:create_component(schema)
local comp_struct
local new_comp = RenderComponent(schema and schema.name or nil)
new_comp.data_root = self
self:add_component(new_comp)
if schema then
new_comp.context = schema.context
comp_struct = { _name = new_comp.name, comp = new_comp }
create_subcomponents(new_comp, comp_struct, schema)
return comp_struct
end
return new_comp
end
---@param component RenderComponent
function RenderData:add_component(component)
self.components[#self.components + 1] = component
end
---@param component RenderComponent
function RenderData:remove_component(component)
for i, c in ipairs(self.components) do
if c == component then
table.remove(self.components, i)
return true
end
end
return false
end
---@param group string
---@param line_idx integer
---@param first integer
---@param last integer
function RenderData:add_hl(group, line_idx, first, last)
self.hl[#self.hl + 1] = {
group = group,
line_idx = line_idx,
first = first,
last = last,
}
end
function RenderData:clear()
self.lines = {}
self.hl = {}
for _, c in ipairs(self.components) do
c:clear()
end
end
function RenderData:destroy()
self.lines = nil
self.hl = nil
for _, c in ipairs(self.components) do
c:destroy()
end
self.components = {}
end
function M.destroy_comp_struct(schema)
schema.comp = nil
for k, v in pairs(schema) do
if type(v) == "table" then
M.destroy_comp_struct(v)
schema[k] = nil
end
end
end
---Create a function to enable easily constraining the cursor to a given list of
---components.
---@param components RenderComponent[]
function M.create_cursor_constraint(components)
local stack = utils.vec_slice(components, 1)
utils.merge_sort(stack, function(a, b)
return a.lstart <= b.lstart
end)
---Given a cursor delta or target: returns the next valid line index inside a
---contraining component. When the cursor is trying to move out of a
---constraint, the next component is determined by the direction the cursor is
---moving.
---@param winid_or_opt number|{from: number, to: number}
---@param delta number The amount of change from the current cursor position.
---Not needed if the first argument is a table.
---@return number
return function(winid_or_opt, delta)
local line_from, line_to
if type(winid_or_opt) == "number" then
local cursor = api.nvim_win_get_cursor(winid_or_opt)
line_from, line_to = cursor[1] - 1, cursor[1] - 1 + delta
else
line_from, line_to = winid_or_opt.from - 1, winid_or_opt.to - 1
end
local min, max = math.min(line_from, line_to), math.max(line_from, line_to)
local nearest_dist, dist, target = math.huge, nil, {}
local top, bot
local fstack = {}
for _, comp in ipairs(stack) do
if comp.height > 0 then
fstack[#fstack + 1] = comp
if min <= comp.lend and max >= comp.lstart then
if not top then
top = { idx = #fstack, comp = comp }
bot = top
else
bot = { idx = #fstack, comp = comp }
end
end
dist = math.min(math.abs(line_to - comp.lstart), math.abs(line_to - comp.lend))
if dist < nearest_dist then
nearest_dist = dist
target = { idx = #fstack, comp = comp }
end
end
end
if not top and target.comp then
return utils.clamp(line_to + 1, target.comp.lstart + 1, target.comp.lend)
elseif top then
if line_to < line_from then
-- moving up
if line_to < top.comp.lstart and top.idx > 1 then
target = { idx = top.idx - 1, comp = fstack[top.idx - 1] }
else
target = top
end
return utils.clamp(line_to + 1, target.comp.lstart + 1, target.comp.lend)
else
-- moving down
if line_to >= bot.comp.lend and bot.idx < #fstack then
target = { idx = bot.idx + 1, comp = fstack[bot.idx + 1] }
else
target = bot
end
return utils.clamp(line_to + 1, target.comp.lstart + 1, target.comp.lend)
end
end
return line_from
end
end
---@param line_idx integer
---@param lines string[]
---@param hl_data renderer.HlData[]
---@param component RenderComponent
---@return integer
local function process_component(line_idx, lines, hl_data, component)
if #component.components > 0 then
component.lstart = line_idx
for _, c in ipairs(component.components) do
line_idx = process_component(line_idx, lines, hl_data, c)
end
component.lend = line_idx
component.height = component.lend - component.lstart
return line_idx
else
for _, line in ipairs(component.lines) do
lines[#lines + 1] = line
end
component.hl.offset = line_idx
hl_data[#hl_data + 1] = component.hl
component.height = #component.lines
if component.height > 0 then
component.lstart = line_idx
component.lend = line_idx + component.height
else
component.lstart = line_idx
component.lend = line_idx
end
return component.lend
end
end
---Render the given render data to the given buffer.
---@param bufid integer
---@param data RenderData
function M.render(bufid, data)
if not api.nvim_buf_is_loaded(bufid) then
return
end
local last = vim.loop.hrtime()
local was_modifiable = api.nvim_buf_get_option(bufid, "modifiable")
api.nvim_buf_set_option(bufid, "modifiable", true)
local lines, hl_data
local line_idx = 0
if #data.components > 0 then
lines = {}
hl_data = {}
for _, c in ipairs(data.components) do
line_idx = process_component(line_idx, lines, hl_data, c)
end
else
lines = data.lines
hl_data = { data.hl }
end
api.nvim_buf_set_lines(bufid, 0, -1, false, lines)
api.nvim_buf_clear_namespace(bufid, data.namespace, 0, -1)
for _, t in ipairs(hl_data) do
for _, hl in ipairs(t) do
api.nvim_buf_add_highlight(
bufid,
data.namespace,
hl.group,
hl.line_idx + (t.offset or 0),
hl.first,
hl.last
)
end
end
api.nvim_buf_set_option(bufid, "modifiable", was_modifiable)
M.last_draw_time = (vim.loop.hrtime() - last) / 1000000
end
M.RenderComponent = RenderComponent
M.RenderData = RenderData
return M

View File

@ -0,0 +1,50 @@
local oop = require("diffview.oop")
---@class Scanner : diffview.Object
---@operator call : Scanner
---@field lines string[]
---@field line_idx integer
local Scanner = oop.create_class("Scanner")
---@param source string|string[]
function Scanner:init(source)
if type(source) == "table" then
self.lines = source
else
self.lines = vim.split(source, "\r?\n")
end
self.line_idx = 0
end
---Peek the nth line after the current line.
---@param n? integer # (default: 1)
---@return string?
function Scanner:peek_line(n)
return self.lines[self.line_idx + math.max(1, n or 1)]
end
function Scanner:cur_line()
return self.lines[self.line_idx]
end
function Scanner:cur_line_idx()
return self.line_idx
end
---Advance the scanner to the next line.
---@return string?
function Scanner:next_line()
self.line_idx = self.line_idx + 1
return self.lines[self.line_idx]
end
---Advance the scanner by n lines.
---@param n? integer # (default: 1)
---@return string?
function Scanner:skip_line(n)
self.line_idx = self.line_idx + math.max(1, n or 1)
return self.lines[self.line_idx]
end
return Scanner

View File

@ -0,0 +1,360 @@
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local Diff1 = lazy.access("diffview.scene.layouts.diff_1", "Diff1") ---@type Diff1|LazyModule
local Diff2 = lazy.access("diffview.scene.layouts.diff_2", "Diff2") ---@type Diff2|LazyModule
local File = lazy.access("diffview.vcs.file", "File") ---@type vcs.File|LazyModule
local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local api = vim.api
local pl = lazy.access(utils, "path") ---@type PathLib
local M = {}
local fstat_cache = {}
---@class GitStats
---@field additions integer
---@field deletions integer
---@field conflicts integer
---@class RevMap
---@field a Rev
---@field b Rev
---@field c Rev
---@field d Rev
---@class FileEntry : diffview.Object
---@field adapter GitAdapter
---@field path string
---@field oldpath string
---@field absolute_path string
---@field parent_path string
---@field basename string
---@field extension string
---@field revs RevMap
---@field layout Layout
---@field status string
---@field stats GitStats
---@field kind vcs.FileKind
---@field commit Commit|nil
---@field merge_ctx vcs.MergeContext?
---@field active boolean
---@field opened boolean
local FileEntry = oop.create_class("FileEntry")
---@class FileEntry.init.Opt
---@field adapter GitAdapter
---@field path string
---@field oldpath string
---@field revs RevMap
---@field layout Layout
---@field status string
---@field stats GitStats
---@field kind vcs.FileKind
---@field commit? Commit
---@field merge_ctx? vcs.MergeContext
---FileEntry constructor
---@param opt FileEntry.init.Opt
function FileEntry:init(opt)
self.adapter = opt.adapter
self.path = opt.path
self.oldpath = opt.oldpath
self.absolute_path = pl:absolute(opt.path, opt.adapter.ctx.toplevel)
self.parent_path = pl:parent(opt.path) or ""
self.basename = pl:basename(opt.path)
self.extension = pl:extension(opt.path)
self.revs = opt.revs
self.layout = opt.layout
self.status = opt.status
self.stats = opt.stats
self.kind = opt.kind
self.commit = opt.commit
self.merge_ctx = opt.merge_ctx
self.active = false
self.opened = false
end
function FileEntry:destroy()
for _, f in ipairs(self.layout:files()) do
f:destroy()
end
self.layout:destroy()
end
---@param new_head Rev
function FileEntry:update_heads(new_head)
for _, file in ipairs(self.layout:files()) do
if file.rev.track_head then
file:dispose_buffer()
file.rev = new_head
end
end
end
---@param flag boolean
function FileEntry:set_active(flag)
self.active = flag
for _, f in ipairs(self.layout:files()) do
f.active = flag
end
end
---@param target_layout Layout
function FileEntry:convert_layout(target_layout)
local get_data
for _, file in ipairs(self.layout:files()) do
if file.get_data then
get_data = file.get_data
break
end
end
local function create_file(rev, symbol)
return File({
adapter = self.adapter,
path = symbol == "a" and self.oldpath or self.path,
kind = self.kind,
commit = self.commit,
get_data = get_data,
rev = rev,
nulled = select(2, pcall(target_layout.should_null, rev, self.status, symbol)),
}) --[[@as vcs.File ]]
end
self.layout = target_layout({
a = utils.tbl_access(self.layout, "a.file") or create_file(self.revs.a, "a"),
b = utils.tbl_access(self.layout, "b.file") or create_file(self.revs.b, "b"),
c = utils.tbl_access(self.layout, "c.file") or create_file(self.revs.c, "c"),
d = utils.tbl_access(self.layout, "d.file") or create_file(self.revs.d, "d"),
})
self:update_merge_context()
end
---@param stat? table
function FileEntry:validate_stage_buffers(stat)
stat = stat or pl:stat(pl:join(self.adapter.ctx.dir, "index"))
local cached_stat = utils.tbl_access(fstat_cache, { self.adapter.ctx.toplevel, "index" })
if stat and (not cached_stat or cached_stat.mtime < stat.mtime.sec) then
for _, f in ipairs(self.layout:files()) do
if f.rev.type == RevType.STAGE and f:is_valid() then
if f.rev.stage > 0 then
-- We only care about stage 0 here
f:dispose_buffer()
else
local is_modified = vim.bo[f.bufnr].modified
if f.blob_hash then
local new_hash = self.adapter:file_blob_hash(f.path)
if new_hash and new_hash ~= f.blob_hash then
if is_modified then
utils.warn((
"A file was changed in the index since you started editing it!"
.. " Be careful not to lose any staged changes when writing to this buffer: %s"
):format(api.nvim_buf_get_name(f.bufnr)))
else
f:dispose_buffer()
end
end
elseif not is_modified then
-- Should be very rare that we don't have an index-buffer's blob
-- hash. But in that case, we can't warn the user when a file
-- changes in the index while they're editing its index buffer.
f:dispose_buffer()
end
end
end
end
end
end
---Update winbar info
---@param ctx? vcs.MergeContext
function FileEntry:update_merge_context(ctx)
ctx = ctx or self.merge_ctx
if ctx then self.merge_ctx = ctx else return end
local layout = self.layout --[[@as Diff4 ]]
if layout.a then
layout.a.file.winbar = (" OURS (Current changes) %s %s"):format(
(ctx.ours.hash):sub(1, 10),
ctx.ours.ref_names and ("(" .. ctx.ours.ref_names .. ")") or ""
)
end
if layout.b then
layout.b.file.winbar = " LOCAL (Working tree)"
end
if layout.c then
layout.c.file.winbar = (" THEIRS (Incoming changes) %s %s"):format(
(ctx.theirs.hash):sub(1, 10),
ctx.theirs.ref_names and ("(" .. ctx.theirs.ref_names .. ")") or ""
)
end
if layout.d then
layout.d.file.winbar = (" BASE (Common ancestor) %s %s"):format(
(ctx.base.hash):sub(1, 10),
ctx.base.ref_names and ("(" .. ctx.base.ref_names .. ")") or ""
)
end
end
---Derive custom folds from the hunks in a diff patch.
---@param diff diff.FileEntry
function FileEntry:update_patch_folds(diff)
if not self.layout:instanceof(Diff2.__get()) then return end
local layout = self.layout --[[@as Diff2 ]]
local folds = {
a = utils.tbl_set(layout.a.file, { "custom_folds" }, { type = "diff_patch" }),
b = utils.tbl_set(layout.b.file, { "custom_folds" }, { type = "diff_patch" }),
}
local lcount_a = api.nvim_buf_line_count(layout.a.file.bufnr)
local lcount_b = api.nvim_buf_line_count(layout.b.file.bufnr)
local prev_last_old, prev_last_new = 0, 0
for i = 1, #diff.hunks + 1 do
local hunk = diff.hunks[i]
local first_old, last_old, first_new, last_new
if hunk then
first_old = hunk.old_row
last_old = first_old + hunk.old_size - 1
first_new = hunk.new_row
last_new = first_new + hunk.new_size - 1
else
first_old = lcount_a + 1
first_new = lcount_b + 1
end
if first_old - prev_last_old > 1 then
local prev_fold = folds.a[#folds.a]
if prev_fold and (prev_last_old + 1) - prev_fold[2] == 1 then
-- This fold is right next to the previous: merge the folds
prev_fold[2] = first_old - 1
else
table.insert(folds.a, { prev_last_old + 1, first_old - 1 })
end
-- print("old:", folds.a[#folds.a][1], folds.a[#folds.a][2])
end
if first_new - prev_last_new > 1 then
local prev_fold = folds.b[#folds.b]
if prev_fold and (prev_last_new + 1) - prev_fold[2] == 1 then
-- This fold is right next to the previous: merge the folds
prev_fold[2] = first_new - 1
else
table.insert(folds.b, { prev_last_new + 1, first_new - 1 })
end
-- print("new:", folds.b[#folds.b][1], folds.b[#folds.b][2])
end
prev_last_old = last_old
prev_last_new = last_new
end
end
---Check if the entry has custom diff patch folds.
---@return boolean
function FileEntry:has_patch_folds()
for _, file in ipairs(self.layout:files()) do
if not file.custom_folds or file.custom_folds.type ~= "diff_patch" then
return false
end
end
return true
end
---@return boolean
function FileEntry:is_null_entry()
return self.path == "null" and self.layout:get_main_win().file == File.NULL_FILE
end
---@static
---@param adapter VCSAdapter
function FileEntry.update_index_stat(adapter, stat)
stat = stat or pl:stat(pl:join(adapter.ctx.toplevel, "index"))
if stat then
if not fstat_cache[adapter.ctx.toplevel] then
fstat_cache[adapter.ctx.toplevel] = {}
end
fstat_cache[adapter.ctx.toplevel].index = {
mtime = stat.mtime.sec,
}
end
end
---@class FileEntry.with_layout.Opt : FileEntry.init.Opt
---@field nulled boolean
---@field get_data git.FileDataProducer?
---@param layout_class Layout (class)
---@param opt FileEntry.with_layout.Opt
---@return FileEntry
function FileEntry.with_layout(layout_class, opt)
local function create_file(rev, symbol)
return File({
adapter = opt.adapter,
path = symbol == "a" and opt.oldpath or opt.path,
kind = opt.kind,
commit = opt.commit,
get_data = opt.get_data,
rev = rev,
nulled = utils.sate(
opt.nulled,
select(2, pcall(layout_class.should_null, rev, opt.status, symbol))
),
}) --[[@as vcs.File ]]
end
return FileEntry({
adapter = opt.adapter,
path = opt.path,
oldpath = opt.oldpath,
status = opt.status,
stats = opt.stats,
kind = opt.kind,
commit = opt.commit,
revs = opt.revs,
layout = layout_class({
a = create_file(opt.revs.a, "a"),
b = create_file(opt.revs.b, "b"),
c = create_file(opt.revs.c, "c"),
d = create_file(opt.revs.d, "d"),
}),
})
end
function FileEntry.new_null_entry(adapter)
return FileEntry({
adapter = adapter,
path = "null",
kind = "working",
binary = false,
nulled = true,
layout = Diff1({
b = File.NULL_FILE,
})
})
end
M.FileEntry = FileEntry
return M

View File

@ -0,0 +1,312 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local EventEmitter = lazy.access("diffview.events", "EventEmitter") ---@type EventEmitter|LazyModule
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local api = vim.api
local await = async.await
local M = {}
---@class Layout : diffview.Object
---@field windows Window[]
---@field emitter EventEmitter
---@field pivot_producer fun(): integer?
---@field name string
---@field state table
local Layout = oop.create_class("Layout")
function Layout:init(opt)
opt = opt or {}
self.windows = opt.windows or {}
self.emitter = opt.emitter or EventEmitter()
self.state = {}
end
---@diagnostic disable: unused-local, missing-return
---@abstract
---@param self Layout
---@param pivot? integer The window ID of the window around which the layout will be created.
Layout.create = async.void(function(self, pivot) oop.abstract_stub() end)
---@abstract
---@param rev Rev
---@param status string Git status symbol.
---@param sym string
---@return boolean
function Layout.should_null(rev, status, sym) oop.abstract_stub() end
---@abstract
---@param self Layout
---@param entry FileEntry
Layout.use_entry = async.void(function(self, entry) oop.abstract_stub() end)
---@abstract
---@return Window
function Layout:get_main_win() oop.abstract_stub() end
---@diagnostic enable: unused-local, missing-return
function Layout:destroy()
for _, win in ipairs(self.windows) do
win:destroy()
end
end
function Layout:clone()
local clone = self.class({ emitter = self.emitter }) --[[@as Layout ]]
for i, win in ipairs(self.windows) do
clone.windows[i]:set_id(win.id)
clone.windows[i]:set_file(win.file)
end
return clone
end
function Layout:create_pre()
self.state.save_equalalways = vim.o.equalalways
vim.opt.equalalways = true
end
---@param self Layout
Layout.create_post = async.void(function(self)
await(self:open_files())
vim.opt.equalalways = self.state.save_equalalways
end)
---Check if any of the windows in the lauout are focused.
---@return boolean
function Layout:is_focused()
for _, win in ipairs(self.windows) do
if win:is_focused() then return true end
end
return false
end
---@param ... Window
function Layout:use_windows(...)
local wins = { ... }
for i = 1, select("#", ...) do
local win = wins[i]
win.parent = self
if utils.vec_indexof(self.windows, win) == -1 then
table.insert(self.windows, win)
end
end
end
---Find or create a window that can be used as a pivot during layout
---creation.
---@return integer winid
function Layout:find_pivot()
local last_win = api.nvim_get_current_win()
for _, win in ipairs(self.windows) do
if win:is_valid() then
local ret
api.nvim_win_call(win.id, function()
vim.cmd("aboveleft vsp")
ret = api.nvim_get_current_win()
end)
return ret
end
end
if vim.is_callable(self.pivot_producer) then
local ret = self.pivot_producer()
if ret then
return ret
end
end
vim.cmd("1windo belowright vsp")
local pivot = api.nvim_get_current_win()
if api.nvim_win_is_valid(last_win) then
api.nvim_set_current_win(last_win)
end
return pivot
end
---@return vcs.File[]
function Layout:files()
return utils.tbl_fmap(self.windows, function(v)
return v.file
end)
end
---Check if the buffers for all the files in the layout are loaded.
---@return boolean
function Layout:is_files_loaded()
for _, f in ipairs(self:files()) do
if not f:is_valid() then
return false
end
end
return true
end
---@param self Layout
Layout.open_files = async.void(function(self)
if #self:files() < #self.windows then
self:open_null()
self.emitter:emit("files_opened")
return
end
vim.cmd("diffoff!")
if not self:is_files_loaded() then
self:open_null()
-- Wait for all files to be loaded before opening
for _, win in ipairs(self.windows) do
await(win:load_file())
end
end
await(async.scheduler())
for _, win in ipairs(self.windows) do
await(win:open_file())
end
self:sync_scroll()
self.emitter:emit("files_opened")
end)
function Layout:open_null()
for _, win in ipairs(self.windows) do
win:open_null()
end
end
---Recover a broken layout.
---@param pivot? integer
function Layout:recover(pivot)
pivot = pivot or self:find_pivot()
---@cast pivot -?
for _, win in ipairs(self.windows) do
if win.id ~= pivot then
pcall(api.nvim_win_close, win.id, true)
end
end
self.windows = {}
self:create(pivot)
end
---@alias Layout.State { [Window]: boolean, valid: boolean }
---Check the validity of all composing layout windows.
---@return Layout.State
function Layout:validate()
if not next(self.windows) then
return { valid = false }
end
local state = { valid = true }
for _, win in ipairs(self.windows) do
state[win] = win:is_valid()
if not state[win] then
state.valid = false
end
end
return state
end
---Check the validity if the layout.
---@return boolean
function Layout:is_valid()
return self:validate().valid
end
---@return boolean
function Layout:is_nulled()
if not self:is_valid() then return false end
for _, win in ipairs(self.windows) do
if not win:is_nulled() then return false end
end
return true
end
---Validate the layout and recover if necessary.
function Layout:ensure()
local state = self:validate()
if not state.valid then
self:recover()
end
end
---Save window local options.
function Layout:save_winopts()
for _, win in ipairs(self.windows) do
win:_save_winopts()
end
end
---Restore saved window local options.
function Layout:restore_winopts()
for _, win in ipairs(self.windows) do
win:_restore_winopts()
end
end
function Layout:detach_files()
for _, win in ipairs(self.windows) do
win:detach_file()
end
end
---Sync the scrollbind.
function Layout:sync_scroll()
local curwin = api.nvim_get_current_win()
local target, max = nil, 0
for _, win in ipairs(self.windows) do
local lcount = api.nvim_buf_line_count(api.nvim_win_get_buf(win.id))
if lcount > max then target, max = win, lcount end
end
local main_win = self:get_main_win()
local cursor = api.nvim_win_get_cursor(main_win.id)
for _, win in ipairs(self.windows) do
api.nvim_win_call(win.id, function()
if win == target then
-- Scroll to trigger the scrollbind and sync the windows. This works more
-- consistently than calling `:syncbind`.
vim.cmd("norm! " .. api.nvim_replace_termcodes("<c-e><c-y>", true, true, true))
end
if win.id ~= curwin then
api.nvim_exec_autocmds("WinLeave", { modeline = false })
end
end)
end
-- Cursor will sometimes move +- the value of 'scrolloff'
api.nvim_win_set_cursor(target.id, cursor)
end
M.Layout = Layout
return M

View File

@ -0,0 +1,169 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local Layout = require("diffview.scene.layout").Layout
local oop = require("diffview.oop")
local Diff3 = lazy.access("diffview.scene.layouts.diff_3", "Diff3") ---@type Diff3|LazyModule
local Diff4 = lazy.access("diffview.scene.layouts.diff_4", "Diff4") ---@type Diff4|LazyModule
local File = lazy.access("diffview.vcs.file", "File") ---@type vcs.File|LazyModule
local Rev = lazy.access("diffview.vcs.rev", "Rev") ---@type Rev|LazyModule
local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule
local Window = lazy.access("diffview.scene.window", "Window") ---@type Window|LazyModule
local api = vim.api
local await = async.await
local M = {}
---@class Diff1 : Layout
---@field b Window
local Diff1 = oop.create_class("Diff1", Layout)
---@alias Diff1.WindowSymbol "b"
---@class Diff1.init.Opt
---@field b vcs.File
---@field winid_b integer
Diff1.name = "diff1_plain"
---@param opt Diff1.init.Opt
function Diff1:init(opt)
self:super()
self.b = Window({ file = opt.b, id = opt.winid_b })
self:use_windows(self.b)
end
---@override
---@param self Diff1
---@param pivot integer?
Diff1.create = async.void(function(self, pivot)
self:create_pre()
local curwin
pivot = pivot or self:find_pivot()
assert(api.nvim_win_is_valid(pivot), "Layout creation requires a valid window pivot!")
for _, win in ipairs(self.windows) do
if win.id ~= pivot then
win:close(true)
end
end
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft vsp")
curwin = api.nvim_get_current_win()
if self.b then
self.b:set_id(curwin)
else
self.b = Window({ id = curwin })
end
end)
api.nvim_win_close(pivot, true)
self.windows = { self.b }
await(self:create_post())
end)
---@param file vcs.File
function Diff1:set_file_b(file)
self.b:set_file(file)
file.symbol = "b"
end
---@param self Diff1
---@param entry FileEntry
Diff1.use_entry = async.void(function(self, entry)
local layout = entry.layout --[[@as Diff1 ]]
assert(layout:instanceof(Diff1))
self:set_file_b(layout.b.file)
if self:is_valid() then
await(self:open_files())
end
end)
function Diff1:get_main_win()
return self.b
end
---@param layout Diff3
---@return Diff3
function Diff1:to_diff3(layout)
assert(layout:instanceof(Diff3.__get()))
local main = self:get_main_win().file
return layout({
a = File({
adapter = main.adapter,
path = main.path,
kind = main.kind,
commit = main.commit,
get_data = main.get_data,
rev = Rev(RevType.STAGE, 2),
nulled = false, -- FIXME
}),
b = self.b.file,
c = File({
adapter = main.adapter,
path = main.path,
kind = main.kind,
commit = main.commit,
get_data = main.get_data,
rev = Rev(RevType.STAGE, 3),
nulled = false, -- FIXME
}),
})
end
---@param layout Diff4
---@return Diff4
function Diff1:to_diff4(layout)
assert(layout:instanceof(Diff4.__get()))
local main = self:get_main_win().file
return layout({
a = File({
adapter = main.adapter,
path = main.path,
kind = main.kind,
commit = main.commit,
get_data = main.get_data,
rev = Rev(RevType.STAGE, 2),
nulled = false, -- FIXME
}),
b = self.b.file,
c = File({
adapter = main.adapter,
path = main.path,
kind = main.kind,
commit = main.commit,
get_data = main.get_data,
rev = Rev(RevType.STAGE, 3),
nulled = false, -- FIXME
}),
d = File({
adapter = main.adapter,
path = main.path,
kind = main.kind,
commit = main.commit,
get_data = main.get_data,
rev = Rev(RevType.STAGE, 1),
nulled = false, -- FIXME
})
})
end
---FIXME
---@override
---@param rev Rev
---@param status string Git status symbol.
---@param sym Diff1.WindowSymbol
function Diff1.should_null(rev, status, sym)
return false
end
M.Diff1 = Diff1
return M

View File

@ -0,0 +1,91 @@
local async = require("diffview.async")
local RevType = require("diffview.vcs.rev").RevType
local Window = require("diffview.scene.window").Window
local Layout = require("diffview.scene.layout").Layout
local oop = require("diffview.oop")
local await = async.await
local M = {}
---@class Diff2 : Layout
---@field a Window
---@field b Window
local Diff2 = oop.create_class("Diff2", Layout)
---@alias Diff2.WindowSymbol "a"|"b"
---@class Diff2.init.Opt
---@field a vcs.File
---@field b vcs.File
---@field winid_a integer
---@field winid_b integer
---@param opt Diff2.init.Opt
function Diff2:init(opt)
self:super()
self.a = Window({ file = opt.a, id = opt.winid_a })
self.b = Window({ file = opt.b, id = opt.winid_b })
self:use_windows(self.a, self.b)
end
---@param file vcs.File
function Diff2:set_file_a(file)
self.a:set_file(file)
file.symbol = "a"
end
---@param file vcs.File
function Diff2:set_file_b(file)
self.b:set_file(file)
file.symbol = "b"
end
---@param self Diff2
---@param entry FileEntry
Diff2.use_entry = async.void(function(self, entry)
local layout = entry.layout --[[@as Diff2 ]]
assert(layout:instanceof(Diff2))
self:set_file_a(layout.a.file)
self:set_file_b(layout.b.file)
if self:is_valid() then
await(self:open_files())
end
end)
function Diff2:get_main_win()
return self.b
end
---@override
---@param rev Rev
---@param status string Git status symbol.
---@param sym Diff2.WindowSymbol
function Diff2.should_null(rev, status, sym)
assert(sym == "a" or sym == "b")
if rev.type == RevType.LOCAL then
return status == "D"
elseif rev.type == RevType.COMMIT then
if sym == "a" then
return vim.tbl_contains({ "?", "A" }, status)
end
return false
elseif rev.type == RevType.STAGE then
if sym == "a" then
return vim.tbl_contains({ "?", "A" }, status)
elseif sym == "b" then
return status == "D"
end
end
error(("Unexpected state! %s, %s, %s"):format(rev, status, sym))
end
M.Diff2 = Diff2
return M

View File

@ -0,0 +1,71 @@
local async = require("diffview.async")
local Window = require("diffview.scene.window").Window
local Diff2 = require("diffview.scene.layouts.diff_2").Diff2
local oop = require("diffview.oop")
local api = vim.api
local await = async.await
local M = {}
---@class Diff2Hor : Diff2
local Diff2Hor = oop.create_class("Diff2Hor", Diff2)
Diff2Hor.name = "diff2_horizontal"
---@class Diff2Hor.init.Opt
---@field a vcs.File
---@field b vcs.File
---@field winid_a integer
---@field winid_b integer
---@param opt Diff2Hor.init.Opt
function Diff2Hor:init(opt)
self:super(opt)
end
---@override
---@param self Diff2Hor
---@param pivot integer?
Diff2Hor.create = async.void(function(self, pivot)
self:create_pre()
local curwin
pivot = pivot or self:find_pivot()
assert(api.nvim_win_is_valid(pivot), "Layout creation requires a valid window pivot!")
for _, win in ipairs(self.windows) do
if win.id ~= pivot then
win:close(true)
end
end
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft vsp")
curwin = api.nvim_get_current_win()
if self.a then
self.a:set_id(curwin)
else
self.a = Window({ id = curwin })
end
end)
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft vsp")
curwin = api.nvim_get_current_win()
if self.b then
self.b:set_id(curwin)
else
self.b = Window({ id = curwin })
end
end)
api.nvim_win_close(pivot, true)
self.windows = { self.a, self.b }
await(self:create_post())
end)
M.Diff2Hor = Diff2Hor
return M

View File

@ -0,0 +1,73 @@
local async = require("diffview.async")
local Window = require("diffview.scene.window").Window
local Diff2 = require("diffview.scene.layouts.diff_2").Diff2
local oop = require("diffview.oop")
local api = vim.api
local await = async.await
local M = {}
---@class Diff2Ver : Diff2
---@field a Window
---@field b Window
local Diff2Ver = oop.create_class("Diff2Ver", Diff2)
Diff2Ver.name = "diff2_vertical"
---@class Diff2Ver.init.Opt
---@field a vcs.File
---@field b vcs.File
---@field winid_a integer
---@field winid_b integer
---@param opt Diff2Hor.init.Opt
function Diff2Ver:init(opt)
self:super(opt)
end
---@override
---@param self Diff2Ver
---@param pivot integer?
Diff2Ver.create = async.void(function(self, pivot)
self:create_pre()
local curwin
pivot = pivot or self:find_pivot()
assert(api.nvim_win_is_valid(pivot), "Layout creation requires a valid window pivot!")
for _, win in ipairs(self.windows) do
if win.id ~= pivot then
win:close(true)
end
end
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft sp")
curwin = api.nvim_get_current_win()
if self.a then
self.a:set_id(curwin)
else
self.a = Window({ id = curwin })
end
end)
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft sp")
curwin = api.nvim_get_current_win()
if self.b then
self.b:set_id(curwin)
else
self.b = Window({ id = curwin })
end
end)
api.nvim_win_close(pivot, true)
self.windows = { self.a, self.b }
await(self:create_post())
end)
M.Diff2Ver = Diff2Ver
return M

View File

@ -0,0 +1,119 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local Window = require("diffview.scene.window").Window
local Layout = require("diffview.scene.layout").Layout
local oop = require("diffview.oop")
local Diff1 = lazy.access("diffview.scene.layouts.diff_1", "Diff1") ---@type Diff1|LazyModule
local Diff4 = lazy.access("diffview.scene.layouts.diff_4", "Diff4") ---@type Diff4|LazyModule
local File = lazy.access("diffview.vcs.file", "File") ---@type vcs.File|LazyModule
local Rev = lazy.access("diffview.vcs.rev", "Rev") ---@type Rev|LazyModule
local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule
local await = async.await
local M = {}
---@class Diff3 : Layout
---@field a Window
---@field b Window
---@field c Window
local Diff3 = oop.create_class("Diff3", Layout)
---@alias Diff3.WindowSymbol "a"|"b"|"c"
---@class Diff3.init.Opt
---@field a vcs.File
---@field b vcs.File
---@field c vcs.File
---@field winid_a integer
---@field winid_b integer
---@field winid_c integer
---@param opt Diff3.init.Opt
function Diff3:init(opt)
self:super()
self.a = Window({ file = opt.a, id = opt.winid_a })
self.b = Window({ file = opt.b, id = opt.winid_b })
self.c = Window({ file = opt.c, id = opt.winid_c })
self:use_windows(self.a, self.b, self.c)
end
---@param file vcs.File
function Diff3:set_file_a(file)
self.a:set_file(file)
file.symbol = "a"
end
---@param file vcs.File
function Diff3:set_file_b(file)
self.b:set_file(file)
file.symbol = "b"
end
---@param file vcs.File
function Diff3:set_file_c(file)
self.c:set_file(file)
file.symbol = "c"
end
---@param self Diff3
---@param entry FileEntry
Diff3.use_entry = async.void(function(self, entry)
local layout = entry.layout --[[@as Diff3 ]]
assert(layout:instanceof(Diff3))
self:set_file_a(layout.a.file)
self:set_file_b(layout.b.file)
self:set_file_c(layout.c.file)
if self:is_valid() then
await(self:open_files())
end
end)
function Diff3:get_main_win()
return self.b
end
---@param layout Diff1
---@return Diff1
function Diff3:to_diff1(layout)
assert(layout:instanceof(Diff1.__get()))
return layout({ a = self:get_main_win().file })
end
---@param layout Diff4
---@return Diff4
function Diff3:to_diff4(layout)
assert(layout:instanceof(Diff4.__get()))
local main = self:get_main_win().file
return layout({
a = self.a.file,
b = self.b.file,
c = self.c.file,
d = File({
adapter = main.adapter,
path = main.path,
kind = main.kind,
commit = main.commit,
get_data = main.get_data,
rev = Rev(RevType.STAGE, 1),
nulled = false, -- FIXME
})
})
end
---FIXME
---@override
---@param rev Rev
---@param status string Git status symbol.
---@param sym Diff3.WindowSymbol
function Diff3.should_null(rev, status, sym)
return false
end
M.Diff3 = Diff3
return M

View File

@ -0,0 +1,78 @@
local async = require("diffview.async")
local Window = require("diffview.scene.window").Window
local Diff3 = require("diffview.scene.layouts.diff_3").Diff3
local oop = require("diffview.oop")
local api = vim.api
local await = async.await
local M = {}
---@class Diff3Hor : Diff3
---@field a Window
---@field b Window
---@field c Window
local Diff3Hor = oop.create_class("Diff3Hor", Diff3)
Diff3Hor.name = "diff3_horizontal"
function Diff3Hor:init(opt)
self:super(opt)
end
---@override
---@param self Diff3Hor
---@param pivot integer?
Diff3Hor.create = async.void(function(self, pivot)
self:create_pre()
local curwin
pivot = pivot or self:find_pivot()
assert(api.nvim_win_is_valid(pivot), "Layout creation requires a valid window pivot!")
for _, win in ipairs(self.windows) do
if win.id ~= pivot then
win:close(true)
end
end
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft vsp")
curwin = api.nvim_get_current_win()
if self.a then
self.a:set_id(curwin)
else
self.a = Window({ id = curwin })
end
end)
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft vsp")
curwin = api.nvim_get_current_win()
if self.b then
self.b:set_id(curwin)
else
self.b = Window({ id = curwin })
end
end)
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft vsp")
curwin = api.nvim_get_current_win()
if self.c then
self.c:set_id(curwin)
else
self.c = Window({ id = curwin })
end
end)
api.nvim_win_close(pivot, true)
self.windows = { self.a, self.b, self.c }
await(self:create_post())
end)
M.Diff3Hor = Diff3Hor
return M

View File

@ -0,0 +1,78 @@
local async = require("diffview.async")
local Window = require("diffview.scene.window").Window
local Diff3 = require("diffview.scene.layouts.diff_3").Diff3
local oop = require("diffview.oop")
local api = vim.api
local await = async.await
local M = {}
---@class Diff3Mixed : Diff3
---@field a Window
---@field b Window
---@field c Window
local Diff3Mixed = oop.create_class("Diff3Mixed", Diff3)
Diff3Mixed.name = "diff3_mixed"
function Diff3Mixed:init(opt)
self:super(opt)
end
---@override
---@param self Diff3Mixed
---@param pivot integer?
Diff3Mixed.create = async.void(function(self, pivot)
self:create_pre()
local curwin
pivot = pivot or self:find_pivot()
assert(api.nvim_win_is_valid(pivot), "Layout creation requires a valid window pivot!")
for _, win in ipairs(self.windows) do
if win.id ~= pivot then
win:close(true)
end
end
api.nvim_win_call(pivot, function()
vim.cmd("belowright sp")
curwin = api.nvim_get_current_win()
if self.b then
self.b:set_id(curwin)
else
self.b = Window({ id = curwin })
end
end)
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft vsp")
curwin = api.nvim_get_current_win()
if self.a then
self.a:set_id(curwin)
else
self.a = Window({ id = curwin })
end
end)
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft vsp")
curwin = api.nvim_get_current_win()
if self.c then
self.c:set_id(curwin)
else
self.c = Window({ id = curwin })
end
end)
api.nvim_win_close(pivot, true)
self.windows = { self.a, self.b, self.c }
await(self:create_post())
end)
M.Diff3Mixed = Diff3Mixed
return M

View File

@ -0,0 +1,78 @@
local async = require("diffview.async")
local Window = require("diffview.scene.window").Window
local Diff3 = require("diffview.scene.layouts.diff_3").Diff3
local oop = require("diffview.oop")
local api = vim.api
local await = async.await
local M = {}
---@class Diff3Ver : Diff3
---@field a Window
---@field b Window
---@field c Window
local Diff3Ver = oop.create_class("Diff3Ver", Diff3)
Diff3Ver.name = "diff3_vertical"
function Diff3Ver:init(opt)
self:super(opt)
end
---@override
---@param self Diff3Ver
---@param pivot integer?
Diff3Ver.create = async.void(function(self, pivot)
self:create_pre()
local curwin
pivot = pivot or self:find_pivot()
assert(api.nvim_win_is_valid(pivot), "Layout creation requires a valid window pivot!")
for _, win in ipairs(self.windows) do
if win.id ~= pivot then
win:close(true)
end
end
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft sp")
curwin = api.nvim_get_current_win()
if self.a then
self.a:set_id(curwin)
else
self.a = Window({ id = curwin })
end
end)
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft sp")
curwin = api.nvim_get_current_win()
if self.b then
self.b:set_id(curwin)
else
self.b = Window({ id = curwin })
end
end)
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft sp")
curwin = api.nvim_get_current_win()
if self.c then
self.c:set_id(curwin)
else
self.c = Window({ id = curwin })
end
end)
api.nvim_win_close(pivot, true)
self.windows = { self.a, self.b, self.c }
await(self:create_post())
end)
M.Diff3Ver = Diff3Ver
return M

View File

@ -0,0 +1,116 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local Window = require("diffview.scene.window").Window
local Layout = require("diffview.scene.layout").Layout
local oop = require("diffview.oop")
local Diff1 = lazy.access("diffview.scene.layouts.diff_1", "Diff1") ---@type Diff1|LazyModule
local Diff3 = lazy.access("diffview.scene.layouts.diff_3", "Diff3") ---@type Diff3|LazyModule
local await = async.await
local M = {}
---@class Diff4 : Layout
---@field a Window
---@field b Window
---@field c Window
---@field d Window
local Diff4 = oop.create_class("Diff4", Layout)
---@alias Diff4.WindowSymbol "a"|"b"|"c"|"d"
---@class Diff4.init.Opt
---@field a vcs.File
---@field b vcs.File
---@field c vcs.File
---@field d vcs.File
---@field winid_a integer
---@field winid_b integer
---@field winid_c integer
---@field winid_d integer
---@param opt Diff4.init.Opt
function Diff4:init(opt)
self:super()
self.a = Window({ file = opt.a, id = opt.winid_a })
self.b = Window({ file = opt.b, id = opt.winid_b })
self.c = Window({ file = opt.c, id = opt.winid_c })
self.d = Window({ file = opt.d, id = opt.winid_d })
self:use_windows(self.a, self.b, self.c, self.d)
end
---@param file vcs.File
function Diff4:set_file_a(file)
self.a:set_file(file)
file.symbol = "a"
end
---@param file vcs.File
function Diff4:set_file_b(file)
self.b:set_file(file)
file.symbol = "b"
end
---@param file vcs.File
function Diff4:set_file_c(file)
self.c:set_file(file)
file.symbol = "c"
end
---@param file vcs.File
function Diff4:set_file_d(file)
self.d:set_file(file)
file.symbol = "d"
end
---@param self Diff4
---@param entry FileEntry
Diff4.use_entry = async.void(function(self, entry)
local layout = entry.layout --[[@as Diff4 ]]
assert(layout:instanceof(Diff4))
self:set_file_a(layout.a.file)
self:set_file_b(layout.b.file)
self:set_file_c(layout.c.file)
self:set_file_d(layout.d.file)
if self:is_valid() then
await(self:open_files())
end
end)
function Diff4:get_main_win()
return self.b
end
---@param layout Diff1
---@return Diff1
function Diff4:to_diff1(layout)
assert(layout:instanceof(Diff1.__get()))
return layout({ a = self:get_main_win().file })
end
---@param layout Diff3
---@return Diff3
function Diff4:to_diff3(layout)
assert(layout:instanceof(Diff3.__get()))
return layout({
a = self.a.file,
b = self.b.file,
c = self.c.file,
})
end
---FIXME
---@override
---@param rev Rev
---@param status string Git status symbol.
---@param sym Diff4.WindowSymbol
function Diff4.should_null(rev, status, sym)
return false
end
M.Diff4 = Diff4
return M

View File

@ -0,0 +1,90 @@
local async = require("diffview.async")
local Window = require("diffview.scene.window").Window
local Diff4 = require("diffview.scene.layouts.diff_4").Diff4
local oop = require("diffview.oop")
local api = vim.api
local await = async.await
local M = {}
---@class Diff4Mixed : Diff4
---@field a Window
---@field b Window
---@field c Window
---@field d Window
local Diff4Mixed = oop.create_class("Diff4Mixed", Diff4)
Diff4Mixed.name = "diff4_mixed"
function Diff4Mixed:init(opt)
self:super(opt)
end
---@override
---@param self Diff4Mixed
---@param pivot integer?
Diff4Mixed.create = async.void(function(self, pivot)
self:create_pre()
local curwin
pivot = pivot or self:find_pivot()
assert(api.nvim_win_is_valid(pivot), "Layout creation requires a valid window pivot!")
for _, win in ipairs(self.windows) do
if win.id ~= pivot then
win:close(true)
end
end
api.nvim_win_call(pivot, function()
vim.cmd("belowright sp")
curwin = api.nvim_get_current_win()
if self.b then
self.b:set_id(curwin)
else
self.b = Window({ id = curwin })
end
end)
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft vsp")
curwin = api.nvim_get_current_win()
if self.a then
self.a:set_id(curwin)
else
self.a = Window({ id = curwin })
end
end)
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft vsp")
curwin = api.nvim_get_current_win()
if self.d then
self.d:set_id(curwin)
else
self.d = Window({ id = curwin })
end
end)
api.nvim_win_call(pivot, function()
vim.cmd("aboveleft vsp")
curwin = api.nvim_get_current_win()
if self.c then
self.c:set_id(curwin)
else
self.c = Window({ id = curwin })
end
end)
api.nvim_win_close(pivot, true)
self.windows = { self.a, self.b, self.c, self.d }
await(self:create_post())
end)
M.Diff4Mixed = Diff4Mixed
return M

View File

@ -0,0 +1,166 @@
local lazy = require("diffview.lazy")
local Diff1 = lazy.access("diffview.scene.layouts.diff_1", "Diff1") ---@type Diff1|LazyModule
local Diff2Hor = lazy.access("diffview.scene.layouts.diff_2_hor", "Diff2Hor") ---@type Diff2Hor|LazyModule
local Diff2Ver = lazy.access("diffview.scene.layouts.diff_2_ver", "Diff2Ver") ---@type Diff2Ver|LazyModule
local Diff3Hor = lazy.access("diffview.scene.layouts.diff_3_hor", "Diff3Hor") ---@type Diff3Hor|LazyModule
local Diff3Ver = lazy.access("diffview.scene.layouts.diff_3_ver", "Diff3Ver") ---@type Diff3Ver|LazyModule
local Diff4Mixed = lazy.access("diffview.scene.layouts.diff_4_mixed", "Diff4Mixed") ---@type Diff4Mixed|LazyModule
local EventEmitter = lazy.access("diffview.events", "EventEmitter") ---@type EventEmitter|LazyModule
local File = lazy.access("diffview.vcs.file", "File") ---@type vcs.File|LazyModule
local Signal = lazy.access("diffview.control", "Signal") ---@type Signal|LazyModule
local config = lazy.require("diffview.config") ---@module "diffview.config"
local oop = lazy.require("diffview.oop") ---@module "diffview.oop"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local api = vim.api
local M = {}
---@enum LayoutMode
local LayoutMode = oop.enum({
HORIZONTAL = 1,
VERTICAL = 2,
})
---@class View : diffview.Object
---@field tabpage integer
---@field emitter EventEmitter
---@field default_layout Layout (class)
---@field ready boolean
---@field closing Signal
local View = oop.create_class("View")
---@diagnostic disable unused-local
---@abstract
function View:init_layout() oop.abstract_stub() end
---@abstract
function View:post_open() oop.abstract_stub() end
---@diagnostic enable unused-local
---View constructor
function View:init(opt)
opt = opt or {}
self.emitter = opt.emitter or EventEmitter()
self.default_layout = opt.default_layout or View.get_default_layout()
self.ready = utils.sate(opt.ready, false)
self.closing = utils.sate(opt.closing, Signal())
local function wrap_event(event)
DiffviewGlobal.emitter:on(event, function(_, view, ...)
local cur_view = require("diffview.lib").get_current_view()
if (view and view == self) or (not view and cur_view == self) then
self.emitter:emit(event, view, ...)
end
end)
end
wrap_event("view_closed")
end
function View:open()
vim.cmd("tab split")
self.tabpage = api.nvim_get_current_tabpage()
self:init_layout()
self:post_open()
DiffviewGlobal.emitter:emit("view_opened", self)
DiffviewGlobal.emitter:emit("view_enter", self)
end
function View:close()
self.closing:send()
if self.tabpage and api.nvim_tabpage_is_valid(self.tabpage) then
DiffviewGlobal.emitter:emit("view_leave", self)
if #api.nvim_list_tabpages() == 1 then
vim.cmd("tabnew")
end
local pagenr = api.nvim_tabpage_get_number(self.tabpage)
vim.cmd("tabclose " .. pagenr)
end
DiffviewGlobal.emitter:emit("view_closed", self)
end
function View:is_cur_tabpage()
return self.tabpage == api.nvim_get_current_tabpage()
end
---@return boolean
local function prefer_horizontal()
return vim.tbl_contains(vim.opt.diffopt:get(), "vertical")
end
---@return Diff1
function View.get_default_diff1()
return Diff1.__get()
end
---@return Diff2
function View.get_default_diff2()
if prefer_horizontal() then
return Diff2Hor.__get()
else
return Diff2Ver.__get()
end
end
---@return Diff3
function View.get_default_diff3()
if prefer_horizontal() then
return Diff3Hor.__get()
else
return Diff3Ver.__get()
end
end
---@return Diff4
function View.get_default_diff4()
return Diff4Mixed.__get()
end
---@return LayoutName|-1
function View.get_default_layout_name()
return config.get_config().view.default.layout
end
---@return Layout # (class) The default layout class.
function View.get_default_layout()
local name = View.get_default_layout_name()
if name == -1 then
return View.get_default_diff2()
end
return config.name_to_layout(name --[[@as string ]])
end
---@return Layout
function View.get_default_merge_layout()
local name = config.get_config().view.merge_tool.layout
if name == -1 then
return View.get_default_diff3()
end
return config.name_to_layout(name)
end
---@return Diff2
function View.get_temp_layout()
local layout_class = View.get_default_layout()
return layout_class({
a = File.NULL_FILE,
b = File.NULL_FILE,
})
end
M.LayoutMode = LayoutMode
M.View = View
return M

View File

@ -0,0 +1,556 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local CommitLogPanel = lazy.access("diffview.ui.panels.commit_log_panel", "CommitLogPanel") ---@type CommitLogPanel|LazyModule
local Diff = lazy.access("diffview.diff", "Diff") ---@type Diff|LazyModule
local EditToken = lazy.access("diffview.diff", "EditToken") ---@type EditToken|LazyModule
local EventName = lazy.access("diffview.events", "EventName") ---@type EventName|LazyModule
local FileDict = lazy.access("diffview.vcs.file_dict", "FileDict") ---@type FileDict|LazyModule
local FileEntry = lazy.access("diffview.scene.file_entry", "FileEntry") ---@type FileEntry|LazyModule
local FilePanel = lazy.access("diffview.scene.views.diff.file_panel", "FilePanel") ---@type FilePanel|LazyModule
local PerfTimer = lazy.access("diffview.perf", "PerfTimer") ---@type PerfTimer|LazyModule
local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule
local StandardView = lazy.access("diffview.scene.views.standard.standard_view", "StandardView") ---@type StandardView|LazyModule
local config = lazy.require("diffview.config") ---@module "diffview.config"
local debounce = lazy.require("diffview.debounce") ---@module "diffview.debounce"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.utils"
local GitAdapter = lazy.access("diffview.vcs.adapters.git", "GitAdapter") ---@type GitAdapter|LazyModule
local api = vim.api
local await = async.await
local fmt = string.format
local logger = DiffviewGlobal.logger
local pl = lazy.access(utils, "path") ---@type PathLib
local M = {}
---@class DiffViewOptions
---@field show_untracked? boolean
---@field selected_file? string Path to the preferred initially selected file.
---@class DiffView : StandardView
---@operator call : DiffView
---@field adapter VCSAdapter
---@field rev_arg string
---@field path_args string[]
---@field left Rev
---@field right Rev
---@field options DiffViewOptions
---@field panel FilePanel
---@field commit_log_panel CommitLogPanel
---@field files FileDict
---@field file_idx integer
---@field merge_ctx? vcs.MergeContext
---@field initialized boolean
---@field valid boolean
---@field watcher uv_fs_poll_t # UV fs poll handle.
local DiffView = oop.create_class("DiffView", StandardView.__get())
---DiffView constructor
function DiffView:init(opt)
self.valid = false
self.files = FileDict()
self.adapter = opt.adapter
self.path_args = opt.path_args
self.rev_arg = opt.rev_arg
self.left = opt.left
self.right = opt.right
self.initialized = false
self.options = opt.options or {}
self.options.selected_file = self.options.selected_file
and pl:chain(self.options.selected_file)
:absolute()
:relative(self.adapter.ctx.toplevel)
:get()
self:super({
panel = FilePanel(
self.adapter,
self.files,
self.path_args,
self.rev_arg or self.adapter:rev_to_pretty_string(self.left, self.right)
),
})
self.attached_bufs = {}
self.emitter:on("file_open_post", utils.bind(self.file_open_post, self))
self.valid = true
end
function DiffView:post_open()
vim.cmd("redraw")
self.commit_log_panel = CommitLogPanel(self.adapter, {
name = fmt("diffview://%s/log/%d/%s", self.adapter.ctx.dir, self.tabpage, "commit_log"),
})
if config.get_config().watch_index and self.adapter:instanceof(GitAdapter.__get()) then
self.watcher = vim.loop.new_fs_poll()
self.watcher:start(
self.adapter.ctx.dir .. "/index",
1000,
---@diagnostic disable-next-line: unused-local
vim.schedule_wrap(function(err, prev, cur)
if not err then
if self:is_cur_tabpage() then
self:update_files()
end
end
end)
)
end
self:init_event_listeners()
vim.schedule(function()
self:file_safeguard()
if self.files:len() == 0 then
self:update_files()
end
self.ready = true
end)
end
---@param e Event
---@param new_entry FileEntry
---@param old_entry FileEntry
---@diagnostic disable-next-line: unused-local
function DiffView:file_open_post(e, new_entry, old_entry)
if new_entry.layout:is_nulled() then return end
if new_entry.kind == "conflicting" then
local file = new_entry.layout:get_main_win().file
local count_conflicts = vim.schedule_wrap(function()
local conflicts = vcs_utils.parse_conflicts(api.nvim_buf_get_lines(file.bufnr, 0, -1, false))
new_entry.stats = new_entry.stats or {}
new_entry.stats.conflicts = #conflicts
self.panel:render()
self.panel:redraw()
end)
count_conflicts()
if file.bufnr and not self.attached_bufs[file.bufnr] then
self.attached_bufs[file.bufnr] = true
local work = debounce.throttle_trailing(
1000,
true,
vim.schedule_wrap(function()
if not self:is_cur_tabpage() or self.cur_entry ~= new_entry then
self.attached_bufs[file.bufnr] = false
return
end
count_conflicts()
end)
)
api.nvim_create_autocmd(
{ "TextChanged", "TextChangedI" },
{
buffer = file.bufnr,
callback = function()
if not self.attached_bufs[file.bufnr] then
work:close()
return true
end
work()
end,
}
)
end
end
end
---@override
function DiffView:close()
if not self.closing:check() then
self.closing:send()
if self.watcher then
self.watcher:stop()
self.watcher:close()
end
for _, file in self.files:iter() do
file:destroy()
end
self.commit_log_panel:destroy()
DiffView.super_class.close(self)
end
end
---@private
---@param self DiffView
---@param file FileEntry
DiffView._set_file = async.void(function(self, file)
self.panel:render()
self.panel:redraw()
vim.cmd("redraw")
self.cur_layout:detach_files()
local cur_entry = self.cur_entry
self.emitter:emit("file_open_pre", file, cur_entry)
self.nulled = false
await(self:use_entry(file))
self.emitter:emit("file_open_post", file, cur_entry)
if not self.cur_entry.opened then
self.cur_entry.opened = true
DiffviewGlobal.emitter:emit("file_open_new", file)
end
end)
---Open the next file.
---@param highlight? boolean Bring the cursor to the file entry in the panel.
---@return FileEntry?
function DiffView:next_file(highlight)
self:ensure_layout()
if self:file_safeguard() then return end
if self.files:len() > 1 or self.nulled then
local cur = self.panel:next_file()
if cur then
if highlight or not self.panel:is_focused() then
self.panel:highlight_file(cur)
end
self:_set_file(cur)
return cur
end
end
end
---Open the previous file.
---@param highlight? boolean Bring the cursor to the file entry in the panel.
---@return FileEntry?
function DiffView:prev_file(highlight)
self:ensure_layout()
if self:file_safeguard() then return end
if self.files:len() > 1 or self.nulled then
local cur = self.panel:prev_file()
if cur then
if highlight or not self.panel:is_focused() then
self.panel:highlight_file(cur)
end
self:_set_file(cur)
return cur
end
end
end
---Set the active file.
---@param self DiffView
---@param file FileEntry
---@param focus? boolean Bring focus to the diff buffers.
---@param highlight? boolean Bring the cursor to the file entry in the panel.
DiffView.set_file = async.void(function(self, file, focus, highlight)
---@diagnostic disable: invisible
self:ensure_layout()
if self:file_safeguard() or not file then return end
for _, f in self.files:iter() do
if f == file then
self.panel:set_cur_file(file)
if highlight or not self.panel:is_focused() then
self.panel:highlight_file(file)
end
await(self:_set_file(file))
if focus then
api.nvim_set_current_win(self.cur_layout:get_main_win().id)
end
end
end
---@diagnostic enable: invisible
end)
---Set the active file.
---@param self DiffView
---@param path string
---@param focus? boolean Bring focus to the diff buffers.
---@param highlight? boolean Bring the cursor to the file entry in the panel.
DiffView.set_file_by_path = async.void(function(self, path, focus, highlight)
---@type FileEntry
for _, file in self.files:iter() do
if file.path == path then
await(self:set_file(file, focus, highlight))
return
end
end
end)
---Get an updated list of files.
---@param self DiffView
---@param callback fun(err?: string[], files: FileDict)
DiffView.get_updated_files = async.wrap(function(self, callback)
vcs_utils.diff_file_list(
self.adapter,
self.left,
self.right,
self.path_args,
self.options,
{
default_layout = DiffView.get_default_layout(),
merge_layout = DiffView.get_default_merge_layout(),
},
callback
)
end)
---Update the file list, including stats and status for all files.
DiffView.update_files = debounce.debounce_trailing(
100,
true,
---@param self DiffView
---@param callback fun(err?: string[])
async.wrap(function(self, callback)
await(async.scheduler())
-- Never update unless the view is in focus
if self.tabpage ~= api.nvim_get_current_tabpage() then
callback({ "The update was cancelled." })
return
end
---@type PerfTimer
local perf = PerfTimer("[DiffView] Status Update")
self:ensure_layout()
-- If left is tracking HEAD and right is LOCAL: Update HEAD rev.
local new_head
if self.left.track_head and self.right.type == RevType.LOCAL then
new_head = self.adapter:head_rev()
if new_head and self.left.commit ~= new_head.commit then
self.left = new_head
else
new_head = nil
end
perf:lap("updated head rev")
end
local index_stat = pl:stat(pl:join(self.adapter.ctx.dir, "index"))
---@type string[]?, FileDict
local err, new_files = await(self:get_updated_files())
await(async.scheduler())
if err then
utils.err("Failed to update files in a diff view!", true)
logger:error("[DiffView] Failed to update files!")
callback(err)
return
end
-- Stop the update if the view is no longer in focus.
if self.tabpage ~= api.nvim_get_current_tabpage() then
callback({ "The update was cancelled." })
return
end
perf:lap("received new file list")
local files = {
{ cur_files = self.files.conflicting, new_files = new_files.conflicting },
{ cur_files = self.files.working, new_files = new_files.working },
{ cur_files = self.files.staged, new_files = new_files.staged },
}
for _, v in ipairs(files) do
-- We diff the old file list against the new file list in order to find
-- the most efficient way to morph the current list into the new. This
-- way we avoid having to discard and recreate buffers for files that
-- exist in both lists.
---@param aa FileEntry
---@param bb FileEntry
local diff = Diff(v.cur_files, v.new_files, function(aa, bb)
return aa.path == bb.path and aa.oldpath == bb.oldpath
end)
local script = diff:create_edit_script()
local ai = 1
local bi = 1
for _, opr in ipairs(script) do
if opr == EditToken.NOOP then
-- Update status and stats
local a_stats = v.cur_files[ai].stats
local b_stats = v.new_files[bi].stats
if a_stats then
v.cur_files[ai].stats = vim.tbl_extend("force", a_stats, b_stats or {})
else
v.cur_files[ai].stats = v.new_files[bi].stats
end
v.cur_files[ai].status = v.new_files[bi].status
v.cur_files[ai]:validate_stage_buffers(index_stat)
if new_head then
v.cur_files[ai]:update_heads(new_head)
end
ai = ai + 1
bi = bi + 1
elseif opr == EditToken.DELETE then
if self.panel.cur_file == v.cur_files[ai] then
local file_list = self.panel:ordered_file_list()
if file_list[1] == self.panel.cur_file then
self.panel:set_cur_file(nil)
else
self.panel:set_cur_file(self.panel:prev_file())
end
end
v.cur_files[ai]:destroy()
table.remove(v.cur_files, ai)
elseif opr == EditToken.INSERT then
table.insert(v.cur_files, ai, v.new_files[bi])
ai = ai + 1
bi = bi + 1
elseif opr == EditToken.REPLACE then
if self.panel.cur_file == v.cur_files[ai] then
local file_list = self.panel:ordered_file_list()
if file_list[1] == self.panel.cur_file then
self.panel:set_cur_file(nil)
else
self.panel:set_cur_file(self.panel:prev_file())
end
end
v.cur_files[ai]:destroy()
v.cur_files[ai] = v.new_files[bi]
ai = ai + 1
bi = bi + 1
end
end
end
perf:lap("updated file list")
self.merge_ctx = next(new_files.conflicting) and self.adapter:get_merge_context() or nil
if self.merge_ctx then
for _, entry in ipairs(self.files.conflicting) do
entry:update_merge_context(self.merge_ctx)
end
end
FileEntry.update_index_stat(self.adapter, index_stat)
self.files:update_file_trees()
self.panel:update_components()
self.panel:render()
self.panel:redraw()
perf:lap("panel redrawn")
self.panel:reconstrain_cursor()
if utils.vec_indexof(self.panel:ordered_file_list(), self.panel.cur_file) == -1 then
self.panel:set_cur_file(nil)
end
-- Set initially selected file
if not self.initialized and self.options.selected_file then
for _, file in self.files:iter() do
if file.path == self.options.selected_file then
self.panel:set_cur_file(file)
break
end
end
end
self:set_file(self.panel.cur_file or self.panel:next_file(), false, not self.initialized)
self.update_needed = false
perf:time()
logger:lvl(5):debug(perf)
logger:fmt_info(
"[%s] Completed update for %d files successfully (%.3f ms)",
self.class:name(),
self.files:len(),
perf.final_time
)
self.emitter:emit("files_updated", self.files)
callback()
end)
)
---Ensures there are files to load, and loads the null buffer otherwise.
---@return boolean
function DiffView:file_safeguard()
if self.files:len() == 0 then
local cur = self.panel.cur_file
if cur then
cur.layout:detach_files()
end
self.cur_layout:open_null()
self.nulled = true
return true
end
return false
end
function DiffView:on_files_staged(callback)
self.emitter:on(EventName.FILES_STAGED, callback)
end
function DiffView:init_event_listeners()
local listeners = require("diffview.scene.views.diff.listeners")(self)
for event, callback in pairs(listeners) do
self.emitter:on(event, callback)
end
end
---Infer the current selected file. If the file panel is focused: return the
---file entry under the cursor. Otherwise return the file open in the view.
---Returns nil if no file is open in the view, or there is no entry under the
---cursor in the file panel.
---@param allow_dir? boolean Allow directory nodes from the file tree.
---@return (FileEntry|DirData)?
function DiffView:infer_cur_file(allow_dir)
if self.panel:is_focused() then
---@type any
local item = self.panel:get_item_at_cursor()
if not item then return end
if not allow_dir and type(item.collapsed) == "boolean" then return end
return item
else
return self.panel.cur_file
end
end
---Check whether or not the instantiation was successful.
---@return boolean
function DiffView:is_valid()
return self.valid
end
M.DiffView = DiffView
return M

View File

@ -0,0 +1,404 @@
local config = require("diffview.config")
local oop = require("diffview.oop")
local renderer = require("diffview.renderer")
local utils = require("diffview.utils")
local Panel = require("diffview.ui.panel").Panel
local api = vim.api
local M = {}
---@class TreeOptions
---@field flatten_dirs boolean
---@field folder_statuses "never"|"only_folded"|"always"
---@class FilePanel : Panel
---@field adapter VCSAdapter
---@field files FileDict
---@field path_args string[]
---@field rev_pretty_name string|nil
---@field cur_file FileEntry
---@field listing_style "list"|"tree"
---@field tree_options TreeOptions
---@field render_data RenderData
---@field components CompStruct
---@field constrain_cursor function
---@field help_mapping string
local FilePanel = oop.create_class("FilePanel", Panel)
FilePanel.winopts = vim.tbl_extend("force", Panel.winopts, {
cursorline = true,
winhl = {
"EndOfBuffer:DiffviewEndOfBuffer",
"Normal:DiffviewNormal",
"CursorLine:DiffviewCursorLine",
"WinSeparator:DiffviewWinSeparator",
"SignColumn:DiffviewNormal",
"StatusLine:DiffviewStatusLine",
"StatusLineNC:DiffviewStatuslineNC",
opt = { method = "prepend" },
},
})
FilePanel.bufopts = vim.tbl_extend("force", Panel.bufopts, {
filetype = "DiffviewFiles",
})
---FilePanel constructor.
---@param adapter VCSAdapter
---@param files FileEntry[]
---@param path_args string[]
function FilePanel:init(adapter, files, path_args, rev_pretty_name)
local conf = config.get_config()
self:super({
config = conf.file_panel.win_config,
bufname = "DiffviewFilePanel",
})
self.adapter = adapter
self.files = files
self.path_args = path_args
self.rev_pretty_name = rev_pretty_name
self.listing_style = conf.file_panel.listing_style
self.tree_options = conf.file_panel.tree_options
self:on_autocmd("BufNew", {
callback = function()
self:setup_buffer()
end,
})
end
---@override
function FilePanel:open()
FilePanel.super_class.open(self)
vim.cmd("wincmd =")
end
function FilePanel:setup_buffer()
local conf = config.get_config()
local default_opt = { silent = true, nowait = true, buffer = self.bufid }
for _, mapping in ipairs(conf.keymaps.file_panel) do
local opt = vim.tbl_extend("force", default_opt, mapping[4] or {}, { buffer = self.bufid })
vim.keymap.set(mapping[1], mapping[2], mapping[3], opt)
end
local help_keymap = config.find_help_keymap(conf.keymaps.file_panel)
if help_keymap then self.help_mapping = help_keymap[2] end
end
function FilePanel:update_components()
local conflicting_files
local working_files
local staged_files
if self.listing_style == "list" then
conflicting_files = { name = "files" }
working_files = { name = "files" }
staged_files = { name = "files" }
for _, file in ipairs(self.files.conflicting) do
table.insert(conflicting_files, {
name = "file",
context = file,
})
end
for _, file in ipairs(self.files.working) do
table.insert(working_files, {
name = "file",
context = file,
})
end
for _, file in ipairs(self.files.staged) do
table.insert(staged_files, {
name = "file",
context = file,
})
end
elseif self.listing_style == "tree" then
self.files.conflicting_tree:update_statuses()
self.files.working_tree:update_statuses()
self.files.staged_tree:update_statuses()
conflicting_files = utils.tbl_merge(
{ name = "files" },
self.files.conflicting_tree:create_comp_schema({
flatten_dirs = self.tree_options.flatten_dirs,
})
)
working_files = utils.tbl_merge(
{ name = "files" },
self.files.working_tree:create_comp_schema({
flatten_dirs = self.tree_options.flatten_dirs,
})
)
staged_files = utils.tbl_merge(
{ name = "files" },
self.files.staged_tree:create_comp_schema({
flatten_dirs = self.tree_options.flatten_dirs,
})
)
end
---@type CompStruct
self.components = self.render_data:create_component({
{ name = "path" },
{
name = "conflicting",
{ name = "title" },
conflicting_files,
{ name = "margin" },
},
{
name = "working",
{ name = "title" },
working_files,
{ name = "margin" },
},
{
name = "staged",
{ name = "title" },
staged_files,
{ name = "margin" },
},
{
name = "info",
{ name = "title" },
{ name = "entries" },
},
})
self.constrain_cursor = renderer.create_cursor_constraint({
self.components.conflicting.files.comp,
self.components.working.files.comp,
self.components.staged.files.comp,
})
end
---@return FileEntry[]
function FilePanel:ordered_file_list()
if self.listing_style == "list" then
local list = {}
for _, file in self.files:iter() do
list[#list + 1] = file
end
return list
else
local nodes = utils.vec_join(
self.files.conflicting_tree.root:leaves(),
self.files.working_tree.root:leaves(),
self.files.staged_tree.root:leaves()
)
return vim.tbl_map(function(node)
return node.data
end, nodes) --[[@as vector ]]
end
end
function FilePanel:set_cur_file(file)
if self.cur_file then
self.cur_file:set_active(false)
end
self.cur_file = file
if self.cur_file then
self.cur_file:set_active(true)
end
end
function FilePanel:prev_file()
local files = self:ordered_file_list()
if not self.cur_file and self.files:len() > 0 then
self:set_cur_file(files[1])
return self.cur_file
end
local i = utils.vec_indexof(files, self.cur_file)
if i ~= -1 then
self:set_cur_file(files[(i - vim.v.count1 - 1) % #files + 1])
return self.cur_file
end
end
function FilePanel:next_file()
local files = self:ordered_file_list()
if not self.cur_file and self.files:len() > 0 then
self:set_cur_file(files[1])
return self.cur_file
end
local i = utils.vec_indexof(files, self.cur_file)
if i ~= -1 then
self:set_cur_file(files[(i + vim.v.count1 - 1) % #files + 1])
return self.cur_file
end
end
---Get the file entry under the cursor.
---@return (FileEntry|DirData)?
function FilePanel:get_item_at_cursor()
if not self:is_open() and self:buf_loaded() then return end
local line = api.nvim_win_get_cursor(self.winid)[1]
local comp = self.components.comp:get_comp_on_line(line)
if comp and comp.name == "file" then
return comp.context
elseif comp and comp.name == "dir_name" then
return comp.parent.context
end
end
---Get the parent directory data of the item under the cursor.
---@return DirData?
---@return RenderComponent?
function FilePanel:get_dir_at_cursor()
if self.listing_style ~= "tree" then return end
if not self:is_open() and self:buf_loaded() then return end
local line = api.nvim_win_get_cursor(self.winid)[1]
local comp = self.components.comp:get_comp_on_line(line)
if not comp then return end
if comp.name == "dir_name" then
local dir_comp = comp.parent
return dir_comp.context, dir_comp
elseif comp.name == "file" then
local dir_comp = comp.parent.parent
return dir_comp.context, dir_comp
end
end
function FilePanel:highlight_file(file)
if not (self:is_open() and self:buf_loaded()) then
return
end
if self.listing_style == "list" then
for _, file_list in ipairs({
self.components.conflicting.files,
self.components.working.files,
self.components.staged.files,
}) do
for _, comp_struct in ipairs(file_list) do
if file == comp_struct.comp.context then
utils.set_cursor(self.winid, comp_struct.comp.lstart + 1, 0)
end
end
end
else -- tree
for _, comp_struct in ipairs({
self.components.conflicting.files,
self.components.working.files,
self.components.staged.files,
}) do
comp_struct.comp:deep_some(function(cur)
if file == cur.context then
local was_concealed = false
local dir = cur.parent.parent
while dir and dir.name == "directory" do
if dir.context and dir.context.collapsed then
was_concealed = true
dir.context.collapsed = false
end
dir = utils.tbl_access(dir, { "parent", "parent" })
end
if was_concealed then
self:render()
self:redraw()
end
utils.set_cursor(self.winid, cur.lstart + 1, 0)
return true
end
return false
end)
end
end
-- Needed to update the cursorline highlight when the panel is not focused.
utils.update_win(self.winid)
end
function FilePanel:highlight_cur_file()
if self.cur_file then
self:highlight_file(self.cur_file)
end
end
function FilePanel:highlight_prev_file()
if not (self:is_open() and self:buf_loaded()) or self.files:len() == 0 then
return
end
pcall(
api.nvim_win_set_cursor,
self.winid,
{ self.constrain_cursor(self.winid, -vim.v.count1), 0 }
)
utils.update_win(self.winid)
end
function FilePanel:highlight_next_file()
if not (self:is_open() and self:buf_loaded()) or self.files:len() == 0 then
return
end
pcall(api.nvim_win_set_cursor, self.winid, {
self.constrain_cursor(self.winid, vim.v.count1),
0,
})
utils.update_win(self.winid)
end
function FilePanel:reconstrain_cursor()
if not (self:is_open() and self:buf_loaded()) or self.files:len() == 0 then
return
end
pcall(api.nvim_win_set_cursor, self.winid, {
self.constrain_cursor(self.winid, 0),
0,
})
end
---@param item DirData|any
---@param open boolean
function FilePanel:set_item_fold(item, open)
if type(item.collapsed) == "boolean" and open == item.collapsed then
item.collapsed = not open
self:render()
self:redraw()
if item.collapsed then
self.components.comp:deep_some(function(comp, _, _)
if comp.context == item then
utils.set_cursor(self.winid, comp.lstart + 1)
return true
end
end)
end
end
end
function FilePanel:toggle_item_fold(item)
self:set_item_fold(item, item.collapsed)
end
function FilePanel:render()
require("diffview.scene.views.diff.render")(self)
end
M.FilePanel = FilePanel
return M

View File

@ -0,0 +1,321 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local EventName = lazy.access("diffview.events", "EventName") ---@type EventName|LazyModule
local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule
local actions = lazy.require("diffview.actions") ---@module "diffview.actions"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.utils"
local api = vim.api
local await = async.await
---@param view DiffView
return function(view)
return {
tab_enter = function()
local file = view.panel.cur_file
if file then
view:set_file(file, false, true)
end
if view.ready then
view:update_files()
end
end,
tab_leave = function()
local file = view.panel.cur_file
if file then
file.layout:detach_files()
end
for _, f in view.panel.files:iter() do
f.layout:restore_winopts()
end
end,
buf_write_post = function()
if view.adapter:has_local(view.left, view.right) then
view.update_needed = true
if api.nvim_get_current_tabpage() == view.tabpage then
view:update_files()
end
end
end,
file_open_new = function(_, entry)
api.nvim_win_call(view.cur_layout:get_main_win().id, function()
utils.set_cursor(0, 1, 0)
if view.cur_entry and view.cur_entry.kind == "conflicting" then
actions.next_conflict()
vim.cmd("norm! zz")
end
end)
view.cur_layout:sync_scroll()
end,
---@diagnostic disable-next-line: unused-local
files_updated = function(_, files)
view.initialized = true
end,
close = function()
if view.panel:is_focused() then
view.panel:close()
elseif view:is_cur_tabpage() then
view:close()
end
end,
select_next_entry = function()
view:next_file(true)
end,
select_prev_entry = function()
view:prev_file(true)
end,
next_entry = function()
view.panel:highlight_next_file()
end,
prev_entry = function()
view.panel:highlight_prev_file()
end,
select_entry = function()
if view.panel:is_open() then
---@type any
local item = view.panel:get_item_at_cursor()
if item then
if type(item.collapsed) == "boolean" then
view.panel:toggle_item_fold(item)
else
view:set_file(item, false)
end
end
end
end,
focus_entry = function()
if view.panel:is_open() then
---@type any
local item = view.panel:get_item_at_cursor()
if item then
if type(item.collapsed) == "boolean" then
view.panel:toggle_item_fold(item)
else
view:set_file(item, true)
end
end
end
end,
open_commit_log = function()
if view.left.type == RevType.STAGE and view.right.type == RevType.LOCAL then
utils.info("Changes not committed yet. No log available for these changes.")
return
end
local range = view.adapter.Rev.to_range(view.left, view.right)
if range then
view.commit_log_panel:update(range)
end
end,
toggle_stage_entry = function()
if not (view.left.type == RevType.STAGE and view.right.type == RevType.LOCAL) then
return
end
local item = view:infer_cur_file(true)
if item then
local success
if item.kind == "working" or item.kind == "conflicting" then
success = view.adapter:add_files({ item.path })
elseif item.kind == "staged" then
success = view.adapter:reset_files({ item.path })
end
if not success then
utils.err(("Failed to stage/unstage file: '%s'"):format(item.path))
return
end
if type(item.collapsed) == "boolean" then
---@cast item DirData
---@type FileTree
local tree
if item.kind == "conflicting" then
tree = view.panel.files.conflicting_tree
elseif item.kind == "working" then
tree = view.panel.files.working_tree
else
tree = view.panel.files.staged_tree
end
---@type Node
local item_node
tree.root:deep_some(function(node, _, _)
if node == item._node then
item_node = node
return true
end
end)
if item_node then
local next_leaf = item_node:next_leaf()
if next_leaf then
view:set_file(next_leaf.data)
else
view:set_file(view.panel.files[1])
end
end
else
view.panel:set_cur_file(item)
view:next_file()
end
view:update_files(
vim.schedule_wrap(function()
view.panel:highlight_cur_file()
end)
)
view.emitter:emit(EventName.FILES_STAGED, view)
end
end,
stage_all = function()
local args = vim.tbl_map(function(file)
return file.path
end, utils.vec_join(view.files.working, view.files.conflicting))
if #args > 0 then
local success = view.adapter:add_files(args)
if not success then
utils.err("Failed to stage files!")
return
end
view:update_files(function()
view.panel:highlight_cur_file()
end)
view.emitter:emit(EventName.FILES_STAGED, view)
end
end,
unstage_all = function()
local success = view.adapter:reset_files()
if not success then
utils.err("Failed to unstage files!")
return
end
view:update_files()
view.emitter:emit(EventName.FILES_STAGED, view)
end,
restore_entry = async.void(function()
if view.right.type ~= RevType.LOCAL then
utils.err("The right side of the diff is not local! Aborting file restoration.")
return
end
local commit
if view.left.type ~= RevType.STAGE then
commit = view.left.commit
end
local file = view:infer_cur_file()
if not file then return end
local bufid = utils.find_file_buffer(file.path)
if bufid and vim.bo[bufid].modified then
utils.err("The file is open with unsaved changes! Aborting file restoration.")
return
end
await(vcs_utils.restore_file(view.adapter, file.path, file.kind, commit))
view:update_files()
end),
listing_style = function()
if view.panel.listing_style == "list" then
view.panel.listing_style = "tree"
else
view.panel.listing_style = "list"
end
view.panel:update_components()
view.panel:render()
view.panel:redraw()
end,
toggle_flatten_dirs = function()
view.panel.tree_options.flatten_dirs = not view.panel.tree_options.flatten_dirs
view.panel:update_components()
view.panel:render()
view.panel:redraw()
end,
focus_files = function()
view.panel:focus()
end,
toggle_files = function()
view.panel:toggle(true)
end,
refresh_files = function()
view:update_files()
end,
open_all_folds = function()
if not view.panel:is_focused() or view.panel.listing_style ~= "tree" then return end
for _, file_set in ipairs({
view.panel.components.conflicting.files,
view.panel.components.working.files,
view.panel.components.staged.files,
}) do
file_set.comp:deep_some(function(comp, _, _)
if comp.name == "directory" then
(comp.context --[[@as DirData ]]).collapsed = false
end
end)
end
view.panel:render()
view.panel:redraw()
end,
close_all_folds = function()
if not view.panel:is_focused() or view.panel.listing_style ~= "tree" then return end
for _, file_set in ipairs({
view.panel.components.conflicting.files,
view.panel.components.working.files,
view.panel.components.staged.files,
}) do
file_set.comp:deep_some(function(comp, _, _)
if comp.name == "directory" then
(comp.context --[[@as DirData ]]).collapsed = true
end
end)
end
view.panel:render()
view.panel:redraw()
end,
open_fold = function()
if not view.panel:is_focused() then return end
local dir = view.panel:get_dir_at_cursor()
if dir then view.panel:set_item_fold(dir, true) end
end,
close_fold = function()
if not view.panel:is_focused() then return end
local dir, comp = view.panel:get_dir_at_cursor()
if dir and comp then
if not dir.collapsed then
view.panel:set_item_fold(dir, false)
else
local dir_parent = utils.tbl_access(comp, "parent.parent")
if dir_parent and dir_parent.name == "directory" then
view.panel:set_item_fold(dir_parent.context, false)
end
end
end
end,
toggle_fold = function()
if not view.panel:is_focused() then return end
local dir = view.panel:get_dir_at_cursor()
if dir then view.panel:toggle_item_fold(dir) end
end,
}
end

View File

@ -0,0 +1,204 @@
local config = require("diffview.config")
local hl = require("diffview.hl")
local utils = require("diffview.utils")
local pl = utils.path
---@param comp RenderComponent
---@param show_path boolean
---@param depth integer|nil
local function render_file(comp, show_path, depth)
---@type FileEntry
local file = comp.context
comp:add_text(file.status .. " ", hl.get_git_hl(file.status))
if depth then
comp:add_text(string.rep(" ", depth * 2 + 2))
end
local icon, icon_hl = hl.get_file_icon(file.basename, file.extension)
comp:add_text(icon, icon_hl)
comp:add_text(file.basename, file.active and "DiffviewFilePanelSelected" or "DiffviewFilePanelFileName")
if file.stats then
if file.stats.additions then
comp:add_text(" " .. file.stats.additions, "DiffviewFilePanelInsertions")
comp:add_text(", ")
comp:add_text(tostring(file.stats.deletions), "DiffviewFilePanelDeletions")
elseif file.stats.conflicts then
local has_conflicts = file.stats.conflicts > 0
comp:add_text(
" " .. (has_conflicts and file.stats.conflicts or config.get_config().signs.done),
has_conflicts and "DiffviewFilePanelConflicts" or "DiffviewFilePanelInsertions"
)
end
end
if file.kind == "conflicting" and not (file.stats and file.stats.conflicts) then
comp:add_text(" !", "DiffviewFilePanelConflicts")
end
if show_path then
comp:add_text(" " .. file.parent_path, "DiffviewFilePanelPath")
end
comp:ln()
end
---@param comp RenderComponent
local function render_file_list(comp)
for _, file_comp in ipairs(comp.components) do
render_file(file_comp, true)
end
end
---@param ctx DirData
---@param tree_options TreeOptions
---@return string
local function get_dir_status_text(ctx, tree_options)
local folder_statuses = tree_options.folder_statuses
if folder_statuses == "always" or (folder_statuses == "only_folded" and ctx.collapsed) then
return ctx.status
end
return " "
end
---@param depth integer
---@param comp RenderComponent
local function render_file_tree_recurse(depth, comp)
local conf = config.get_config()
if comp.name == "file" then
render_file(comp, false, depth)
return
end
if comp.name ~= "directory" then return end
-- Directory component structure:
-- {
-- name = "directory",
-- context = <DirData>,
-- { name = "dir_name" },
-- { name = "items", ...<files> },
-- }
local dir = comp.components[1]
local items = comp.components[2]
local ctx = comp.context --[[@as DirData ]]
dir:add_text(
get_dir_status_text(ctx, conf.file_panel.tree_options) .. " ",
hl.get_git_hl(ctx.status)
)
dir:add_text(string.rep(" ", depth * 2))
dir:add_text(ctx.collapsed and conf.signs.fold_closed or conf.signs.fold_open, "DiffviewNonText")
if conf.use_icons then
dir:add_text(
" " .. (ctx.collapsed and conf.icons.folder_closed or conf.icons.folder_open) .. " ",
"DiffviewFolderSign"
)
end
dir:add_text(ctx.name, "DiffviewFolderName")
dir:ln()
if not ctx.collapsed then
for _, item in ipairs(items.components) do
render_file_tree_recurse(depth + 1, item)
end
end
end
---@param comp RenderComponent
local function render_file_tree(comp)
for _, c in ipairs(comp.components) do
render_file_tree_recurse(0, c)
end
end
---@param listing_style "list"|"tree"
---@param comp RenderComponent
local function render_files(listing_style, comp)
if listing_style == "list" then
return render_file_list(comp)
end
render_file_tree(comp)
end
---@param panel FilePanel
return function(panel)
if not panel.render_data then
return
end
panel.render_data:clear()
local conf = config.get_config()
local width = panel:infer_width()
local comp = panel.components.path.comp
comp:add_line(
pl:truncate(pl:vim_fnamemodify(panel.adapter.ctx.toplevel, ":~"), width - 6),
"DiffviewFilePanelRootPath"
)
if conf.show_help_hints and panel.help_mapping then
comp:add_text("Help: ", "DiffviewFilePanelPath")
comp:add_line(panel.help_mapping, "DiffviewFilePanelCounter")
comp:add_line()
end
if #panel.files.conflicting > 0 then
comp = panel.components.conflicting.title.comp
comp:add_text("Conflicts ", "DiffviewFilePanelTitle")
comp:add_text("(" .. #panel.files.conflicting .. ")", "DiffviewFilePanelCounter")
comp:ln()
render_files(panel.listing_style, panel.components.conflicting.files.comp)
panel.components.conflicting.margin.comp:add_line()
end
local has_other_files = #panel.files.conflicting > 0 or #panel.files.staged > 0
-- Don't show the 'Changes' section if it's empty and we have other visible
-- sections.
if #panel.files.working > 0 or not has_other_files then
comp = panel.components.working.title.comp
comp:add_text("Changes ", "DiffviewFilePanelTitle")
comp:add_text("(" .. #panel.files.working .. ")", "DiffviewFilePanelCounter")
comp:ln()
render_files(panel.listing_style, panel.components.working.files.comp)
panel.components.working.margin.comp:add_line()
end
if #panel.files.staged > 0 then
comp = panel.components.staged.title.comp
comp:add_text("Staged changes ", "DiffviewFilePanelTitle")
comp:add_text("(" .. #panel.files.staged .. ")", "DiffviewFilePanelCounter")
comp:ln()
render_files(panel.listing_style, panel.components.staged.files.comp)
panel.components.staged.margin.comp:add_line()
end
if panel.rev_pretty_name or (panel.path_args and #panel.path_args > 0) then
local extra_info = utils.vec_join({ panel.rev_pretty_name }, panel.path_args or {})
comp = panel.components.info.title.comp
comp:add_line("Showing changes for:", "DiffviewFilePanelTitle")
comp = panel.components.info.entries.comp
for _, arg in ipairs(extra_info) do
local relpath = pl:relative(arg, panel.adapter.ctx.toplevel)
if relpath == "" then relpath = "." end
comp:add_line(pl:truncate(relpath, width - 5), "DiffviewFilePanelPath")
end
end
end

View File

@ -0,0 +1,551 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local FHOptionPanel = lazy.access("diffview.scene.views.file_history.option_panel", "FHOptionPanel") ---@type FHOptionPanel|LazyModule
local JobStatus = lazy.access("diffview.vcs.utils", "JobStatus") ---@type JobStatus|LazyModule
local LogEntry = lazy.access("diffview.vcs.log_entry", "LogEntry") ---@type LogEntry|LazyModule
local Panel = lazy.access("diffview.ui.panel", "Panel") ---@type Panel|LazyModule
local PerfTimer = lazy.access("diffview.perf", "PerfTimer") ---@type PerfTimer|LazyModule
local Signal = lazy.access("diffview.control", "Signal") ---@type Signal|LazyModule
local WorkPool = lazy.access("diffview.control", "WorkPool") ---@type WorkPool|LazyModule
local config = lazy.require("diffview.config") ---@module "diffview.config"
local debounce = lazy.require("diffview.debounce") ---@module "diffview.debounce"
local oop = lazy.require("diffview.oop") ---@module "diffview.oop"
local panel_renderer = lazy.require("diffview.scene.views.file_history.render") ---@module "diffview.scene.views.file_history.render"
local renderer = lazy.require("diffview.renderer") ---@module "diffview.renderer"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local api = vim.api
local await = async.await
local fmt = string.format
local logger = DiffviewGlobal.logger
local M = {}
---@type PerfTimer
local perf_render = PerfTimer("[FileHistoryPanel] render")
---@type PerfTimer
local perf_update = PerfTimer("[FileHistoryPanel] update")
---@alias FileHistoryPanel.CurItem { [1]: LogEntry, [2]: FileEntry }
---@class FileHistoryPanel : Panel
---@field parent FileHistoryView
---@field adapter VCSAdapter
---@field entries LogEntry[]
---@field rev_range RevRange
---@field log_options ConfigLogOptions
---@field cur_item FileHistoryPanel.CurItem
---@field single_file boolean
---@field work_pool WorkPool
---@field shutdown Signal
---@field updating boolean
---@field render_data RenderData
---@field option_panel FHOptionPanel
---@field option_mapping string
---@field help_mapping string
---@field components CompStruct
---@field constrain_cursor function
local FileHistoryPanel = oop.create_class("FileHistoryPanel", Panel.__get())
FileHistoryPanel.winopts = vim.tbl_extend("force", Panel.winopts, {
cursorline = true,
winhl = {
"EndOfBuffer:DiffviewEndOfBuffer",
"Normal:DiffviewNormal",
"CursorLine:DiffviewCursorLine",
"WinSeparator:DiffviewWinSeparator",
"SignColumn:DiffviewNormal",
"StatusLine:DiffviewStatusLine",
"StatusLineNC:DiffviewStatuslineNC",
},
})
FileHistoryPanel.bufopts = vim.tbl_extend("force", Panel.bufopts, {
filetype = "DiffviewFileHistory",
})
---@class FileHistoryPanel.init.Opt
---@field parent FileHistoryView
---@field adapter VCSAdapter
---@field entries LogEntry[]
---@field log_options LogOptions
---FileHistoryPanel constructor.
---@param opt FileHistoryPanel.init.Opt
function FileHistoryPanel:init(opt)
local conf = config.get_config()
self:super({
config = conf.file_history_panel.win_config,
bufname = "DiffviewFileHistoryPanel",
})
self.parent = opt.parent
self.adapter = opt.adapter
self.entries = opt.entries
self.cur_item = {}
self.single_file = opt.entries[1] and opt.entries[1].single_file
self.work_pool = WorkPool()
self.shutdown = Signal()
self.updating = false
self.option_panel = FHOptionPanel(self, self.adapter.flags)
self.log_options = {
single_file = vim.tbl_extend(
"force",
conf.file_history_panel.log_options[self.adapter.config_key].single_file,
opt.log_options
),
multi_file = vim.tbl_extend(
"force",
conf.file_history_panel.log_options[self.adapter.config_key].multi_file,
opt.log_options
),
}
self:on_autocmd("BufNew", {
callback = function()
self:setup_buffer()
end,
})
end
---@override
function FileHistoryPanel:open()
FileHistoryPanel.super_class.open(self)
vim.cmd("wincmd =")
end
---@override
---@param self FileHistoryPanel
FileHistoryPanel.destroy = async.sync_void(function(self)
self.shutdown:send()
await(self.work_pool)
await(async.scheduler())
for _, entry in ipairs(self.entries) do
entry:destroy()
end
self.entries = nil
self.cur_item = nil
self.option_panel:destroy()
self.option_panel = nil
self.render_data:destroy()
if self.components then
renderer.destroy_comp_struct(self.components)
end
FileHistoryPanel.super_class.destroy(self)
end)
function FileHistoryPanel:setup_buffer()
local conf = config.get_config()
local default_opt = { silent = true, nowait = true, buffer = self.bufid }
for _, mapping in ipairs(conf.keymaps.file_history_panel) do
local opt = vim.tbl_extend("force", default_opt, mapping[4] or {}, { buffer = self.bufid })
vim.keymap.set(mapping[1], mapping[2], mapping[3], opt)
end
local option_keymap = config.find_option_keymap(conf.keymaps.file_history_panel)
if option_keymap then self.option_mapping = option_keymap[2] end
local help_keymap = config.find_help_keymap(conf.keymaps.file_history_panel)
if help_keymap then self.help_mapping = help_keymap[2] end
end
function FileHistoryPanel:update_components()
self.render_data:destroy()
if self.components then
renderer.destroy_comp_struct(self.components)
end
local entry_schema = { name = "entries" }
for i, entry in ipairs(utils.vec_slice(self.entries)) do
if self.updating and i > 128 then
break
end
table.insert(entry_schema, {
name = "entry",
context = entry,
{ name = "commit" },
{ name = "files" },
})
end
---@type CompStruct
self.components = self.render_data:create_component({
{ name = "header" },
{
name = "log",
{ name = "title" },
entry_schema,
},
})
self.constrain_cursor = renderer.create_cursor_constraint({ self.components.log.entries.comp })
end
---@param self FileHistoryPanel
---@param callback function
FileHistoryPanel.update_entries = async.wrap(function(self, callback)
perf_update:reset()
local checkout = self.work_pool:check_in()
for _, entry in ipairs(self.entries) do
entry:destroy()
end
panel_renderer.clear_cache(self)
self.cur_item = {}
self.entries = {}
self.updating = true
local stream = self.adapter:file_history({
log_opt = self.log_options,
layout_opt = { default_layout = self.parent.get_default_layout() },
})
self:sync()
local render = debounce.throttle_render(
15,
function()
if self.shutdown:check() then return end
if not self:cur_file() then
self:update_components()
self.parent:next_item()
else
self:sync()
end
vim.cmd("redraw")
end
)
local ret = {}
for _, item in stream:iter() do
if self.shutdown:check() then
stream:close(self.shutdown:new_consumer())
ret = { nil, JobStatus.KILLED }
break
end
---@type JobStatus, LogEntry?, string?
local status, entry, msg = unpack(item, 1, 3)
if status == JobStatus.ERROR then
utils.err(fmt("Updating file history failed! Error message: %s", msg), true)
ret = { nil, JobStatus.ERROR, msg }
break
elseif status == JobStatus.SUCCESS then
ret = { self.entries, status }
perf_update:time()
logger:fmt_info(
"[FileHistory] Completed update for %d entries successfully (%.3f ms).",
#self.entries,
perf_update.final_time
)
elseif status == JobStatus.PROGRESS then
---@cast entry -?
local was_empty = #self.entries == 0
self.entries[#self.entries+1] = entry
if was_empty then
self.single_file = self.entries[1].single_file
end
render()
else
error("Unexpected state!")
end
end
await(async.scheduler())
self.updating = false
if not self.shutdown:check() then
self:sync()
self.option_panel:sync()
vim.cmd("redraw")
end
checkout:send()
callback(unpack(ret, 1, 3))
end)
function FileHistoryPanel:num_items()
if self.single_file then
return #self.entries
else
local count = 0
for _, entry in ipairs(self.entries) do
count = count + #entry.files
end
return count
end
end
---@return FileEntry[]
function FileHistoryPanel:list_files()
local files = {}
for _, entry in ipairs(self.entries) do
for _, file in ipairs(entry.files) do
table.insert(files, file)
end
end
return files
end
---@param file FileEntry
function FileHistoryPanel:find_entry(file)
for _, entry in ipairs(self.entries) do
for _, f in ipairs(entry.files) do
if f == file then
return entry
end
end
end
end
---Get the log or file entry under the cursor.
---@return (LogEntry|FileEntry)?
function FileHistoryPanel:get_item_at_cursor()
if not self:is_open() and self:buf_loaded() then return end
local cursor = api.nvim_win_get_cursor(self.winid)
local line = cursor[1]
local comp = self.components.comp:get_comp_on_line(line)
if comp and (comp.name == "commit" or comp.name == "files") then
local entry = comp.parent.context --[[@as table ]]
if comp.name == "files" then
return entry.files[line - comp.lstart]
end
return entry
end
end
---Get the parent log entry of the item under the cursor.
---@return LogEntry?
function FileHistoryPanel:get_log_entry_at_cursor()
local item = self:get_item_at_cursor()
if not item then return end
if item:instanceof(LogEntry.__get()) then
return item --[[@as LogEntry ]]
end
return self:find_entry(item --[[@as FileEntry ]])
end
---@param new_item FileHistoryPanel.CurItem
function FileHistoryPanel:set_cur_item(new_item)
if self.cur_item[2] then
self.cur_item[2]:set_active(false)
end
self.cur_item = new_item
if self.cur_item and self.cur_item[2] then
self.cur_item[2]:set_active(true)
end
end
function FileHistoryPanel:set_entry_from_file(item)
local file = self.cur_item[2]
if item:instanceof(LogEntry.__get()) then
self:set_cur_item({ item, item.files[1] })
else
local entry = self:find_entry(file)
if entry then
self:set_cur_item({ entry, file })
end
end
end
function FileHistoryPanel:cur_file()
return self.cur_item[2]
end
---@private
---@param entry_idx integer
---@param file_idx integer
---@param offset integer
---@return LogEntry?
---@return FileEntry?
function FileHistoryPanel:_get_entry_by_file_offset(entry_idx, file_idx, offset)
local cur_entry = self.entries[entry_idx]
if cur_entry.files[file_idx + offset] then
return cur_entry, cur_entry.files[file_idx + offset]
end
local sign = utils.sign(offset)
local delta = math.abs(offset) - (sign > 0 and #cur_entry.files - file_idx or file_idx - 1)
local i = (entry_idx + (sign > 0 and 0 or -2)) % #self.entries + 1
while i ~= entry_idx do
local files = self.entries[i].files
if (#files - delta) >= 0 then
local target_file = sign > 0 and files[delta] or files[#files - (delta - 1)]
return self.entries[i], target_file
end
delta = delta - #files
i = (i + (sign > 0 and 0 or -2)) % #self.entries + 1
end
end
function FileHistoryPanel:set_file_by_offset(offset)
if self:num_items() == 0 then return end
local entry, file = self.cur_item[1], self.cur_item[2]
if not (entry and file) and self:num_items() > 0 then
self:set_cur_item({ self.entries[1], self.entries[1].files[1] })
return self.cur_item[2]
end
if self:num_items() > 1 then
local entry_idx = utils.vec_indexof(self.entries, entry)
local file_idx = utils.vec_indexof(entry.files, file)
if entry_idx ~= -1 and file_idx ~= -1 then
local next_entry, next_file = self:_get_entry_by_file_offset(entry_idx, file_idx, offset)
self:set_cur_item({ next_entry, next_file })
if next_entry ~= entry then
self:set_entry_fold(entry, false)
end
return self.cur_item[2]
end
else
self:set_cur_item({ self.entries[1], self.entries[1].files[1] })
return self.cur_item[2]
end
end
function FileHistoryPanel:prev_file()
return self:set_file_by_offset(-vim.v.count1)
end
function FileHistoryPanel:next_file()
return self:set_file_by_offset(vim.v.count1)
end
---@param item LogEntry|FileEntry
function FileHistoryPanel:highlight_item(item)
if not (self:is_open() and self:buf_loaded()) then return end
if item:instanceof(LogEntry.__get()) then
---@cast item LogEntry
for _, comp_struct in ipairs(self.components.log.entries) do
if comp_struct.comp.context == item then
pcall(api.nvim_win_set_cursor, self.winid, { comp_struct.comp.lstart, 0 })
end
end
else
---@cast item FileEntry
for _, comp_struct in ipairs(self.components.log.entries) do
local i = utils.vec_indexof(comp_struct.comp.context.files, item)
if i ~= -1 then
if self.single_file then
pcall(api.nvim_win_set_cursor, self.winid, { comp_struct.comp.lstart + 1, 0 })
else
if comp_struct.comp.context.folded then
comp_struct.comp.context.folded = false
self:render()
self:redraw()
end
pcall(api.nvim_win_set_cursor, self.winid, { comp_struct.comp.lstart + i + 1, 0 })
end
end
end
end
-- Needed to update the cursorline highlight when the panel is not focused.
utils.update_win(self.winid)
end
function FileHistoryPanel:highlight_prev_item()
if not (self:is_open() and self:buf_loaded()) or #self.entries == 0 then return end
pcall(api.nvim_win_set_cursor, self.winid, {
self.constrain_cursor(self.winid, -vim.v.count1),
0,
})
utils.update_win(self.winid)
end
function FileHistoryPanel:highlight_next_file()
if not (self:is_open() and self:buf_loaded()) or #self.entries == 0 then return end
pcall(api.nvim_win_set_cursor, self.winid, {
self.constrain_cursor(self.winid, vim.v.count1),
0,
})
utils.update_win(self.winid)
end
---@param entry LogEntry
---@param open boolean
function FileHistoryPanel:set_entry_fold(entry, open)
if not self.single_file and open == entry.folded then
entry.folded = not open
self:render()
self:redraw()
if entry.folded then
-- Set the cursor at the top of the log entry
self.components.log.entries.comp:some(function(comp, _, _)
if comp.context == entry then
utils.set_cursor(self.winid, comp.lstart + 1)
return true
end
end)
end
end
end
---@param entry LogEntry
function FileHistoryPanel:toggle_entry_fold(entry)
self:set_entry_fold(entry, entry.folded)
end
function FileHistoryPanel:render()
perf_render:reset()
panel_renderer.file_history_panel(self)
perf_render:time()
logger:lvl(10):debug(perf_render)
end
---@return LogOptions
function FileHistoryPanel:get_log_options()
if self.single_file then
return self.log_options.single_file
else
return self.log_options.multi_file
end
end
M.FileHistoryPanel = FileHistoryPanel
return M

View File

@ -0,0 +1,262 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local CommitLogPanel = lazy.access("diffview.ui.panels.commit_log_panel", "CommitLogPanel") ---@type CommitLogPanel|LazyModule
local EventName = lazy.access("diffview.events", "EventName") ---@type EventName|LazyModule
local FileHistoryPanel = lazy.access("diffview.scene.views.file_history.file_history_panel", "FileHistoryPanel") ---@type FileHistoryPanel|LazyModule
local JobStatus = lazy.access("diffview.vcs.utils", "JobStatus") ---@type JobStatus|LazyModule
local LogEntry = lazy.access("diffview.vcs.log_entry", "LogEntry") ---@type LogEntry|LazyModule
local StandardView = lazy.access("diffview.scene.views.standard.standard_view", "StandardView") ---@type StandardView|LazyModule
local config = lazy.require("diffview.config") ---@module "diffview.config"
local api = vim.api
local await = async.await
local M = {}
---@class FileHistoryView : StandardView
---@operator call:FileHistoryView
---@field adapter VCSAdapter
---@field panel FileHistoryPanel
---@field commit_log_panel CommitLogPanel
---@field valid boolean
local FileHistoryView = oop.create_class("FileHistoryView", StandardView.__get())
function FileHistoryView:init(opt)
self.valid = false
self.adapter = opt.adapter
self:super({
panel = FileHistoryPanel({
parent = self,
adapter = self.adapter,
entries = {},
log_options = opt.log_options,
}),
})
self.valid = true
end
function FileHistoryView:post_open()
self.commit_log_panel = CommitLogPanel(self.adapter, {
name = ("diffview://%s/log/%d/%s"):format(self.adapter.ctx.dir, self.tabpage, "commit_log"),
})
self:init_event_listeners()
vim.schedule(function()
self:file_safeguard()
---@diagnostic disable-next-line: unused-local
self.panel:update_entries(function(entries, status)
if status < JobStatus.ERROR and not self.panel:cur_file() then
local file = self.panel:next_file()
if file then
self:set_file(file)
end
end
end)
self.ready = true
end)
end
---@override
function FileHistoryView:close()
if not self.closing:check() then
self.closing:send()
for _, entry in ipairs(self.panel.entries or {}) do
entry:destroy()
end
self.commit_log_panel:destroy()
FileHistoryView.super_class.close(self)
end
end
---@return FileEntry?
function FileHistoryView:cur_file()
return self.panel.cur_item[2]
end
---@private
---@param self FileHistoryView
---@param file FileEntry
FileHistoryView._set_file = async.void(function(self, file)
self.panel:render()
self.panel:redraw()
vim.cmd("redraw")
self.cur_layout:detach_files()
local cur_entry = self.cur_entry
self.emitter:emit("file_open_pre", file, cur_entry)
self.nulled = false
await(self:use_entry(file))
local log_options = self.panel:get_log_options()
-- For line tracing diffs: create custom folds derived from the diff patch
-- hunks. Should not be used with custom `++base` as then we won't know
-- where to create the custom folds in the base file.
if log_options.L and next(log_options.L) and not log_options.base then
local log_entry = self.panel.cur_item[1]
local diff = log_entry:get_diff(file.path)
if diff and not file:has_patch_folds() then
file:update_patch_folds(diff)
for _, win in ipairs(self.cur_layout.windows) do
win:use_winopts({ foldmethod = "manual" })
win:apply_custom_folds()
end
end
end
self.emitter:emit("file_open_post", file, cur_entry)
if not self.cur_entry.opened then
self.cur_entry.opened = true
DiffviewGlobal.emitter:emit("file_open_new", file)
end
end)
function FileHistoryView:next_item()
self:ensure_layout()
if self:file_safeguard() then return end
if self.panel:num_items() > 1 or self.nulled then
local cur = self.panel:next_file()
if cur then
self.panel:highlight_item(cur)
self.nulled = false
self:_set_file(cur)
return cur
end
end
end
function FileHistoryView:prev_item()
self:ensure_layout()
if self:file_safeguard() then return end
if self.panel:num_items() > 1 or self.nulled then
local cur = self.panel:prev_file()
if cur then
self.panel:highlight_item(cur)
self.nulled = false
self:_set_file(cur)
return cur
end
end
end
---@param self FileHistoryView
---@param file FileEntry
---@param focus? boolean
FileHistoryView.set_file = async.void(function(self, file, focus)
---@diagnostic disable: invisible
self:ensure_layout()
if self:file_safeguard() or not file then return end
local entry = self.panel:find_entry(file)
if entry then
self.panel:set_cur_item({ entry, file })
self.panel:highlight_item(file)
self.nulled = false
await(self:_set_file(file))
if focus then
api.nvim_set_current_win(self.cur_layout:get_main_win().id)
end
end
---@diagnostic enable: invisible
end)
---Ensures there are files to load, and loads the null buffer otherwise.
---@return boolean
function FileHistoryView:file_safeguard()
if self.panel:num_items() == 0 then
local cur = self.panel.cur_item[2]
if cur then
cur.layout:detach_files()
end
self.cur_layout:open_null()
self.nulled = true
return true
end
return false
end
function FileHistoryView:on_files_staged(callback)
self.emitter:on(EventName.FILES_STAGED, callback)
end
function FileHistoryView:init_event_listeners()
local listeners = require("diffview.scene.views.file_history.listeners")(self)
for event, callback in pairs(listeners) do
self.emitter:on(event, callback)
end
end
---Infer the current selected file. If the file panel is focused: return the
---file entry under the cursor. Otherwise return the file open in the view.
---Returns nil if no file is open in the view, or there is no entry under the
---cursor in the file panel.
---@return FileEntry?
function FileHistoryView:infer_cur_file()
if self.panel:is_focused() then
local item = self.panel:get_item_at_cursor()
if LogEntry.__get():ancestorof(item) then
---@cast item LogEntry
return item.files[1]
end
return item --[[@as FileEntry ]]
end
return self.panel.cur_item[2]
end
---Check whether or not the instantiation was successful.
---@return boolean
function FileHistoryView:is_valid()
return self.valid
end
---@override
function FileHistoryView.get_default_layout_name()
return config.get_config().view.file_history.layout
end
---@override
---@return Layout # (class) The default layout class.
function FileHistoryView.get_default_layout()
local name = FileHistoryView.get_default_layout_name()
if name == -1 then
return FileHistoryView.get_default_diff2()
end
return config.name_to_layout(name --[[@as string ]])
end
M.FileHistoryView = FileHistoryView
return M

View File

@ -0,0 +1,198 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local DiffView = lazy.access("diffview.scene.views.diff.diff_view", "DiffView") ---@type DiffView|LazyModule
local JobStatus = lazy.access("diffview.vcs.utils", "JobStatus") ---@type JobStatus|LazyModule
local lib = lazy.require("diffview.lib") ---@module "diffview.lib"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.utils"
local await = async.await
---@param view FileHistoryView
return function(view)
return {
tab_enter = function()
local file = view.panel.cur_item[2]
if file then
view:set_file(file)
end
end,
tab_leave = function()
local file = view.panel.cur_item[2]
if file then
file.layout:detach_files()
end
for _, entry in ipairs(view.panel.entries) do
for _, f in ipairs(entry.files) do
f.layout:restore_winopts()
end
end
end,
file_open_new = function(_, entry)
utils.set_cursor(view.cur_layout:get_main_win().id, 1, 0)
view.cur_layout:sync_scroll()
end,
open_in_diffview = function()
local file = view:infer_cur_file()
if file then
local layout = file.layout --[[@as Diff2 ]]
local new_view = DiffView({
adapter = view.adapter,
rev_arg = view.adapter:rev_to_pretty_string(layout.a.file.rev, layout.b.file.rev),
left = layout.a.file.rev,
right = layout.b.file.rev,
options = { selected_file = file.absolute_path },
})
lib.add_view(new_view)
new_view:open()
end
end,
select_next_entry = function()
view:next_item()
end,
select_prev_entry = function()
view:prev_item()
end,
next_entry = function()
view.panel:highlight_next_file()
end,
prev_entry = function()
view.panel:highlight_prev_item()
end,
select_entry = function()
if view.panel:is_focused() then
local item = view.panel:get_item_at_cursor()
if item then
if item.files then
if view.panel.single_file then
view:set_file(item.files[1], false)
else
view.panel:toggle_entry_fold(item --[[@as LogEntry ]])
end
else
view:set_file(item, false)
end
end
elseif view.panel.option_panel:is_focused() then
local option = view.panel.option_panel:get_item_at_cursor()
if option then
view.panel.option_panel.emitter:emit("set_option", option.key)
end
end
end,
focus_entry = function()
if view.panel:is_focused() then
local item = view.panel:get_item_at_cursor()
if item then
if item.files then
if view.panel.single_file then
view:set_file(item.files[1], true)
else
view.panel:toggle_entry_fold(item --[[@as LogEntry ]])
end
else
view:set_file(item, true)
end
end
end
end,
open_commit_log = function()
local file = view:infer_cur_file()
if file then
local entry = view.panel:find_entry(file)
if entry then
view.commit_log_panel:update(view.adapter.Rev.to_range(entry.commit.hash))
end
end
end,
focus_files = function()
view.panel:focus()
end,
toggle_files = function()
view.panel:toggle(true)
end,
refresh_files = function()
view.panel:update_entries(function(_, status)
if status >= JobStatus.ERROR then
return
end
if not view:cur_file() then
view:next_item()
end
end)
end,
open_all_folds = function()
if view.panel:is_focused() and not view.panel.single_file then
for _, entry in ipairs(view.panel.entries) do
entry.folded = false
end
view.panel:render()
view.panel:redraw()
end
end,
close_all_folds = function()
if view.panel:is_focused() and not view.panel.single_file then
for _, entry in ipairs(view.panel.entries) do
entry.folded = true
end
view.panel:render()
view.panel:redraw()
end
end,
open_fold = function()
if view.panel.single_file or not view.panel:is_focused() then return end
local entry = view.panel:get_log_entry_at_cursor()
if entry then view.panel:set_entry_fold(entry, true) end
end,
close_fold = function()
if view.panel.single_file or not view.panel:is_focused() then return end
local entry = view.panel:get_log_entry_at_cursor()
if entry then view.panel:set_entry_fold(entry, false) end
end,
toggle_fold = function()
if view.panel.single_file or not view.panel:is_focused() then return end
local entry = view.panel:get_log_entry_at_cursor()
if entry then view.panel:toggle_entry_fold(entry) end
end,
close = function()
if view.panel.option_panel:is_focused() then
view.panel.option_panel:close()
elseif view.panel:is_focused() then
view.panel:close()
elseif view:is_cur_tabpage() then
view:close()
end
end,
options = function()
view.panel.option_panel:focus()
end,
copy_hash = function()
if view.panel:is_focused() then
local item = view.panel:get_item_at_cursor()
if item then
vim.fn.setreg("+", item.commit.hash)
utils.info(string.format("Copied '%s' to the clipboard.", item.commit.hash))
end
end
end,
restore_entry = async.void(function()
local item = view:infer_cur_file()
if not item then return end
local bufid = utils.find_file_buffer(item.path)
if bufid and vim.bo[bufid].modified then
utils.err("The file is open with unsaved changes! Aborting file restoration.")
return
end
await(vcs_utils.restore_file(view.adapter, item.path, item.kind, item.commit.hash))
end),
}
end

View File

@ -0,0 +1,232 @@
local lazy = require("diffview.lazy")
local EventEmitter = lazy.access("diffview.events", "EventEmitter") ---@type EventEmitter|LazyModule
local JobStatus = lazy.access("diffview.vcs.utils", "JobStatus") ---@type JobStatus|LazyModule
local Panel = lazy.access("diffview.ui.panel", "Panel") ---@type Panel|LazyModule
local arg_parser = lazy.require("diffview.arg_parser") ---@module "diffview.arg_parser"
local config = lazy.require("diffview.config") ---@module "diffview.config"
local oop = lazy.require("diffview.oop") ---@module "diffview.oop"
local panel_renderer = lazy.require("diffview.scene.views.file_history.render") ---@module "diffview.scene.views.file_history.render"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local api = vim.api
local M = {}
---@class FHOptionPanel : Panel
---@field parent FileHistoryPanel
---@field emitter EventEmitter
---@field render_data RenderData
---@field option_state LogOptions
---@field components CompStruct
local FHOptionPanel = oop.create_class("FHOptionPanel", Panel.__get())
FHOptionPanel.winopts = vim.tbl_extend("force", Panel.winopts, {
cursorline = true,
winhl = {
"EndOfBuffer:DiffviewEndOfBuffer",
"Normal:DiffviewNormal",
"CursorLine:DiffviewCursorLine",
"WinSeparator:DiffviewWinSeparator",
"SignColumn:DiffviewNormal",
"StatusLine:DiffviewStatusLine",
"StatusLineNC:DiffviewStatuslineNC",
},
})
FHOptionPanel.bufopts = {
swapfile = false,
buftype = "nofile",
modifiable = false,
filetype = "DiffviewFileHistory",
bufhidden = "hide",
}
---FHOptionPanel constructor.
---@param parent FileHistoryPanel
function FHOptionPanel:init(parent)
self:super({
---@type PanelSplitSpec
config = {
position = "bottom",
height = #parent.adapter.flags.switches + #parent.adapter.flags.options + 4,
},
bufname = "DiffviewFHOptionPanel",
})
self.parent = parent
self.emitter = EventEmitter()
self.flags = parent.adapter.flags
---@param option_name string
self.emitter:on("set_option", function(_, option_name)
local log_options = self.parent:get_log_options()
local cur_value = log_options[option_name]
if self.flags.switches[option_name] then
self:_set_option(option_name, not cur_value)
self:render()
self:redraw()
elseif self.flags.options[option_name] then
local o = self.flags.options[option_name]
if o.select then
vim.ui.select(o.select, {
prompt = o:render_prompt(),
format_item = function(item)
return item == "" and "<unset>" or item
end,
}, function(choice)
if choice then
self:_set_option(option_name, choice)
end
self:render()
self:redraw()
end)
else
local completion = type(o.completion) == "function" and o.completion(self) or o.completion
utils.input(o:render_prompt(), {
default = o:render_default(cur_value),
completion = type(completion) == "function" and function(_, cmd_line, cur_pos)
---@cast completion fun(ctx: CmdLineContext): string[]
local ctx = arg_parser.scan(cmd_line, { cur_pos = cur_pos })
return arg_parser.process_candidates(completion(ctx), ctx, true)
end or completion,
callback = function(response)
if response ~= "__INPUT_CANCELLED__" then
local values = response == nil and { "" } or arg_parser.scan(response).args
if o.transform then
values = o:transform(values)
end
if not o.expect_list then
---@cast values string
values = values[1]
end
self:_set_option(option_name, values)
end
self:render()
self:redraw()
end,
})
end
end
end)
self:on_autocmd("BufNew", {
callback = function()
self:setup_buffer()
end,
})
self:on_autocmd("WinClosed", {
callback = function()
if not vim.deep_equal(self.option_state, self.parent:get_log_options()) then
vim.schedule(function ()
self.option_state = nil
self.winid = nil
self.parent:update_entries(function(_, status)
if status >= JobStatus.ERROR then
return
end
if not self.parent:cur_file() then
self.parent.parent:next_item()
end
end)
end)
end
end,
})
end
---@private
function FHOptionPanel:_set_option(name, value)
self.parent.log_options.single_file[name] = value
self.parent.log_options.multi_file[name] = value
end
---@override
function FHOptionPanel:open()
FHOptionPanel.super_class.open(self)
self.option_state = utils.tbl_deep_clone(self.parent:get_log_options())
api.nvim_win_call(self.winid, function()
vim.cmd("norm! zb")
end)
end
function FHOptionPanel:setup_buffer()
local conf = config.get_config()
local default_opt = { silent = true, buffer = self.bufid }
for _, mapping in ipairs(conf.keymaps.option_panel) do
local opt = vim.tbl_extend("force", default_opt, mapping[4] or {}, { buffer = self.bufid })
vim.keymap.set(mapping[1], mapping[2], mapping[3], opt)
end
for _, group in pairs(self.flags) do
---@cast group FlagOption[]
for option_name, v in pairs(group) do
vim.keymap.set(
"n",
v.keymap,
function()
self.emitter:emit("set_option", option_name)
end,
{ silent = true, buffer = self.bufid }
)
end
end
end
function FHOptionPanel:update_components()
local switch_schema = {}
local option_schema = {}
for _, option in ipairs(self.flags.switches) do
table.insert(switch_schema, { name = "switch", context = { option = option, }, })
end
for _, option in ipairs(self.flags.options) do
table.insert(option_schema, { name = "option", context = { option = option }, })
end
---@type CompStruct
self.components = self.render_data:create_component({
{
name = "switches",
{ name = "title" },
{ name = "items", unpack(switch_schema) },
},
{
name = "options",
{ name = "title" },
{ name = "items", unpack(option_schema) },
},
})
end
---Get the file entry under the cursor.
---@return FlagOption?
function FHOptionPanel:get_item_at_cursor()
if not (self:is_open() and self:buf_loaded()) then
return
end
local cursor = api.nvim_win_get_cursor(self.winid)
local line = cursor[1]
local comp = self.components.comp:get_comp_on_line(line)
if comp and (comp.name == "switch" or comp.name == "option") then
return comp.context.option
end
end
function FHOptionPanel:render()
panel_renderer.fh_option_panel(self)
end
M.FHOptionPanel = FHOptionPanel
return M

View File

@ -0,0 +1,318 @@
local PerfTimer = require("diffview.perf").PerfTimer
local config = require("diffview.config")
local hl = require("diffview.hl")
local utils = require("diffview.utils")
local fmt = string.format
local logger = DiffviewGlobal.logger
local perf = PerfTimer("[FileHistoryPanel] Render internal")
local pl = utils.path
local cache = setmetatable({}, { __mode = "k" })
---@param comp RenderComponent
---@param files FileEntry[]
local function render_files(comp, files)
for i, file in ipairs(files) do
comp:add_text(i == #files and "" or "", "DiffviewNonText")
if file:is_null_entry() then
comp:add_text(
"No diff",
file.active and "DiffviewFilePanelSelected" or "DiffviewFilePanelFileName"
)
else
if file.status then
comp:add_text(file.status .. " ", hl.get_git_hl(file.status))
else
comp:add_text("-" .. " ", "DiffviewNonText")
end
local icon, icon_hl = hl.get_file_icon(file.basename, file.extension)
comp:add_text(icon, icon_hl)
if #file.parent_path > 0 then
comp:add_text(file.parent_path .. "/", "DiffviewFilePanelPath")
end
comp:add_text(file.basename, file.active and "DiffviewFilePanelSelected" or "DiffviewFilePanelFileName")
if file.stats then
comp:add_text(" " .. file.stats.additions, "DiffviewFilePanelInsertions")
comp:add_text(", ")
comp:add_text(tostring(file.stats.deletions), "DiffviewFilePanelDeletions")
end
end
comp:ln()
end
perf:lap("files")
end
---@param panel FileHistoryPanel
---@param parent CompStruct RenderComponent struct
---@param entries LogEntry[]
---@param updating boolean
local function render_entries(panel, parent, entries, updating)
local c = config.get_config()
local max_num_files = -1
local max_len_stats = -1
for _, entry in ipairs(entries) do
if #entry.files > max_num_files then
max_num_files = #entry.files
end
if entry.stats then
local adds = tostring(entry.stats.additions)
local dels = tostring(entry.stats.deletions)
local l = 7
local w = l - (#adds + #dels)
if w < 1 then
l = (#adds + #dels) - ((#adds + #dels) % 2) + 2
end
max_len_stats = l > max_len_stats and l or max_len_stats
end
end
for i, entry in ipairs(entries) do
if i > #parent or (updating and i > 128) then
break
end
local entry_struct = parent[i]
local comp = entry_struct.commit.comp
if not entry.single_file then
comp:add_text((entry.folded and c.signs.fold_closed or c.signs.fold_open) .. " ", "CursorLineNr")
end
if entry.status then
comp:add_text(entry.status, hl.get_git_hl(entry.status))
else
comp:add_text("-", "DiffviewNonText")
end
if not entry.single_file then
local s_num_files = tostring(max_num_files)
if entry.nulled then
comp:add_text(utils.str_center_pad("empty", #s_num_files + 7), "DiffviewFilePanelCounter")
else
comp:add_text(
fmt(
" %s file%s",
utils.str_left_pad(tostring(#entry.files), #s_num_files),
#entry.files > 1 and "s" or " "
),
"DiffviewFilePanelCounter"
)
end
end
if max_len_stats ~= -1 then
local adds = { "-", "DiffviewNonText" }
local dels = { "-", "DiffviewNonText" }
if entry.stats and entry.stats.additions then
adds = { tostring(entry.stats.additions), "DiffviewFilePanelInsertions" }
end
if entry.stats and entry.stats.deletions then
dels = { tostring(entry.stats.deletions), "DiffviewFilePanelDeletions" }
end
comp:add_text(" | ", "DiffviewNonText")
comp:add_text(unpack(adds))
comp:add_text(string.rep(" ", max_len_stats - (#adds[1] + #dels[1])))
comp:add_text(unpack(dels))
comp:add_text(" |", "DiffviewNonText")
end
if entry.commit.hash then
comp:add_text(" " .. entry.commit.hash:sub(1, 8), "DiffviewHash")
end
if (entry.commit --[[@as GitCommit ]]).reflog_selector then
comp:add_text((" %s"):format((entry.commit --[[@as GitCommit ]]).reflog_selector), "DiffviewReflogSelector")
end
if entry.commit.ref_names then
comp:add_text((" (%s)"):format(entry.commit.ref_names), "DiffviewReference")
end
local subject = utils.str_trunc(entry.commit.subject, 72)
if subject == "" then
subject = "[empty message]"
end
comp:add_text(
" " .. subject,
panel.cur_item[1] == entry and "DiffviewFilePanelSelected" or "DiffviewFilePanelFileName"
)
if entry.commit then
-- 3 months
local date = (
os.difftime(os.time(), entry.commit.time) > 60 * 60 * 24 * 30 * 3
and entry.commit.iso_date
or entry.commit.rel_date
)
comp:add_text(" " .. entry.commit.author .. ", " .. date, "DiffviewFilePanelPath")
end
comp:ln()
perf:lap("entry " .. entry.commit.hash:sub(1, 7))
if not entry.single_file and not entry.folded then
render_files(entry_struct.files.comp, entry.files)
end
end
end
---@param panel FileHistoryPanel
local function prepare_panel_cache(panel)
local c = {}
cache[panel] = c
c.root_path = panel.state.form == "column"
and pl:truncate(
pl:vim_fnamemodify(panel.adapter.ctx.toplevel, ":~"),
panel:infer_width() - 6
)
or pl:vim_fnamemodify(panel.adapter.ctx.toplevel, ":~")
c.args = table.concat(panel.log_options.single_file.path_args, " ")
end
return {
---@param panel FileHistoryPanel
file_history_panel = function(panel)
if not panel.render_data then
return
end
perf:reset()
panel.render_data:clear()
if not cache[panel] then
prepare_panel_cache(panel)
end
local conf = config.get_config()
local comp = panel.components.header.comp
local log_options = panel:get_log_options()
local cached = cache[panel]
-- root path
comp:add_text(cached.root_path, "DiffviewFilePanelRootPath")
comp:ln()
if panel.single_file then
if #panel.entries > 0 then
local file = panel.entries[1].files[1]
-- file path
local icon, icon_hl = hl.get_file_icon(file.basename, file.extension)
comp:add_text(icon, icon_hl)
if #file.parent_path > 0 then
comp:add_text(file.parent_path .. "/", "DiffviewFilePanelPath")
end
comp:add_text(file.basename, "DiffviewFilePanelFileName")
comp:ln()
end
elseif #cached.args > 0 then
comp:add_text("Showing history for: ", "DiffviewFilePanelPath")
comp:add_text(cached.args, "DiffviewFilePanelFileName")
comp:ln()
end
if log_options.rev_range and log_options.rev_range ~= "" then
comp:add_text("Revision range: ", "DiffviewFilePanelPath")
comp:add_text(log_options.rev_range, "DiffviewFilePanelFileName")
comp:ln()
end
if panel.option_mapping then
comp:add_text("Options: ", "DiffviewFilePanelPath")
comp:add_text(panel.option_mapping, "DiffviewFilePanelCounter")
comp:ln()
end
if conf.show_help_hints and panel.help_mapping then
comp:add_text("Help: ", "DiffviewFilePanelPath")
comp:add_text(panel.help_mapping, "DiffviewFilePanelCounter")
comp:ln()
end
-- title
comp = panel.components.log.title.comp
comp:add_line()
comp:add_text("File History ", "DiffviewFilePanelTitle")
comp:add_text("(" .. #panel.entries .. ")", "DiffviewFilePanelCounter")
if panel.updating then
comp:add_text(" (Updating...)", "DiffviewDim1")
end
comp:ln()
perf:lap("header")
if #panel.entries > 0 then
render_entries(panel, panel.components.log.entries, panel.entries, panel.updating)
end
perf:time()
logger:lvl(10):debug(perf)
end,
---@param panel FHOptionPanel
fh_option_panel = function(panel)
if not panel.render_data then
return
end
panel.render_data:clear()
local comp = panel.components.switches.title.comp
local log_options = panel.parent:get_log_options()
comp:add_line("Switches", "DiffviewFilePanelTitle")
for _, item in ipairs(panel.components.switches.items) do
comp = item.comp
local option = comp.context.option --[[@as FlagOption ]]
local enabled = log_options[option.key] --[[@as boolean ]]
comp:add_text(" " .. option.keymap .. " ", "DiffviewSecondary")
comp:add_text(option.desc .. " (", "DiffviewFilePanelFileName")
comp:add_text(option.flag_name, enabled and "DiffviewFilePanelCounter" or "DiffviewDim1")
comp:add_text(")", "DiffviewFilePanelFileName")
comp:ln()
end
comp = panel.components.options.title.comp
comp:add_line()
comp:add_line("Options", "DiffviewFilePanelTitle")
for _, item in ipairs(panel.components.options.items) do
comp = item.comp
local option = comp.context.option --[[@as FlagOption ]]
local value = log_options[option.key] or ""
comp:add_text(" " .. option.keymap .. " ", "DiffviewSecondary")
comp:add_text(option.desc .. " (", "DiffviewFilePanelFileName")
local empty, display_value = option:render_display(value)
comp:add_text(display_value, not empty and "DiffviewFilePanelCounter" or "DiffviewDim1")
comp:add_text(")", "DiffviewFilePanelFileName")
comp:ln()
end
end,
clear_cache = function(panel)
cache[panel] = nil
end,
}

View File

@ -0,0 +1,182 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local Diff1 = lazy.access("diffview.scene.layouts.diff_1", "Diff1") ---@type Diff1|LazyModule
local Diff2 = lazy.access("diffview.scene.layouts.diff_2", "Diff2") ---@type Diff2|LazyModule
local Diff3 = lazy.access("diffview.scene.layouts.diff_3", "Diff3") ---@type Diff3|LazyModule
local Diff4 = lazy.access("diffview.scene.layouts.diff_4", "Diff4") ---@type Diff4|LazyModule
local Panel = lazy.access("diffview.ui.panel", "Panel") ---@type Panel|LazyModule
local View = lazy.access("diffview.scene.view", "View") ---@type View|LazyModule
local config = lazy.require("diffview.config") ---@module "diffview.config"
local oop = lazy.require("diffview.oop") ---@module "diffview.oop"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local api = vim.api
local await = async.await
local M = {}
---@class StandardView : View
---@field panel Panel
---@field winopts table
---@field nulled boolean
---@field cur_layout Layout
---@field cur_entry FileEntry
---@field layouts table<Layout, Layout>
local StandardView = oop.create_class("StandardView", View.__get())
---StandardView constructor
function StandardView:init(opt)
opt = opt or {}
self:super(opt)
self.nulled = utils.sate(opt.nulled, false)
self.panel = opt.panel or Panel()
self.layouts = opt.layouts or {}
self.winopts = opt.winopts or {
diff1 = { a = {} },
diff2 = { a = {}, b = {} },
diff3 = { a = {}, b = {}, c = {} },
diff4 = { a = {}, b = {}, c = {}, d = {} },
}
self.emitter:on("post_layout", utils.bind(self.post_layout, self))
end
---@override
function StandardView:close()
self.panel:destroy()
View.close(self)
end
---@override
function StandardView:init_layout()
local first_init = not vim.t[self.tabpage].diffview_view_initialized
local curwin = api.nvim_get_current_win()
self:use_layout(StandardView.get_temp_layout())
self.cur_layout:create()
vim.t[self.tabpage].diffview_view_initialized = true
if first_init then
api.nvim_win_close(curwin, false)
end
self.panel:focus()
self.emitter:emit("post_layout")
end
function StandardView:post_layout()
if config.get_config().enhanced_diff_hl then
self.winopts.diff2.a.winhl = {
"DiffAdd:DiffviewDiffAddAsDelete",
"DiffDelete:DiffviewDiffDeleteDim",
"DiffChange:DiffviewDiffChange",
"DiffText:DiffviewDiffText",
}
self.winopts.diff2.b.winhl = {
"DiffDelete:DiffviewDiffDeleteDim",
"DiffAdd:DiffviewDiffAdd",
"DiffChange:DiffviewDiffChange",
"DiffText:DiffviewDiffText",
}
end
DiffviewGlobal.emitter:emit("view_post_layout", self)
end
---@override
---Ensure both left and right windows exist in the view's tabpage.
function StandardView:ensure_layout()
if self.cur_layout then
self.cur_layout:ensure()
else
self:init_layout()
end
end
---@param layout Layout
function StandardView:use_layout(layout)
self.cur_layout = layout:clone()
self.layouts[layout.class] = self.cur_layout
self.cur_layout.pivot_producer = function()
local was_open = self.panel:is_open()
local was_only_win = was_open and #utils.tabpage_list_normal_wins(self.tabpage) == 1
self.panel:close()
-- If the panel was the only window before closing, then a temp window was
-- already created by `Panel:close()`.
if not was_only_win then
vim.cmd("1windo aboveleft vsp")
end
local pivot = api.nvim_get_current_win()
if was_open then
self.panel:open()
end
return pivot
end
end
---@param self StandardView
---@param entry FileEntry
StandardView.use_entry = async.void(function(self, entry)
local layout_key
if entry.layout:instanceof(Diff1.__get()) then
layout_key = "diff1"
elseif entry.layout:instanceof(Diff2.__get()) then
layout_key = "diff2"
elseif entry.layout:instanceof(Diff3.__get()) then
layout_key = "diff3"
elseif entry.layout:instanceof(Diff4.__get()) then
layout_key = "diff4"
end
for _, sym in ipairs({ "a", "b", "c", "d" }) do
if entry.layout[sym] then
entry.layout[sym].file.winopts = vim.tbl_extend(
"force",
entry.layout[sym].file.winopts,
self.winopts[layout_key][sym] or {}
)
end
end
local old_layout = self.cur_layout
self.cur_entry = entry
if entry.layout.class == self.cur_layout.class then
self.cur_layout.emitter = entry.layout.emitter
await(self.cur_layout:use_entry(entry))
else
if self.layouts[entry.layout.class] then
self.cur_layout = self.layouts[entry.layout.class]
self.cur_layout.emitter = entry.layout.emitter
else
self:use_layout(entry.layout)
self.cur_layout.emitter = entry.layout.emitter
end
await(self.cur_layout:use_entry(entry))
local future = self.cur_layout:create()
old_layout:destroy()
-- Wait for files to be created + opened
await(future)
if not vim.o.equalalways then
vim.cmd("wincmd =")
end
if self.cur_layout:is_focused() then
self.cur_layout:get_main_win():focus()
end
end
end)
M.StandardView = StandardView
return M

View File

@ -0,0 +1,340 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local EventEmitter = lazy.access("diffview.events", "EventEmitter") ---@type EventEmitter|LazyModule
local File = lazy.access("diffview.vcs.file", "File") ---@type vcs.File|LazyModule
local FileHistoryView = lazy.access("diffview.scene.views.file_history.file_history_view", "FileHistoryView") ---@type FileHistoryView|LazyModule
local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule
local config = lazy.require("diffview.config") ---@module "diffview.config"
local lib = lazy.require("diffview.lib") ---@module "diffview.lib"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local api = vim.api
local await, pawait = async.await, async.pawait
local fmt = string.format
local logger = DiffviewGlobal.logger
local M = {}
local HAS_NVIM_0_8 = vim.fn.has("nvim-0.8") == 1
---@class Window : diffview.Object
---@field id integer
---@field file vcs.File
---@field parent Layout
---@field emitter EventEmitter
local Window = oop.create_class("Window")
Window.winopt_store = {}
---@class Window.init.opt
---@field id integer
---@field file vcs.File
---@field parent Layout
---@param opt Window.init.opt
function Window:init(opt)
self.id = opt.id
self.file = opt.file
self.parent = opt.parent
self.emitter = EventEmitter()
self.emitter:on("post_open", utils.bind(self.post_open, self))
end
function Window:destroy()
self:_restore_winopts()
self:close(true)
end
function Window:clone()
return Window({ file = self.file })
end
---@return boolean
function Window:is_valid()
return self.id and api.nvim_win_is_valid(self.id)
end
---@return boolean
function Window:is_file_open()
return self:is_valid()
and self.file
and self.file:is_valid()
and api.nvim_win_get_buf(self.id) == self.file.bufnr
end
---@param force? boolean
function Window:close(force)
if self:is_valid() then
api.nvim_win_close(self.id, not not force)
self:set_id(nil)
end
end
function Window:focus()
if self:is_valid() then
api.nvim_set_current_win(self.id)
end
end
function Window:is_focused()
return self:is_valid() and api.nvim_get_current_win() == self.id
end
function Window:post_open()
self:apply_custom_folds()
end
---@param self Window
---@param callback fun(ok: boolean)
Window.load_file = async.wrap(function(self, callback)
assert(self.file)
if self.file:is_valid() then return callback(true) end
local ok, err = pawait(self.file.create_buffer, self.file)
if ok and not self.file:is_valid() then
-- The buffer may have been destroyed during the await
ok = false
err = "The file buffer is invalid!"
end
if not ok then
logger:error(err)
utils.err(fmt("Failed to create diff buffer: '%s:%s'", self.file.rev, self.file.path), true)
end
callback(ok)
end)
---@private
function Window:open_fallback()
self.emitter:emit("pre_open")
File.load_null_buffer(self.id)
self:apply_null_winopts()
if self:show_winbar_info() then
vim.wo[self.id].winbar = self.file.winbar
end
self.emitter:emit("post_open")
end
---@param self Window
Window.open_file = async.void(function(self)
---@diagnostic disable: invisible
assert(self.file)
if not (self:is_valid() and self.file.active) then return end
if not self.file:is_valid() then
local ok = await(self:load_file())
await(async.scheduler())
-- Ensure validity after await
if not (self:is_valid() and self.file.active) then return end
if not ok then
self:open_fallback()
return
end
end
self.emitter:emit("pre_open")
local conf = config.get_config()
api.nvim_win_set_buf(self.id, self.file.bufnr)
if self.file.rev.type == RevType.LOCAL then
self:_save_winopts()
end
if self:is_nulled() then
self:apply_null_winopts()
else
self:apply_file_winopts()
end
local view = lib.get_current_view()
local disable_diagnostics = false
if self.file.kind == "conflicting" then
disable_diagnostics = conf.view.merge_tool.disable_diagnostics
elseif view and FileHistoryView.__get():ancestorof(view) then
disable_diagnostics = conf.view.file_history.disable_diagnostics
else
disable_diagnostics = conf.view.default.disable_diagnostics
end
self.file:attach_buffer(false, {
keymaps = config.get_layout_keymaps(self.parent),
disable_diagnostics = disable_diagnostics,
})
if self:show_winbar_info() then
vim.wo[self.id].winbar = self.file.winbar
end
self.emitter:emit("post_open")
api.nvim_win_call(self.id, function()
DiffviewGlobal.emitter:emit("diff_buf_win_enter", self.file.bufnr, self.id, {
symbol = self.file.symbol,
layout_name = self.parent.name,
})
end)
---@diagnostic enable: invisible
end)
---@return boolean
function Window:show_winbar_info()
if self.file and self.file.winbar and HAS_NVIM_0_8 then
local conf = config.get_config()
local view = lib.get_current_view()
if self.file.kind == "conflicting" then
return conf.view.merge_tool.winbar_info
else
if view and view.class == FileHistoryView.__get() then
return conf.view.file_history.winbar_info
else
return conf.view.default.winbar_info
end
end
end
return false
end
function Window:is_nulled()
return self:is_valid() and api.nvim_win_get_buf(self.id) == File.NULL_FILE.bufnr
end
function Window:open_null()
if self:is_valid() then
self.emitter:emit("pre_open")
File.load_null_buffer(self.id)
end
end
function Window:detach_file()
if self.file and self.file:is_valid() then
self.file:detach_buffer()
end
end
---Check if the file buffer is in use in the current view's layout.
---@private
---@return boolean
function Window:_is_file_in_use()
local view = lib.get_current_view() --[[@as StandardView? ]]
if view and view.cur_layout ~= self.parent then
local main = view.cur_layout:get_main_win()
return main.file.bufnr and main.file.bufnr == self.file.bufnr
end
return false
end
function Window:_save_winopts()
if Window.winopt_store[self.file.bufnr] then return end
Window.winopt_store[self.file.bufnr] = {}
api.nvim_win_call(self.id, function()
for option, _ in pairs(self.file.winopts) do
Window.winopt_store[self.file.bufnr][option] = vim.o[option]
end
end)
end
function Window:_restore_winopts()
if
Window.winopt_store[self.file.bufnr]
and api.nvim_buf_is_loaded(self.file.bufnr)
and not self:_is_file_in_use()
then
utils.no_win_event_call(function()
local winid = utils.temp_win(self.file.bufnr)
utils.set_local(winid, Window.winopt_store[self.file.bufnr])
if HAS_NVIM_0_8 then
vim.wo[winid].winbar = nil
end
api.nvim_win_close(winid, true)
end)
end
end
function Window:apply_file_winopts()
assert(self.file)
if self.file.winopts then
utils.set_local(self.id, self.file.winopts)
end
end
function Window:apply_null_winopts()
if File.NULL_FILE.winopts then
utils.set_local(self.id, File.NULL_FILE.winopts)
end
local file_winhl = utils.tbl_access(self, "file.winopts.winhl")
if file_winhl then
utils.set_local(self.id, { winhl = file_winhl })
end
end
---Use the given map of local options. These options are saved and restored
---when the file gets unloaded.
---@param opts WindowOptions
function Window:use_winopts(opts)
if not self:is_file_open() then
self.emitter:once("post_open", utils.bind(self.use_winopts, self, opts))
return
end
local opt_store = utils.tbl_ensure(Window.winopt_store, { self.file.bufnr })
api.nvim_win_call(self.id, function()
for option, v in pairs(opts) do
if opt_store[option] == nil then
opt_store[option] = vim.o[option]
end
self.file.winopts[option] = v
utils.set_local(self.id, { [option] = v })
end
end)
end
function Window:apply_custom_folds()
if self.file.custom_folds
and not self:is_nulled()
and vim.wo[self.id].foldmethod == "manual"
then
api.nvim_win_call(self.id, function()
pcall(vim.cmd, "norm! zE") -- Delete all folds in the window
for _, fold in ipairs(self.file.custom_folds) do
vim.cmd(fmt("%d,%dfold", fold[1], fold[2]))
-- print(fmt("%d,%dfold", fold[1], fold[2]))
end
end)
end
end
function Window:set_id(id)
self.id = id
end
function Window:set_file(file)
self.file = file
end
M.Window = Window
return M

View File

@ -0,0 +1,320 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local control = lazy.require("diffview.control") ---@module "diffview.control"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local await = async.await
local M = {}
---@generic T
---@class Stream<T> : diffview.Object
---@operator call : Stream
---@field src Stream.SrcFunc
---@field head integer
---@field drained boolean
local Stream = oop.create_class("Stream")
M.Stream = Stream
Stream.EOF = oop.Symbol("Stream.EOF");
---@alias Stream.SrcFunc fun(): (item: unknown, continue: boolean?)
---@param src table|Stream.SrcFunc
function Stream:init(src)
self.src = self:create_src(src)
self.head = 1
self.drained = false
end
---@private
---@param src table|function
function Stream:create_src(src)
if type(src) == "table" then
if utils.islist(src) then
local itr = ipairs(src)
return function()
local _, v = itr(src, self.head - 1)
return v
end
else
error("Unimplemented!")
end
else
return src
end
end
---@return unknown? item
---@return integer? index
function Stream:next()
if self.drained then
error("Attempted to consume a drained stream!")
end
local idx = self.head
local v, cont = self.src()
if v == Stream.EOF or (v == nil and not cont) then
self.drained = true
return Stream.EOF, nil
end
self.head = self.head + 1
return v, idx
end
---@param n? integer
function Stream:skip(n)
if not n then
self:next()
return
end
for _ = 1, n do self:next() end
return self
end
---@return fun(): (index: integer, item: unknown)
function Stream:iter()
return function()
local v, i = self:next()
---@diagnostic disable-next-line: missing-return-value, return-type-mismatch
if v == Stream.EOF then return nil end
---@cast i -?
return i, v
end
end
---@return unknown[]
function Stream:collect()
local ret = {}
for i, v in self:iter() do ret[i] = v end
return ret
end
---@param first? integer (default: 1)
---@param last? integer (default: math.huge)
---@return Stream
function Stream:slice(first, last)
if first == nil then first = 1 end
if last == nil then last = math.huge end
return Stream(function()
if self.head > last then return nil, false end
if first > self.head then
self:skip(first - self.head)
end
return (self:next())
end)
end
---@param f fun(item: unknown): unknown
---@return Stream
function Stream:map(f)
return Stream(function()
local v = self:next()
while v ~= Stream.EOF do
v = f(v)
if v ~= nil then break end
v = self:next()
end
if v == Stream.EOF then
return nil, false
end
return v
end)
end
---@param f fun(item: unknown): boolean
---@return Stream
function Stream:filter(f)
return self:map(function(item)
if not f(item) then
return nil
end
return item
end)
end
---@generic T
---@param f fun(acc: unknown, cur: unknown): T # Reducer
---@param init? any # Initial value of the accumulator. Defaults to the next value in the stream.
---@return T
function Stream:reduce(f, init)
local acc = init
if not acc then acc = self:next() end
for _, v in self:iter() do acc = f(acc, v) end
return acc
end
---@class AsyncStream : Stream, Waitable
---@operator call : AsyncStream
local AsyncStream = oop.create_class("AsyncStream", Stream)
M.AsyncStream = AsyncStream
AsyncStream.next = async.sync_wrap(
---@param self AsyncStream
---@param callback function
function(self, callback)
if self.drained then
error("Attempted to consume a drained stream!")
end
local idx = self.head
local v, cont = await(self.src())
if v == Stream.EOF or (v == nil and not cont) then
self.drained = true
callback(Stream.EOF, nil)
return
end
self.head = self.head + 1
callback(v, idx)
end
)
AsyncStream.await = async.sync_wrap(
---@param self AsyncStream
---@param callback function
function(self, callback)
callback(self:collect())
end
)
---@enum StreamState
local StreamState = oop.enum({
OPEN = 1,
CLOSING = 2,
CLOSED = 3,
})
---@class AsyncListStream : AsyncStream
---@operator call : AsyncListStream
---@field private data unknown[]
---@field private state { [AsyncListStream.EventKind]: { listeners: function[], args?: unknown[] } }
---@field private flow_state StreamState
---@field private sem Semaphore
---@field private close_listeners? (fun(...))[]
---@field private post_close_listeners (fun())[]
---@field private on_close_args? unknown[]
local AsyncListStream = oop.create_class("AsyncListStream", AsyncStream)
M.AsyncListStream = AsyncListStream
---@alias AsyncListStream.EventKind "on_close"|"on_post_close"
function AsyncListStream:init(opt)
opt = opt or {}
self.data = {}
self.state = {
on_close = { listeners = { opt.on_close } },
on_post_close = { listeners = { opt.on_post_close } },
}
self.flow_state = StreamState.OPEN
self.sem = control.Semaphore(1)
local src = async.wrap(function(callback)
if self.data[self.head] == nil then
self.resume = callback
return
end
callback(self.data[self.head])
end)
self:super(src)
end
---Append the given items to the end of the stream. Pushing `Stream.EOF` will
---close the stream.
function AsyncListStream:push(...)
if self:is_closed() then return end
local args = { ... }
local permit = await(self.sem:acquire()) --[[@as Permit ]]
for i = 1, select("#", ...) do
if args[i] ~= nil then
if args[i] == Stream.EOF then
if self.flow_state ~= StreamState.CLOSING then
self.flow_state = StreamState.CLOSING
-- Release permit while calling 'on_close' callbacks so that they're
-- able to invoke some final pushes before fully closing the stream.
permit:forget()
self:invoke_listeners("on_close")
permit = await(self.sem:acquire()) --[[@as Permit ]]
self.data[#self.data+1] = args[i]
self.flow_state = StreamState.CLOSED
self:invoke_listeners("on_post_close")
break
end
else
self.data[#self.data+1] = args[i]
end
end
end
permit:forget()
if self.resume then
local resume = self.resume
self.resume = nil
resume(self.data[self.head])
end
end
---@param ... any Arguments to pass to the `on_close` callback.
function AsyncListStream:close(...)
if self:is_closed() then return end
self.state.on_close.args = utils.tbl_pack(...)
self:push(Stream.EOF)
end
function AsyncListStream:is_closed()
return self.flow_state == StreamState.CLOSED
end
---@param callback function
function AsyncListStream:on_close(callback)
table.insert(self.state.on_close.listeners, callback)
end
---@param callback function
function AsyncListStream:on_post_close(callback)
table.insert(self.state.on_post_close.listeners, callback)
end
---@private
---@param kind AsyncListStream.EventKind
function AsyncListStream:invoke_listeners(kind)
local event_state = self.state[kind]
for _, listener in ipairs(event_state.listeners) do
if event_state.args then
listener(utils.tbl_unpack(event_state.args))
else
listener()
end
end
event_state.args = nil
end
return M

View File

@ -0,0 +1,323 @@
local helpers = require("diffview.tests.helpers")
local eq, neq = helpers.eq, helpers.neq
-- Windows path standards:
-- https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats
describe("diffview.path", function()
local PathLib = require("diffview.path").PathLib
describe("convert()", function()
it("converts to the default sep when non is specified", function()
local pl = PathLib({ os = "unix" })
eq("/foo/bar/baz", pl:convert("/foo/bar/baz"))
eq("/foo/bar/baz", pl:convert([[\foo\bar\baz]]))
eq("/foo/bar/baz", pl:convert("////foo///bar//baz"))
eq("/foo/bar/baz", pl:convert([[\\\\foo\\//\bar\\baz]]))
pl = PathLib({ os = "windows" })
eq([[C:\foo\bar\baz]], pl:convert([[C:\foo\bar\baz]]))
eq([[C:\foo\bar\baz]], pl:convert([[C:/foo/bar/baz]]))
eq([[C:\foo\bar\baz]], pl:convert([[C:\\\\foo\\\bar\\baz]]))
eq([[C:\foo\bar\baz]], pl:convert([[C:/foo//\\//bar//baz]]))
eq([[\foo\bar\baz]], pl:convert([[\foo\bar\baz]]))
eq([[\foo\bar\baz]], pl:convert([[/foo/bar/baz]]))
-- Windows UNC paths
eq([[\\wsl.localhost\foo\bar\baz]], pl:convert([[\\wsl.localhost\foo\bar\baz]]))
eq([[\\wsl.localhost\foo\bar\baz]], pl:convert([[\\wsl.localhost/foo/bar/baz]]))
eq([[\\]], pl:convert([[\\]]))
-- Windows DOS Device paths
eq([[\\.\foo\bar\baz]], pl:convert([[\\.\foo\bar\baz]]))
eq([[\\.\foo\bar\baz]], pl:convert([[\\.\foo/bar/baz]]))
eq([[\\.\]], pl:convert([[\\.\]]))
eq([[\\?\foo\bar\baz]], pl:convert([[\\?\foo\bar\baz]]))
eq([[\\?\foo\bar\baz]], pl:convert([[\\?\foo/bar/baz]]))
eq([[\\?\]], pl:convert([[\\?\]]))
end)
it("converts to the specified sep", function()
local pl = PathLib({ os = "unix" })
eq([[/foo/bar/baz]], pl:convert([[/foo/bar/baz]], "/"))
eq([[/foo/bar/baz]], pl:convert([[\foo\bar\baz]], "/"))
eq([[\foo\bar\baz]], pl:convert([[/foo/bar/baz]], "\\"))
eq([[\foo\bar\baz]], pl:convert([[\foo\bar\baz]], "\\"))
pl = PathLib({ os = "windows" })
eq([[C:\foo\bar\baz]], pl:convert([[C:\foo\bar\baz]], "\\"))
eq([[C:\foo\bar\baz]], pl:convert([[C:/foo/bar/baz]], "\\"))
eq([[C:/foo/bar/baz]], pl:convert([[C:\foo\bar\baz]], "/"))
eq([[C:/foo/bar/baz]], pl:convert([[C:/foo/bar/baz]], "/"))
-- UNC
eq([[\\wsl.localhost\foo\bar\baz]], pl:convert([[\\wsl.localhost\foo\bar\baz]], "\\"))
eq([[\\wsl.localhost\foo\bar\baz]], pl:convert([[//wsl.localhost/foo/bar/baz]], "\\"))
eq([[//wsl.localhost/foo/bar/baz]], pl:convert([[\\wsl.localhost\foo\bar\baz]], "/"))
eq([[//wsl.localhost/foo/bar/baz]], pl:convert([[//wsl.localhost/foo/bar/baz]], "/"))
-- DOS Device
eq([[\\.\foo\bar\baz]], pl:convert([[\\.\foo\bar\baz]], "\\"))
eq([[\\.\foo\bar\baz]], pl:convert([[//./foo/bar/baz]], "\\"))
eq([[//./foo/bar/baz]], pl:convert([[\\.\foo\bar\baz]], "/"))
eq([[//./foo/bar/baz]], pl:convert([[//./foo/bar/baz]], "/"))
eq([[\\?\foo\bar\baz]], pl:convert([[\\?\foo\bar\baz]], "\\"))
eq([[\\?\foo\bar\baz]], pl:convert([[//?/foo/bar/baz]], "\\"))
eq([[//?/foo/bar/baz]], pl:convert([[\\?\foo\bar\baz]], "/"))
eq([[//?/foo/bar/baz]], pl:convert([[//?/foo/bar/baz]], "/"))
end)
it("handles URI's correctly", function()
local pl = PathLib({ os = "unix" })
eq("test:///foo/bar/baz", pl:convert("test:///foo/bar/baz"))
eq("test://foo/bar/baz", pl:convert("test://foo/bar/baz"))
eq("test:///foo/bar/baz", pl:convert([[test://\foo\bar\baz]]))
pl = PathLib({ os = "windows" })
eq("test:///foo/bar/baz", pl:convert("test:///foo/bar/baz"))
eq("test://foo/bar/baz", pl:convert("test://foo/bar/baz"))
eq("test:///foo/bar/baz", pl:convert([[test://\foo\bar\baz]]))
end)
end)
describe("is_abs()", function()
it("works for UNIX paths", function()
local pl = PathLib({ os = "unix" })
eq(true, pl:is_abs("/foo/bar/baz"))
eq(true, pl:is_abs("/"))
eq(false, pl:is_abs("foo/bar/baz"))
eq(false, pl:is_abs(""))
end)
it("works for Windows paths", function()
local pl = PathLib({ os = "windows" })
-- fs
eq(true, pl:is_abs("C:/foo/bar/baz"))
eq(true, pl:is_abs("C:/"))
eq(true, pl:is_abs("/"))
eq(false, pl:is_abs("foo/bar/baz"))
eq(false, pl:is_abs(""))
-- UNC
eq(true, pl:is_abs([[\\wsl.localhost\Ubuntu1804]]))
eq(true, pl:is_abs([[\\]]))
-- DOS Device
eq(true, pl:is_abs([[\\.\foo\bar\baz]]))
eq(true, pl:is_abs([[\\.\]]))
eq(true, pl:is_abs([[\\?\foo\bar\baz]]))
eq(true, pl:is_abs([[\\?\]]))
end)
end)
describe("absolute()", function()
it("works for UNIX paths", function()
local pl = PathLib({ os = "unix" })
eq([[/foo/bar/baz]], pl:absolute([[bar/baz]], [[/foo]]))
eq([[/foo/bar/baz]], pl:absolute([[/foo/bar/baz]], [[/foo]]))
end)
it("works for Windows paths", function()
local pl = PathLib({ os = "windows" })
eq([[C:\foo\bar\baz]], pl:absolute([[bar\baz]], [[C:\foo]]))
eq([[C:\foo\bar\baz]], pl:absolute([[C:\foo\bar\baz]], [[C:\foo]]))
eq([[C:\foo\bar\baz]], pl:absolute([[\foo\bar\baz]], [[C:\lorem\ipsum]]))
eq([[D:\foo\bar\baz]], pl:absolute([[\foo\bar\baz]], [[D:\lorem\ipsum]]))
eq([[\\wsl.localhost\foo\bar\baz]], pl:absolute([[bar\baz]], [[\\wsl.localhost\foo]]))
eq([[\\wsl.localhost\foo\bar\baz]], pl:absolute([[\\wsl.localhost\foo\bar\baz]], [[\\wsl.localhost\foo]]))
eq([[\\.\foo\bar\baz]], pl:absolute([[bar\baz]], [[\\.\foo]]))
eq([[\\.\foo\bar\baz]], pl:absolute([[\\.\foo\bar\baz]], [[\\.\foo]]))
eq([[\\?\foo\bar\baz]], pl:absolute([[bar\baz]], [[\\?\foo]]))
eq([[\\?\foo\bar\baz]], pl:absolute([[\\?\foo\bar\baz]], [[\\?\foo]]))
end)
end)
describe("is_root()", function()
it("works for UNIX paths", function()
local pl = PathLib({ os = "unix" })
eq(true, pl:is_root("/"))
eq(false, pl:is_root("/foo"))
end)
it("works for Windows paths", function()
local pl = PathLib({ os = "windows" })
eq(true, pl:is_root([[C:\]]))
eq(true, pl:is_root([[C:]]))
eq(true, pl:is_root([[\]]))
eq(false, pl:is_root([[C:\foo]]))
eq(true, pl:is_root([[\\]]))
eq(false, pl:is_root([[\\foo]]))
eq(true, pl:is_root([[\\.\]]))
eq(false, pl:is_root([[\\.\foo]]))
eq(true, pl:is_root([[\\?\]]))
eq(false, pl:is_root([[\\?\foo]]))
end)
end)
describe("root()", function()
it("works for UNIX paths", function()
local pl = PathLib({ os = "unix" })
eq("/", pl:root("/"))
eq("/", pl:root("/foo"))
eq(nil, pl:root("foo/bar/baz"))
end)
it("works for Windows paths", function()
local pl = PathLib({ os = "windows" })
eq(nil, pl:root([[foo\bar\baz]]))
eq([[C:]], pl:root([[C:]]))
eq([[C:]], pl:root([[C:\foo\bar\baz]]))
eq([[\]], pl:root([[\]]))
eq([[\]], pl:root([[\foo\bar\baz]]))
eq([[\\]], pl:root([[\\]]))
eq([[\\]], pl:root([[\\foo\bar\baz]]))
eq([[\\.\]], pl:root([[\\.\]]))
eq([[\\.\]], pl:root([[\\.\foo\bar\baz]]))
eq([[\\?\]], pl:root([[\\?\]]))
eq([[\\?\]], pl:root([[\\?\foo\bar\baz]]))
end)
end)
describe("normalize()", function()
it("works for UNIX paths", function()
local pl = PathLib({ os = "unix" })
eq("foo/bar/baz", pl:normalize("foo/bar/././baz", { cwd = "/lorem/ipsum/dolor" }))
eq("foo/baz", pl:normalize("foo/bar/../baz", { cwd = "/lorem/ipsum/dolor" }))
eq("/lorem/ipsum/baz", pl:normalize("foo/../../baz", { cwd = "/lorem/ipsum/dolor" }))
eq(".", pl:normalize("foo/..", { cwd = "/lorem/ipsum/dolor" }))
end)
it("works for Windows paths", function()
local pl = PathLib({ os = "windows" })
-- Resolves relative drive
eq([[D:\foo\bar\baz]], pl:normalize([[\foo\bar\baz]], { cwd = [[D:\lorem\ipsum\dolor]] }))
end)
end)
describe("expand()", function()
local save_env = {}
before_each(function()
local env = {
HOME = "/lorem/ipsum/dolor",
VAR_FOO = "EXPANDED_FOO",
VAR_BAR = "EXPANDED_BAR",
}
for k, v in pairs(env) do
save_env[k] = vim.env[k] or ""
vim.env[k] = v
end
end)
after_each(function()
for k, v in pairs(save_env) do vim.env[k] = v end
end)
it("works", function()
local pl = PathLib({ os = "unix" })
eq("/lorem/ipsum/dolor/foo", pl:expand("~/foo"))
eq("foo/EXPANDED_FOO/EXPANDED_BAR/baz", pl:expand("foo/$VAR_FOO/$VAR_BAR/baz"))
end)
end)
describe("join()", function()
it("works for UNIX paths", function()
local pl = PathLib({ os = "unix" })
eq([[/foo/bar/baz]], pl:join({ "/", "foo", "bar", "baz" }))
eq([[/foo/bar/baz]], pl:join({ "/foo/bar", "baz" }))
eq([[/foo/bar/baz]], pl:join({ "/", "foo/", "/bar///", "/baz" }))
eq([[foo/bar/baz]], pl:join({ "", "foo", "bar", "baz" }))
end)
it("works for Windows paths", function()
local pl = PathLib({ os = "windows" })
eq([[C:\foo\bar\baz]], pl:join({ "C:", "foo", "bar", "baz" }))
eq([[C:\foo\bar\baz]], pl:join({ "C:\\foo\\bar", "baz" }))
eq([[C:\foo\bar\baz]], pl:join({ "C:\\", "foo\\", "\\bar\\\\", "\\baz" }))
eq([[\foo\bar\baz]], pl:join({ "\\", "foo", "bar", "baz" }))
eq([[foo\bar\baz]], pl:join({ "", "foo", "bar", "baz" }))
eq([[\\foo\bar\baz]], pl:join({ [[\\]], "foo", "bar", "baz" }))
eq([[\\foo\bar\baz]], pl:join({ [[\\foo\\bar]], "baz" }))
eq([[\\foo\bar\baz]], pl:join({ [[\\]], "foo\\", "\\bar\\\\", "\\baz" }))
eq([[\\.\foo\bar\baz]], pl:join({ [[\\.\]], "foo", "bar", "baz" }))
eq([[\\.\foo\bar\baz]], pl:join({ [[\\.\foo\\bar]], "baz" }))
eq([[\\.\foo\bar\baz]], pl:join({ [[\\.\]], "foo\\", "\\bar\\\\", "\\baz" }))
eq([[\\?\foo\bar\baz]], pl:join({ [[\\?\]], "foo", "bar", "baz" }))
eq([[\\?\foo\bar\baz]], pl:join({ [[\\?\foo\\bar]], "baz" }))
eq([[\\?\foo\bar\baz]], pl:join({ [[\\?\]], "foo\\", "\\bar\\\\", "\\baz" }))
end)
it("works for URIs", function()
local pl = PathLib({ os = "unix" })
eq([[test:///foo/bar/baz]], pl:join({ "test://", "/", "foo", "bar", "baz"}))
eq([[test://foo/bar/baz]], pl:join({ "test://", "foo", "bar", "baz"}))
eq([[test://foo/bar/baz]], pl:join({ "test://", "foo/", "//bar/", "baz"}))
end)
end)
describe("explode()", function()
it("works for UNIX paths", function()
local pl = PathLib({ os = "unix" })
eq({ "/", "foo", "bar", "baz" }, pl:explode("/foo/bar/baz"))
eq({ "foo", "bar", "baz" }, pl:explode("foo/bar/baz"))
end)
it("works for Windows paths", function()
local pl = PathLib({ os = "windows" })
eq({ "C:", "foo", "bar", "baz" }, pl:explode([[C:\foo\bar\baz]]))
eq({ "foo", "bar", "baz" }, pl:explode([[foo\bar\baz]]))
eq({ [[\]], "foo", "bar", "baz" }, pl:explode([[\foo\bar\baz]]))
eq({ [[\\]], "foo", "bar", "baz" }, pl:explode([[\\foo\bar\baz]]))
eq({ [[\\.\]], "foo", "bar", "baz" }, pl:explode([[\\.\foo\bar\baz]]))
eq({ [[\\?\]], "foo", "bar", "baz" }, pl:explode([[\\?\foo\bar\baz]]))
end)
it("works for URIs", function()
local pl = PathLib({ os = "unix" })
eq({ "test://", "/", "foo", "bar", "baz" }, pl:explode("test:///foo/bar/baz"))
eq({ "test://", "foo", "bar", "baz" }, pl:explode("test://foo/bar/baz"))
end)
end)
end)

View File

@ -0,0 +1,245 @@
local async = require("diffview.async")
local helpers = require("diffview.tests.helpers")
local await = async.await
local async_test = helpers.async_test
local eq, neq = helpers.eq, helpers.neq
describe("diffview.stream", function()
local Stream = require("diffview.stream").Stream
local AsyncStream = require("diffview.stream").AsyncStream
local AsyncListStream = require("diffview.stream").AsyncListStream
local arr1 = {
{ name = "a", i = 1 },
{ name = "b", i = 2 },
{ name = "c", i = 3 },
{ name = "d", i = 4 },
{ name = "e", i = 5 },
}
describe("Stream", function()
it("can collect a simple array", function()
eq(arr1, Stream(arr1):collect())
end)
it("can iterate a simple array", function()
local s0 = {}
local s1 = {}
for i, v in ipairs(arr1) do
s0[#s0+1] = { i, v }
end
for i, v in Stream(arr1):iter() do
s1[#s1 + 1] = { i, v }
end
eq(s0, s1)
end)
it("can slice", function()
eq(
vim.list_slice(arr1, 2, 4),
Stream(arr1):slice(2, 4):collect()
)
end)
it("can map", function()
local function f(item)
return item.name:upper():rep(5)
end
eq(
vim.tbl_map(f, arr1),
Stream(arr1):map(f):collect()
)
end)
it("can filter", function()
local function f(item)
return item.i % 2 ~= 0
end
eq(
vim.tbl_filter(f, arr1),
Stream(arr1):filter(f):collect()
)
end)
it("can reduce without an init value", function()
eq(
"abcde",
Stream({ "a", "b", "c", "d", "e" }):reduce(function(acc, cur)
return acc .. cur
end)
)
end)
it("can reduce with an init value", function()
eq(
"init abcde",
Stream(arr1):reduce(function(acc, cur)
return acc .. cur.name
end, "init ")
)
end)
it("can run a pipeline of transforms", function()
eq(
"AAAAACCCCCEEEEE",
Stream(arr1)
:filter(function(item)
return item.i % 2 ~= 0
end)
:map(function(item)
return item.name:upper():rep(5)
end)
:reduce(function(acc, cur)
return acc .. cur
end, "")
)
end)
end)
describe("AsyncStream", function()
local function mock_iter(src_arr)
src_arr = src_arr or arr1
local iter = ipairs(src_arr)
local i = 0
return async.wrap(function(callback)
if i == #src_arr then return callback(nil) end
await(async.timeout(1))
local _, ret = iter(src_arr, i)
i = i + 1
return callback(ret)
end)
end
it("can iterate", async_test(function()
local s0 = {}
local s1 = {}
for i, v in ipairs(arr1) do
s0[#s0+1] = { i, v }
end
for i, v in AsyncStream(mock_iter(arr1)):iter() do
s1[#s1 + 1] = { i, v }
end
eq(s0, s1)
end))
it("can be awaited", async_test(function()
local stream = AsyncStream(mock_iter(arr1))
eq(arr1, await(stream))
end))
end)
describe("AsyncListStream", function()
local mock_worker = async.void(function(stream, src_array)
for _, v in ipairs(src_array or arr1) do
await(async.timeout(10))
stream:push(v)
end
stream:close()
end)
it("can iterate", async_test(function()
local s0 = {}
local s1 = {}
for i, v in ipairs(arr1) do
s0[#s0+1] = { i, v }
end
local stream = AsyncListStream()
mock_worker(stream)
for i, v in stream:iter() do
s1[#s1 + 1] = { i, v }
end
eq(s0, s1)
end))
it("can be awaited", async_test(function()
local stream = AsyncListStream()
mock_worker(stream)
eq(arr1, await(stream))
end))
it("can close early", async_test(function()
local stream = AsyncListStream()
mock_worker(stream)
local ret = {}
for i, v in stream:iter() do
ret[i] = v
if i == 3 then stream:close() end
end
eq(vim.list_slice(arr1, 1, 3), ret)
end))
it("can't push items after close", async_test(function()
local stream = AsyncListStream()
stream:push(1, 2, 3)
stream:close()
stream:push(4, 5, 6)
eq({ 1, 2, 3 }, stream:collect())
end))
it("can push final items during on_close()", async_test(function()
local final_arr = { "final_1", "final_2", "final_3" }
local stream
stream = AsyncListStream({
on_close = function()
stream:push(unpack(final_arr))
end,
})
mock_worker(stream)
local ret = {}
for i, v in stream:iter() do
ret[i] = v
if i == 3 then stream:close() end
end
eq(
vim.list_extend(vim.list_slice(arr1, 1, 3), final_arr),
ret
)
end))
it("calls on_close() callbacks with the appropriate args", async.sync_wrap(function(done)
local stream = AsyncListStream({
on_close = function(...)
eq({ nil, 1, nil, 2, 3 }, { ... })
done()
end,
})
stream:close(nil, 1, nil, 2, 3)
end))
it("calls the event callbacks in the appropriate order", async_test(function()
local ret = {}
local stream = AsyncListStream({
on_close = function()
table.insert(ret, 1)
end,
on_post_close = function()
table.insert(ret, 2)
end,
})
stream:close()
await(stream)
eq({ 1, 2 }, ret)
end))
end)
end)

View File

@ -0,0 +1,32 @@
local assert = require("luassert")
local async = require("diffview.async")
local await, pawait = async.await, async.pawait
local M = {}
function M.eq(a, b)
if a == nil or b == nil then return assert.are.equal(a, b) end
return assert.are.same(a, b)
end
function M.neq(a, b)
if a == nil or b == nil then return assert.are_not.equal(a, b) end
return assert.are_not.same(a, b)
end
---@param test_func function
function M.async_test(test_func)
local afunc = async.void(test_func)
return function(...)
local ok, err = pawait(afunc(...))
await(async.scheduler())
if not ok then
error(err)
end
end
end
return M

View File

@ -0,0 +1 @@
require("diffview.bootstrap")

View File

@ -0,0 +1,22 @@
local oop = require("diffview.oop")
local M = {}
---@class Model : diffview.Object
local Model = oop.create_class("Model")
---@diagnostic disable unused-local
---@abstract
---@param data table
---@return CompSchema
function Model:create_comp_schema(data) oop.abstract_stub() end
---@abstract
---@param render_data RenderData
function Model:render(render_data) oop.abstract_stub() end
---@diagnostic enable unused-local
M.Model = Model
return M

View File

@ -0,0 +1,155 @@
local oop = require("diffview.oop")
local utils = require("diffview.utils")
local Node = require("diffview.ui.models.file_tree.node").Node
local Model = require("diffview.ui.model").Model
local pl = utils.path
local M = {}
---@class DirData
---@field name string
---@field path string
---@field kind vcs.FileKind
---@field collapsed boolean
---@field status string
---@field _node Node
---@class FileTree : Model
---@field root Node
local FileTree = oop.create_class("FileTree", Model)
---FileTree constructor
---@param files FileEntry[]?
function FileTree:init(files)
self.root = Node("__ROOT__")
for _, file in ipairs(files or {}) do
self:add_file_entry(file)
end
end
---@param file FileEntry
function FileTree:add_file_entry(file)
local parts = pl:explode(file.path)
local cur_node = self.root
local path = parts[1]
-- Create missing intermediate pathname components
for i = 1, #parts - 1 do
local name = parts[i]
if i > 1 then
path = pl:join(path, parts[i])
end
if not cur_node.children[name] then
---@type DirData
local dir_data = {
name = name,
path = path,
kind = file.kind,
collapsed = false,
status = " ", -- updated later in FileTree:update_statuses()
}
cur_node = cur_node:add_child(Node(name, dir_data))
else
cur_node = cur_node.children[name]
end
end
cur_node:add_child(Node(parts[#parts], file))
end
---@param a string
---@param b string
---@return string
local function combine_statuses(a, b)
if a == " " or a == "?" or a == "!" or a == b then
return b
end
return "M"
end
function FileTree:update_statuses()
---@return string the node's status
local function recurse(node)
if not node:has_children() then
return node.data.status
end
local parent_status = " "
for _, child in ipairs(node.children) do
local child_status = recurse(child)
parent_status = combine_statuses(parent_status, child_status)
end
node.data.status = parent_status
return parent_status
end
for _, node in ipairs(self.root.children) do
recurse(node)
end
end
function FileTree:create_comp_schema(data)
self.root:sort()
---@type CompSchema
local schema = {}
---@param parent CompSchema
---@param node Node
local function recurse(parent, node)
if not node:has_children() then
parent[#parent + 1] = { name = "file", context = node.data }
return
end
---@type DirData
local dir_data = node.data
if data.flatten_dirs then
while #node.children == 1 and node.children[1]:has_children() do
---@type DirData
local subdir_data = node.children[1].data
dir_data = {
name = pl:join(dir_data.name, subdir_data.name),
path = subdir_data.path,
kind = subdir_data.kind,
collapsed = dir_data.collapsed and subdir_data.collapsed,
status = dir_data.status,
_node = node,
}
node = node.children[1]
end
end
local items = { name = "items" }
local struct = {
name = "directory",
context = dir_data,
{ name = "dir_name" },
items,
}
parent[#parent + 1] = struct
for _, child in ipairs(node.children) do
recurse(items, child)
end
end
for _, node in ipairs(self.root.children) do
recurse(schema, node)
end
return schema
end
M.FileTree = FileTree
return M

View File

@ -0,0 +1,221 @@
local oop = require("diffview.oop")
local utils = require("diffview.utils")
local M = {}
---@class Node : diffview.Object
---@field parent Node
---@field name string
---@field data any
---@field children Node[]
---@field depth integer|nil
local Node = oop.create_class("Node")
---Node constructor
---@param name string
---@param data any|nil
function Node:init(name, data)
self.name = name
self.data = data
self.children = {}
if self.data then
self.data._node = self
end
end
---Adds a child if it doesn not already exist.
---@param child Node
---@return Node
function Node:add_child(child)
if not self.children[child.name] then
self.children[child.name] = child
self.children[#self.children + 1] = child
child.parent = self
end
return self.children[child.name]
end
---@return boolean
function Node:has_children()
for _ in pairs(self.children) do
return true
end
return false
end
---Compare against another node alphabetically and case-insensitive by node names.
---Directory nodes come before file nodes.
---@param a Node
---@param b Node
---@return boolean true if node a comes before node b
function Node.comparator(a, b)
if a:has_children() == b:has_children() then
return string.lower(a.name) < string.lower(b.name)
else
return a:has_children()
end
end
function Node:sort()
for _, child in ipairs(self.children) do
child:sort()
end
utils.merge_sort(self.children, Node.comparator)
end
---@return boolean
function Node:is_root()
return not self.parent
end
---@param callback fun(node: Node, i: integer, parent: Node): boolean?
function Node:some(callback)
for i, child in ipairs(self.children) do
if callback(child, i, self) then
return
end
end
end
---@param callback fun(node: Node, i: integer, parent: Node): boolean?
function Node:deep_some(callback)
local function wrap(node, i, parent)
if callback(node, i, parent) then
return true
else
return node:some(wrap)
end
end
self:some(wrap)
end
---@return Node[]
function Node:leaves()
local leaves = {}
self:deep_some(function(node)
if #node.children == 0 then
leaves[#leaves + 1] = node
end
return false
end)
return leaves
end
---@return Node?
function Node:first_leaf()
if #self.children == 0 then
return
end
local cur = self
while cur:has_children() do
cur = cur.children[1]
end
return cur
end
---@return Node?
function Node:last_leaf()
if #self.children == 0 then
return
end
local cur = self
while cur:has_children() do
cur = cur.children[#cur.children]
end
return cur
end
---@return Node?
function Node:next_leaf()
if not self.parent then
return
end
local cur = self:has_children() and self:group_parent() or self
local sibling = cur:next_sibling()
if sibling then
if not sibling:has_children() then
return sibling
else
return sibling:first_leaf()
end
end
end
---@return Node?
function Node:prev_leaf()
if not self.parent then
return
end
local cur = self:has_children() and self:group_parent() or self
local sibling = cur:prev_sibling()
if sibling then
if not sibling:has_children() then
return sibling
else
return sibling:last_leaf()
end
end
end
---@return Node?
function Node:next_sibling()
if not self.parent then
return
end
local i = utils.vec_indexof(self.parent.children, self)
if i > -1 and i < #self.parent.children then
return self.parent.children[i + 1]
end
end
---@return Node?
function Node:prev_sibling()
if not self.parent then
return
end
local i = utils.vec_indexof(self.parent.children, self)
if i > 1 and #self.parent.children > 1 then
return self.parent.children[i - 1]
end
end
---Get the closest parent that has more than one child, or is a child of the
---root node.
---@return Node?
function Node:group_parent()
if self:is_root() then
return
end
local cur = self:has_children() and self or self.parent
while not cur.parent:is_root() and #cur.parent.children == 1 do
cur = cur.parent
end
return cur
end
M.Node = Node
return M

View File

@ -0,0 +1,566 @@
local EventEmitter = require("diffview.events").EventEmitter
local File = require("diffview.vcs.file").File
local PerfTimer = require("diffview.perf").PerfTimer
local oop = require("diffview.oop")
local renderer = require("diffview.renderer")
local utils = require("diffview.utils")
local api = vim.api
local logger = DiffviewGlobal.logger
local pl = utils.path
local M = {}
local uid_counter = 0
---@alias PanelConfig PanelFloatSpec|PanelSplitSpec
---@alias PanelType "split"|"float"
---@type PerfTimer
local perf = PerfTimer("[Panel] redraw")
---@class Panel : diffview.Object
---@field type PanelType
---@field config_producer PanelConfig|fun(): PanelConfig
---@field state table
---@field bufid integer
---@field winid integer
---@field render_data RenderData
---@field components any
---@field bufname string
---@field au_event_map table<string, function[]>
---@field init_buffer_opts function Abstract
---@field update_components function Abstract
---@field render function Abstract
local Panel = oop.create_class("Panel")
Panel.winopts = {
relativenumber = false,
number = false,
list = false,
winfixwidth = true,
winfixheight = true,
foldenable = false,
spell = false,
wrap = false,
signcolumn = "yes",
colorcolumn = "",
foldmethod = "manual",
foldcolumn = "0",
scrollbind = false,
cursorbind = false,
diff = false,
}
Panel.bufopts = {
swapfile = false,
buftype = "nofile",
modifiable = false,
bufhidden = "hide",
modeline = false,
undolevels = -1,
}
Panel.default_type = "split"
---@class PanelSplitSpec
---@field type "split"
---@field position "left"|"top"|"right"|"bottom"
---@field relative "editor"|"win"
---@field win integer
---@field width? integer
---@field height? integer
---@field win_opts WindowOptions
---@type PanelSplitSpec
Panel.default_config_split = {
type = "split",
position = "left",
relative = "editor",
win = 0,
win_opts = {}
}
---@class PanelFloatSpec
---@field type "float"
---@field relative "editor"|"win"|"cursor"
---@field win integer
---@field anchor "NW"|"NE"|"SW"|"SE"
---@field width integer
---@field height integer
---@field row number
---@field col number
---@field zindex integer
---@field style "minimal"
---@field border "none"|"single"|"double"|"rounded"|"solid"|"shadow"|string[]
---@field win_opts WindowOptions
---@type PanelFloatSpec
Panel.default_config_float = {
type = "float",
relative = "editor",
row = 0,
col = 0,
zindex = 50,
style = "minimal",
border = "single",
win_opts = {}
}
Panel.au = {
---@type integer
group = api.nvim_create_augroup("diffview_panels", {}),
---@type EventEmitter
emitter = EventEmitter(),
---@type table<string, integer> Map of autocmd event names to its created autocmd ID.
events = {},
---Delete all autocmds with no subscribed listeners.
prune = function()
for event, id in pairs(Panel.au.events) do
if #(Panel.au.emitter:get(event) or {}) == 0 then
api.nvim_del_autocmd(id)
Panel.au.events[event] = nil
end
end
end,
}
---@class PanelSpec
---@field type PanelType
---@field config PanelConfig|fun(): PanelConfig
---@field bufname string
---@param opt PanelSpec
function Panel:init(opt)
self.config_producer = opt.config or {}
self.state = {}
self.bufname = opt.bufname or "DiffviewPanel"
self.au_event_map = {}
end
---Produce and validate config.
---@return PanelConfig
function Panel:get_config()
local config
if vim.is_callable(self.config_producer) then
config = self.config_producer()
elseif type(self.config_producer) == "table" then
config = utils.tbl_deep_clone(self.config_producer)
end
---@cast config table
local default_config = self:get_default_config(config.type)
config = vim.tbl_deep_extend("force", default_config, config or {}) --[[@as table ]]
local function valid_enum(arg, values, optional)
return {
arg,
function(v) return (optional and v == nil) or vim.tbl_contains(values, v) end,
table.concat(vim.tbl_map(function(v) return ([['%s']]):format(v) end, values), "|"),
}
end
vim.validate({ type = valid_enum(config.type, { "split", "float" }) })
if config.type == "split" then
---@cast config PanelSplitSpec
self.state.form = vim.tbl_contains({ "top", "bottom" }, config.position) and "row" or "column"
vim.validate({
position = valid_enum(config.position, { "left", "top", "right", "bottom" }),
relative = valid_enum(config.relative, { "editor", "win" }),
width = { config.width, "number", true },
height = { config.height, "number", true },
win_opts = { config.win_opts, "table" }
})
else
---@cast config PanelFloatSpec
local border = { "none", "single", "double", "rounded", "solid", "shadow" }
vim.validate({
relative = valid_enum(config.relative, { "editor", "win", "cursor" }),
win = { config.win, "n", true },
anchor = valid_enum(config.anchor, { "NW", "NE", "SW", "SE" }, true),
width = { config.width, "n", false },
height = { config.height, "n", false },
row = { config.row, "n", false },
col = { config.col, "n", false },
zindex = { config.zindex, "n", true },
style = valid_enum(config.style, { "minimal" }, true),
win_opts = { config.win_opts, "table" },
border = {
config.border,
function(v)
if v == nil then return true end
if type(v) == "table" then
return #v >= 2
end
return vim.tbl_contains(border, v)
end,
("%s or a list of length >=2"):format(
table.concat(vim.tbl_map(function(v)
return ([['%s']]):format(v)
end, border), "|")
)
},
})
end
return config
end
---@param tabpage? integer
---@return boolean
function Panel:is_open(tabpage)
local valid = self.winid and api.nvim_win_is_valid(self.winid)
if not valid then
self.winid = nil
elseif tabpage then
return vim.tbl_contains(api.nvim_tabpage_list_wins(tabpage), self.winid)
end
return valid
end
function Panel:is_focused()
return self:is_open() and api.nvim_get_current_win() == self.winid
end
---@param no_open? boolean Don't open the panel if it's closed.
function Panel:focus(no_open)
if self:is_open() then
api.nvim_set_current_win(self.winid)
elseif not no_open then
self:open()
api.nvim_set_current_win(self.winid)
end
end
function Panel:resize()
if not self:is_open(0) then
return
end
local config = self:get_config()
if config.type == "split" then
if self.state.form == "column" and config.width then
api.nvim_win_set_width(self.winid, config.width)
elseif self.state.form == "row" and config.height then
api.nvim_win_set_height(self.winid, config.height)
end
elseif config.type == "float" then
api.nvim_win_set_width(self.winid, config.width)
api.nvim_win_set_height(self.winid, config.height)
end
end
function Panel:open()
if not self:buf_loaded() then
self:init_buffer()
end
if self:is_open() then
return
end
local config = self:get_config()
if config.type == "split" then
local split_dir = vim.tbl_contains({ "top", "left" }, config.position) and "aboveleft" or "belowright"
local split_cmd = self.state.form == "row" and "sp" or "vsp"
local rel_winid = config.relative == "win"
and api.nvim_win_is_valid(config.win or -1)
and config.win
or 0
api.nvim_win_call(rel_winid, function()
vim.cmd(split_dir .. " " .. split_cmd)
self.winid = api.nvim_get_current_win()
api.nvim_win_set_buf(self.winid, self.bufid)
if config.relative == "editor" then
local dir = ({ left = "H", bottom = "J", top = "K", right = "L" })[config.position]
vim.cmd("wincmd " .. dir)
vim.cmd("wincmd =")
end
end)
elseif config.type == "float" then
self.winid = vim.api.nvim_open_win(self.bufid, false, utils.sanitize_float_config(config))
if self.winid == 0 then
self.winid = nil
error("[diffview.nvim] Failed to open float panel window!")
end
end
self:resize()
utils.set_local(self.winid, self.class.winopts)
utils.set_local(self.winid, config.win_opts)
end
function Panel:close()
if self:is_open() then
local num_wins = api.nvim_tabpage_list_wins(api.nvim_win_get_tabpage(self.winid))
if #num_wins == 1 then
-- Ensure that the tabpage doesn't close if the panel is the last window.
api.nvim_win_call(self.winid, function()
vim.cmd("sp")
File.load_null_buffer(0)
end)
elseif self:is_focused() then
vim.cmd("wincmd p")
end
pcall(api.nvim_win_close, self.winid, true)
end
end
function Panel:destroy()
self:close()
if self:buf_loaded() then
api.nvim_buf_delete(self.bufid, { force = true })
end
-- Disable autocmd listeners
for _, cbs in pairs(self.au_event_map) do
for _, cb in ipairs(cbs) do
Panel.au.emitter:off(cb)
end
end
Panel.au.prune()
end
---@param focus? boolean Focus the panel if it's opened.
function Panel:toggle(focus)
if self:is_open() then
self:close()
elseif focus then
self:focus()
else
self:open()
end
end
function Panel:buf_loaded()
return self.bufid and api.nvim_buf_is_loaded(self.bufid)
end
function Panel:init_buffer()
local bn = api.nvim_create_buf(false, false)
for k, v in pairs(self.class.bufopts) do
api.nvim_buf_set_option(bn, k, v)
end
local bufname
if pl:is_abs(self.bufname) or pl:is_uri(self.bufname) then
bufname = self.bufname
else
bufname = string.format("diffview:///panels/%d/%s", Panel.next_uid(), self.bufname)
end
local ok = pcall(api.nvim_buf_set_name, bn, bufname)
if not ok then
utils.wipe_named_buffer(bufname)
api.nvim_buf_set_name(bn, bufname)
end
self.bufid = bn
self.render_data = renderer.RenderData(bufname)
api.nvim_buf_call(self.bufid, function()
vim.api.nvim_exec_autocmds({ "BufNew", "BufFilePre" }, {
group = Panel.au.group,
buffer = self.bufid,
modeline = false,
})
end)
self:update_components()
self:render()
self:redraw()
return bn
end
function Panel:update_components() oop.abstract_stub() end
function Panel:render() oop.abstract_stub() end
function Panel:redraw()
if not self.render_data then
return
end
perf:reset()
renderer.render(self.bufid, self.render_data)
perf:time()
logger:lvl(10):debug(perf)
end
---Update components, render and redraw.
function Panel:sync()
if self:buf_loaded() then
self:update_components()
self:render()
self:redraw()
end
end
---@class PanelAutocmdSpec
---@field callback function
---@field once? boolean
---@param event string|string[]
---@param opts PanelAutocmdSpec
function Panel:on_autocmd(event, opts)
if type(event) ~= "table" then
event = { event }
end
local callback = function(_, state)
local win_match, buf_match
if state.event:match("^Win") then
if vim.tbl_contains({ "WinLeave", "WinEnter" }, state.event)
and api.nvim_get_current_win() == self.winid
then
buf_match = state.buf
else
win_match = tonumber(state.match)
end
elseif state.event:match("^Buf") then
buf_match = state.buf
end
if (win_match and win_match == self.winid)
or (buf_match and buf_match == self.bufid) then
opts.callback(state)
end
end
for _, e in ipairs(event) do
if not self.au_event_map[e] then
self.au_event_map[e] = {}
end
table.insert(self.au_event_map[e], callback)
if not Panel.au.events[e] then
Panel.au.events[e] = api.nvim_create_autocmd(e, {
group = Panel.au.group,
callback = function(state)
Panel.au.emitter:emit(e, state)
end,
})
end
if opts.once then
Panel.au.emitter:once(e, callback)
else
Panel.au.emitter:on(e, callback)
end
end
end
---Unsubscribe an autocmd listener. If no event is given, the callback is
---disabled for all events.
---@param callback function
---@param event? string
function Panel:off_autocmd(callback, event)
for e, cbs in pairs(self.au_event_map) do
if (event == nil or event == e) and utils.vec_indexof(cbs, callback) ~= -1 then
Panel.au.emitter:off(callback, event)
end
Panel.au.prune()
end
end
function Panel:get_default_config(panel_type)
local producer = self.class["default_config_" .. (panel_type or self.class.default_type)]
local config
if vim.is_callable(producer) then
config = producer()
elseif type(producer) == "table" then
config = producer
end
return config
end
---@return integer?
function Panel:get_width()
if self:is_open() then
return api.nvim_win_get_width(self.winid)
end
end
---@return integer?
function Panel:get_height()
if self:is_open() then
return api.nvim_win_get_height(self.winid)
end
end
function Panel:infer_width()
local cur_width = self:get_width()
if cur_width then return cur_width end
local config = self:get_config()
if config.width then return config.width end
-- PanelFloatSpec requires both width and height to be defined. If we get
-- here then the panel is a split.
---@cast config PanelSplitSpec
if config.win and api.nvim_win_is_valid(config.win) then
if self.state.form == "row" then
return api.nvim_win_get_width(config.win)
elseif self.state.form == "column" then
return math.floor(api.nvim_win_get_width(config.win) / 2)
end
end
if self.state.form == "row" then
return vim.o.columns
end
return math.floor(vim.o.columns / 2)
end
function Panel:infer_height()
local cur_height = self:get_height()
if cur_height then return cur_height end
local config = self:get_config()
if config.height then return config.height end
-- PanelFloatSpec requires both width and height to be defined. If we get
-- here then the panel is a split.
---@cast config PanelSplitSpec
if config.win and api.nvim_win_is_valid(config.win) then
if self.state.form == "row" then
return math.floor(api.nvim_win_get_height(config.win) / 2)
elseif self.state.form == "column" then
return api.nvim_win_get_height(config.win)
end
end
if self.state.form == "row" then
return math.floor(vim.o.lines / 2)
end
return vim.o.lines
end
function Panel.next_uid()
local uid = uid_counter
uid_counter = uid_counter + 1
return uid
end
M.Panel = Panel
return M

View File

@ -0,0 +1,121 @@
local Job = require("diffview.job").Job
local Panel = require("diffview.ui.panel").Panel
local async = require("diffview.async")
local get_user_config = require("diffview.config").get_config
local oop = require("diffview.oop")
local utils = require("diffview.utils")
local await = async.await
local M = {}
---@class CommitLogPanel : Panel
---@field adapter VCSAdapter
---@field args string[]
---@field job_out string[]
local CommitLogPanel = oop.create_class("CommitLogPanel", Panel)
CommitLogPanel.winopts = vim.tbl_extend("force", Panel.winopts, {
wrap = true,
breakindent = true,
})
CommitLogPanel.bufopts = vim.tbl_extend("force", Panel.bufopts, {
buftype = "nowrite",
filetype = "git",
})
CommitLogPanel.default_type = "float"
CommitLogPanel.default_config_split = vim.tbl_extend("force", Panel.default_config_split, {
position = "bottom",
height = 14,
})
CommitLogPanel.default_config_float = function()
local c = vim.deepcopy(Panel.default_config_float)
local viewport_width = vim.o.columns
local viewport_height = vim.o.lines
c.width = math.min(100, viewport_width)
c.height = math.min(24, viewport_height)
c.col = math.floor(viewport_width * 0.5 - c.width * 0.5)
c.row = math.floor(viewport_height * 0.5 - c.height * 0.5)
return c
end
---@class CommitLogPanelSpec
---@field config PanelConfig
---@field args string[]
---@field name string
---@param adapter VCSAdapter
---@param opt CommitLogPanelSpec
function CommitLogPanel:init(adapter, opt)
self:super({
bufname = opt.name,
config = opt.config or get_user_config().commit_log_panel.win_config,
})
self.adapter = adapter
self.args = opt.args or { "-n256" }
self:on_autocmd("BufWinEnter" , {
callback = function()
vim.bo[self.bufid].bufhidden = "wipe"
end,
})
end
---@param self CommitLogPanel
---@param args string|string[]
CommitLogPanel.update = async.void(function(self, args)
if type(args) ~= "table" then
args = { args }
end
local job = Job({
command = self.adapter:bin(),
args = self.adapter:get_log_args(args or self.args),
cwd = self.adapter.ctx.toplevel,
})
local ok = await(job)
await(async.scheduler())
if not ok then
utils.err("Failed to open log!")
return
end
self.job_out = utils.vec_slice(job.stdout)
if not next(self.job_out) then
utils.info("No log content available for these changes.")
return
end
if not self:is_open() then
self:init_buffer()
else
self:render()
self:redraw()
end
self:focus()
vim.cmd("norm! gg")
end)
function CommitLogPanel:update_components()
end
function CommitLogPanel:render()
self.render_data:clear()
if self.job_out then
self.render_data.lines = utils.vec_slice(self.job_out)
end
end
M.CommitLogPanel = CommitLogPanel
return M

View File

@ -0,0 +1,231 @@
local Panel = require("diffview.ui.panel").Panel
local get_user_config = require("diffview.config").get_config
local oop = require("diffview.oop")
local utils = require("diffview.utils")
local api = vim.api
local M = {}
---@class HelpPanel : Panel
---@field parent StandardView
---@field keymap_groups string[]
---@field state table
local HelpPanel = oop.create_class("HelpPanel", Panel)
HelpPanel.winopts = vim.tbl_extend("force", Panel.winopts, {
wrap = false,
breakindent = true,
signcolumn = "no",
})
HelpPanel.bufopts = vim.tbl_extend("force", Panel.bufopts, {
buftype = "nofile",
})
HelpPanel.default_type = "float"
---@class HelpPanelSpec
---@field config PanelConfig
---@field name string
---@param parent StandardView
---@param keymap_groups string[]
---@param opt HelpPanelSpec
function HelpPanel:init(parent, keymap_groups, opt)
opt = opt or {}
self:super({
bufname = opt.name,
config = opt.config or function()
local c = vim.deepcopy(Panel.default_config_float)
local viewport_width = vim.o.columns
local viewport_height = vim.o.lines
c.col = math.floor(viewport_width * 0.5 - self.state.width * 0.5)
c.row = math.floor(viewport_height * 0.5 - self.state.height * 0.5)
c.width = self.state.width
c.height = self.state.height
return c
end,
})
self.parent = parent
self.keymap_groups = keymap_groups
self.lines = {}
self.state = {
width = 50,
height = 4,
}
self:on_autocmd("BufWinEnter", {
callback = function()
vim.bo[self.bufid].bufhidden = "wipe"
end,
})
self:on_autocmd("WinLeave", {
callback = function()
self:close()
end,
})
parent.emitter:on("close", function(e)
if self:is_focused() then
self:close()
e:stop_propagation()
end
end)
end
function HelpPanel:apply_cmd()
local row, _ = unpack(vim.api.nvim_win_get_cursor(0))
local comp = self.components.comp:get_comp_on_line(row)
if comp then
local mapping = comp.context.mapping
local last_winid = vim.fn.win_getid(vim.fn.winnr("#"))
if mapping then
api.nvim_win_call(last_winid, function()
api.nvim_feedkeys(utils.t(mapping[2]), "m", false)
end)
self:close()
end
end
end
function HelpPanel:init_buffer()
HelpPanel.super_class.init_buffer(self)
local conf = get_user_config().keymaps
local default_opt = { silent = true, nowait = true, buffer = self.bufid }
for _, mapping in ipairs(conf.help_panel) do
local map_opt = vim.tbl_extend("force", default_opt, mapping[4] or {}, { buffer = self.bufid })
vim.keymap.set(mapping[1], mapping[2], mapping[3], map_opt)
end
vim.keymap.set("n", "<cr>", function()
self:apply_cmd()
end, default_opt)
end
function HelpPanel:update_components()
local keymaps = get_user_config().keymaps
local width = 50
local height = 0
local sections = { name = "sections" }
for _, group in ipairs(self.keymap_groups) do
local maps = keymaps[group]
if not maps then
utils.err(("help_panel :: Unknown keymap group '%s'!"):format(group))
else
maps = utils.tbl_fmap(maps, function(v)
if v[1] ~= "n" then return nil end
local desc = v[4] and v[4].desc
if not desc then
if type(v[3]) == "string" then
desc = v[3]
elseif type(v[3]) == "function" then
local info = debug.getinfo(v[3], "S")
desc = ("<Lua @ %s:%d>"):format(info.short_src, info.linedefined)
end
end
local ret = utils.tbl_clone(v)
ret[5] = desc
return ret
end)
if #maps == 0 then goto continue end
-- Sort mappings by description
table.sort(maps, function(a, b)
a, b = a[5], b[5]
-- Ensure lua functions are sorted last
if a:match("^<Lua") then a = "~" .. a end
if b:match("^<Lua") then b = "~" .. b end
return a < b
end)
local items = { name = "items" }
local section_schema = {
name = "section",
{
name = "section_heading",
context = {
label = group:upper():gsub("_", "-")
},
},
items,
}
for _, mapping in ipairs(maps) do
local desc = mapping[5]
if desc ~= "diffview_ignore" then
width = math.max(width, 14 + 4 + #mapping[5] + 2)
table.insert(items, {
name = "item",
context = {
label_lhs = ("%14s"):format(mapping[2]),
label_rhs = desc,
mapping = mapping,
},
})
end
end
height = height + #items + 3
table.insert(sections, section_schema)
end
::continue::
end
self.state.width = width
self.state.height = height + 1
self.components = self.render_data:create_component({
{ name = "heading" },
sections,
})
end
function HelpPanel:render()
self.render_data:clear()
local s = ""
-- Heading
local comp = self.components.heading.comp
s = "Keymap Overview — <CR> To Use"
s = string.rep(" ", math.floor(self.state.width * 0.5 - vim.str_utfindex(s) * 0.5)) .. s
comp:add_line(s, "DiffviewFilePanelTitle")
for _, section in ipairs(self.components.sections) do
---@cast section CompStruct
-- Section heading
comp = section.section_heading.comp
comp:add_line()
s = string.rep(" ", math.floor(self.state.width * 0.5 - #comp.context.label * 0.5)) .. comp.context.label
comp:add_line(s, "Statement")
comp:add_line(("%14s CALLBACK"):format("KEYS"), "DiffviewFilePanelCounter")
for _, item in ipairs(section.items) do
---@cast item CompStruct
comp = item.comp
comp:add_text(comp.context.label_lhs, "DiffviewSecondary")
comp:add_text(" -> ", "DiffviewNonText")
comp:add_text(comp.context.label_rhs)
comp:ln()
end
end
end
M.HelpPanel = HelpPanel
return M

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,402 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local AsyncListStream = lazy.access("diffview.stream", "AsyncListStream") ---@type AsyncListStream|LazyModule
local Job = lazy.access("diffview.job", "Job") ---@type diffview.Job|LazyModule
local Rev = lazy.access("diffview.vcs.rev", "Rev") ---@type Rev|LazyModule
local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule
local arg_parser = lazy.require("diffview.arg_parser") ---@module "diffview.arg_parser"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.utils"
local await = async.await
local logger = DiffviewGlobal.logger
local M = {}
---@class vcs.adapter.LayoutOpt
---@field default_layout Diff2
---@field merge_layout Layout
---@class vcs.adapter.VCSAdapter.Bootstrap
---@field done boolean # Did the bootstrapping
---@field ok boolean # Bootstrapping was successful
---@field version table
---@field version_string string
---@field target_version table
---@field target_version_string string
---@class vcs.adapter.VCSAdapter.Flags
---@field switches FlagOption[]
---@field options FlagOption[]
---@class vcs.adapter.VCSAdapter.Ctx
---@field toplevel string # VCS repository toplevel directory
---@field dir string # VCS directory
---@field path_args string[] # Resolved path arguments
---@class VCSAdapter: diffview.Object
---@field bootstrap vcs.adapter.VCSAdapter.Bootstrap
---@field ctx vcs.adapter.VCSAdapter.Ctx
---@field flags vcs.adapter.VCSAdapter.Flags
local VCSAdapter = oop.create_class("VCSAdapter")
VCSAdapter.Rev = Rev
VCSAdapter.config_key = nil
VCSAdapter.bootstrap = {
done = false,
ok = false,
version = {},
}
function VCSAdapter.run_bootstrap()
VCSAdapter.bootstrap.done = true
VCSAdapter.bootstrap.ok = false
end
---@diagnostic disable: unused-local, missing-return
---@abstract
---@param path_args string[] # Raw path args
---@param cpath string? # Cwd path given by the `-C` flag option
---@return string[] path_args # Resolved path args
---@return string[] top_indicators # Top-level indicators
function VCSAdapter.get_repo_paths(path_args, cpath) oop.abstract_stub() end
---Try to find the top-level of a working tree by using the given indicative
---paths.
---@abstract
---@param top_indicators string[] A list of paths that might indicate what working tree we are in.
---@return string? err
---@return string toplevel # Absolute path
function VCSAdapter.find_toplevel(top_indicators) oop.abstract_stub() end
---@diagnostic enable: unused-local, missing-return
---@class vcs.adapter.VCSAdapter.Opt
---@field cpath string? # CWD path
---@field toplevel string # VCS toplevel path
---@field path_args string[] # Extra path arguments
function VCSAdapter:init()
self.ctx = {}
self.comp = {
file_history = arg_parser.FlagValueMap(),
open = arg_parser.FlagValueMap(),
}
end
---@diagnostic disable: unused-local, missing-return
---@param path string
---@param rev Rev
---@return boolean -- True if the file was binary for the given rev, or it didn't exist.
function VCSAdapter:is_binary(path, rev)
oop.abstract_stub()
end
---Initialize completion parameters
function VCSAdapter:init_completion()
oop.abstract_stub()
end
---@class RevCompletionSpec
---@field accept_range boolean
---Completion for revisions.
---@param arg_lead string
---@param opt? RevCompletionSpec
---@return string[]
function VCSAdapter:rev_candidates(arg_lead, opt)
oop.abstract_stub()
end
---@return Rev?
function VCSAdapter:head_rev()
oop.abstract_stub()
end
---Get the hash for a file's blob in a given rev.
---@param path string
---@param rev_arg string?
---@return string?
function VCSAdapter:file_blob_hash(path, rev_arg)
oop.abstract_stub()
end
---@return string[] # path to binary for VCS command
function VCSAdapter:get_command()
oop.abstract_stub()
end
---@diagnostic enable: unused-local, missing-return
---@return string cmd The VCS binary.
function VCSAdapter:bin()
return self:get_command()[1]
end
---@return string[] args The default VCS args.
function VCSAdapter:args()
return utils.vec_slice(self:get_command(), 2)
end
---Execute a VCS command synchronously.
---@param args string[]
---@param cwd_or_opt? string|utils.job.Opt
---@return string[] stdout
---@return integer code
---@return string[] stderr
---@overload fun(self: VCSAdapter, args: string[], cwd?: string)
---@overload fun(self: VCSAdapter, args: string[], opt?: utils.job.Opt)
function VCSAdapter:exec_sync(args, cwd_or_opt)
if not self.class.bootstrap.done then self.class.run_bootstrap() end
local cmd = utils.flatten({ self:get_command(), args })
if not self.class.bootstrap.ok then
logger:error(
("[VCSAdapter] Can't exec adapter command because bootstrap failed! Cmd: %s")
:format(table.concat(cmd, " "))
)
return
end
return utils.job(cmd, cwd_or_opt)
end
---@param thread thread
---@param ok boolean
---@param result any
---@return boolean ok
---@return any result
function VCSAdapter:handle_co(thread, ok, result)
if not ok then
local err_msg = utils.vec_join(
"Coroutine failed!",
debug.traceback(thread, result, 1)
)
utils.err(err_msg, true)
logger:error(table.concat(err_msg, "\n"))
end
return ok, result
end
-- File History
---@diagnostic disable: unused-local, missing-return
---@param path string
---@param rev Rev?
---@return string[] args to show commit content
function VCSAdapter:get_show_args(path, rev)
oop.abstract_stub()
end
---@param args string[]
---@return string[] args to show commit log message
function VCSAdapter:get_log_args(args)
oop.abstract_stub()
end
---@class vcs.MergeContext
---@field ours { hash: string, ref_names: string? }
---@field theirs { hash: string, ref_names: string? }
---@field base { hash: string, ref_names: string? }
---@return vcs.MergeContext?
function VCSAdapter:get_merge_context()
oop.abstract_stub()
end
---@param range? { [1]: integer, [2]: integer }
---@param paths string[]
---@param argo ArgObject
---@return string[] # Options to show file history
function VCSAdapter:file_history_options(range, paths, argo)
oop.abstract_stub()
end
---@param self VCSAdapter
---@param out_stream AsyncListStream
---@param opt vcs.adapter.FileHistoryWorkerSpec
VCSAdapter.file_history_worker = async.void(function(self, out_stream, opt)
oop.abstract_stub()
end)
---@diagnostic enable: unused-local, missing-return
---@class vcs.adapter.FileHistoryWorkerSpec
---@field log_opt ConfigLogOptions
---@field layout_opt vcs.adapter.LayoutOpt
---@param opt vcs.adapter.FileHistoryWorkerSpec
---@return AsyncListStream out_stream
function VCSAdapter:file_history(opt)
local out_stream = AsyncListStream()
self:file_history_worker(out_stream, opt)
return out_stream
end
-- Diff View
---@diagnostic disable: unused-local, missing-return
---Convert revs to rev args.
---@param left Rev
---@param right Rev
---@return string[]
function VCSAdapter:rev_to_args(left, right)
oop.abstract_stub()
end
---Restore a file to the requested state
---@param path string # file to restore
---@param kind '"staged"'|'"working"'
---@param commit string
---@return string? Command to undo the restore
function VCSAdapter:restore_file(path, kind, commit)
oop.abstract_stub()
end
---Add file(s)
---@param paths string[]
---@return boolean # add was successful
function VCSAdapter:add_files(paths)
oop.abstract_stub()
end
---Reset file(s)
---@param paths string[]?
---@return boolean # reset was successful
function VCSAdapter:reset_files(paths)
oop.abstract_stub()
end
---@param argo ArgObject
---@return {left: string, right: string, options: string[]}
function VCSAdapter:diffview_options(argo)
oop.abstract_stub()
end
---@class VCSAdapter.show_untracked.Opt
---@field dv_opt? DiffViewOptions
---@field revs? { left: Rev, right: Rev }
---Check whether untracked files should be listed.
---@param opt? VCSAdapter.show_untracked.Opt
---@return boolean
function VCSAdapter:show_untracked(opt)
oop.abstract_stub()
end
---Restore file
---@param self VCSAdapter
---@param path string
---@param kind vcs.FileKind
---@param commit string?
---@return boolean success
---@return string? undo # If the adapter supports it: a command that will undo the restoration.
VCSAdapter.file_restore = async.void(function(self, path, kind, commit)
oop.abstract_stub()
end)
---Update the index entry for a given file with the contents of an index buffer.
---@param file vcs.File
---@return boolean success
function VCSAdapter:stage_index_file(file)
oop.abstract_stub()
end
---@param self VCSAdapter
---@param left Rev
---@param right Rev
---@param args string[]
---@param kind vcs.FileKind
---@param opt vcs.adapter.LayoutOpt
---@param callback function
VCSAdapter.tracked_files = async.wrap(function(self, left, right, args, kind, opt, callback)
oop.abstract_stub()
end)
---@param self VCSAdapter
---@param left Rev
---@param right Rev
---@param opt vcs.adapter.LayoutOpt
---@param callback? function
VCSAdapter.untracked_files = async.wrap(function(self, left, right, opt, callback)
oop.abstract_stub()
end)
---@diagnostic enable: unused-local, missing-return
---@param self VCSAdapter
---@param path string
---@param rev? Rev
---@param callback fun(stderr: string[]?, stdout: string[]?)
VCSAdapter.show = async.wrap(function(self, path, rev, callback)
local job
job = Job({
command = self:bin(),
args = self:get_show_args(path, rev),
cwd = self.ctx.toplevel,
retry = 2,
fail_cond = Job.FAIL_COND.on_empty,
log_opt = { label = "VCSAdapter:show()" },
on_exit = async.void(function(_, ok, err)
if not ok or job.code ~= 0 then
callback(utils.vec_join(err, job.stderr), nil)
return
end
callback(nil, job.stdout)
end),
})
-- Problem: Running multiple 'show' jobs simultaneously may cause them to fail
-- silently.
-- Solution: queue them and run them one after another.
await(vcs_utils.queue_sync_job(job))
end)
---Convert revs to string representation.
---@param left Rev
---@param right Rev
---@return string|nil
function VCSAdapter:rev_to_pretty_string(left, right)
if left.track_head and right.type == RevType.LOCAL then
return nil
elseif left.commit and right.type == RevType.LOCAL then
return left:abbrev()
elseif left.commit and right.commit then
return left:abbrev() .. ".." .. right:abbrev()
end
return nil
end
---Check if any of the given revs are LOCAL.
---@param left Rev
---@param right Rev
---@return boolean
function VCSAdapter:has_local(left, right)
return left.type == RevType.LOCAL or right.type == RevType.LOCAL
end
VCSAdapter.flags = {
---@type FlagOption[]
switches = {},
---@type FlagOption[]
options = {},
}
---@param arg_lead string
---@return string[]
function VCSAdapter:path_candidates(arg_lead)
return vim.fn.getcompletion(arg_lead, "file", 0)
end
M.VCSAdapter = VCSAdapter
return M

View File

@ -0,0 +1,81 @@
local lazy = require("diffview.lazy")
local oop = require('diffview.oop')
local Commit = lazy.access("diffview.vcs.commit", "Commit") ---@type Commit|LazyModule
local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local M = {}
---@class GitCommit : Commit
---@field reflog_selector? string
local GitCommit = oop.create_class("GitCommit", Commit.__get())
function GitCommit:init(opt)
self:super(opt)
self.reflog_selector = opt.reflog_selector ~= "" and opt.reflog_selector or nil
if opt.time_offset then
self.time_offset = Commit.parse_time_offset(opt.time_offset)
self.time = self.time - self.time_offset
else
self.time_offset = 0
end
self.iso_date = Commit.time_to_iso(self.time, self.time_offset)
end
---@param rev_arg string
---@param adapter GitAdapter
---@return GitCommit?
function GitCommit.from_rev_arg(rev_arg, adapter)
local out, code = adapter:exec_sync({
"show",
"--pretty=format:%H %P%n%an%n%ad%n%ar%n %s",
"--date=raw",
"--name-status",
rev_arg,
"--",
}, adapter.ctx.toplevel)
if code ~= 0 then
return
end
local right_hash, _, _ = unpack(utils.str_split(out[1]))
local time, time_offset = unpack(utils.str_split(out[3]))
return GitCommit({
hash = right_hash,
author = out[2],
time = tonumber(time),
time_offset = time_offset,
rel_date = out[4],
subject = out[5]:sub(3),
})
end
---@param rev Rev
---@param adapter GitAdapter
---@return GitCommit?
function GitCommit.from_rev(rev, adapter)
assert(rev.type == RevType.COMMIT, "Rev must be of type COMMIT!")
return GitCommit.from_rev_arg(rev.commit, adapter)
end
function GitCommit.parse_time_offset(iso_date)
local sign, h, m = vim.trim(iso_date):match("([+-])(%d%d):?(%d%d)$")
local offset = tonumber(h) * 60 * 60 + tonumber(m) * 60
if sign == "-" then
offset = -offset
end
return offset
end
M.GitCommit = GitCommit
return M

View File

@ -0,0 +1,148 @@
local oop = require("diffview.oop")
local Rev = require('diffview.vcs.rev').Rev
local RevType = require('diffview.vcs.rev').RevType
local M = {}
---@class GitRev : Rev
local GitRev = oop.create_class("GitRev", Rev)
-- The special SHA for git's empty tree.
GitRev.NULL_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
---GitRev constructor
---@param rev_type RevType
---@param revision string|number Commit SHA or stage number.
---@param track_head? boolean
function GitRev:init(rev_type, revision, track_head)
local t = type(revision)
assert(
revision == nil or t == "string" or t == "number",
"'revision' must be one of: nil, string, number!"
)
if t == "string" then
assert(revision ~= "", "'revision' cannot be an empty string!")
elseif t == "number" then
assert(
revision >= 0 and revision <= 3,
"'revision' must be a valid stage number ([0-3])!"
)
end
t = type(track_head)
assert(t == "boolean" or t == "nil", "'track_head' must be of type boolean!")
self.type = rev_type
self.track_head = track_head or false
if type(revision) == "string" then
---@cast revision string
self.commit = revision
elseif type(revision) == "number" then
---@cast revision number
self.stage = revision
end
if self.type == RevType.STAGE and not self.stage then
self.stage = 0
end
end
---@param rev_from GitRev|string
---@param rev_to? GitRev|string
---@return string?
function GitRev.to_range(rev_from, rev_to)
if type(rev_from) ~= "string" and rev_from.type ~= RevType.COMMIT then
-- The range between either LOCAL or STAGE, and any other rev, will always
-- be empty.
return nil
end
local name_from = type(rev_from) == "string" and rev_from or rev_from:object_name()
local name_to
if rev_to then
if type(rev_to) == "string" then
name_to = rev_to
else
-- If the rev is either of type LOCAL or STAGE, just fall back to HEAD.
name_to = rev_to.type == RevType.COMMIT and rev_to:object_name() or "HEAD"
end
end
if not name_to then
return name_from .. "^!"
else
return name_from .. ".." .. name_to
end
end
---@param name string
---@param adapter GitAdapter
---@return Rev?
function GitRev.from_name(name, adapter)
local out, code = adapter:exec_sync({ "rev-parse", "--revs-only", name }, adapter.ctx.toplevel)
if code ~= 0 then
return
end
return GitRev(RevType.COMMIT, out[1]:gsub("^%^", ""))
end
---@param adapter GitAdapter
---@return Rev?
function GitRev.earliest_commit(adapter)
local out, code = adapter:exec_sync({
"rev-list", "--max-parents=0", "--first-parent", "HEAD"
}, adapter.ctx.toplevel)
if code ~= 0 then
return
end
return GitRev(RevType.COMMIT, ({ out[1]:gsub("^%^", "") })[1])
end
---Create a new commit rev with the special empty tree SHA.
---@return Rev
function GitRev.new_null_tree()
return GitRev(RevType.COMMIT, GitRev.NULL_TREE_SHA)
end
---Determine if this rev is currently the head.
---@param adapter GitAdapter
---@return boolean?
function GitRev:is_head(adapter)
if self.type ~= RevType.COMMIT then
return false
end
local out, code = adapter:exec_sync({ "rev-parse", "HEAD", "--" }, adapter.ctx.toplevel)
if code ~= 0 or not (out[2] ~= nil or out[1] and out[1] ~= "") then
return
end
return self.commit == vim.trim(out[1]):gsub("^%^", "")
end
---@param abbrev_len? integer
---@return string
function GitRev:object_name(abbrev_len)
if self.type == RevType.COMMIT then
if abbrev_len then
return self.commit:sub(1, abbrev_len)
end
return self.commit
elseif self.type == RevType.STAGE then
return ":" .. self.stage
end
return "UNKNOWN"
end
M.GitRev = GitRev
return M

View File

@ -0,0 +1,42 @@
local lazy = require("diffview.lazy")
local oop = require('diffview.oop')
local utils = require("diffview.utils")
local Commit = require('diffview.vcs.commit').Commit
local M = {}
---@class HgCommit : Commit
local HgCommit = oop.create_class('HgCommit', Commit)
function HgCommit:init(opt)
self:super(opt)
if opt.time_offset then
self.time_offset = HgCommit.parse_time_offset(opt.time_offset)
self.time = self.time - self.time_offset
else
self.time_offset = 0
end
self.iso_date = Commit.time_to_iso(self.time, self.time_offset)
end
---@param iso_date string?
function HgCommit.parse_time_offset(iso_date)
if not iso_date or iso_date == "" then
return 0
end
local sign, offset = vim.trim(iso_date):match("([+-])(%d+)")
offset = tonumber(offset)
if sign == "-" then
offset = -offset
end
return offset
end
M.HgCommit = HgCommit
return M

View File

@ -0,0 +1,71 @@
local oop = require("diffview.oop")
local Rev = require('diffview.vcs.rev').Rev
local RevType = require('diffview.vcs.rev').RevType
local M = {}
---@class HgRev : Rev
local HgRev = oop.create_class("HgRev", Rev)
HgRev.NULL_TREE_SHA = "0000000000000000000000000000000000000000"
function HgRev:init(rev_type, revision, track_head)
local t = type(revision)
assert(
revision == nil or t == "string" or t == "number",
"'revision' must be one of: nil, string, number!"
)
if t == "string" then
assert(revision ~= "", "'revision' cannot be an empty string!")
end
t = type(track_head)
assert(t == "boolean" or t == "nil", "'track_head' must be of type boolean!")
self.type = rev_type
self.track_head = track_head or false
self.commit = revision
end
function HgRev.new_null_tree()
return HgRev(RevType.COMMIT, HgRev.NULL_TREE_SHA)
end
function HgRev:object_name(abbrev_len)
if self.commit then
if abbrev_len then
return self.commit:sub(1, abbrev_len)
end
return self.commit
end
return "UNKNOWN"
end
---@param rev_from HgRev|string
---@param rev_to HgRev|string
---@return string?
function HgRev.to_range(rev_from, rev_to)
local name_from = type(rev_from) == "string" and rev_from or rev_from:object_name()
local name_to
if rev_to then
if type(rev_to) == "string" then
name_to = rev_to
elseif rev_to.type == RevType.COMMIT then
name_to = rev_to:object_name()
end
end
if name_from and name_to then
return name_from .. "::" .. name_to
else
return name_from .. "::" .. name_from
end
end
M.HgRev = HgRev
return M

View File

@ -0,0 +1,83 @@
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local M = {}
---@class Commit : diffview.Object
---@field hash string
---@field author string
---@field time number
---@field time_offset number
---@field date string
---@field iso_date string
---@field rel_date string
---@field ref_names string
---@field subject string
---@field body string
---@field diff? diff.FileEntry[]
local Commit = oop.create_class("Commit")
function Commit:init(opt)
self.hash = opt.hash
self.author = opt.author
self.time = opt.time
self.rel_date = opt.rel_date
self.ref_names = opt.ref_names ~= "" and opt.ref_names or nil
self.subject = opt.subject
self.body = opt.body
self.diff = opt.diff
end
---@diagnostic disable: unused-local, missing-return
---@param rev_arg string
---@param adapter VCSAdapter
---@return Commit?
function Commit.from_rev_arg(rev_arg, adapter)
oop.abstract_stub()
end
---@diagnostic enable: unused-local, missing-return
---@param rev Rev
---@param adapter VCSAdapter
---@return Commit?
function Commit.from_rev(rev, adapter)
assert(rev.type == RevType.COMMIT, "Rev must be of type COMMIT!")
return Commit.from_rev_arg(rev.commit, adapter)
end
function Commit.parse_time_offset(iso_date)
local sign, h, m = vim.trim(iso_date):match("([+-])(%d%d):?(%d%d)$")
local offset = tonumber(h) * 60 * 60 + tonumber(m) * 60
if sign == "-" then
offset = -offset
end
return offset
end
function Commit.time_to_iso(time, time_offset)
local iso = os.date("%Y-%m-%d %H:%M:%S", time + time_offset)
local sign = utils.sign(time_offset)
time_offset = math.abs(time_offset)
local tm = (time_offset - (time_offset % 60)) / 60
local m = tm % 60
local h = (tm - (tm % 60)) / 60
return string.format(
"%s %s%s%s",
iso,
sign < 0 and "-" or "+",
utils.str_left_pad(tostring(h), 2, "0"),
utils.str_left_pad(tostring(m), 2, "0")
)
end
M.Commit = Commit
return M

View File

@ -0,0 +1,475 @@
local async = require("diffview.async")
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local GitRev = lazy.access("diffview.vcs.adapters.git.rev", "GitRev") ---@type GitRev|LazyModule
local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule
local config = lazy.require("diffview.config") ---@module "diffview.config"
local lib = lazy.require("diffview.lib") ---@module "diffview.lib"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local await = async.await
local fmt = string.format
local pl = lazy.access(utils, "path") ---@type PathLib
local api = vim.api
local M = {}
local HAS_NVIM_0_10 = vim.fn.has("nvim-0.10") == 1
---@alias git.FileDataProducer fun(kind: vcs.FileKind, path: string, pos: "left"|"right"): string[]
---@class CustomFolds
---@field type string
---@field [integer] { [1]: integer, [2]: integer }
---@class vcs.File : diffview.Object
---@field adapter GitAdapter
---@field path string
---@field absolute_path string
---@field parent_path string
---@field basename string
---@field extension string
---@field kind vcs.FileKind
---@field nulled boolean
---@field rev Rev
---@field blob_hash string?
---@field commit Commit?
---@field symbol string?
---@field get_data git.FileDataProducer?
---@field bufnr integer
---@field binary boolean
---@field active boolean
---@field ready boolean
---@field winbar string?
---@field winopts WindowOptions
---@field custom_folds? CustomFolds
local File = oop.create_class("vcs.File")
---@type table<integer, vcs.File.AttachState>
File.attached = {}
---@type table<string, table<string, integer>>
File.index_bufmap = {}
---@static
File.bufopts = {
buftype = "nowrite",
modifiable = false,
swapfile = false,
bufhidden = "hide",
undolevels = -1,
}
---File constructor
---@param opt table
function File:init(opt)
self.adapter = opt.adapter
self.path = opt.path
self.absolute_path = pl:absolute(opt.path, opt.adapter.ctx.toplevel)
self.parent_path = pl:parent(opt.path) or ""
self.basename = pl:basename(opt.path)
self.extension = pl:extension(opt.path)
self.kind = opt.kind
self.binary = utils.sate(opt.binary)
self.nulled = not not opt.nulled
self.rev = opt.rev
self.commit = opt.commit
self.symbol = opt.symbol
self.get_data = opt.get_data
self.active = false
self.ready = false
self.winopts = opt.winopts or {
diff = true,
scrollbind = true,
cursorbind = true,
foldmethod = "diff",
scrollopt = { "ver", "hor", "jump" },
foldcolumn = "1",
foldlevel = 0,
foldenable = true,
winhl = {
"DiffAdd:DiffviewDiffAdd",
"DiffDelete:DiffviewDiffDelete",
"DiffChange:DiffviewDiffChange",
"DiffText:DiffviewDiffText",
},
}
-- Set winbar info
if self.rev then
local winbar, label
if self.rev.type == RevType.LOCAL then
winbar = " WORKING TREE - ${path}"
elseif self.rev.type == RevType.COMMIT then
winbar = " ${object_path}"
elseif self.rev.type == RevType.STAGE then
if self.kind == "conflicting" then
label = ({
[1] = "(Common ancestor) ",
[2] = "(Current changes) ",
[3] = "(Incoming changes) ",
})[self.rev.stage] or ""
end
winbar = " INDEX ${label}- ${object_path}"
end
if winbar then
self.winbar = utils.str_template(winbar, {
path = self.path,
object_path = self.rev:object_name(10) .. ":" .. self.path,
label = label or "",
})
end
end
end
---@param force? boolean Also delete buffers for LOCAL files.
function File:destroy(force)
self.active = false
self:detach_buffer()
if force or self.rev.type ~= RevType.LOCAL and not lib.is_buf_in_use(self.bufnr, { self }) then
File.safe_delete_buf(self.bufnr)
end
end
function File:post_buf_created()
local view = require("diffview.lib").get_current_view()
if view then
view.emitter:on("diff_buf_win_enter", function(_, bufnr, winid, ctx)
if bufnr == self.bufnr then
api.nvim_win_call(winid, function()
DiffviewGlobal.emitter:emit("diff_buf_read", self.bufnr, ctx)
end)
return true
end
end)
end
end
function File:_create_local_buffer()
self.bufnr = utils.find_file_buffer(self.absolute_path)
if not self.bufnr then
local winid = utils.temp_win()
assert(winid ~= 0, "Failed to create temporary window!")
api.nvim_win_call(winid, function()
vim.cmd("edit " .. vim.fn.fnameescape(self.absolute_path))
self.bufnr = api.nvim_get_current_buf()
vim.bo[self.bufnr].bufhidden = "hide"
end)
api.nvim_win_close(winid, true)
else
-- NOTE: LSP servers might load buffers in the background and unlist
-- them. Explicitly set the buffer as listed when loading it here.
vim.bo[self.bufnr].buflisted = true
end
self:post_buf_created()
end
---@private
---@param self vcs.File
---@param callback (fun(err?: string[], data?: string[]))
File.produce_data = async.wrap(function(self, callback)
if self.get_data and vim.is_callable(self.get_data) then
local pos = self.symbol == "a" and "left" or "right"
local data = self.get_data(self.kind, self.path, pos)
callback(nil, data)
else
local err, data = await(self.adapter:show(self.path, self.rev))
if err then
callback(err)
return
end
callback(nil, data)
end
end)
---@param self vcs.File
---@param callback function
File.create_buffer = async.wrap(function(self, callback)
---@diagnostic disable: invisible
await(async.scheduler())
if self == File.NULL_FILE then
callback(File._get_null_buffer())
return
elseif self:is_valid() then
callback(self.bufnr)
return
end
if self.binary == nil and not config.get_config().diff_binaries then
self.binary = self.adapter:is_binary(self.path, self.rev)
end
if self.nulled or self.binary then
self.bufnr = File._get_null_buffer()
self:post_buf_created()
callback(self.bufnr)
return
end
if self.rev.type == RevType.LOCAL then
self:_create_local_buffer()
callback(self.bufnr)
return
end
local context
if self.rev.type == RevType.COMMIT then
context = self.rev:abbrev(11)
elseif self.rev.type == RevType.STAGE then
context = fmt(":%d:", self.rev.stage)
elseif self.rev.type == RevType.CUSTOM then
context = "[custom]"
end
local fullname = pl:join("diffview://", self.adapter.ctx.dir, context, self.path)
self.bufnr = utils.find_named_buffer(fullname)
if self.bufnr then
callback(self.bufnr)
return
end
-- Create buffer and set name *before* calling `produce_data()` to ensure
-- that multiple file instances won't ever try to create the same file.
self.bufnr = api.nvim_create_buf(false, false)
api.nvim_buf_set_name(self.bufnr, fullname)
local err, lines = await(self:produce_data())
if err then error(table.concat(err, "\n")) end
await(async.scheduler())
-- Revalidate buffer in case the file was destroyed before `produce_data()`
-- returned.
if not api.nvim_buf_is_valid(self.bufnr) then
error("The buffer has been invalidated!")
return
end
local bufopts = vim.deepcopy(File.bufopts)
if self.rev.type == RevType.STAGE and self.rev.stage == 0 then
self.blob_hash = self.adapter:file_blob_hash(self.path)
bufopts.modifiable = true
bufopts.buftype = nil
bufopts.undolevels = nil
utils.tbl_set(File.index_bufmap, { self.adapter.ctx.toplevel, self.path }, self.bufnr)
api.nvim_create_autocmd("BufWriteCmd", {
buffer = self.bufnr,
nested = true,
callback = function()
self.adapter:stage_index_file(self)
end,
})
end
for option, value in pairs(bufopts) do
api.nvim_buf_set_option(self.bufnr, option, value)
end
local last_modifiable = vim.bo[self.bufnr].modifiable
local last_modified = vim.bo[self.bufnr].modified
vim.bo[self.bufnr].modifiable = true
api.nvim_buf_set_lines(self.bufnr, 0, -1, false, lines)
api.nvim_buf_call(self.bufnr, function()
vim.cmd("filetype detect")
end)
vim.bo[self.bufnr].modifiable = last_modifiable
vim.bo[self.bufnr].modified = last_modified
self:post_buf_created()
callback(self.bufnr)
---@diagnostic enable: invisible
end)
function File:is_valid()
return self.bufnr and api.nvim_buf_is_valid(self.bufnr)
end
---@param t1 table
---@param t2 table
---@return vcs.File.AttachState
local function prepare_attach_opt(t1, t2)
local res = vim.tbl_extend("keep", t1, {
keymaps = {},
disable_diagnostics = false,
})
for k, v in pairs(t2) do
local t = type(res[k])
if t == "boolean" then
res[k] = res[k] or v
elseif t == "table" and type(v) == "table" then
res[k] = vim.tbl_extend("force", res[k], v)
else
res[k] = v
end
end
return res
end
---@class vcs.File.AttachState
---@field keymaps table
---@field disable_diagnostics boolean
---@param force? boolean
---@param opt? vcs.File.AttachState
function File:attach_buffer(force, opt)
if self.bufnr then
local new_opt = false
local cur_state = File.attached[self.bufnr] or {}
local state = prepare_attach_opt(cur_state, opt or {})
if opt then
new_opt = not vim.deep_equal(cur_state or {}, opt)
end
if force or new_opt or not cur_state then
local conf = config.get_config()
-- Keymaps
state.keymaps = config.extend_keymaps(conf.keymaps.view, state.keymaps)
local default_map_opt = { silent = true, nowait = true, buffer = self.bufnr }
for _, mapping in ipairs(state.keymaps) do
local map_opt = vim.tbl_extend("force", default_map_opt, mapping[4] or {}, { buffer = self.bufnr })
vim.keymap.set(mapping[1], mapping[2], mapping[3], map_opt)
end
-- Diagnostics
if state.disable_diagnostics then
if HAS_NVIM_0_10 then
vim.diagnostic.enable(false, { bufnr = self.bufnr })
else
---@diagnostic disable-next-line: deprecated
vim.diagnostic.disable(self.bufnr)
end
end
File.attached[self.bufnr] = state
end
end
end
function File:detach_buffer()
if self.bufnr then
local state = File.attached[self.bufnr]
if state then
-- Keymaps
for lhs, mapping in pairs(state.keymaps) do
if type(lhs) == "number" then
local modes = type(mapping[1]) == "table" and mapping[1] or { mapping[1] }
for _, mode in ipairs(modes) do
pcall(api.nvim_buf_del_keymap, self.bufnr, mode, mapping[2])
end
else
pcall(api.nvim_buf_del_keymap, self.bufnr, "n", lhs)
end
end
-- Diagnostics
if state.disable_diagnostics then
if HAS_NVIM_0_10 then
vim.diagnostic.enable(true, { bufnr = self.bufnr })
else
---@diagnostic disable-next-line: param-type-mismatch
vim.diagnostic.enable(self.bufnr)
end
end
File.attached[self.bufnr] = nil
end
end
end
function File:dispose_buffer()
if self.bufnr and api.nvim_buf_is_loaded(self.bufnr) then
self:detach_buffer()
if not lib.is_buf_in_use(self.bufnr, { self }) then
File.safe_delete_buf(self.bufnr)
end
self.bufnr = nil
end
end
function File.safe_delete_buf(bufnr)
if not bufnr or bufnr == File.NULL_FILE.bufnr or not api.nvim_buf_is_loaded(bufnr) then
return
end
for _, winid in ipairs(utils.win_find_buf(bufnr, 0)) do
File.load_null_buffer(winid)
end
pcall(api.nvim_buf_delete, bufnr, { force = true })
end
---@static Get the bufid of the null buffer. Create it if it's not loaded.
---@return integer
function File._get_null_buffer()
if not api.nvim_buf_is_loaded(File.NULL_FILE.bufnr or -1) then
local bn = api.nvim_create_buf(false, false)
for option, value in pairs(File.bufopts) do
api.nvim_buf_set_option(bn, option, value)
end
local bufname = "diffview://null"
local ok = pcall(api.nvim_buf_set_name, bn, bufname)
if not ok then
utils.wipe_named_buffer(bufname)
api.nvim_buf_set_name(bn, bufname)
end
File.NULL_FILE.bufnr = bn
end
return File.NULL_FILE.bufnr
end
---@static
function File.load_null_buffer(winid)
local bn = File._get_null_buffer()
api.nvim_win_set_buf(winid, bn)
File.NULL_FILE:attach_buffer()
end
---@type vcs.File
File.NULL_FILE = File({
-- NOTE: consider changing this adapter to be an actual adapter instance
adapter = {
ctx = {
toplevel = "diffview://",
},
},
path = "null",
kind = "working",
status = "X",
binary = false,
nulled = true,
rev = GitRev.new_null_tree(),
})
M.File = File
return M

View File

@ -0,0 +1,92 @@
local oop = require("diffview.oop")
local FileTree = require("diffview.ui.models.file_tree.file_tree").FileTree
local M = {}
---@alias vcs.FileKind "conflicting"|"working"|"staged"
---@class FileDict : diffview.Object
---@field [integer] FileEntry
---@field sets FileEntry[][]
---@field conflicting FileEntry[]
---@field working FileEntry[]
---@field staged FileEntry[]
---@field conflicting_tree FileTree
---@field working_tree FileTree
---@field staged_tree FileTree
local FileDict = oop.create_class("FileDict")
---FileDict constructor.
function FileDict:init()
self.conflicting = {}
self.working = {}
self.staged = {}
self.sets = { self.conflicting, self.working, self.staged }
self:update_file_trees()
end
function FileDict:__index(k)
if type(k) == "number" then
local offset = 0
for _, set in ipairs(self.sets) do
if k - offset <= #set then
return set[k - offset]
end
offset = offset + #set
end
else
return FileDict[k]
end
end
function FileDict:update_file_trees()
self.conflicting_tree = FileTree(self.conflicting)
self.working_tree = FileTree(self.working)
self.staged_tree = FileTree(self.staged)
end
function FileDict:len()
local l = 0
for _, set in ipairs(self.sets) do l = l + #set end
return l
end
function FileDict:iter()
local i = 0
local n = self:len()
---@return integer?, FileEntry?
return function()
i = i + 1
if i <= n then
return i, self[i]
end
end
end
---@param files FileEntry[]
function FileDict:set_conflicting(files)
for i = 1, math.max(#self.conflicting, #files) do
self.conflicting[i] = files[i] or nil
end
end
---@param files FileEntry[]
function FileDict:set_working(files)
for i = 1, math.max(#self.working, #files) do
self.working[i] = files[i] or nil
end
end
---@param files FileEntry[]
function FileDict:set_staged(files)
for i = 1, math.max(#self.staged, #files) do
self.staged[i] = files[i] or nil
end
end
M.FileDict = FileDict
return M

View File

@ -0,0 +1,147 @@
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local M = {}
---@alias FlagOption.CompletionWrapper fun(parent: FHOptionPanel): fun(ctx: CmdLineContext): string[]
---@class FlagOption
---@field flag_name string
---@field keymap string
---@field desc string
---@field key string
---@field expect_list boolean
---@field prompt_label string
---@field prompt_fmt string
---@field value_fmt string
---@field display_fmt string
---@field select? string[]
---@field completion? string|FlagOption.CompletionWrapper
local FlagOption = oop.create_class("FlagOption")
---@class FlagOption.init.Opt
---@field flag_name string
---@field keymap string
---@field desc string
---@field key string
---@field expect_list boolean
---@field prompt_label string
---@field prompt_fmt string
---@field value_fmt string
---@field display_fmt string
---@field select? string[]
---@field completion? string|FlagOption.CompletionWrapper
---@field transform function
---@field prepare_values function
---@field render_prompt function
---@field render_value function
---@field render_display function
---@field render_default function
---@param keymap string
---@param flag_name string
---@param desc string
---@param opt FlagOption.init.Opt
function FlagOption:init(keymap, flag_name, desc, opt)
opt = opt or {}
self.keymap = keymap
self.flag_name = flag_name
self.desc = desc
self.key = opt.key or utils.str_match(flag_name, {
"^%-%-?([^=]+)=?",
"^%+%+?([^=]+)=?",
}):gsub("%-", "_")
self.select = opt.select
self.completion = opt.completion
self.expect_list = utils.sate(opt.expect_list, false)
self.prompt_label = opt.prompt_label or ""
self.prompt_fmt = opt.prompt_fmt or "${label}${flag_name}"
self.value_fmt = opt.value_fmt or "${flag_name}${value}"
self.display_fmt = opt.display_fmt or "${values}"
self.transform = opt.transform or self.transform
self.render_prompt = opt.render_prompt or self.render_prompt
self.render_value = opt.render_value or self.render_value
self.render_display = opt.render_display or self.render_display
self.render_default = opt.render_default or self.render_default
end
---@param values any|any[]
---@return string[]
function FlagOption:prepare_values(values)
if values == nil then
return {}
elseif type(values) ~= "table" then
return { tostring(values) }
else
return vim.tbl_map(tostring, values)
end
end
---Transform the values given by the user.
---@param values any|any[]
function FlagOption:transform(values)
return utils.tbl_fmap(self:prepare_values(values), function(v)
v = utils.str_match(v, { "^" .. vim.pesc(self.flag_name) .. "(.*)", ".*" })
if v == "" then return nil end
return v
end)
end
function FlagOption:render_prompt()
return utils.str_template(self.prompt_fmt, {
label = self.prompt_label and self.prompt_label .. " " or "",
flag_name = self.flag_name .. " ",
}):sub(1, -2)
end
---Render a single option value
---@param value string
function FlagOption:render_value(value)
value = value:gsub("\\", "\\\\")
return utils.str_template(self.value_fmt, {
flag_name = self.flag_name,
value = utils.str_quote(value, { only_if_whitespace = true }),
})
end
---Render the displayed text for the panel.
---@param values any|any[]
---@return boolean empty
---@return string rendered_text
function FlagOption:render_display(values)
values = self:prepare_values(values)
if #values == 0 or (#values == 1 and values[1] == "") then
return true, self.flag_name
end
local quoted = table.concat(vim.tbl_map(function(v)
return self:render_value(v)
end, values), " ")
return false, utils.str_template(self.display_fmt, {
flag_name = self.flag_name,
values = quoted,
})
end
---Render the default text for |input()|.
---@param values any|any[]
function FlagOption:render_default(values)
values = self:prepare_values(values)
local ret = vim.tbl_map(function(v)
return self:render_value(v)
end, values)
if #ret > 0 then
ret[1] = ret[1]:match("^" .. vim.pesc(self.flag_name) .. "(.*)") or ret[1]
end
return table.concat(ret, " ")
end
M.FlagOption = FlagOption
return M

View File

@ -0,0 +1,49 @@
local GitAdapter = require('diffview.vcs.adapters.git').GitAdapter
local HgAdapter = require('diffview.vcs.adapters.hg').HgAdapter
local M = {}
---@class vcs.init.get_adapter.Opt
---@field top_indicators string[]?
---@field cmd_ctx vcs.init.get_adapter.Opt.Cmd_Ctx? # Context data from a command call.
---@class vcs.init.get_adapter.Opt.Cmd_Ctx
---@field path_args string[] # Raw path args
---@field cpath string? # Cwd path given by the `-C` flag option
---@param opt vcs.init.get_adapter.Opt
---@return string? err
---@return VCSAdapter? adapter
function M.get_adapter(opt)
local adapter_kinds = { GitAdapter, HgAdapter }
if not opt.cmd_ctx then
opt.cmd_ctx = {}
end
for _, kind in ipairs(adapter_kinds) do
local path_args
local top_indicators = opt.top_indicators
if not kind.bootstrap.done then kind.run_bootstrap() end
if not kind.bootstrap.ok then goto continue end
if not top_indicators then
path_args, top_indicators = kind.get_repo_paths(opt.cmd_ctx.path_args, opt.cmd_ctx.cpath)
end
local err, toplevel = kind.find_toplevel(top_indicators)
if not err then
-- Create a new adapter instance. Store the resolved path args and the
-- cpath in the adapter context.
return kind.create(toplevel, path_args, opt.cmd_ctx.cpath)
end
::continue::
end
return "Not a repo (or any parent), or no supported VCS adapter!"
end
return M

View File

@ -0,0 +1,105 @@
local lazy = require("diffview.lazy")
local oop = require("diffview.oop")
local FileEntry = lazy.access("diffview.scene.file_entry", "FileEntry") ---@type FileEntry
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"
local M = {}
---@class LogEntry : diffview.Object
---@operator call : LogEntry
---@field path_args string[]
---@field commit Commit
---@field files FileEntry[]
---@field status string
---@field stats GitStats
---@field single_file boolean
---@field folded boolean
---@field nulled boolean
local LogEntry = oop.create_class("LogEntry")
function LogEntry:init(opt)
self.path_args = opt.path_args
self.commit = opt.commit
self.files = opt.files
self.folded = true
self.single_file = opt.single_file
self.nulled = utils.sate(opt.nulled, false)
self:update_status()
self:update_stats()
end
function LogEntry:destroy()
for _, file in ipairs(self.files) do
file:destroy()
end
end
function LogEntry:update_status()
self.status = nil
local missing_status = 0
for _, file in ipairs(self.files) do
if not file.status then
missing_status = missing_status + 1
else
if self.status and file.status ~= self.status then
self.status = "M"
return
elseif self.status ~= file.status then
self.status = file.status
end
end
end
if missing_status < #self.files and not self.status then
self.status = "X"
end
end
function LogEntry:update_stats()
self.stats = { additions = 0, deletions = 0 }
local missing_stats = 0
for _, file in ipairs(self.files) do
if not file.stats then
missing_stats = missing_stats + 1
else
self.stats.additions = self.stats.additions + file.stats.additions
self.stats.deletions = self.stats.deletions + file.stats.deletions
end
end
if missing_stats == #self.files then
self.stats = nil
end
end
---@param path string
---@return diff.FileEntry?
function LogEntry:get_diff(path)
if not self.commit.diff then return nil end
for _, diff_entry in ipairs(self.commit.diff) do
if path == (diff_entry.path_new or diff_entry.path_old) then
return diff_entry
end
end
end
---@param adapter VCSAdapter
---@param opt table
---@return LogEntry
function LogEntry.new_null_entry(adapter, opt)
opt = opt or {}
return LogEntry(
vim.tbl_extend("force", opt, {
nulled = true,
files = { FileEntry.new_null_entry(adapter) },
})
)
end
M.LogEntry = LogEntry
return M

View File

@ -0,0 +1,125 @@
local oop = require("diffview.oop")
local M = {}
---@enum RevType
local RevType = oop.enum({
LOCAL = 1,
COMMIT = 2,
STAGE = 3,
CUSTOM = 4,
})
---@alias RevRange { first: Rev, last: Rev }
---@class Rev : diffview.Object
---@field type integer
---@field commit? string A commit SHA.
---@field stage? integer A stage number.
---@field track_head boolean If true, indicates that the rev should be updated when HEAD changes.
local Rev = oop.create_class("Rev")
---Rev constructor
---@param rev_type RevType
---@param revision string|number Commit SHA or stage number.
---@param track_head? boolean
function Rev:init(rev_type, revision, track_head)
local t = type(revision)
assert(
revision == nil or t == "string" or t == "number",
"'revision' must be one of: nil, string, number!"
)
if t == "string" then
assert(revision ~= "", "'revision' cannot be an empty string!")
elseif t == "number" then
assert(
revision >= 0 and revision <= 3,
"'revision' must be a valid stage number ([0-3])!"
)
end
t = type(track_head)
assert(t == "boolean" or t == "nil", "'track_head' must be of type boolean!")
self.type = rev_type
self.track_head = track_head or false
if type(revision) == "string" then
---@cast revision string
self.commit = revision
elseif type(revision) == "number" then
---@cast revision number
self.stage = revision
end
end
function Rev:__tostring()
if self.type == RevType.COMMIT or self.type == RevType.STAGE then
return self:object_name()
elseif self.type == RevType.LOCAL then
return "LOCAL"
elseif self.type == RevType.CUSTOM then
return "CUSTOM"
end
end
---@diagnostic disable: unused-local, missing-return
---Get the argument describing the range between the two given revs. If a
---single rev is given, the returned argument describes the *range* of the
---single commit pointed to by that rev.
---@abstract
---@param rev_from Rev|string
---@param rev_to? Rev|string
---@return string?
function Rev.to_range(rev_from, rev_to) oop.abstract_stub() end
---@param name string
---@param adapter? VCSAdapter
---@return Rev?
function Rev.from_name(name, adapter)
oop.abstract_stub()
end
---@param adapter VCSAdapter
---@return Rev?
function Rev.earliest_commit(adapter)
oop.abstract_stub()
end
---Create a new commit rev with the special empty tree SHA.
---@return Rev
function Rev.new_null_tree()
oop.abstract_stub()
end
---Determine if this rev is currently the head.
---@param adapter VCSAdapter
---@return boolean?
function Rev:is_head(adapter)
oop.abstract_stub()
end
---@param abbrev_len? integer
---@return string
function Rev:object_name(abbrev_len)
oop.abstract_stub()
end
---@diagnostic enable: unused-local, missing-return
---Get an abbreviated commit SHA. Returns `nil` if this Rev is not a commit.
---@param length integer|nil
---@return string|nil
function Rev:abbrev(length)
if self.commit then
return self.commit:sub(1, length or 7)
end
return nil
end
M.RevType = RevType
M.Rev = Rev
return M

View File

@ -0,0 +1,623 @@
local FileDict = require("diffview.vcs.file_dict").FileDict
local RevType = require("diffview.vcs.rev").RevType
local Scanner = require("diffview.scanner")
local Semaphore = require("diffview.control").Semaphore
local async = require("diffview.async")
local oop = require("diffview.oop")
local utils = require("diffview.utils")
local api = vim.api
local await = async.await
local fmt = string.format
local logger = DiffviewGlobal.logger
local M = {}
---@enum JobStatus
local JobStatus = oop.enum({
SUCCESS = 1,
PROGRESS = 2,
ERROR = 3,
KILLED = 4,
FATAL = 5,
})
---@type diffview.Job[]
local sync_jobs = {}
local job_queue_sem = Semaphore(1)
---@param job diffview.Job
M.resume_sync_queue = async.void(function(job)
local permit = await(job_queue_sem:acquire()) --[[@as Permit ]]
local idx = utils.vec_indexof(sync_jobs, job)
if idx > -1 then
table.remove(sync_jobs, idx)
end
permit:forget()
if sync_jobs[1] and not sync_jobs[1]:is_started() then
sync_jobs[1]:start()
end
end)
---@param job diffview.Job
M.queue_sync_job = async.void(function(job)
job:on_exit(function()
M.resume_sync_queue(job)
end)
local permit = await(job_queue_sem:acquire()) --[[@as Permit ]]
table.insert(sync_jobs, job)
if #sync_jobs == 1 then
job:start()
end
permit:forget()
end)
---Get a list of files modified between two revs.
---@param adapter VCSAdapter
---@param left Rev
---@param right Rev
---@param path_args string[]|nil
---@param dv_opt DiffViewOptions
---@param opt vcs.adapter.LayoutOpt
---@param callback function
---@return string[]? err
---@return FileDict?
M.diff_file_list = async.wrap(function(adapter, left, right, path_args, dv_opt, opt, callback)
---@type FileDict
local files = FileDict()
local rev_args = adapter:rev_to_args(left, right)
local errors = {}
;(function()
local err, tfiles, tconflicts = await(
adapter:tracked_files(
left,
right,
utils.vec_join(rev_args, "--", path_args),
"working",
opt
)
)
if err then
errors[#errors+1] = err
utils.err("Failed to get git status for tracked files!", true)
return
end
files:set_working(tfiles)
files:set_conflicting(tconflicts)
if not adapter:show_untracked({
dv_opt = dv_opt,
revs = { left = left, right = right },
})
then return end
---@diagnostic disable-next-line: redefined-local
local err, ufiles = await(adapter:untracked_files(left, right, opt))
if err then
errors[#errors+1] = err
utils.err("Failed to get git status for untracked files!", true)
else
files:set_working(utils.vec_join(files.working, ufiles))
utils.merge_sort(files.working, function(a, b)
return a.path:lower() < b.path:lower()
end)
end
end)()
if left.type == RevType.STAGE and right.type == RevType.LOCAL then
local left_rev = adapter:head_rev() or adapter.Rev.new_null_tree()
local right_rev = adapter.Rev(RevType.STAGE, 0)
---@diagnostic disable-next-line: redefined-local
local err, tfiles = await(
adapter:tracked_files(
left_rev,
right_rev,
utils.vec_join("--cached", left_rev.commit, "--", path_args),
"staged",
opt
)
)
if err then
errors[#errors+1] = err
utils.err("Failed to get git status for staged files!", true)
else
files:set_staged(tfiles)
end
end
if #errors > 0 then
callback(utils.vec_join(unpack(errors)), nil)
return
end
files:update_file_trees()
callback(nil, files)
end, 7)
---Restore a file to the state it was in, in a given commit / rev. If no commit
---is given, unstaged files are restored to the state in index, and staged files
---are restored to the state in HEAD. The file will also be written into the
---object database such that the action can be undone.
---@param adapter VCSAdapter
---@param path string
---@param kind vcs.FileKind
---@param commit? string
M.restore_file = async.void(function(adapter, path, kind, commit)
local ok, undo = await(adapter:file_restore(path, kind, commit))
if not ok then
utils.err("Failed to revert file! See ':DiffviewLog' for details.", true)
return
end
local rev_name = (commit and commit:sub(1, 11)) or (kind == "staged" and "HEAD" or "index")
local msg = fmt("File restored from %s. %s", rev_name, undo and ("Undo with " .. undo) or "")
logger:info(msg)
utils.info(msg, true)
end)
--[[
Standard change:
diff --git a/lua/diffview/health.lua b/lua/diffview/health.lua
index c05dcda..07bdd33 100644
--- a/lua/diffview/health.lua
+++ b/lua/diffview/health.lua
@@ -48,7 +48,7 @@ function M.check()
Rename with change:
diff --git a/test/index_watcher_spec.lua b/test/gitdir_watcher_spec.lua
similarity index 94%
rename from test/index_watcher_spec.lua
rename to test/gitdir_watcher_spec.lua
index 008beab..66116dc 100644
--- a/test/index_watcher_spec.lua
+++ b/test/gitdir_watcher_spec.lua
@@ -17,7 +17,7 @@ local get_buf_name = helpers.curbufmeths.get_name
--]]
local DIFF_HEADER = [[^diff %-%-git ]]
local DIFF_SIMILARITY = [[^similarity index (%d+)%%]]
local DIFF_INDEX = { [[^index (%x-)%.%.(%x-) (%d+)]], [[^index (%x-)%.%.(%x-)]] }
local DIFF_PATH_OLD = { [[^%-%-%- a/(.*)]], [[^%-%-%- (/dev/null)]] }
local DIFF_PATH_NEW = { [[^%+%+%+ b/(.*)]], [[^%+%+%+ (/dev/null)]] }
local DIFF_HUNK_HEADER = [[^@@+ %-(%d+),(%d+) %+(%d+),(%d+) @@+]]
---@class diff.Hunk
---@field old_row integer
---@field old_size integer
---@field new_row integer
---@field new_size integer
---@field common_content string[]
---@field old_content { [1]: integer, [2]: string[] }[]
---@field new_content { [1]: integer, [2]: string[] }[]
---@param scanner Scanner
---@param old_row integer
---@param old_size integer
---@param new_row integer
---@param new_size integer
---@return diff.Hunk
local function parse_diff_hunk(scanner, old_row, old_size, new_row, new_size)
local ret = {
old_row = old_row,
old_size = old_size,
new_row = new_row,
new_size = new_size,
common_content = {},
old_content = {},
new_content = {},
}
local common_idx, old_offset, new_offset = 1, 0, 0
local line = scanner:peek_line()
local cur_start = (line or ""):match("^([%+%- ])")
while cur_start do
line = scanner:next_line() --[[@as string ]]
if cur_start == " " then
ret.common_content[#ret.common_content + 1] = line:sub(2) or ""
common_idx = common_idx + 1
elseif cur_start == "-" then
local content = { line:sub(2) or "" }
while (scanner:peek_line() or ""):sub(1, 1) == "-" do
content[#content + 1] = scanner:next_line():sub(2) or ""
end
ret.old_content[#ret.old_content + 1] = { common_idx + old_offset, content }
old_offset = old_offset + #content
elseif cur_start == "+" then
local content = { line:sub(2) or "" }
while (scanner:peek_line() or ""):sub(1, 1) == "+" do
content[#content + 1] = scanner:next_line():sub(2) or ""
end
ret.new_content[#ret.new_content + 1] = { common_idx + new_offset, content }
new_offset = new_offset + #content
end
cur_start = (scanner:peek_line() or ""):match("^([%+%- ])")
end
return ret
end
---@class diff.FileEntry
---@field renamed boolean
---@field similarity? integer
---@field dissimilarity? integer
---@field index_old? integer
---@field index_new? integer
---@field mode? integer
---@field old_mode? integer
---@field new_mode? integer
---@field deleted_file_mode? integer
---@field new_file_mode? integer
---@field path_old? string
---@field path_new? string
---@field hunks diff.Hunk[]
---@param scanner Scanner
---@return diff.FileEntry
local function parse_file_diff(scanner)
---@type diff.FileEntry
local ret = { renamed = false, hunks = {} }
-- The current line will here be the diff header
-- Extended git diff headers
while scanner:peek_line() and
not utils.str_match(scanner:peek_line() or "", { DIFF_HEADER, DIFF_HUNK_HEADER })
do
-- Extended header lines:
-- old mode <mode>
-- new mode <mode>
-- deleted file mode <mode>
-- new file mode <mode>
-- copy from <path>
-- copy to <path>
-- rename from <path>
-- rename to <path>
-- similarity index <number>
-- dissimilarity index <number>
-- index <hash>..<hash> <mode>
--
-- Note: Combined diffs have even more variations
local last_line_idx = scanner:cur_line_idx()
-- Similarity
local similarity = (scanner:peek_line() or ""):match(DIFF_SIMILARITY)
if similarity then
ret.similarity = tonumber(similarity) or -1
scanner:next_line()
end
-- Dissimilarity
local dissimilarity = (scanner:peek_line() or ""):match([[^dissimilarity index (%d+)%%]])
if dissimilarity then
ret.dissimilarity = tonumber(dissimilarity) or -1
scanner:next_line()
end
-- Renames
local rename_from = (scanner:peek_line() or ""):match([[^rename from (.*)]])
if rename_from then
ret.renamed = true
ret.path_old = rename_from
scanner:skip_line()
ret.path_new = (scanner:next_line() or ""):match([[^rename to (.*)]])
end
-- Copies
local copy_from = (scanner:peek_line() or ""):match([[^copy from (.*)]])
if copy_from then
ret.path_old = copy_from
scanner:skip_line()
ret.path_new = (scanner:next_line() or ""):match([[^copy to (.*)]])
end
-- Old mode
local old_mode = (scanner:peek_line() or ""):match([[^old mode (%d+)]])
if old_mode then
ret.old_mode = old_mode
scanner:next_line()
end
-- New mode
local new_mode = (scanner:peek_line() or ""):match([[^new mode (%d+)]])
if new_mode then
ret.new_mode = new_mode
scanner:next_line()
end
-- Deleted file
local deleted_file_mode = (scanner:peek_line() or ""):match([[^deleted file mode (%d+)]])
if deleted_file_mode then
ret.old_file_mode = deleted_file_mode
scanner:next_line()
end
-- New file
local new_file_mode = (scanner:peek_line() or ""):match([[^new file mode (%d+)]])
if new_file_mode then
ret.new_file_mode = new_file_mode
scanner:next_line()
end
-- Index
local index_old, index_new, mode = utils.str_match(scanner:peek_line() or "", DIFF_INDEX)
if index_old then
ret.index_old = index_old
ret.index_new = index_new
ret.mode = mode
scanner:next_line()
end
-- Paths
local path_old = utils.str_match(scanner:peek_line() or "", DIFF_PATH_OLD)
if path_old then
if not ret.path_old then
ret.path_old = path_old ~= "/dev/null" and path_old or nil
scanner:skip_line()
local path_new = utils.str_match(scanner:next_line() or "", DIFF_PATH_NEW)
ret.path_new = path_new ~= "/dev/null" and path_new or nil
else
scanner:skip_line(2)
end
end
if last_line_idx == scanner:cur_line_idx() then
-- Non-git patches don't have the extended header lines
break
end
end
-- Hunks
local line = scanner:peek_line()
while line and not line:match(DIFF_HEADER) do
local old_row, old_size, new_row, new_size = line:match(DIFF_HUNK_HEADER)
scanner:next_line() -- Current line is now the hunk header
if old_row then
table.insert(ret.hunks, parse_diff_hunk(
scanner,
tonumber(old_row) or -1,
tonumber(old_size) or -1,
tonumber(new_row) or -1,
tonumber(new_size) or -1
))
end
line = scanner:peek_line()
end
return ret
end
---Parse a diff patch.
---@param lines string[]
---@return diff.FileEntry[]
function M.parse_diff(lines)
local ret = {}
local scanner = Scanner(lines)
while scanner:peek_line() do
local line = scanner:next_line() --[[@as string ]]
-- TODO: Diff headers and patch format can take a few different forms. I.e. combined diffs
if line:match(DIFF_HEADER) then
table.insert(ret, parse_file_diff(scanner))
end
end
return ret
end
---Build either the old or the new version of a diff hunk.
---@param hunk diff.Hunk
---@param version "old"|"new"
---@return string[]
function M.diff_build_hunk(hunk, version)
local vcontent = version == "old" and hunk.old_content or hunk.new_content
local size = version == "old" and hunk.old_size or hunk.new_size
local common_idx = 1
local chunk_idx = 1
local ret = {}
local i = 1
while i <= size do
local chunk = vcontent[chunk_idx]
if chunk and chunk[1] == i then
for _, line in ipairs(chunk[2]) do
ret[#ret + 1] = line
end
i = i + (#chunk[2] - 1)
chunk_idx = chunk_idx + 1
else
ret[#ret + 1] = hunk.common_content[common_idx]
common_idx = common_idx + 1
end
i = i + 1
end
return ret
end
local CONFLICT_START = [[^<<<<<<< ]]
local CONFLICT_BASE = [[^||||||| ]]
local CONFLICT_SEP = [[^=======$]]
local CONFLICT_END = [[^>>>>>>> ]]
---@class ConflictRegion
---@field first integer
---@field last integer
---@field ours { first: integer, last: integer, content?: string[] }
---@field base { first: integer, last: integer, content?: string[] }
---@field theirs { first: integer, last: integer, content?: string[] }
---@param lines string[]
---@param winid? integer
---@return ConflictRegion[] conflicts
---@return ConflictRegion? cur_conflict The conflict under the cursor in the given window.
---@return integer cur_conflict_idx Index of the current conflict. Will be 0 if the cursor is before the first conflict, and `#conflicts + 1` if the cursor is after the last conflict.
function M.parse_conflicts(lines, winid)
local ret = {}
local has_start, has_base, has_sep = false, false, false
local cur, cursor, cur_conflict, cur_idx
if winid and api.nvim_win_is_valid(winid) then
cursor = api.nvim_win_get_cursor(winid)
end
local function handle(data)
local first = math.min(
data.ours.first or math.huge,
data.base.first or math.huge,
data.theirs.first or math.huge
)
if first == math.huge then return end
local last = math.max(
data.ours.last or -1,
data.base.last or -1,
data.theirs.last or -1
)
if last == -1 then return end
if data.ours.first and data.ours.last and data.ours.first < data.ours.last then
data.ours.content = utils.vec_slice(lines, data.ours.first + 1, data.ours.last)
end
if data.base.first and data.base.last and data.base.first < data.base.last then
data.base.content = utils.vec_slice(lines, data.base.first + 1, data.base.last)
end
if data.theirs.first and data.theirs.last and data.theirs.first < data.theirs.last - 1 then
data.theirs.content = utils.vec_slice(lines, data.theirs.first + 1, data.theirs.last - 1)
end
if cursor then
if not cur_conflict and cursor[1] >= first and cursor[1] <= last then
cur_conflict = data
cur_idx = #ret + 1
elseif cursor[1] > last then
cur_idx = (cur_idx or 0) + 1
end
end
data.first = first
data.last = last
ret[#ret + 1] = data
end
local function new_cur()
return {
ours = {},
base = {},
theirs = {},
}
end
cur = new_cur()
for i, line in ipairs(lines) do
if line:match(CONFLICT_START) then
if has_start then
handle(cur)
cur, has_start, has_base, has_sep = new_cur(), false, false, false
end
has_start = true
cur.ours.first = i
cur.ours.last = i
elseif line:match(CONFLICT_BASE) then
if has_base then
handle(cur)
cur, has_start, has_base, has_sep = new_cur(), false, false, false
end
has_base = true
cur.base.first = i
cur.ours.last = i - 1
elseif line:match(CONFLICT_SEP) then
if has_sep then
handle(cur)
cur, has_start, has_base, has_sep = new_cur(), false, false, false
end
has_sep = true
cur.theirs.first = i
cur.theirs.last = i
if has_base then
cur.base.last = i - 1
else
cur.ours.last = i - 1
end
elseif line:match(CONFLICT_END) then
if not has_sep then
if has_base then
cur.base.last = i - 1
elseif has_start then
cur.ours.last = i - 1
end
end
cur.theirs.first = cur.theirs.first or i
cur.theirs.last = i
handle(cur)
cur, has_start, has_base, has_sep = new_cur(), false, false, false
end
end
handle(cur)
if cursor and cur_idx then
if cursor[1] > ret[#ret].last then
cur_idx = #ret + 1
end
end
return ret, cur_conflict, cur_idx or 0
end
---@param version { major: integer, minor: integer, patch: integer }
---@param required { major: integer, minor: integer, patch: integer }
---@return boolean
function M.check_semver(version, required)
if version.major ~= required.major then
return version.major > required.major
elseif version.minor ~= required.minor then
return version.minor > required.minor
elseif version.patch ~= required.patch then
return version.patch > required.patch
end
return true
end
M.JobStatus = JobStatus
return M