Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adding mini.diff module as inline diff mechanism #210

Merged
merged 7 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,8 @@ Use Markdown formatting and include the programming language name at the start o
layout = "vertical", -- vertical|horizontal|buffer
diff = {
enabled = true,
-- mini_diff is using inline diff in the same buffer but requires the plugin to be installed: https://github.com/echasnovski/mini.diff
diff_method = "default", -- default|mini_diff
close_chat_at = 240, -- Close an open chat buffer if the total columns of your display are less than...
layout = "vertical", -- vertical|horizontal
opts = { "internal", "filler", "closeoff", "algorithm:patience", "followwrap", "linematch:120" },
Expand Down Expand Up @@ -1327,6 +1329,27 @@ require('legendary').setup({
})
```

**Mini.Diff**

if you're using [mini.diff](https://github.com/echasnovski/mini.diff) you can put an icon in the statusline to indicate which diff is used currently, git or llm changes:

```lua
local function getDiffSource()
local buf_id, diff_source, diffIcon
buf_id = vim.api.nvim_get_current_buf()
diff_source = vim.b[buf_id].diffCompGit
if not diff_source then
return ""
end
if diff_source == "git" then
diffIcon = "󰊤 "
elseif diff_source == "llm" then
diffIcon = " "
end
return string.format("%%#StatusLineLSP#%s", diffIcon)
end
```

## :toolbox: Troubleshooting

Before raising an [issue](https://github.com/olimorris/codecompanion.nvim/issues), there are a number of steps you can take to troubleshoot a problem:
Expand Down
1 change: 1 addition & 0 deletions lua/codecompanion/config.lua
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should give users the choice of a provider i.e. default or mini_diff. I think we should hardcode the revert_delay in too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a setting called config.display.inline.diff.diff_method that can be set to either default or mini_diff. I'm not sure if this is what you asking for.

I also removed the revert_delay setting and replaced it with a hardcoded value.

Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ Use Markdown formatting and include the programming language name at the start o
layout = "vertical", -- vertical|horizontal|buffer
diff = {
enabled = true,
diff_method = "default", -- default|mini_diff
close_chat_at = 240, -- Close an open chat buffer if the total columns of your display are less than...
layout = "vertical", -- vertical|horizontal
opts = { "internal", "filler", "closeoff", "algorithm:patience", "followwrap", "linematch:120" },
Expand Down
101 changes: 60 additions & 41 deletions lua/codecompanion/strategies/inline.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ local config = require("codecompanion").config

local keymaps = require("codecompanion.utils.keymaps")
local log = require("codecompanion.utils.log")
local mini_diff = require("codecompanion.strategies.inline.mini_diff")
local msg_utils = require("codecompanion.utils.messages")
local ui = require("codecompanion.utils.ui")
local util = require("codecompanion.utils.util")
Expand Down Expand Up @@ -173,6 +174,13 @@ end
---Start the classification of the user's prompt
---@param opts? table
function Inline:start(opts)
-- NOTE: we need to add this here to intiate the mini.diff early to be
-- working properly
if config.display.inline.diff.diff_method == "mini_diff" then
log:trace("CodeCompanion: Using mini diff for inline display")
require("codecompanion.strategies.inline.mini_diff").setup()
end

log:trace("Starting Inline with opts: %s", opts)

if opts and opts[1] then
Expand Down Expand Up @@ -562,61 +570,72 @@ function Inline:start_diff()
if config.display.inline.diff.enabled == false then
return
end
if config.display.inline.diff.diff_method == "mini_diff" then
return -- no need to do anything here, since it's handled in mini_diff.lua
else
-- Taken from the awesome:
-- https://github.com/S1M0N38/dante.nvim

-- Taken from the awesome:
-- https://github.com/S1M0N38/dante.nvim

-- Get current window properties
local wrap = vim.wo.wrap
local linebreak = vim.wo.linebreak
local breakindent = vim.wo.breakindent
vim.cmd("set diffopt=" .. table.concat(config.display.inline.diff.opts, ","))
-- Get current window properties
local wrap = vim.wo.wrap
local linebreak = vim.wo.linebreak
local breakindent = vim.wo.breakindent
vim.cmd("set diffopt=" .. table.concat(config.display.inline.diff.opts, ","))

-- Close the chat buffer
local last_chat = require("codecompanion").last_chat()
if last_chat and last_chat:is_visible() and config.display.inline.diff.close_chat_at > vim.o.columns then
last_chat:hide()
end
-- Close the chat buffer
local last_chat = require("codecompanion").last_chat()
if last_chat and last_chat:is_visible() and config.display.inline.diff.close_chat_at > vim.o.columns then
last_chat:hide()
end

-- Create the diff buffer
if config.display.inline.diff.layout == "vertical" then
vim.cmd("vsplit")
else
vim.cmd("split")
-- Create the diff buffer
if config.display.inline.diff.layout == "vertical" then
vim.cmd("vsplit")
else
vim.cmd("split")
end
self.diff.winnr = api.nvim_get_current_win()
self.diff.bufnr = api.nvim_create_buf(false, true)
api.nvim_win_set_buf(self.diff.winnr, self.diff.bufnr)
api.nvim_set_option_value("filetype", self.context.filetype, { buf = self.diff.bufnr })
api.nvim_set_option_value("wrap", wrap, { win = self.diff.winnr })
api.nvim_set_option_value("linebreak", linebreak, { win = self.diff.winnr })
api.nvim_set_option_value("breakindent", breakindent, { win = self.diff.winnr })

-- Set the diff buffer to the contents, prior to any modifications
api.nvim_buf_set_lines(self.diff.bufnr, 0, 0, true, self.diff.lines)
api.nvim_win_set_cursor(self.diff.winnr, { self.context.cursor_pos[1], self.context.cursor_pos[2] })

-- Begin diffing
api.nvim_set_current_win(self.diff.winnr)
vim.cmd("diffthis")
api.nvim_set_current_win(self.context.winnr)
vim.cmd("diffthis")
end
self.diff.winnr = api.nvim_get_current_win()
self.diff.bufnr = api.nvim_create_buf(false, true)
api.nvim_win_set_buf(self.diff.winnr, self.diff.bufnr)
api.nvim_set_option_value("filetype", self.context.filetype, { buf = self.diff.bufnr })
api.nvim_set_option_value("wrap", wrap, { win = self.diff.winnr })
api.nvim_set_option_value("linebreak", linebreak, { win = self.diff.winnr })
api.nvim_set_option_value("breakindent", breakindent, { win = self.diff.winnr })

-- Set the diff buffer to the contents, prior to any modifications
api.nvim_buf_set_lines(self.diff.bufnr, 0, 0, true, self.diff.lines)
api.nvim_win_set_cursor(self.diff.winnr, { self.context.cursor_pos[1], self.context.cursor_pos[2] })

-- Begin diffing
api.nvim_set_current_win(self.diff.winnr)
vim.cmd("diffthis")
api.nvim_set_current_win(self.context.winnr)
vim.cmd("diffthis")
end

---Accept the changes in the diff
---@return nil
function Inline:accept()
api.nvim_win_close(self.diff.winnr, false)
self.diff = {}
if config.display.inline.diff.diff_method == "mini_diff" then
mini_diff.accept(self.context.bufnr)
else
api.nvim_win_close(self.diff.winnr, false)
self.diff = {}
end
end

---Reject the changes in the diff
---@return nil
function Inline:reject()
vim.cmd("diffoff")
api.nvim_win_close(self.diff.winnr, false)
api.nvim_buf_set_lines(self.context.bufnr, 0, -1, true, self.diff.lines)
self.diff = {}
if config.display.inline.diff.diff_method == "mini_diff" then
mini_diff.reject(self.context.bufnr)
else
vim.cmd("diffoff")
api.nvim_win_close(self.diff.winnr, false)
api.nvim_buf_set_lines(self.context.bufnr, 0, -1, true, self.diff.lines)
self.diff = {}
end
end

return Inline
214 changes: 214 additions & 0 deletions lua/codecompanion/strategies/inline/mini_diff.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
-- implementing mini.diff for llm changes
-- to see the main logic, please head to the setup function below
local M = {}

local original_buffer_content = {} -- Store the original buffer content
local codecompanion_buffers = {} -- Store which buffers are using CodeCompanion source
local revert_timers = {} -- Store timers for reverting to Git source
local log = require("codecompanion.utils.log")
local ok, MiniDiff = pcall(require, "mini.diff")
if not ok then
return log:error("Failed to load mini.diff: ", vim.log.levels.WARN)
end
local git_source = MiniDiff.gen_source.git()
local REVERT_DELAY = 5 * 60 * 1000 -- 5 minutes

---@param buf_id number
---@return boolean Whether
local function is_valid_buffer(buf_id)
return buf_id and vim.api.nvim_buf_is_valid(buf_id)
end

-- store a buffer variable to know which buffer is using codecompanion
---@param buf_id number
---@param source string
local function set_diff_source(buf_id, source)
if is_valid_buffer(buf_id) then
log:debug("Setting diff source for buffer %d to '%s'", buf_id, source)
vim.b[buf_id].diffCompGit = source
else
log:debug("Attempted to set diff source for invalid buffer %d", buf_id)
end
end

-- Define the codecompanion source for mini.diff to use
---@see https://github.com/echasnovski/mini.nvim/blob/main/doc/mini_diff.txt
---@class CodeCompanionSource
---@field name string
---@field attach fun(buf_id: number): boolean
---@field detach fun(buf_id: number)
local codecompanion_source = {
name = "codecompanion",
---@param buf_id number
---@return boolean whether the attachment was successful
attach = function(buf_id)
if not is_valid_buffer(buf_id) then
return false
end
original_buffer_content[buf_id] = vim.api.nvim_buf_get_lines(buf_id, 0, -1, false)
log:trace("original_buffer_content assinged" .. "step 0")
set_diff_source(buf_id, "llm")
return true
end,
---@param buf_id number
detach = function(buf_id)
original_buffer_content[buf_id] = nil
log:trace("original_buffer_content detached" .. "step 1")
set_diff_source(buf_id, "git")
end,
}

-- used to switch back to diff agaist llm changes
---@param buf_id number
function M.switch_to_codecompanion(buf_id)
if not codecompanion_buffers[buf_id] then
log:debug("Switching buffer %d to CodeCompanion source", buf_id)
codecompanion_buffers[buf_id] = true
MiniDiff.disable(buf_id)
MiniDiff.enable(buf_id, { source = codecompanion_source })
M.update_diff(buf_id)
set_diff_source(buf_id, "llm")
else
log:debug("Buffer %d is already using CodeCompanion source", buf_id)
end
end

-- used to switch back to diff agaist git, used in 'gr' and 'ga' or by timer.
---@param buf_id number
function M.switch_to_git(buf_id)
if codecompanion_buffers[buf_id] then
log:debug("Switching buffer %d to Git source", buf_id)
codecompanion_buffers[buf_id] = nil
MiniDiff.disable(buf_id)
MiniDiff.enable(buf_id, { source = git_source })
set_diff_source(buf_id, "git")
else
log:debug("Buffer %d is already using Git source", buf_id)
end
end

function M.update_diff(buf_id)
if not is_valid_buffer(buf_id) then
return
end

local current_content = vim.api.nvim_buf_get_lines(buf_id, 0, -1, false)
pcall(MiniDiff.set_ref_text, buf_id, original_buffer_content[buf_id] or {})
original_buffer_content[buf_id] = current_content
log:trace("original_buffer_content assinged " .. "step 1")
end

-- this function is called every time the diff is updated to schedule return
-- to default behaviour of mini.diff against git, if the user didn't already
-- press 'gr' to reject or 'ga' to accept.
---@param buf_id number
---@param delay number
function M.schedule_revert_to_git(buf_id, delay)
if revert_timers[buf_id] then
log:debug("Stopping existing revert timer for buffer %d", buf_id)
revert_timers[buf_id]:stop()
end
log:debug("Scheduling revert to Git source for buffer %d in %d milliseconds", buf_id, delay)
revert_timers[buf_id] = vim.defer_fn(function()
M.switch_to_git(buf_id)
revert_timers[buf_id] = nil
end, delay)
end

-- setup is the main mechanism/logic for this module.
---@param config? table
function M.setup(config)
config = config or {}
-- MiniDiff.setup({ source = git_source })

vim.api.nvim_create_autocmd("User", {
pattern = "CodeCompanionInline*",
callback = function(args)
local buf_id = args.buf
if not is_valid_buffer(buf_id) then
return
end

if args.match == "CodeCompanionInlineStarted" then
M.switch_to_codecompanion(buf_id)
elseif args.match == "CodeCompanionInlineFinished" then
-- local current_content = vim.api.nvim_buf_get_lines(buf_id, 0, -1, false)
pcall(MiniDiff.set_ref_text, buf_id, original_buffer_content[buf_id] or {})
-- original_buffer_content[buf_id] = current_content
log:trace("original_buffer_content assinged " .. "step 2")
M.schedule_revert_to_git(buf_id, REVERT_DELAY)
MiniDiff.toggle_overlay()
end
end,
})

vim.api.nvim_create_autocmd("BufReadPost", {
callback = function(args)
local buf_id = args.buf
if is_valid_buffer(buf_id) and vim.b[buf_id].diffCompGit == nil then
set_diff_source(buf_id, "git")
end
end,
})
end

---@param buf_id number
function M.accept(buf_id)
if not is_valid_buffer(buf_id) then
return
end

original_buffer_content[buf_id] = vim.api.nvim_buf_get_lines(buf_id, 0, -1, false)
M.update_diff(buf_id)
M.switch_to_git(buf_id)
end

---@param buf_id number
function M.reject(buf_id)
if not is_valid_buffer(buf_id) then
return
end

vim.api.nvim_buf_set_lines(buf_id, 0, -1, false, original_buffer_content[buf_id] or {})
M.update_diff(buf_id) -- NOTE: why do we do this here again
M.switch_to_git(buf_id)
end

-- APIS ---------------------------------------------------------------

-- this API function could benefit for the user to know which diff he's using
-- at the current monoment - could be used in the statusline
---@param buf_id? number
---@return string
function M.get_current_source(buf_id)
buf_id = buf_id or vim.api.nvim_get_current_buf()
return vim.b[buf_id].diffCompGit or "git"
end

-- API to force switch back to git, could used by keymap
---@param buf_id? number
function M.force_git(buf_id)
buf_id = buf_id or vim.api.nvim_get_current_buf()
M.switch_to_git(buf_id)
end

-- API to force switch to codecompanion, could used by keymap
---@param buf_id? number
function M.force_codecompanion(buf_id)
buf_id = buf_id or vim.api.nvim_get_current_buf()
if not is_valid_buffer(buf_id) then
print("Invalid buffer ID")
return
end

-- Ensure we have original content to diff against
if not original_buffer_content[buf_id] then
original_buffer_content[buf_id] = vim.api.nvim_buf_get_lines(buf_id, 0, -1, false)
end

M.switch_to_codecompanion(buf_id)
-- Force an update of the diff
M.update_diff(buf_id)
end

return M