diff --git a/README.md b/README.md index b14de1a..eb345db 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,20 @@ vim.keymap.set("n", "", function() end, { desc = "Clear Copilot suggestion or fallback" }) ``` + +#### Restoring previous suggestions + +The history automatically stores the last 2 suggestions. Each time you call restore, it cycles to the next previous suggestion. When you reach the end, it wraps back to the most recent one. + +You can restore and cycle through the last 2 suggestions: +```lua +-- Restore previous suggestions (cycles through last 2) +vim.keymap.set("n", "cr", function() + require('copilot-lsp.nes').restore_suggestion() +end, { desc = "Restore previous Copilot suggestion" }) +``` + + ## Default Configuration diff --git a/lua/copilot-lsp/nes/init.lua b/lua/copilot-lsp/nes/init.lua index 430466e..b019ecc 100644 --- a/lua/copilot-lsp/nes/init.lua +++ b/lua/copilot-lsp/nes/init.lua @@ -138,4 +138,17 @@ function M.clear() return false end +--- Restore the last suggestion from history +---@param bufnr? integer +---@return boolean -- true if suggestion was restored, false otherwise +function M.restore_suggestion(bufnr) + bufnr = bufnr and bufnr > 0 and bufnr or vim.api.nvim_get_current_buf() + if not nes_ui.has_history(bufnr) then + return false + end + -- Set flag to indicate this is a restoration, not a new suggestion + vim.b[bufnr].copilotlsp_nes_restoring = true + return nes_ui.restore_suggestion(bufnr, nes_ns) +end + return M diff --git a/lua/copilot-lsp/nes/ui.lua b/lua/copilot-lsp/nes/ui.lua index 87b9a21..2feb770 100644 --- a/lua/copilot-lsp/nes/ui.lua +++ b/lua/copilot-lsp/nes/ui.lua @@ -1,17 +1,35 @@ local M = {} local config = require("copilot-lsp.config").config +local buffer_histories = {} + ---@param bufnr integer ---@param ns_id integer local function _dismiss_suggestion(bufnr, ns_id) pcall(vim.api.nvim_buf_clear_namespace, bufnr, ns_id, 0, -1) end +---@private +---@param bufnr integer +---@param state copilotlsp.InlineEdit +local function _store_suggestion_history(bufnr, state) + if not buffer_histories[bufnr] then + buffer_histories[bufnr] = vim.ringbuf(2) + end + buffer_histories[bufnr]:push(vim.deepcopy(state)) +end + +---@private +---@param bufnr integer +local function _clear_suggestion_history(bufnr) + buffer_histories[bufnr] = nil + vim.b[bufnr].copilotlsp_nes_restore_index = nil +end + ---@param bufnr? integer ---@param ns_id integer function M.clear_suggestion(bufnr, ns_id) bufnr = bufnr and bufnr > 0 and bufnr or vim.api.nvim_get_current_buf() - -- Validate buffer exists before accessing buffer-scoped variables if not vim.api.nvim_buf_is_valid(bufnr) then return end @@ -21,18 +39,57 @@ function M.clear_suggestion(bufnr, ns_id) end _dismiss_suggestion(bufnr, ns_id) ---@type copilotlsp.InlineEdit - local state = vim.b[bufnr].nes_state - if not state then - return - end - - -- Clear buffer variables vim.b[bufnr].nes_state = nil vim.b[bufnr].copilotlsp_nes_cursor_moves = nil vim.b[bufnr].copilotlsp_nes_last_line = nil vim.b[bufnr].copilotlsp_nes_last_col = nil end +--- Check if there's history for a buffer +---@param bufnr integer +---@return boolean +function M.has_history(bufnr) + local history = buffer_histories[bufnr] + if not history then + return false + end + -- Check if ringbuf has any items + local item = history:peek() + return item ~= nil +end + +---@param bufnr? integer +---@param ns_id integer +---@return boolean -- true if suggestion was restored, false otherwise +function M.restore_suggestion(bufnr, ns_id) + bufnr = bufnr and bufnr > 0 and bufnr or vim.api.nvim_get_current_buf() + if not vim.api.nvim_buf_is_valid(bufnr) then + return false + end + local history = buffer_histories[bufnr] + if not history then + return false + end + local suggestion = history:pop() + if not suggestion then + return false + end + -- Validate suggestion is still applicable + local start_line = suggestion.range.start.line + if start_line >= vim.api.nvim_buf_line_count(bufnr) then + _clear_suggestion_history(bufnr) + return false + end + _dismiss_suggestion(bufnr, ns_id) + local preview = M._calculate_preview(bufnr, suggestion) + M._display_preview(bufnr, ns_id, preview) + vim.b[bufnr].nes_state = suggestion + vim.b[bufnr].copilotlsp_nes_namespace_id = ns_id + vim.b[bufnr].copilotlsp_nes_cursor_moves = 1 + history:push(suggestion) + return true +end + ---@private ---@param bufnr integer ---@param edit lsp.TextEdit @@ -166,10 +223,11 @@ end ---@param ns_id integer ---@param edits copilotlsp.InlineEdit[] function M._display_next_suggestion(bufnr, ns_id, edits) - M.clear_suggestion(bufnr, ns_id) if not edits or #edits == 0 then return end + -- Clear current suggestion first + M.clear_suggestion(bufnr, ns_id) local suggestion = edits[1] local preview = M._calculate_preview(bufnr, suggestion) @@ -179,6 +237,10 @@ function M._display_next_suggestion(bufnr, ns_id, edits) vim.b[bufnr].copilotlsp_nes_namespace_id = ns_id vim.b[bufnr].copilotlsp_nes_cursor_moves = 1 + -- Store this suggestion in history immediately after displaying it + _store_suggestion_history(bufnr, suggestion) + vim.b[bufnr].copilotlsp_nes_restore_index = 0 + vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { buffer = bufnr, callback = function() @@ -276,4 +338,11 @@ function M._display_next_suggestion(bufnr, ns_id, edits) }) end +-- Clean up history when buffer is deleted +vim.api.nvim_create_autocmd("BufDelete", { + callback = function(ev) + buffer_histories[ev.buf] = nil + end, +}) + return M diff --git a/tests/nes/test_ui_preview.lua b/tests/nes/test_ui_preview.lua index 7428b16..ea1aafc 100644 --- a/tests/nes/test_ui_preview.lua +++ b/tests/nes/test_ui_preview.lua @@ -315,43 +315,205 @@ T["ui_preview"]["cursor_aware_suggestion_clearing"] = function() ref(child.get_screenshot()) end -T["ui_preview"]["suggestion_preserves_on_movement_towards"] = function() - set_content("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8") - ref(child.get_screenshot()) +T["ui_preview"]["suggestion_history_basic_cycle"] = function() + set_content("line1\nline2\nline3") + -- Create first suggestion and display it + local edit1 = { + range = { start = { line = 1, character = 0 }, ["end"] = { line = 1, character = 0 } }, + newText = "-- first suggestion", + } + child.g.test_edit = edit1 + child.lua_func(function() + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + require("copilot-lsp.nes.ui")._display_next_suggestion(bufnr, ns_id, { vim.g.test_edit }) + vim.uv.sleep(300) + end) - -- Position cursor at line 8 - child.cmd("normal! gg7j") + -- Create and display second suggestion + local edit2 = { + range = { start = { line = 2, character = 0 }, ["end"] = { line = 2, character = 0 } }, + newText = "-- second suggestion", + } + child.g.test_edit = edit2 + child.lua_func(function() + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + require("copilot-lsp.nes.ui")._display_next_suggestion(bufnr, ns_id, { vim.g.test_edit }) + vim.uv.sleep(300) + end) + + child.lua_func(function() + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + require("copilot-lsp.nes.ui").clear_suggestion(bufnr, ns_id) + vim.uv.sleep(300) + end) + + local has_history = child.lua_func(function() + local bufnr = vim.api.nvim_get_current_buf() + return require("copilot-lsp.nes.ui").has_history(bufnr) + end) + eq(has_history, true) + + -- Test cycling through suggestions + local restored1 = child.lua_func(function() + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + local result = require("copilot-lsp.nes.ui").restore_suggestion(bufnr, ns_id) + vim.uv.sleep(300) + return result + end) + eq(restored1, true) + + local restored2 = child.lua_func(function() + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + local result = require("copilot-lsp.nes.ui").restore_suggestion(bufnr, ns_id) + vim.uv.sleep(300) + return result + end) + eq(restored2, true) + + local restored3 = child.lua_func(function() + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + local result = require("copilot-lsp.nes.ui").restore_suggestion(bufnr, ns_id) + vim.uv.sleep(300) + return result + end) + eq(restored3, true) +end + +T["ui_preview"]["suggestion_history_max_two_items"] = function() + set_content("line1\nline2\nline3\nline4") + + -- Create and display three suggestions + local suggestions = { + { newText = "-- first", line = 0 }, + { newText = "-- second", line = 1 }, + { newText = "-- third", line = 2 }, + } + + for _, suggestion in ipairs(suggestions) do + local edit = { + range = { + start = { line = suggestion.line, character = 0 }, + ["end"] = { line = suggestion.line, character = 0 }, + }, + newText = suggestion.newText, + } + + child.g.test_edit = edit + child.lua_func(function() + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + require("copilot-lsp.nes.ui")._display_next_suggestion(bufnr, ns_id, { vim.g.test_edit }) + vim.uv.sleep(300) + end) + end + + child.lua_func(function() + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + require("copilot-lsp.nes.ui").clear_suggestion(bufnr, ns_id) + vim.uv.sleep(300) + end) + + local has_history = child.lua_func(function() + local bufnr = vim.api.nvim_get_current_buf() + return require("copilot-lsp.nes.ui").has_history(bufnr) + end) + eq(has_history, true) + + -- Verify we can cycle through suggestions (should only have 2 most recent) + local restore_results = {} + for i = 1, 4 do -- Try 4 restores to test cycling + local restored = child.lua_func(function() + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + local result = require("copilot-lsp.nes.ui").restore_suggestion(bufnr, ns_id) + vim.uv.sleep(300) + return result + end) + table.insert(restore_results, restored) + end + + -- All restores should succeed (cycling between 2 items) + for _, result in ipairs(restore_results) do + eq(result, true) + end +end + +T["ui_preview"]["suggestion_history_invalid_after_text_changes"] = function() + set_content("line1\nline2\nline3\nline4\nline5") - -- Create a suggestion at line 3 local edit = { - range = { - start = { line = 2, character = 0 }, - ["end"] = { line = 2, character = 0 }, - }, - newText = "suggested text ", + range = { start = { line = 4, character = 0 }, ["end"] = { line = 4, character = 0 } }, + newText = "-- comment on line 5", } - -- Display suggestion child.g.test_edit = edit child.lua_func(function() local ns_id = vim.api.nvim_create_namespace("nes_test") - local edits = { vim.g.test_edit } - require("copilot-lsp.nes.ui")._display_next_suggestion(0, ns_id, edits) + local bufnr = vim.api.nvim_get_current_buf() + require("copilot-lsp.nes.ui")._display_next_suggestion(bufnr, ns_id, { vim.g.test_edit }) + vim.uv.sleep(300) end) - ref(child.get_screenshot()) - -- Test: Moving cursor towards the suggestion (even outside buffer zone) shouldn't clear it - child.cmd("normal! 4k") -- Move to line 4, moving towards the suggestion + -- Clear suggestion to store in history child.lua_func(function() - vim.uv.sleep(500) + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + require("copilot-lsp.nes.ui").clear_suggestion(bufnr, ns_id) + vim.uv.sleep(300) end) - -- Verify suggestion still exists - local suggestion_exists = child.lua_func(function() - return vim.b[0].nes_state ~= nil + -- Verify history exists before deletion + local has_history_before = child.lua_func(function() + local bufnr = vim.api.nvim_get_current_buf() + return require("copilot-lsp.nes.ui").has_history(bufnr) end) - eq(suggestion_exists, true) - ref(child.get_screenshot()) + eq(has_history_before, true) + + -- Delete lines to make history invalid + child.api.nvim_buf_set_lines(0, 3, -1, false, {}) + child.lua_func(function() + vim.uv.sleep(300) + end) + + -- Try to restore (should fail and clear history) + local restored = child.lua_func(function() + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + local result = require("copilot-lsp.nes.ui").restore_suggestion(bufnr, ns_id) + vim.uv.sleep(300) + return result + end) + eq(restored, false) + + local has_history_after = child.lua_func(function() + local bufnr = vim.api.nvim_get_current_buf() + return require("copilot-lsp.nes.ui").has_history(bufnr) + end) + eq(has_history_after, false) +end + +T["ui_preview"]["suggestion_history_no_restore_when_empty"] = function() + set_content("line1\nline2\nline3") + -- Try to restore when no history exists + local restored = child.lua_func(function() + local ns_id = vim.api.nvim_create_namespace("nes_test") + local bufnr = vim.api.nvim_get_current_buf() + return require("copilot-lsp.nes.ui").restore_suggestion(bufnr, ns_id) + end) + eq(restored, false) + + local has_history = child.lua_func(function() + local bufnr = vim.api.nvim_get_current_buf() + return require("copilot-lsp.nes.ui").has_history(bufnr) + end) + eq(has_history, false) end return T