From ba1cea61a588d610b1daaa8ea0f8548eefa89774 Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Mon, 28 Apr 2025 21:03:57 +0500 Subject: [PATCH 01/15] WIP: first try of new UI --- lua/spectre/init.lua | 223 ++-------------- lua/spectre/ui/nui_components/init.lua | 356 +++++++++++++++++++++++++ 2 files changed, 384 insertions(+), 195 deletions(-) create mode 100644 lua/spectre/ui/nui_components/init.lua diff --git a/lua/spectre/init.lua b/lua/spectre/init.lua index 4977482..31d7796 100644 --- a/lua/spectre/init.lua +++ b/lua/spectre/init.lua @@ -24,7 +24,7 @@ local config = require('spectre.config') local state = require('spectre.state') local state_utils = require('spectre.state_utils') local utils = require('spectre.utils') -local ui = require('spectre.ui') +local ui = require('spectre.ui.nui_components') local log = require('spectre._log') local async = require('plenary.async') @@ -32,8 +32,9 @@ local scheduler = async.util.scheduler local M = {} -M.setup = function(cfg) - state.user_config = vim.tbl_deep_extend('force', config, cfg or {}) +M.setup = function(opts) + opts = opts or {} + state.user_config = vim.tbl_deep_extend("force", state.user_config, opts) for _, opt in pairs(state.user_config.default.find.options) do state.options[opt] = true end @@ -101,102 +102,19 @@ M.toggle_file_search = function(opts) end M.close = function() - if state.bufnr ~= nil then - local wins = vim.fn.win_findbuf(state.bufnr) - if not wins then - return - end - for _, win_id in pairs(wins) do - vim.api.nvim_win_close(win_id, true) - end - state.is_open = false - end + ui.close() end -M.open = function(opts) - log.debug('Start') - if state.user_config == nil then - M.setup() - end - - opts = vim.tbl_extend('force', { - cwd = nil, - is_insert_mode = state.user_config.is_insert_mode, - search_text = '', - replace_text = '', - path = '', - is_close = false, -- close an exists instance of spectre then open new - is_file = false, - begin_line_num = 3, - }, opts or {}) or {} - - state.is_open = true - state.status_line = '' - opts.search_text = utils.trim(opts.search_text) +function M.open(opts) + opts = opts or {} + state.cwd = opts.cwd or vim.fn.getcwd() + state.query = opts or {} + state.options = opts.options or {} + state.search_paths = opts.search_paths or {} state.target_winid = api.nvim_get_current_win() state.target_bufnr = api.nvim_get_current_buf() - if opts.is_close then - M.close() - end - - local is_new = true - --check reopen panel by reuse bufnr - if state.bufnr ~= nil and not opts.is_close then - local wins = vim.fn.win_findbuf(state.bufnr) - if #wins >= 1 then - for _, win_id in pairs(wins) do - if vim.fn.win_gotoid(win_id) == 1 then - is_new = false - end - end - end - end - if state.bufnr == nil or is_new then - if type(state.user_config.open_cmd) == 'function' then - state.user_config.open_cmd() - else - vim.cmd(state.user_config.open_cmd) - end - else - if state.query.path ~= nil and #state.query.path > 1 and opts.path == '' then - opts.path = state.query.path - end - end - vim.wo.foldenable = false - vim.bo.buftype = 'nofile' - vim.bo.buflisted = false - state.bufnr = api.nvim_get_current_buf() - vim.cmd(string.format('file %s/spectre', state.bufnr)) - vim.bo.filetype = config.filetype - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_status, 0, -1) - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_result, 0, -1) - api.nvim_buf_set_lines(state.bufnr, 0, -1, false, {}) - - vim.api.nvim_buf_attach(state.bufnr, false, { - on_detach = M.stop, - }) - ui.render_text_query(opts) - - state.cwd = opts.cwd - state.search_paths = opts.search_paths - M.change_view('reset') - ui.render_search_ui() - - if opts.is_insert_mode == true then - vim.api.nvim_feedkeys('A', 'n', true) - end - - M.mapping_buffer(state.bufnr) - - if #opts.search_text > 0 then - M.search({ - cwd = opts.cwd, - search_query = opts.search_text, - replace_query = opts.replace_text, - path = opts.path, - }) - end + ui.open() end M.toggle = function(opts) @@ -375,8 +293,7 @@ M.on_write = function() end M.toggle_live_update = function() - state.user_config.live_update = not state.user_config.live_update - ui.render_header(state.user_config) + ui.toggle_live_update() end M.on_close = function() @@ -444,25 +361,8 @@ M.do_replace_text = function(opts, async_id) end M.change_view = function(reset) - if reset then - state.view.mode = '' - end - if state.view.mode == 'replace' then - state.view.mode = 'search' - state.view.show_search = true - state.view.show_replace = false - elseif state.view.mode == 'both' then - state.view.mode = 'replace' - state.view.show_search = false - state.view.show_replace = true - else - state.view.mode = 'both' - state.view.show_search = true - state.view.show_replace = true - end - if not reset then - M.async_replace() - end + if not ui then return end + ui.change_view(reset) end M.toggle_checked = function() @@ -474,64 +374,8 @@ M.toggle_checked = function() end M.toggle_line = function(line_visual) - if can_edit_line() then - -- delete line content - vim.cmd([[:normal! ^d$]]) - return false - end - local lnum = line_visual or unpack(vim.api.nvim_win_get_cursor(0)) - local item = state.total_item[lnum] - if item ~= nil and item.display_lnum == lnum - 1 then - item.disable = not item.disable - ui.render_line(state.bufnr, config.namespace, { - search_query = state.query.search_query, - replace_query = state.query.replace_query, - search_text = item.search_text, - lnum = item.display_lnum, - item_line = item.lnum, - is_replace = true, - }, { - is_disable = item.disable, - padding_text = state.user_config.result_padding, - padding = #state.user_config.result_padding, - show_search = state.view.show_search, - show_replace = state.view.show_replace, - }, state.regex) - - return - elseif not line_visual then - -- delete all item in 1 file - local line = vim.fn.getline(lnum) - local check = string.find(line, '([^%s]*%:%d*:%d*:)$') - if check then - check = state.total_item[lnum + 1] - if check == nil then - return - end - local disable = not check.disable - item = check - local index = lnum + 1 - while item ~= nil and check.filename == item.filename do - item.disable = disable - ui.render_line(state.bufnr, config.namespace, { - search_query = state.query.search_query, - replace_query = state.query.replace_query, - search_text = item.search_text, - lnum = item.display_lnum, - item_line = item.lnum, - is_replace = true, - }, { - is_disable = item.disable, - padding_text = state.user_config.result_padding, - padding = #state.user_config.result_padding, - show_search = state.view.show_search, - show_replace = state.view.show_replace, - }, state.regex) - index = index + 1 - item = state.total_item[index] - end - end - end + if not ui then return end + ui.toggle_line() end M.search_handler = function() @@ -697,14 +541,8 @@ M.change_options = function(key) end M.show_options = function() - local option_cmd = ui.show_options() - ---@diagnostic disable-next-line: param-type-mismatch - vim.defer_fn(function() - local char = vim.fn.getchar() - 48 - if option_cmd[char] then - M.change_options(option_cmd[char]) - end - end, 200) + if not ui then return end + ui.show_options() end M.get_fold = function(lnum) @@ -730,23 +568,18 @@ M.get_fold = function(lnum) end M.tab = function() - local line = vim.api.nvim_win_get_cursor(0)[1] - if line == 3 then - vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 5, 1 }) - end - if line == 5 then - vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 7, 1 }) - end + if not ui then return end + ui.tab() end M.tab_shift = function() - local line = vim.api.nvim_win_get_cursor(0)[1] - if line == 5 then - vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 3, 1 }) - end - if line == 7 then - vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 5, 1 }) - end + if not ui then return end + ui.tab_shift() +end + +M.toggle_preview = function() + if not ui then return end + ui.toggle_preview() end return M diff --git a/lua/spectre/ui/nui_components/init.lua b/lua/spectre/ui/nui_components/init.lua new file mode 100644 index 0000000..d90d9a5 --- /dev/null +++ b/lua/spectre/ui/nui_components/init.lua @@ -0,0 +1,356 @@ +local n = require("nui-components") +if not n then + error("Failed to load nui-components") +end + +local state = require("spectre.state") +local config = require("spectre.config") +local utils = require("spectre.utils") +local api = vim.api +local state_utils = require("spectre.state_utils") + +local M = {} + +local renderer = nil +local search_input = nil +local replace_input = nil +local path_input = nil +local results_buffer = nil +local preview_win = nil +local preview_buf = nil + +local function create_search_ui() + -- Create a new buffer for the results + local bufnr = api.nvim_create_buf(false, true) + api.nvim_buf_set_name(bufnr, "spectre") + api.nvim_buf_set_option(bufnr, "filetype", "spectre_panel") + api.nvim_buf_set_option(bufnr, "buftype", "nofile") + api.nvim_buf_set_option(bufnr, "bufhidden", "wipe") + api.nvim_buf_set_option(bufnr, "buflisted", false) + + local signal = n.create_signal({ + search_text = "", + replace_text = "", + path = "", + is_file = false, + }) + + local body = function() + return n.rows( + n.columns( + { flex = 0 }, + n.text_input({ + id = "search-input", + border_label = "Search", + autofocus = true, + flex = 1, + max_lines = 1, + on_change = function(value) + signal.search_text = value + vim.schedule(function() + M.on_search_change() + end) + end, + }) + ), + n.columns( + { flex = 0 }, + n.text_input({ + id = "replace-input", + border_label = "Replace", + flex = 1, + max_lines = 1, + on_change = function(value) + signal.replace_text = value + vim.schedule(function() + M.on_search_change() + end) + end, + }) + ), + n.columns( + { flex = 0 }, + n.text_input({ + id = "path-input", + border_label = "Path", + flex = 1, + max_lines = 1, + on_change = function(value) + signal.path = value + vim.schedule(function() + M.on_search_change() + end) + end, + }) + ), + n.columns( + { flex = 0 }, + n.button({ + label = "Options", + on_press = function() + print("Options") + vim.schedule(function() + M.show_options() + end) + end, + }), + n.gap(3), + n.button({ + label = "Replace All", + on_press = function() + vim.schedule(function() + M.run_replace() + end) + end, + }), + n.gap(3), + n.button({ + label = "Live Update", + on_press = function() + vim.schedule(function() + M.toggle_live_update() + end) + end, + }), + n.gap(3), + n.button({ + label = "Preview", + on_press = function() + vim.schedule(function() + M.toggle_preview() + end) + end, + }) + ), + n.buffer({ + id = "results-buffer", + flex = 1, + border_label = "Results", + autoscroll = true, + buf = bufnr, + }) + ) + end + + local new_renderer = n.create_renderer({ + width = 80, + height = 20, + buf = bufnr, + parent = vim.api.nvim_get_current_win(), + border = { + style = "rounded", + text = { + top = "[Nvim Spectre]", + top_align = "center", + }, + }, + }) + + if not new_renderer then + error("Failed to create renderer") + end + + new_renderer:render(body) + return new_renderer +end + +function M.open() + if renderer then + print ("close") + M.close() + end + + local new_renderer = create_search_ui() + -- if not new_renderer or type(new_renderer.mount) ~= "function" then + -- error("Failed to create search UI: renderer is invalid") + -- end + + renderer = new_renderer + -- renderer:render() +end + +function M.on_search_change() + if not renderer then return end + print("on_search_change") + local query = { + search_query = renderer:get_component_by_id("search-input"):get_current_value(), + replace_query = renderer:get_component_by_id("replace-input"):get_current_value(), + path = renderer:get_component_by_id("path-input"):get_current_value(), + } + state.query = query -- Store the query in state + M.search(query) +end + +function M.search(query) + if not renderer then return end + -- Clear results buffer + local results_component = renderer:get_component_by_id("results-buffer") + if not results_component then return end + local bufnr = results_component.bufnr + + vim.schedule(function() + api.nvim_buf_set_lines(bufnr, 0, -1, false, {}) + + -- Start search + local finder_creator = state_utils.get_finder_creator() + state.finder_instance = finder_creator:new(state_utils.get_search_engine_config(), { + on_result = function(result) + local line = string.format("%s:%d:%d: %s", result.filename, result.lnum, result.col, result.text) + api.nvim_buf_set_lines(bufnr, -1, -1, false, { line }) + end, + on_finish = function() + state.finder_instance = nil + if preview_win and api.nvim_win_is_valid(preview_win) then + M.toggle_preview() -- Refresh preview if it's open + end + end, + }) + + state.finder_instance:search({ + cwd = state.cwd, + search_text = query.search_query, + path = query.path, + }) + end) +end + +function M.run_replace() + if not renderer then return end + local entries = M.get_all_entries() + if #entries == 0 then + vim.notify("No entries to replace") + return + end + + vim.schedule(function() + local replacer_creator = state_utils.get_replace_creator() + local replacer = replacer_creator:new(state_utils.get_replace_engine_config(), { + on_done = function(result) + if result.ref then + M.set_entry_finish(result.ref.display_lnum) + end + end, + on_error = function(result) + if result.ref then + vim.notify("Error replacing: " .. result.value, vim.log.levels.ERROR) + end + end, + }) + + for _, entry in ipairs(entries) do + if not entry.is_replace_finish then + replacer:replace({ + lnum = entry.lnum, + col = entry.col, + cwd = state.cwd, + display_lnum = entry.display_lnum, + filename = entry.filename, + search_text = state.query.search_query, + replace_text = state.query.replace_query, + }) + end + end + end) +end + +function M.show_options() + if not renderer then return end + local cfg = state_utils.get_search_engine_config() + local options = {} + local i = 1 + + for key, option in pairs(cfg.options) do + table.insert(options, { + text = string.format("%d: %s", i, option.desc or ""), + value = key, + }) + i = i + 1 + end + + vim.schedule(function() + local menu = n.menu({ + position = "50%", + size = { + width = 30, + height = #options + 2, + }, + border = { + style = "rounded", + text = { + top = "[Options]", + top_align = "center", + }, + }, + }, { + lines = options, + on_submit = function(item) + state.options[item.value] = not state.options[item.value] + M.on_search_change() + end, + }) + + menu:mount() + end) +end + +function M.toggle_live_update() + state.user_config.live_update = not state.user_config.live_update + M.on_search_change() +end + +function M.toggle_preview() + if not renderer then return end + local results_component = renderer:get_component_by_id("results-buffer") + if not results_component then return end + + if preview_win and api.nvim_win_is_valid(preview_win) then + api.nvim_win_close(preview_win, true) + preview_win = nil + preview_buf = nil + return + end + + local bufnr = results_component.bufnr + local cursor_pos = api.nvim_win_get_cursor(0) + local line = api.nvim_buf_get_lines(bufnr, cursor_pos[1] - 1, cursor_pos[1], false)[1] + + if not line then return end + + local filename, lnum, col = line:match("([^:]+):(%d+):(%d+):") + if not filename or not lnum or not col then return end + + local full_path = vim.fn.fnamemodify(filename, ":p") + if not vim.fn.filereadable(full_path) then return end + + preview_buf = api.nvim_create_buf(false, true) + api.nvim_buf_set_lines(preview_buf, 0, -1, false, vim.fn.readfile(full_path)) + api.nvim_buf_set_option(preview_buf, "filetype", vim.fn.fnamemodify(filename, ":e")) + + preview_win = api.nvim_open_win(preview_buf, false, { + relative = "win", + row = 0, + col = api.nvim_win_get_width(0), + width = 40, + height = 20, + border = "rounded", + }) + + api.nvim_win_set_cursor(preview_win, { tonumber(lnum), tonumber(col) - 1 }) + api.nvim_win_set_option(preview_win, "wrap", true) + api.nvim_win_set_option(preview_win, "number", true) + api.nvim_win_set_option(preview_win, "relativenumber", true) +end + +function M.close() + if renderer then + -- renderer:unmount() + renderer = nil + end + if preview_win and api.nvim_win_is_valid(preview_win) then + api.nvim_win_close(preview_win, true) + preview_win = nil + preview_buf = nil + end +end + +return M \ No newline at end of file From f859de53a9d645f0fa67a21d6778f655653402ce Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Mon, 28 Apr 2025 21:08:25 +0500 Subject: [PATCH 02/15] Update release.yml --- .github/workflows/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6292737..432984e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,10 +3,17 @@ on: push: tags: - 'v*' + branches: + - master pull_request: jobs: + test: + name: Run tests + uses: ./.github/workflows/ci.yml luarocks-upload: + needs: test runs-on: ubuntu-22.04 + if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v3 - name: LuaRocks Upload From 1c5f5c617b395c44fd3c845e5d4b1c3f6646b89d Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Tue, 29 Apr 2025 06:09:43 +0500 Subject: [PATCH 03/15] Finally make search work --- lua/spectre/actions.lua | 187 ++++++++++++------------- lua/spectre/state.lua | 107 ++++++++++---- lua/spectre/ui/nui_components/init.lua | 161 ++++++++++++--------- 3 files changed, 258 insertions(+), 197 deletions(-) diff --git a/lua/spectre/actions.lua b/lua/spectre/actions.lua index ee87ca9..4e90cd1 100644 --- a/lua/spectre/actions.lua +++ b/lua/spectre/actions.lua @@ -39,15 +39,14 @@ local get_file_path = function(filename) end M.select_entry = function() - local t = M.get_current_entry() - if t == nil then - return nil - end - if config.is_open_target_win and state.target_winid ~= nil then - open_file(t.filename, t.lnum, t.col, state.target_winid) - else - open_file(t.filename, t.lnum, t.col) - end + local entry = M.get_current_entry() + if not entry then return end + + local full_path = vim.fn.fnamemodify(entry.filename, ":p") + if not vim.fn.filereadable(full_path) then return end + + vim.cmd("edit " .. full_path) + api.nvim_win_set_cursor(0, { entry.lnum, entry.col - 1 }) end M.get_state = function() @@ -66,45 +65,63 @@ M.set_entry_finish = function(display_lnum) end end -M.get_current_entry = function() - if not state.total_item then - return - end - local lnum = unpack(vim.api.nvim_win_get_cursor(0)) - local item = state.total_item[lnum] - if item ~= nil and item.display_lnum == lnum - 1 then - local t = vim.deepcopy(item) - t.filename = get_file_path(item.filename) - return t - end +function M.get_current_entry() + local bufnr = api.nvim_get_current_buf() + local cursor_pos = api.nvim_win_get_cursor(0) + local line = api.nvim_buf_get_lines(bufnr, cursor_pos[1] - 1, cursor_pos[1], false)[1] + + if not line then return nil end + + local filename, lnum, col = line:match("([^:]+):(%d+):(%d+):") + if not filename or not lnum or not col then return nil end + + return { + filename = filename, + lnum = tonumber(lnum), + col = tonumber(col), + text = line:match(":[^:]+$"):sub(2), + } end -M.get_all_entries = function() +function M.get_all_entries() local entries = {} - for _, item in pairs(state.total_item) do - if not item.disable then - local t = vim.deepcopy(item) - t.filename = get_file_path(item.filename) - table.insert(entries, t) + local bufnr = api.nvim_get_current_buf() + local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) + + for _, line in ipairs(lines) do + local filename, lnum, col = line:match("([^:]+):(%d+):(%d+):") + if filename and lnum and col then + table.insert(entries, { + filename = filename, + lnum = tonumber(lnum), + col = tonumber(col), + text = line:match(":[^:]+$"):sub(2), + }) end end + return entries end M.send_to_qf = function() local entries = M.get_all_entries() - vim.fn.setqflist(entries, 'r') - vim.fn.setqflist({}, 'r', { - title = string.format('Result Search: [%s]', state.query.search_query), - }) - local trouble_avail, _ = pcall(require, 'trouble') - local status = trouble_avail and state.user_config.use_trouble_qf - if status then - vim.cmd([[Trouble quickfix win.relative=win focus=true]]) - else - vim.cmd([[copen]]) + if #entries == 0 then + vim.notify("No entries to send to quickfix") + return end - return entries + + local qf_list = {} + for _, entry in ipairs(entries) do + table.insert(qf_list, { + filename = entry.filename, + lnum = entry.lnum, + col = entry.col, + text = entry.text, + }) + end + + vim.fn.setqflist(qf_list) + vim.cmd("copen") end -- input that comand to run on vim @@ -135,83 +152,53 @@ M.replace_cmd = function() end end -M.run_current_replace = function() +function M.run_current_replace() local entry = M.get_current_entry() if entry then M.run_replace({ entry }) else - vim.notify('Not found any entry to replace.') + vim.notify("Not found any entry to replace.") end end local is_running = false -M.run_replace = function(entries) - if is_running == true then - print('it is already running') +function M.run_replace(entries) + entries = entries or M.get_all_entries() + if #entries == 0 then + vim.notify("No entries to replace") return end - is_running = true - entries = entries or M.get_all_entries() - local replacer_creator = state_utils.get_replace_creator() - local done_item = 0 - local error_item = 0 - state.status_line = 'Run Replace.' - local replacer = replacer_creator:new(state_utils.get_replace_engine_config(), { - on_done = function(result) - if result.ref then - done_item = done_item + 1 - state.status_line = 'Replace: ' .. done_item .. ' Error:' .. error_item - M.set_entry_finish(result.ref.display_lnum) - local value = result.ref - value.text = ' DONE' - vim.fn.setqflist(entries, 'r') - api.nvim_buf_set_extmark( - state.bufnr, - config.namespace, - value.display_lnum, - 0, - { virt_text = { { '󰄲 DONE', 'String' } }, virt_text_pos = 'eol' } - ) - end - end, - on_error = function(result) - if type(result.value) == 'string' then - for line in result.value:gmatch('[^\r\n]+') do - print(line) + + vim.schedule(function() + local replacer_creator = state_utils.get_replace_creator() + local replacer = replacer_creator:new(state_utils.get_replace_engine_config(), { + on_done = function(result) + if result.ref then + M.set_entry_finish(result.ref.display_lnum) end + end, + on_error = function(result) + if result.ref then + vim.notify("Error replacing: " .. result.value, vim.log.levels.ERROR) + end + end, + }) + + for _, entry in ipairs(entries) do + if not entry.is_replace_finish then + replacer:replace({ + lnum = entry.lnum, + col = entry.col, + cwd = state.cwd, + display_lnum = entry.display_lnum, + filename = entry.filename, + search_text = state.query.search_query, + replace_text = state.query.replace_query, + }) end - if result.ref then - error_item = error_item + 1 - local value = result.ref - value.text = 'ERROR' - vim.fn.setqflist(entries, 'r') - state.status_line = 'Replace: ' .. done_item .. ' Error:' .. error_item - api.nvim_buf_set_extmark( - state.bufnr, - config.namespace, - value.display_lnum, - 0, - { virt_text = { { '󰄱 ERROR', 'Error' } }, virt_text_pos = 'eol' } - ) - end - end, - }) - for _, value in pairs(entries) do - if not value.is_replace_finish then - replacer:replace({ - lnum = value.lnum, - col = value.col, - cwd = state.cwd, - display_lnum = value.display_lnum, - filename = value.filename, - search_text = state.query.search_query, - replace_text = state.query.replace_query, - }) end - end - is_running = false - vim.cmd.checktime() + end) end M.delete_line_file_current = function() diff --git a/lua/spectre/state.lua b/lua/spectre/state.lua index 1c9f7b6..6050121 100644 --- a/lua/spectre/state.lua +++ b/lua/spectre/state.lua @@ -19,42 +19,87 @@ ---@field async_id number ---@field target_winid number ---@field target_bufnr number -local state = { - -- current config - status_line = '', - query = { - search_query = '', - replace_query = '', - path = '', - is_file = false, -- search in current file +local M = {} + +M.user_config = { + default = { + find = { + cmd = "rg", + }, + replace = { + cmd = "sed", + }, + }, + find_engine = { + rg = { + cmd = "rg", + args = { + "--color=never", + "--no-heading", + "--with-filename", + "--line-number", + "--column", + }, + options = { + ["ignore-case"] = { + value = "-i", + icon = "[I]", + desc = "ignore case", + }, + ["hidden"] = { + value = "--hidden", + desc = "hidden file", + icon = "[H]", + }, + }, + }, }, - query_backup = nil, - -- display text and highlight on result - view = { - mode = 'both', - search = true, - replace = true, + replace_engine = { + oxi = { + cmd = "oxi", + args = {}, + options = { + ["ignore-case"] = { + value = "-i", + icon = "[I]", + desc = "ignore case", + }, + }, + }, }, - -- virtual text namespace - vt = {}, - --for options - options = { - ['ignore-case'] = false, - ['hidden'] = false, + live_update = false, + line_sep = "└──────────────────────────────────────────────────────", + result_padding = "│ ", + line_sep_start = "┌──────────────────────────────────────────────────────", + highlight = { + ui = "SpectreBody", + search = "SpectreSearch", + replace = "SpectreReplace", + border = "SpectreBorder", }, - regex = nil, - user_config = nil, - bufnr = nil, - cwd = nil, - target_winid = nil, - total_item = {}, - is_running = false, - is_open = false, } +M.query = {} +M.options = {} +M.search_paths = {} +M.cwd = nil +M.target_winid = nil +M.target_bufnr = nil +M.finder_instance = nil +M.total_item = {} +M.is_running = false +M.status_line = "" +M.async_id = nil +M.view = { + mode = "both", + show_search = true, + show_replace = true, +} +M.regex = nil + if _G.__is_dev then - _G.__spectre_state = _G.__spectre_state or state - state = _G.__spectre_state + _G.__spectre_state = _G.__spectre_state or M + M = _G.__spectre_state end -return state +return M diff --git a/lua/spectre/ui/nui_components/init.lua b/lua/spectre/ui/nui_components/init.lua index d90d9a5..0293b21 100644 --- a/lua/spectre/ui/nui_components/init.lua +++ b/lua/spectre/ui/nui_components/init.lua @@ -8,6 +8,7 @@ local config = require("spectre.config") local utils = require("spectre.utils") local api = vim.api local state_utils = require("spectre.state_utils") +local has_devicons, devicons = pcall(require, 'nvim-web-devicons') local M = {} @@ -33,6 +34,7 @@ local function create_search_ui() replace_text = "", path = "", is_file = false, + results = {}, }) local body = function() @@ -88,7 +90,6 @@ local function create_search_ui() n.button({ label = "Options", on_press = function() - print("Options") vim.schedule(function() M.show_options() end) @@ -122,12 +123,31 @@ local function create_search_ui() end, }) ), - n.buffer({ - id = "results-buffer", + n.tree({ + id = "results-tree", flex = 1, border_label = "Results", - autoscroll = true, - buf = bufnr, + data = signal.results, + on_select = function(node, component) + if node.is_done ~= nil then + node.is_done = not node.is_done + component:render() + end + end, + prepare_node = function(node, line, component) + if node.is_done then + line:append("✔", "String") + else + line:append("◻", "Comment") + end + + if node.icon then + line:append(" " .. node.icon .. " ", node.icon_highlight) + end + + line:append(" " .. node.text) + return line + end, }) ) end @@ -151,22 +171,17 @@ local function create_search_ui() end new_renderer:render(body) - return new_renderer + return new_renderer, signal end function M.open() if renderer then - print ("close") M.close() end - local new_renderer = create_search_ui() - -- if not new_renderer or type(new_renderer.mount) ~= "function" then - -- error("Failed to create search UI: renderer is invalid") - -- end - + local new_renderer, signal = create_search_ui() renderer = new_renderer - -- renderer:render() + M.signal = signal end function M.on_search_change() @@ -183,35 +198,57 @@ end function M.search(query) if not renderer then return end - -- Clear results buffer - local results_component = renderer:get_component_by_id("results-buffer") + local results_component = renderer:get_component_by_id("results-tree") if not results_component then return end - local bufnr = results_component.bufnr - vim.schedule(function() - api.nvim_buf_set_lines(bufnr, 0, -1, false, {}) - - -- Start search - local finder_creator = state_utils.get_finder_creator() - state.finder_instance = finder_creator:new(state_utils.get_search_engine_config(), { - on_result = function(result) - local line = string.format("%s:%d:%d: %s", result.filename, result.lnum, result.col, result.text) - api.nvim_buf_set_lines(bufnr, -1, -1, false, { line }) - end, - on_finish = function() - state.finder_instance = nil - if preview_win and api.nvim_win_is_valid(preview_win) then - M.toggle_preview() -- Refresh preview if it's open + local results = {} + local last_filename = "" + local current_group = nil + + -- Start search + local finder_creator = state_utils.get_finder_creator() + state.finder_instance = finder_creator:new(state_utils.get_search_engine_config(), { + on_result = function(result) + if last_filename ~= result.filename then + local icon, icon_highlight = "", "" + if has_devicons then + icon, icon_highlight = devicons.get_icon(result.filename, "", { default = true }) end - end, - }) + + current_group = n.node({ + text = result.filename, + icon = icon, + icon_highlight = icon_highlight, + children = {} + }) + table.insert(results, current_group) + last_filename = result.filename + end - state.finder_instance:search({ - cwd = state.cwd, - search_text = query.search_query, - path = query.path, - }) - end) + if current_group then + table.insert(results, n.node({ + text = string.format("%d:%d: %s", result.lnum, result.col, result.text), + is_done = false + })) + end + end, + on_finish = function() + state.finder_instance = nil + if M.signal then + M.signal.results = results + renderer:redraw() + end + if preview_win and api.nvim_win_is_valid(preview_win) then + M.toggle_preview() -- Refresh preview if it's open + end + end, + }) + + state.finder_instance:search({ + cwd = state.cwd, + search_text = query.search_query, + path = query.path, + }) end function M.run_replace() @@ -260,36 +297,29 @@ function M.show_options() local i = 1 for key, option in pairs(cfg.options) do - table.insert(options, { - text = string.format("%d: %s", i, option.desc or ""), - value = key, - }) + table.insert(options, string.format("%d: %s", i, option.desc or "")) i = i + 1 end - vim.schedule(function() - local menu = n.menu({ - position = "50%", - size = { - width = 30, - height = #options + 2, - }, - border = { - style = "rounded", - text = { - top = "[Options]", - top_align = "center", - }, - }, - }, { - lines = options, - on_submit = function(item) - state.options[item.value] = not state.options[item.value] + vim.ui.select(options, { + prompt = "Select option to toggle:", + format_item = function(item) + return item + end, + }, function(choice) + if not choice then return end + local index = tonumber(choice:match("(%d+):")) + if not index then return end + + local i = 1 + for key, _ in pairs(cfg.options) do + if i == index then + state.options[key] = not state.options[key] M.on_search_change() - end, - }) - - menu:mount() + break + end + i = i + 1 + end end) end @@ -300,7 +330,7 @@ end function M.toggle_preview() if not renderer then return end - local results_component = renderer:get_component_by_id("results-buffer") + local results_component = renderer:get_component_by_id("results-tree") if not results_component then return end if preview_win and api.nvim_win_is_valid(preview_win) then @@ -343,7 +373,6 @@ end function M.close() if renderer then - -- renderer:unmount() renderer = nil end if preview_win and api.nvim_win_is_valid(preview_win) then From 0542ac17197ba437b5593243c9be65b05088dcc8 Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Tue, 29 Apr 2025 10:29:31 +0500 Subject: [PATCH 04/15] Add preview --- lua/spectre/ui/nui_components/init.lua | 233 +++++++++++++++++-------- lua/spectre/utils.lua | 2 + 2 files changed, 163 insertions(+), 72 deletions(-) diff --git a/lua/spectre/ui/nui_components/init.lua b/lua/spectre/ui/nui_components/init.lua index 0293b21..7212e15 100644 --- a/lua/spectre/ui/nui_components/init.lua +++ b/lua/spectre/ui/nui_components/init.lua @@ -1,40 +1,46 @@ +local M = {} + local n = require("nui-components") if not n then error("Failed to load nui-components") end - local state = require("spectre.state") -local config = require("spectre.config") -local utils = require("spectre.utils") local api = vim.api local state_utils = require("spectre.state_utils") local has_devicons, devicons = pcall(require, 'nvim-web-devicons') - -local M = {} +local utils = require("spectre.utils") local renderer = nil -local search_input = nil -local replace_input = nil -local path_input = nil -local results_buffer = nil local preview_win = nil local preview_buf = nil +local preview_namespace = api.nvim_create_namespace('SPECTRE_PREVIEW') local function create_search_ui() -- Create a new buffer for the results local bufnr = api.nvim_create_buf(false, true) - api.nvim_buf_set_name(bufnr, "spectre") api.nvim_buf_set_option(bufnr, "filetype", "spectre_panel") api.nvim_buf_set_option(bufnr, "buftype", "nofile") api.nvim_buf_set_option(bufnr, "bufhidden", "wipe") api.nvim_buf_set_option(bufnr, "buflisted", false) + -- Create a separate buffer for preview + local preview_bufnr = api.nvim_create_buf(false, true) + api.nvim_buf_set_option(preview_bufnr, "filetype", "markdown") + api.nvim_buf_set_option(preview_bufnr, "buftype", "nofile") + api.nvim_buf_set_option(preview_bufnr, "bufhidden", "wipe") + api.nvim_buf_set_option(preview_bufnr, "buflisted", false) + api.nvim_buf_set_option(preview_bufnr, "wrap", true) + api.nvim_buf_set_option(preview_bufnr, "number", true) + api.nvim_buf_set_option(preview_bufnr, "relativenumber", true) + local signal = n.create_signal({ search_text = "", replace_text = "", path = "", is_file = false, results = {}, + has_search = false, + preview_visible = false, }) local body = function() @@ -49,6 +55,7 @@ local function create_search_ui() max_lines = 1, on_change = function(value) signal.search_text = value + signal.has_search = #value > 0 vim.schedule(function() M.on_search_change() end) @@ -70,6 +77,123 @@ local function create_search_ui() end, }) ), + + n.tree({ + id = "results-tree", + flex = 1, + border_label = "Results", + data = signal.results, + hidden = signal.has_search:negate(), + on_select = function(node, component) + if node.is_done ~= nil then + node.is_done = not node.is_done + component:render() + end + end, + on_focus = function(component) + signal.preview_visible = true + end, + on_blur = function(node, component) + signal.preview_visible = false + component:render() + end, + on_change = function(focused_node, component) + if focused_node.filename then + local full_path = vim.fn.fnamemodify(focused_node.filename, ":p") + if vim.fn.filereadable(full_path) then + local lines = vim.fn.readfile(full_path) + api.nvim_buf_set_lines(preview_bufnr, 0, -1, false, lines) + + -- Clear previous highlights + api.nvim_buf_clear_namespace(preview_bufnr, preview_namespace, 0, -1) + + -- Add search highlighting if there's a search query + if state.query and state.query.search_query and #state.query.search_query > 0 then + for i, line in ipairs(lines) do + local matches = utils.match_text_line(state.query.search_query, line, 0) + for _, match in ipairs(matches) do + api.nvim_buf_add_highlight( + preview_bufnr, + preview_namespace, + state.user_config.highlight.search, + i - 1, + match[1], + match[2] + ) + end + end + end + + -- Highlight the current line in the preview buffer + if focused_node.lnum then + local line_num = tonumber(focused_node.lnum) + if line_num then + -- Set cursor to the line number in the preview buffer + api.nvim_buf_call(preview_bufnr, function() + vim.cmd("normal! " .. line_num .. "G") + end) + end + end + end + else + api.nvim_buf_set_lines(preview_bufnr, 0, -1, false, {}) + end + end, + prepare_node = function(node, line, component) + if node.is_done ~= nil then + if node.is_done then + local icon = "✔" + local hl = "String" + if has_devicons then + icon = '󰱒' + end + line:append('  ' .. icon .. ' ', hl) + else + local icon = "◻" + local hl = "Comment" + if has_devicons then + icon = '' + end + line:append('  ' .. icon .. ' ', hl) + end + end + + if node.icon then + line:append(" " .. node.icon .. " ", node.icon_highlight) + end + + -- Add search highlighting if there's a search query + if state.query and state.query.search_query and #state.query.search_query > 0 and node.text then + local matches = utils.match_text_line(state.query.search_query, node.text, 0) + local last_pos = 0 + for _, match in ipairs(matches) do + -- Add text before the match + if match[1] > last_pos then + line:append(" " .. node.text:sub(last_pos + 1, match[1])) + end + -- Add highlighted match + line:append(node.text:sub(match[1] + 1, match[2]), state.user_config.highlight.search) + last_pos = match[2] + end + -- Add remaining text after last match + if last_pos < #node.text then + line:append(" " .. node.text:sub(last_pos + 1)) + end + else + line:append(" " .. node.text) + end + return line + end, + }), + n.buffer({ + id = "preview-buffer", + flex = 1, + border_label = "Preview", + hidden = signal.preview_visible:negate(), + buf = preview_bufnr, + autoscroll = true, + }), + n.columns( { flex = 0 }, n.text_input({ @@ -112,49 +236,14 @@ local function create_search_ui() M.toggle_live_update() end) end, - }), - n.gap(3), - n.button({ - label = "Preview", - on_press = function() - vim.schedule(function() - M.toggle_preview() - end) - end, }) - ), - n.tree({ - id = "results-tree", - flex = 1, - border_label = "Results", - data = signal.results, - on_select = function(node, component) - if node.is_done ~= nil then - node.is_done = not node.is_done - component:render() - end - end, - prepare_node = function(node, line, component) - if node.is_done then - line:append("✔", "String") - else - line:append("◻", "Comment") - end - - if node.icon then - line:append(" " .. node.icon .. " ", node.icon_highlight) - end - - line:append(" " .. node.text) - return line - end, - }) + ) ) end local new_renderer = n.create_renderer({ width = 80, - height = 20, + height = 40, buf = bufnr, parent = vim.api.nvim_get_current_win(), border = { @@ -184,9 +273,8 @@ function M.open() M.signal = signal end -function M.on_search_change() +function M.on_search_change() if not renderer then return end - print("on_search_change") local query = { search_query = renderer:get_component_by_id("search-input"):get_current_value(), replace_query = renderer:get_component_by_id("replace-input"):get_current_value(), @@ -214,7 +302,7 @@ function M.search(query) if has_devicons then icon, icon_highlight = devicons.get_icon(result.filename, "", { default = true }) end - + current_group = n.node({ text = result.filename, icon = icon, @@ -227,6 +315,9 @@ function M.search(query) if current_group then table.insert(results, n.node({ + filename = result.filename, + col = result.col, + lnum = result.lnum, text = string.format("%d:%d: %s", result.lnum, result.col, result.text), is_done = false })) @@ -297,30 +388,29 @@ function M.show_options() local i = 1 for key, option in pairs(cfg.options) do - table.insert(options, string.format("%d: %s", i, option.desc or "")) + table.insert(options, n.option(string.format("%d: %s", i, option.desc or ""), { id = key })) i = i + 1 end - vim.ui.select(options, { - prompt = "Select option to toggle:", - format_item = function(item) - return item - end, - }, function(choice) - if not choice then return end - local index = tonumber(choice:match("(%d+):")) - if not index then return end - - local i = 1 - for key, _ in pairs(cfg.options) do - if i == index then - state.options[key] = not state.options[key] - M.on_search_change() - break + local signal = n.create_signal({ + selected = {}, + }) + + local select_component = n.select({ + border_label = "Options", + data = options, + selected = signal.selected, + multiselect = true, + on_select = function(nodes) + signal.selected = nodes + for _, node in ipairs(nodes) do + state.options[node.id] = not state.options[node.id] end - i = i + 1 - end - end) + M.on_search_change() + end, + }) + + select_component:mount() end function M.toggle_live_update() @@ -354,7 +444,6 @@ function M.toggle_preview() preview_buf = api.nvim_create_buf(false, true) api.nvim_buf_set_lines(preview_buf, 0, -1, false, vim.fn.readfile(full_path)) - api.nvim_buf_set_option(preview_buf, "filetype", vim.fn.fnamemodify(filename, ":e")) preview_win = api.nvim_open_win(preview_buf, false, { relative = "win", @@ -382,4 +471,4 @@ function M.close() end end -return M \ No newline at end of file +return M \ No newline at end of file diff --git a/lua/spectre/utils.lua b/lua/spectre/utils.lua index 8c7dde1..79e3b23 100644 --- a/lua/spectre/utils.lua +++ b/lua/spectre/utils.lua @@ -155,6 +155,8 @@ local function match_text_line(match, str, padding) return col_tbl end +M.match_text_line = match_text_line + --- get highlight text from search_text and replace_text --- @params opts {search_query, replace_query, search_text, padding} --- @param regex RegexEngine From ce45d47ade6eea8084a546869ddf19b74069a6d9 Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Tue, 29 Apr 2025 16:56:23 +0500 Subject: [PATCH 05/15] Finally fix replace --- lua/spectre/actions.lua | 57 ++- lua/spectre/config.lua | 4 +- lua/spectre/init.lua | 527 ++++--------------------- lua/spectre/state.lua | 2 + lua/spectre/ui/nui_components/init.lua | 373 +++++++---------- 5 files changed, 278 insertions(+), 685 deletions(-) diff --git a/lua/spectre/actions.lua b/lua/spectre/actions.lua index 4e90cd1..8654631 100644 --- a/lua/spectre/actions.lua +++ b/lua/spectre/actions.lua @@ -85,21 +85,18 @@ end function M.get_all_entries() local entries = {} - local bufnr = api.nvim_get_current_buf() - local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) - - for _, line in ipairs(lines) do - local filename, lnum, col = line:match("([^:]+):(%d+):(%d+):") - if filename and lnum and col then + for display_lnum, item in ipairs(state.total_item) do + if item and item.filename then table.insert(entries, { - filename = filename, - lnum = tonumber(lnum), - col = tonumber(col), - text = line:match(":[^:]+$"):sub(2), + filename = item.filename, + lnum = item.lnum, + col = item.col, + text = item.text, + display_lnum = display_lnum - 1, + is_replace_finish = item.is_replace_finish or false }) end end - return entries end @@ -176,11 +173,41 @@ function M.run_replace(entries) on_done = function(result) if result.ref then M.set_entry_finish(result.ref.display_lnum) + -- Update UI by adding a checkmark to the line + local bufnr = api.nvim_get_current_buf() + local line = result.ref.display_lnum + api.nvim_buf_set_extmark( + bufnr, + config.namespace, + line, + 0, + { virt_text = { { '✓', 'String' } }, virt_text_pos = 'eol' } + ) + -- Trigger renderer redraw + if state.renderer then + print("redrawing") + state.renderer:redraw() + end end end, on_error = function(result) if result.ref then vim.notify("Error replacing: " .. result.value, vim.log.levels.ERROR) + -- Add error mark to the line + local bufnr = api.nvim_get_current_buf() + local line = result.ref.display_lnum + api.nvim_buf_set_extmark( + bufnr, + config.namespace, + line, + 0, + { virt_text = { { '✗', 'Error' } }, virt_text_pos = 'eol' } + ) + -- Trigger renderer redraw + if state.renderer then + print("redrawing") + state.renderer:redraw() + end end end, }) @@ -232,6 +259,10 @@ M.run_delete_line = function(entries) { virt_text = { { '󰄲 DONE', 'String' } }, virt_text_pos = 'eol' } ) end + -- Trigger renderer redraw + if state.renderer then + state.renderer:redraw() + end end end, on_error = function(result) @@ -249,6 +280,10 @@ M.run_delete_line = function(entries) { virt_text = { { '󰄱 ERROR', 'Error' } }, virt_text_pos = 'eol' } ) end + -- Trigger renderer redraw + if state.renderer then + state.renderer:redraw() + end end end, }) diff --git a/lua/spectre/config.lua b/lua/spectre/config.lua index a7d9e43..54a85c8 100644 --- a/lua/spectre/config.lua +++ b/lua/spectre/config.lua @@ -73,12 +73,12 @@ local config = { ['run_current_replace'] = { map = 'rc', cmd = "lua require('spectre.actions').run_current_replace()", - desc = 'replace item', + desc = 'replace current item', }, ['run_replace'] = { map = 'R', cmd = "lua require('spectre.actions').run_replace()", - desc = 'replace all', + desc = 'replace all items', }, -- only show replace text in result UI ['change_view_mode'] = { diff --git a/lua/spectre/init.lua b/lua/spectre/init.lua index 31d7796..69fa970 100644 --- a/lua/spectre/init.lua +++ b/lua/spectre/init.lua @@ -43,485 +43,132 @@ M.setup = function(opts) end M.check_replace_cmd_bins = function() - if state.user_config.default.replace.cmd == 'sed' then - if vim.loop.os_uname().sysname == 'Darwin' and vim.fn.executable('sed') == 0 then - config.replace_engine.sed.cmd = 'gsed' - if vim.fn.executable('gsed') == 0 and state.user_config.replace_engine.sed.warn then - print("You need to install gnu sed 'brew install gnu-sed'") - end - end - - if vim.loop.os_uname().sysname == 'Windows_NT' then - if vim.fn.executable('sed') == 0 and state.user_config.replace_engine.sed.warn then - print("You need to install gnu sed with 'scoop install sed' or 'choco install sed'") - end - end - end - - if state.user_config.default.replace.cmd == 'sd' then - if vim.fn.executable('sd') == 0 and state.user_config.replace_engine.sd.warn then - print("You need to install or build 'sd' from: https://github.com/chmln/sd") - end - end -end - -M.open_visual = function(opts) - opts = opts or {} - if opts.select_word then - opts.search_text = vim.fn.expand('') - else - opts.search_text = utils.get_visual_selection() - end - M.open(opts) -end - -M.open_file_search = function(opts) - opts = opts or {} - if opts.select_word then - opts.search_text = vim.fn.expand('') - else - opts.search_text = utils.get_visual_selection() - end - - opts.path = vim.fn.fnameescape(vim.fn.expand('%:p:.')) - - if vim.loop.os_uname().sysname == 'Windows_NT' then - opts.path = vim.fn.substitute(opts.path, '\\', '/', 'g') + local replace_cmd = state.user_config.default.replace.cmd + if replace_cmd == 'oxi' then + local job = require('plenary.job') + job:new({ + command = 'which', + args = { 'oxi' }, + on_exit = function(j, return_val) + if return_val ~= 0 then + vim.notify( + 'oxi not found. Please install it with: cargo install oxi', + vim.log.levels.WARN + ) + end + end, + }):sync() end - - M.open(opts) end -M.toggle_file_search = function(opts) +M.open = function(opts) opts = opts or {} - if state.is_open then - M.close() - else - M.open_file_search(opts) - end -end - -M.close = function() - ui.close() -end - -function M.open(opts) - opts = opts or {} - state.cwd = opts.cwd or vim.fn.getcwd() - state.query = opts or {} - state.options = opts.options or {} - state.search_paths = opts.search_paths or {} + state.is_open = true + state.cwd = opts.cwd or state.cwd or vim.fn.getcwd() state.target_winid = api.nvim_get_current_win() state.target_bufnr = api.nvim_get_current_buf() + state.query = vim.tbl_extend('force', state.query, opts) + state.query_backup = vim.deepcopy(state.query) + state.is_running = false + state.total_item = {} + state.status_line = '' + state.async_id = nil + state.view = { + mode = "both", + show_search = true, + show_replace = true, + } + state.regex = nil ui.open() end -M.toggle = function(opts) - if state.is_open then - M.close() - else - M.open(opts) - end -end - -function M.mapping_buffer(bufnr) - _G.__spectre_fold = M.get_fold - vim.cmd([[augroup spectre_panel - au! - au InsertEnter lua require"spectre".on_insert_enter() - au InsertLeave lua require"spectre".on_search_change() - au BufLeave lua require("spectre").on_leave() - au BufUnload lua require("spectre").on_close() - augroup END ]]) - vim.opt_local.wrap = false - vim.opt_local.foldexpr = 'spectre#foldexpr()' - vim.opt_local.foldmethod = 'expr' - local map_opt = { noremap = true, silent = _G.__is_dev == nil } - api.nvim_buf_set_keymap(bufnr, 'n', 'x', 'xlua require("spectre").on_search_change()', map_opt) - api.nvim_buf_set_keymap(bufnr, 'n', 'p', "plua require('spectre').on_search_change()", map_opt) - api.nvim_buf_set_keymap(bufnr, 'v', 'p', "plua require('spectre').on_search_change()", map_opt) - api.nvim_buf_set_keymap(bufnr, 'v', 'P', "Plua require('spectre').on_search_change()", map_opt) - api.nvim_buf_set_keymap(bufnr, 'n', 'd', '', map_opt) - api.nvim_buf_set_keymap(bufnr, 'i', '', '', map_opt) - api.nvim_buf_set_keymap(bufnr, 'v', 'd', 'lua require("spectre").toggle_checked()', map_opt) - api.nvim_buf_set_keymap(bufnr, 'n', 'o', 'ji', map_opt) -- don't append line on can make the UI wrong - api.nvim_buf_set_keymap(bufnr, 'n', 'O', 'ki', map_opt) - api.nvim_buf_set_keymap(bufnr, 'n', 'u', '', map_opt) -- disable undo, It breaks the UI. - api.nvim_buf_set_keymap(bufnr, 'n', 'yy', "lua require('spectre.actions').copy_current_line()", map_opt) - api.nvim_buf_set_keymap(bufnr, 'n', '?', "lua require('spectre').show_help()", map_opt) - - for _, map in pairs(state.user_config.mapping) do - if map.cmd then - api.nvim_buf_set_keymap( - bufnr, - 'n', - map.map, - map.cmd, - vim.tbl_deep_extend('force', map_opt, { desc = map.desc }) - ) - end - end - - vim.api.nvim_create_autocmd('BufWritePost', { - group = vim.api.nvim_create_augroup('SpectrePanelWrite', { clear = true }), - pattern = '*', - callback = require('spectre').on_write, - desc = 'spectre write autocmd', - }) - vim.api.nvim_create_autocmd('WinClosed', { - group = vim.api.nvim_create_augroup('SpectreStateOpened', { clear = true }), - buffer = 0, - callback = function() - if vim.api.nvim_buf_get_option(vim.api.nvim_get_current_buf(), 'filetype') == 'spectre_panel' then - state.is_open = false - end - end, - desc = 'Ensure spectre state when its window is closed by any mean', - }) - - if state.user_config.is_block_ui_break then - -- Anti UI breakage - -- * If the user enters insert mode on a forbidden line: leave insert mode. - -- * If the user passes over a forbidden line on insert mode: leave insert mode. - -- * Disable backspace jumping lines. - local backspace = vim.api.nvim_get_option('backspace') - local anti_insert_breakage_group = vim.api.nvim_create_augroup('SpectreAntiInsertBreakage', { clear = true }) - vim.api.nvim_create_autocmd({ 'InsertEnter', 'CursorMovedI' }, { - group = anti_insert_breakage_group, - buffer = 0, - callback = function() - local current_filetype = vim.bo.filetype - if current_filetype == 'spectre_panel' then - vim.cmd('set backspace=indent,start') - local line = vim.api.nvim_win_get_cursor(0)[1] - if line == 1 or line == 2 or line == 4 or line == 6 or line >= 8 then - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'n', true) - end - end - end, - desc = 'spectre anti-insert-breakage → protect the user from breaking the UI while on insert mode.', - }) - vim.api.nvim_create_autocmd({ 'WinLeave' }, { - group = anti_insert_breakage_group, - buffer = 0, - callback = function() - local current_filetype = vim.bo.filetype - if current_filetype == 'spectre_panel' then - vim.cmd('set backspace=' .. backspace) - end - end, - desc = "spectre anti-insert-breakage → restore the 'backspace' option.", - }) - api.nvim_buf_set_keymap(bufnr, 'i', '', '', map_opt) -- disable ENTER on insert mode, it breaks the UI. - end -end - -local function hl_match(opts) - if #opts.search_query > 0 then - api.nvim_buf_add_highlight(state.bufnr, config.namespace, state.user_config.highlight.search, 2, 0, -1) - end - if #opts.replace_query > 0 then - api.nvim_buf_add_highlight(state.bufnr, config.namespace, state.user_config.highlight.replace, 4, 0, -1) - end -end - -local function can_edit_line() - local line = vim.fn.getpos('.') - if line[2] > config.lnum_UI then - return false - end - return true -end - -M.on_insert_enter = function() - if can_edit_line() then - return - end - local key = api.nvim_replace_termcodes('', true, false, true) - api.nvim_feedkeys(key, 'm', true) - print("You can't make changes in results.") -end - -M.on_search_change = function() - if not can_edit_line() then - return - end - local lines = api.nvim_buf_get_lines(state.bufnr, 0, config.lnum_UI, false) - - local query = { - replace_query = '', - search_query = '', - path = '', - } - - for index, line in pairs(lines) do - if index <= 3 and #line > 0 then - query.search_query = query.search_query .. line - end - if index >= 5 and index < 7 and #line > 0 then - query.replace_query = query.replace_query .. line - end - if index >= 7 and index <= 9 and #line > 0 then - query.path = query.path .. line - end - end - local line = vim.fn.getpos('.') - -- check path to verify search in current file - if state.target_winid ~= nil then - local ok, bufnr = pcall(api.nvim_win_get_buf, state.target_winid) - if ok then - -- can't use api.nvim_buf_get_name it get a full path - local bufname = vim.fn.bufname(bufnr) - query.is_file = query.path == bufname - else - state.target_winid = nil - end - end - - if line[2] >= 5 and line[2] < 7 then - M.async_replace(query) - else - M.search(query) - end +M.close = function() + state.is_open = false + ui.close() end M.on_write = function() - if state.user_config.live_update == true then - M.search() - end -end - -M.toggle_live_update = function() - ui.toggle_live_update() -end - -M.on_close = function() - M.stop() - vim.api.nvim_create_augroup('SpectrePanelWrite', { clear = true }) - state.query_backup = vim.tbl_extend('force', state.query, {}) -end - -M.on_leave = function() - state.query_backup = vim.tbl_extend('force', state.query, {}) -end - -M.resume_last_search = function() - if not state.query_backup then - print('No previous search!') - return - end - ui.render_text_query({ - replace_text = state.query_backup.replace_query, - search_text = state.query_backup.search_query, - path = state.query_backup.path, - }) - ui.render_search_ui() - M.search(state.query_backup) -end - -M.async_replace = function(query) - -- clear old search result - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_result, 0, -1) - state.async_id = vim.loop.hrtime() - async.void(function() - M.do_replace_text(query, state.async_id) - end)() -end - -M.do_replace_text = function(opts, async_id) - state.query = opts or state.query - hl_match(state.query) - local count = 1 - for _, item in pairs(state.total_item) do - if state.async_id ~= async_id then - return - end - ui.render_line(state.bufnr, config.namespace, { - search_query = state.query.search_query, - replace_query = state.query.replace_query, - search_text = item.search_text, - lnum = item.display_lnum, - item_line = item.lnum, - is_replace = true, - }, { - is_disable = item.disable, - padding_text = state.user_config.result_padding, - padding = #state.user_config.result_padding, - show_search = state.view.show_search, - show_replace = state.view.show_replace, - }, state.regex) - count = count + 1 - -- delay to next scheduler after 100 time - if count > 100 then - scheduler() - count = 0 - end - end -end - -M.change_view = function(reset) - if not ui then return end - ui.change_view(reset) -end - -M.toggle_checked = function() - local startline = unpack(vim.api.nvim_buf_get_mark(0, '<')) - local endline = unpack(vim.api.nvim_buf_get_mark(0, '>')) - for i = startline, endline, 1 do - M.toggle_line(i) + if not state.is_open then return end + if state.user_config.live_update then + M.search(state.query) end end -M.toggle_line = function(line_visual) - if not ui then return end - ui.toggle_line() -end +M.search = function(query) + query = query or state.query + if not query.search_query or #query.search_query == 0 then return end -M.search_handler = function() - local c_line = 0 - local total = 0 - local start_time = 0 - local padding = #state.user_config.result_padding - local cfg = state.user_config or {} - local last_filename = '' - return { - on_start = function() - state.total_item = {} - state.is_running = true - state.status_line = 'Start search' - c_line = config.line_result - total = 0 - start_time = vim.loop.hrtime() - end, - on_result = function(item) - if not state.is_running then - return - end - item.replace_text = '' - if string.match(item.filename, '^%.%/') then - item.filename = item.filename:sub(3, #item.filename) - end - item.search_text = utils.truncate(utils.trim(item.text), 255) - if #state.query.replace_query > 1 then - item.replace_text = - state.regex.replace_all(state.query.search_query, state.query.replace_query, item.search_text) - end - if last_filename ~= item.filename then - ui.render_filename(state.bufnr, config.namespace, c_line, item) - c_line = c_line + 1 - last_filename = item.filename - end + state.is_running = true + state.query = query + state.total_item = {} + state.status_line = 'Searching...' - item.display_lnum = c_line - ui.render_line(state.bufnr, config.namespace, { - search_query = state.query.search_query, - replace_query = state.query.replace_query, - search_text = item.search_text, - lnum = item.display_lnum, - item_line = item.lnum, - is_replace = false, - }, { - is_disable = item.disable, - padding_text = cfg.result_padding, - padding = padding, - show_search = state.view.show_search, - show_replace = state.view.show_replace, - }, state.regex) - c_line = c_line + 1 - total = total + 1 - state.status_line = 'Item ' .. total - state.total_item[c_line] = item + local finder_creator = state_utils.get_finder_creator() + state.finder_instance = finder_creator:new(state_utils.get_search_engine_config(), { + on_result = function(result) + if not state.is_running then return end + table.insert(state.total_item, result) end, on_error = function(error_msg) - api.nvim_buf_set_lines(state.bufnr, c_line, c_line + 1, false, { cfg.result_padding .. error_msg }) - api.nvim_buf_add_highlight(state.bufnr, config.namespace, cfg.highlight.border, c_line, 0, padding) - c_line = c_line + 1 + if not state.is_running then return end + state.status_line = 'Error: ' .. error_msg state.finder_instance = nil end, on_finish = function() - if not state.is_running then - return - end - local end_time = (vim.loop.hrtime() - start_time) / 1E9 - state.status_line = string.format('Total: %s match, time: %ss', total, end_time) - - api.nvim_buf_set_lines(state.bufnr, c_line, c_line, false, { - cfg.line_sep, - }) - api.nvim_buf_add_highlight(state.bufnr, config.namespace, cfg.highlight.border, c_line, 0, -1) - - state.vt.status_id = utils.write_virtual_text( - state.bufnr, - config.namespace_status, - config.line_result - 2, - { { state.status_line, 'Question' } } - ) + if not state.is_running then return end + state.status_line = 'Search completed' state.finder_instance = nil state.is_running = false end, - } + }) + + state.finder_instance:search({ + cwd = state.cwd, + search_text = query.search_query, + path = query.path, + }) end -M.stop = function() - state.is_running = false - log.debug('spectre stop') - if state.finder_instance ~= nil then - state.finder_instance:stop() - state.finder_instance = nil +M.run_replace = function() + if not state.is_running then + require('spectre.actions').run_replace() end end -M.search = function(opts) - M.stop() - opts = opts or state.query - local finder_creator = state_utils.get_finder_creator() - state.finder_instance = finder_creator:new(state_utils.get_search_engine_config(), M.search_handler()) - if not opts.search_query or #opts.search_query < 2 then - return +M.run_current_replace = function() + if not state.is_running then + require('spectre.actions').run_current_replace() end - state.query = opts - -- clear old search result - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_result, 0, -1) - api.nvim_buf_set_lines(state.bufnr, config.line_result - 1, -1, false, {}) - hl_match(opts) - local c_line = config.line_result - api.nvim_buf_set_lines(state.bufnr, c_line - 1, c_line - 1, false, { state.user_config.line_sep_start }) - api.nvim_buf_add_highlight(state.bufnr, config.namespace, state.user_config.highlight.border, c_line - 1, 0, -1) - state.total_item = {} - state.finder_instance:search({ - cwd = state.cwd, - search_text = state.query.search_query, - path = state.query.path, - search_paths = state.search_paths, - }) - M.init_regex() end -M.init_regex = function() - local replace_config = state_utils.get_replace_engine_config() - if replace_config.cmd == 'oxi' then - state.regex = require('spectre.regex.rust') - else - state.regex = require('spectre.regex.vim') +M.delete_line_file_current = function() + if not state.is_running then + require('spectre.actions').delete_line_file_current() end - state.regex.change_options(replace_config.options_value) end -M.show_help = function() - ui.show_help() +M.send_to_qf = function() + if not state.is_running then + require('spectre.actions').send_to_qf() + end end -M.change_engine_replace = function(engine_name) - if state.user_config.replace_engine[engine_name] then - state.user_config.default.replace.cmd = engine_name - M.init_regex() - vim.notify('change replace engine to: ' .. engine_name) - ui.render_header(state.user_config) - M.search() - return - else - vim.notify(string.format('engine %s not found ' .. engine_name)) +M.select_entry = function() + if not state.is_running then + require('spectre.actions').select_entry() + end +end + +M.select_template = function() + if not state.is_running then + require('spectre.actions').select_template() + end +end + +M.copy_current_line = function() + if not state.is_running then + require('spectre.actions').copy_current_line() end end diff --git a/lua/spectre/state.lua b/lua/spectre/state.lua index 6050121..865d114 100644 --- a/lua/spectre/state.lua +++ b/lua/spectre/state.lua @@ -19,6 +19,7 @@ ---@field async_id number ---@field target_winid number ---@field target_bufnr number +---@field renderer any|nil local M = {} M.user_config = { @@ -96,6 +97,7 @@ M.view = { show_replace = true, } M.regex = nil +M.renderer = nil if _G.__is_dev then _G.__spectre_state = _G.__spectre_state or M diff --git a/lua/spectre/ui/nui_components/init.lua b/lua/spectre/ui/nui_components/init.lua index 7212e15..b606a40 100644 --- a/lua/spectre/ui/nui_components/init.lua +++ b/lua/spectre/ui/nui_components/init.lua @@ -10,8 +10,6 @@ local state_utils = require("spectre.state_utils") local has_devicons, devicons = pcall(require, 'nvim-web-devicons') local utils = require("spectre.utils") -local renderer = nil -local preview_win = nil local preview_buf = nil local preview_namespace = api.nvim_create_namespace('SPECTRE_PREVIEW') @@ -25,9 +23,6 @@ local function create_search_ui() -- Create a separate buffer for preview local preview_bufnr = api.nvim_create_buf(false, true) - api.nvim_buf_set_option(preview_bufnr, "filetype", "markdown") - api.nvim_buf_set_option(preview_bufnr, "buftype", "nofile") - api.nvim_buf_set_option(preview_bufnr, "bufhidden", "wipe") api.nvim_buf_set_option(preview_bufnr, "buflisted", false) api.nvim_buf_set_option(preview_bufnr, "wrap", true) api.nvim_buf_set_option(preview_bufnr, "number", true) @@ -77,123 +72,128 @@ local function create_search_ui() end, }) ), - - n.tree({ - id = "results-tree", - flex = 1, - border_label = "Results", - data = signal.results, - hidden = signal.has_search:negate(), - on_select = function(node, component) - if node.is_done ~= nil then - node.is_done = not node.is_done - component:render() - end - end, - on_focus = function(component) - signal.preview_visible = true - end, - on_blur = function(node, component) - signal.preview_visible = false - component:render() - end, - on_change = function(focused_node, component) - if focused_node.filename then - local full_path = vim.fn.fnamemodify(focused_node.filename, ":p") - if vim.fn.filereadable(full_path) then - local lines = vim.fn.readfile(full_path) - api.nvim_buf_set_lines(preview_bufnr, 0, -1, false, lines) - - -- Clear previous highlights - api.nvim_buf_clear_namespace(preview_bufnr, preview_namespace, 0, -1) - - -- Add search highlighting if there's a search query - if state.query and state.query.search_query and #state.query.search_query > 0 then - for i, line in ipairs(lines) do - local matches = utils.match_text_line(state.query.search_query, line, 0) - for _, match in ipairs(matches) do - api.nvim_buf_add_highlight( - preview_bufnr, - preview_namespace, - state.user_config.highlight.search, - i - 1, - match[1], - match[2] - ) - end - end - end - - -- Highlight the current line in the preview buffer - if focused_node.lnum then - local line_num = tonumber(focused_node.lnum) - if line_num then - -- Set cursor to the line number in the preview buffer - api.nvim_buf_call(preview_bufnr, function() - vim.cmd("normal! " .. line_num .. "G") - end) + + n.tree({ + id = "results-tree", + flex = 1, + border_label = "Results", + data = signal.results, + hidden = signal.has_search:negate(), + on_select = function(node, component) + if node.is_done ~= nil then + node.is_done = not node.is_done + -- component:redraw() + end + end, + on_focus = function() + signal.preview_visible = true + end, + on_blur = function() + signal.preview_visible = false + end, + on_change = function(focused_node) + if focused_node.filename then + local full_path = vim.fn.fnamemodify(focused_node.filename, ":p") + if vim.fn.filereadable(full_path) then + local lines = vim.fn.readfile(full_path) + api.nvim_buf_set_lines(preview_bufnr, 0, -1, false, lines) + + -- Clear previous highlights + api.nvim_buf_clear_namespace(preview_bufnr, preview_namespace, 0, -1) + + -- Add search highlighting if there's a search query + if state.query and state.query.search_query and #state.query.search_query > 0 then + for i, line in ipairs(lines) do + local matches = utils.match_text_line(state.query.search_query, line, 0) + for _, match in ipairs(matches) do + api.nvim_buf_add_highlight( + preview_bufnr, + preview_namespace, + state.user_config.highlight.search, + i - 1, + match[1], + match[2] + ) end end end - else - api.nvim_buf_set_lines(preview_bufnr, 0, -1, false, {}) - end - end, - prepare_node = function(node, line, component) - if node.is_done ~= nil then - if node.is_done then - local icon = "✔" - local hl = "String" - if has_devicons then - icon = '󰱒' - end - line:append('  ' .. icon .. ' ', hl) - else - local icon = "◻" - local hl = "Comment" - if has_devicons then - icon = '' - end - line:append('  ' .. icon .. ' ', hl) - end - end - - if node.icon then - line:append(" " .. node.icon .. " ", node.icon_highlight) - end - -- Add search highlighting if there's a search query - if state.query and state.query.search_query and #state.query.search_query > 0 and node.text then - local matches = utils.match_text_line(state.query.search_query, node.text, 0) - local last_pos = 0 - for _, match in ipairs(matches) do - -- Add text before the match - if match[1] > last_pos then - line:append(" " .. node.text:sub(last_pos + 1, match[1])) + -- Highlight the current line in the preview buffer + if focused_node.lnum then + local line_num = tonumber(focused_node.lnum) + if line_num then + -- Set cursor to the line number in the preview buffer + api.nvim_buf_call(preview_bufnr, function() + vim.cmd("normal! " .. line_num .. "G") + end) end - -- Add highlighted match - line:append(node.text:sub(match[1] + 1, match[2]), state.user_config.highlight.search) - last_pos = match[2] end - -- Add remaining text after last match - if last_pos < #node.text then - line:append(" " .. node.text:sub(last_pos + 1)) + end + else + api.nvim_buf_set_lines(preview_bufnr, 0, -1, false, {}) + end + end, + prepare_node = function(node, line, component) + if node.is_done ~= nil then + if node.is_done then + local icon = "✔" + local hl = "String" + if has_devicons then + icon = '󰱒' end + line:append('  ' .. icon .. ' ', hl) else - line:append(" " .. node.text) + local icon = "◻" + local hl = "Comment" + if has_devicons then + icon = '' + end + line:append('  ' .. icon .. ' ', hl) end - return line - end, - }), - n.buffer({ - id = "preview-buffer", - flex = 1, - border_label = "Preview", - hidden = signal.preview_visible:negate(), - buf = preview_bufnr, - autoscroll = true, - }), - + end + + if node.icon then + line:append(" " .. node.icon .. " ", node.icon_highlight) + end + + -- Add search highlighting if there's a search query + if state.query and state.query.search_query and #state.query.search_query > 0 and node.text then + local matches = utils.match_text_line(state.query.search_query, node.text, 0) + local last_pos = 0 + local max_width = vim.api.nvim_win_get_width(0) - + 15 -- Leave some space for icons and padding + local truncated_text = utils.truncate(node.text, max_width) + + for _, match in ipairs(matches) do + -- Add text before the match + if match[1] > last_pos then + line:append(truncated_text:sub(last_pos + 1, match[1])) + end + -- Add highlighted match + line:append(truncated_text:sub(match[1] + 1, match[2]), state.user_config.highlight.search) + last_pos = match[2] + end + -- Add remaining text after last match + if last_pos < #truncated_text then + line:append(truncated_text:sub(last_pos + 1)) + end + else + local max_width = vim.api.nvim_win_get_width(0) - + 15 -- Leave some space for icons and padding + line:append(utils.truncate(node.text, max_width)) + end + return line + end, + }), + n.buffer({ + id = "preview-buffer", + flex = 1, + border_label = "Preview", + hidden = signal.preview_visible:negate(), + is_focusable = false, + buf = preview_bufnr, + autoscroll = true, + }), n.columns( { flex = 0 }, n.text_input({ @@ -224,16 +224,8 @@ local function create_search_ui() label = "Replace All", on_press = function() vim.schedule(function() - M.run_replace() - end) - end, - }), - n.gap(3), - n.button({ - label = "Live Update", - on_press = function() - vim.schedule(function() - M.toggle_live_update() + require('spectre.actions').run_replace() + M.on_search_change() end) end, }) @@ -264,34 +256,35 @@ local function create_search_ui() end function M.open() - if renderer then + if state.renderer then M.close() end local new_renderer, signal = create_search_ui() - renderer = new_renderer + state.renderer = new_renderer M.signal = signal end function M.on_search_change() - if not renderer then return end + if not state.renderer then return end local query = { - search_query = renderer:get_component_by_id("search-input"):get_current_value(), - replace_query = renderer:get_component_by_id("replace-input"):get_current_value(), - path = renderer:get_component_by_id("path-input"):get_current_value(), + search_query = state.renderer:get_component_by_id("search-input"):get_current_value(), + replace_query = state.renderer:get_component_by_id("replace-input"):get_current_value(), + path = state.renderer:get_component_by_id("path-input"):get_current_value(), } state.query = query -- Store the query in state M.search(query) end function M.search(query) - if not renderer then return end - local results_component = renderer:get_component_by_id("results-tree") + if not state.renderer then return end + local results_component = state.renderer:get_component_by_id("results-tree") if not results_component then return end local results = {} local last_filename = "" local current_group = nil + state.total_item = {} -- Reset total_item -- Start search local finder_creator = state_utils.get_finder_creator() @@ -314,23 +307,30 @@ function M.search(query) end if current_group then - table.insert(results, n.node({ + local entry = n.node({ filename = result.filename, col = result.col, lnum = result.lnum, text = string.format("%d:%d: %s", result.lnum, result.col, result.text), is_done = false - })) + }) + table.insert(results, entry) + -- Store the entry in state.total_item with all required fields + table.insert(state.total_item, { + filename = result.filename, + col = result.col, + lnum = result.lnum, + text = result.text, + display_lnum = #state.total_item, + is_replace_finish = false + }) end end, on_finish = function() state.finder_instance = nil if M.signal then M.signal.results = results - renderer:redraw() - end - if preview_win and api.nvim_win_is_valid(preview_win) then - M.toggle_preview() -- Refresh preview if it's open + state.renderer:redraw() end end, }) @@ -342,47 +342,8 @@ function M.search(query) }) end -function M.run_replace() - if not renderer then return end - local entries = M.get_all_entries() - if #entries == 0 then - vim.notify("No entries to replace") - return - end - - vim.schedule(function() - local replacer_creator = state_utils.get_replace_creator() - local replacer = replacer_creator:new(state_utils.get_replace_engine_config(), { - on_done = function(result) - if result.ref then - M.set_entry_finish(result.ref.display_lnum) - end - end, - on_error = function(result) - if result.ref then - vim.notify("Error replacing: " .. result.value, vim.log.levels.ERROR) - end - end, - }) - - for _, entry in ipairs(entries) do - if not entry.is_replace_finish then - replacer:replace({ - lnum = entry.lnum, - col = entry.col, - cwd = state.cwd, - display_lnum = entry.display_lnum, - filename = entry.filename, - search_text = state.query.search_query, - replace_text = state.query.replace_query, - }) - end - end - end) -end - function M.show_options() - if not renderer then return end + if not state.renderer then return end local cfg = state_utils.get_search_engine_config() local options = {} local i = 1 @@ -413,62 +374,10 @@ function M.show_options() select_component:mount() end -function M.toggle_live_update() - state.user_config.live_update = not state.user_config.live_update - M.on_search_change() -end - -function M.toggle_preview() - if not renderer then return end - local results_component = renderer:get_component_by_id("results-tree") - if not results_component then return end - - if preview_win and api.nvim_win_is_valid(preview_win) then - api.nvim_win_close(preview_win, true) - preview_win = nil - preview_buf = nil - return - end - - local bufnr = results_component.bufnr - local cursor_pos = api.nvim_win_get_cursor(0) - local line = api.nvim_buf_get_lines(bufnr, cursor_pos[1] - 1, cursor_pos[1], false)[1] - - if not line then return end - - local filename, lnum, col = line:match("([^:]+):(%d+):(%d+):") - if not filename or not lnum or not col then return end - - local full_path = vim.fn.fnamemodify(filename, ":p") - if not vim.fn.filereadable(full_path) then return end - - preview_buf = api.nvim_create_buf(false, true) - api.nvim_buf_set_lines(preview_buf, 0, -1, false, vim.fn.readfile(full_path)) - - preview_win = api.nvim_open_win(preview_buf, false, { - relative = "win", - row = 0, - col = api.nvim_win_get_width(0), - width = 40, - height = 20, - border = "rounded", - }) - - api.nvim_win_set_cursor(preview_win, { tonumber(lnum), tonumber(col) - 1 }) - api.nvim_win_set_option(preview_win, "wrap", true) - api.nvim_win_set_option(preview_win, "number", true) - api.nvim_win_set_option(preview_win, "relativenumber", true) -end - function M.close() - if renderer then - renderer = nil - end - if preview_win and api.nvim_win_is_valid(preview_win) then - api.nvim_win_close(preview_win, true) - preview_win = nil - preview_buf = nil + if state.renderer then + state.renderer = nil end end -return M \ No newline at end of file +return M From 9b529ff52aaebcff569c2c63eb67044e7b4f12c5 Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Wed, 30 Apr 2025 16:41:46 +0500 Subject: [PATCH 06/15] Show replace word when replaced (WIP) --- lua/spectre/actions.lua | 109 +++++++++++++++++------- lua/spectre/state_utils.lua | 40 +++++++++ lua/spectre/ui.lua | 1 + lua/spectre/ui/nui_components/init.lua | 111 +++++++++++++++++++++---- lua/spectre/utils.lua | 39 +++++++-- 5 files changed, 248 insertions(+), 52 deletions(-) diff --git a/lua/spectre/actions.lua b/lua/spectre/actions.lua index 8654631..c9468ec 100644 --- a/lua/spectre/actions.lua +++ b/lua/spectre/actions.lua @@ -59,7 +59,14 @@ M.get_state = function() end M.set_entry_finish = function(display_lnum) - local item = state.total_item[display_lnum + 1] + -- Safety check: ensure display_lnum is valid and state.total_item exists + if not display_lnum or not state.total_item then return end + + -- In Lua, arrays are 1-indexed but display_lnum might be 0-indexed + local index = display_lnum + 1 + + -- Check if the item exists in total_item + local item = state.total_item[index] if item then item.is_replace_finish = true end @@ -171,8 +178,15 @@ function M.run_replace(entries) local replacer_creator = state_utils.get_replace_creator() local replacer = replacer_creator:new(state_utils.get_replace_engine_config(), { on_done = function(result) - if result.ref then + if result.ref and result.ref.display_lnum ~= nil then + -- Set the entry as finished and mark it as replaced M.set_entry_finish(result.ref.display_lnum) + + -- Add a safety check before accessing state.total_item + if state.total_item and state.total_item[result.ref.display_lnum] then + state.total_item[result.ref.display_lnum].is_replace = true + end + -- Update UI by adding a checkmark to the line local bufnr = api.nvim_get_current_buf() local line = result.ref.display_lnum @@ -183,16 +197,41 @@ function M.run_replace(entries) 0, { virt_text = { { '✓', 'String' } }, virt_text_pos = 'eol' } ) - -- Trigger renderer redraw + + -- If we have a renderer, trigger a full redraw if state.renderer then - print("redrawing") - state.renderer:redraw() + -- Update the node in the UI + local tree = state.renderer:get_component_by_id("results-tree") + -- Check if tree exists and has the get_nodes method + if tree and type(tree) == "table" and type(tree.get_nodes) == "function" then + local success, nodes = pcall(function() + return tree:get_nodes() + end) + + if success and nodes then + for _, node in ipairs(nodes) do + -- Add safety check for node.display_lnum + if node.display_lnum and node.display_lnum == result.ref.display_lnum then + node.is_done = true + -- This triggers the prepare_node function + pcall(function() state.renderer:redraw() end) + break + end + end + else + -- If we can't get nodes, just redraw + pcall(function() state.renderer:redraw() end) + end + else + -- If tree doesn't exist or doesn't have get_nodes, just redraw + pcall(function() state.renderer:redraw() end) + end end end end, on_error = function(result) - if result.ref then - vim.notify("Error replacing: " .. result.value, vim.log.levels.ERROR) + if result.ref and result.ref.display_lnum ~= nil then + vim.notify("Error replacing: " .. (result.value or "unknown error"), vim.log.levels.ERROR) -- Add error mark to the line local bufnr = api.nvim_get_current_buf() local line = result.ref.display_lnum @@ -205,8 +244,10 @@ function M.run_replace(entries) ) -- Trigger renderer redraw if state.renderer then - print("redrawing") - state.renderer:redraw() + -- Make sure renderer has redraw method + if type(state.renderer) == "table" and type(state.renderer.redraw) == "function" then + pcall(function() state.renderer:redraw() end) + end end end end, @@ -245,44 +286,54 @@ M.run_delete_line = function(entries) local replacer_creator = state_utils.get_replace_creator() local replacer = replacer_creator:new(state_utils.get_replace_engine_config(), { on_done = function(result) - if result.ref then + if result.ref and result.ref.display_lnums then done_item = done_item + 1 local value = result.ref state.status_line = 'Delete line: ' .. done_item .. ' Error:' .. error_item for _, display_lnum in ipairs(value.display_lnums) do - M.set_entry_finish(display_lnum) - api.nvim_buf_set_extmark( - state.bufnr, - config.namespace, - display_lnum, - 0, - { virt_text = { { '󰄲 DONE', 'String' } }, virt_text_pos = 'eol' } - ) + if display_lnum ~= nil then + M.set_entry_finish(display_lnum) + api.nvim_buf_set_extmark( + state.bufnr, + config.namespace, + display_lnum, + 0, + { virt_text = { { '󰄲 DONE', 'String' } }, virt_text_pos = 'eol' } + ) + end end -- Trigger renderer redraw if state.renderer then - state.renderer:redraw() + -- Make sure renderer has redraw method + if type(state.renderer) == "table" and type(state.renderer.redraw) == "function" then + pcall(function() state.renderer:redraw() end) + end end end end, on_error = function(result) - if result.ref then + if result.ref and result.ref.display_lnums then error_item = error_item + 1 local value = result.ref state.status_line = 'Delete line: ' .. done_item .. ' Error:' .. error_item for _, display_lnum in ipairs(value.display_lnums) do - M.set_entry_finish(display_lnum) - api.nvim_buf_set_extmark( - state.bufnr, - config.namespace, - display_lnum, - 0, - { virt_text = { { '󰄱 ERROR', 'Error' } }, virt_text_pos = 'eol' } - ) + if display_lnum ~= nil then + M.set_entry_finish(display_lnum) + api.nvim_buf_set_extmark( + state.bufnr, + config.namespace, + display_lnum, + 0, + { virt_text = { { '󰄱 ERROR', 'Error' } }, virt_text_pos = 'eol' } + ) + end end -- Trigger renderer redraw if state.renderer then - state.renderer:redraw() + -- Make sure renderer has redraw method + if type(state.renderer) == "table" and type(state.renderer.redraw) == "function" then + pcall(function() state.renderer:redraw() end) + end end end end, diff --git a/lua/spectre/state_utils.lua b/lua/spectre/state_utils.lua index 0279f0b..e52c3b7 100644 --- a/lua/spectre/state_utils.lua +++ b/lua/spectre/state_utils.lua @@ -11,6 +11,46 @@ M.get_replace_creator = function() return replace_engine[state.user_config.default.replace.cmd] end +-- Get a regex engine instance, initializing it if needed +M.get_regex = function() + -- If regex is not initialized yet, initialize it + if not state.regex then + -- Default to vim regex as a fallback + local regex_engine_name = 'vim' + + -- Try to use the current replace engine's regex + if state.user_config and state.user_config.default and state.user_config.default.replace and state.user_config.default.replace.cmd then + local replace_cmd = state.user_config.default.replace.cmd + + -- Map replace engines to regex engines + if replace_cmd == 'oxi' then + regex_engine_name = 'rust' + elseif replace_cmd == 'sed' then + regex_engine_name = 'vim' + elseif replace_cmd == 'sd' then + regex_engine_name = 'rust' + end + end + + -- Require the regex engine + local success, regex = pcall(require, 'spectre.regex.' .. regex_engine_name) + if success then + state.regex = regex + + -- Initialize options if available + local cfg = M.get_replace_engine_config() + if cfg and cfg.options_value then + state.regex.change_options(cfg.options_value) + end + else + -- Fallback to vim regex if the preferred engine couldn't be loaded + state.regex = require('spectre.regex.vim') + end + end + + return state.regex +end + local get_options = function(cfg) local options_value = {} for key, value in pairs(state.options) do diff --git a/lua/spectre/ui.lua b/lua/spectre/ui.lua index 3da0692..78295e5 100644 --- a/lua/spectre/ui.lua +++ b/lua/spectre/ui.lua @@ -21,6 +21,7 @@ M.render_line = function(bufnr, namespace, text_opts, view_opts, regex) search_text = text_opts.search_text, show_search = view_opts.show_search, show_replace = view_opts.show_replace, + is_replace = text_opts.is_replace, }, regex) local end_lnum = text_opts.is_replace == true and text_opts.lnum + 1 or text_opts.lnum diff --git a/lua/spectre/ui/nui_components/init.lua b/lua/spectre/ui/nui_components/init.lua index b606a40..874bef7 100644 --- a/lua/spectre/ui/nui_components/init.lua +++ b/lua/spectre/ui/nui_components/init.lua @@ -82,7 +82,6 @@ local function create_search_ui() on_select = function(node, component) if node.is_done ~= nil then node.is_done = not node.is_done - -- component:redraw() end end, on_focus = function() @@ -104,16 +103,28 @@ local function create_search_ui() -- Add search highlighting if there's a search query if state.query and state.query.search_query and #state.query.search_query > 0 then for i, line in ipairs(lines) do - local matches = utils.match_text_line(state.query.search_query, line, 0) + -- Safely get matches with error handling + local matches = {} + local success, result = pcall(function() + return utils.match_text_line(state.query.search_query, line, 0) + end) + + if success and type(result) == "table" then + matches = result + end + for _, match in ipairs(matches) do - api.nvim_buf_add_highlight( - preview_bufnr, - preview_namespace, - state.user_config.highlight.search, - i - 1, - match[1], - match[2] - ) + -- Safely add highlight + pcall(function() + api.nvim_buf_add_highlight( + preview_bufnr, + preview_namespace, + state.user_config.highlight.search, + i - 1, + match[1], + match[2] + ) + end) end end end @@ -141,14 +152,14 @@ local function create_search_ui() if has_devicons then icon = '󰱒' end - line:append('  ' .. icon .. ' ', hl) + line:append(' ' .. icon .. ' ', hl) else local icon = "◻" local hl = "Comment" if has_devicons then - icon = '' + icon = '' end - line:append('  ' .. icon .. ' ', hl) + line:append(' ' .. icon .. ' ', hl) end end @@ -158,19 +169,87 @@ local function create_search_ui() -- Add search highlighting if there's a search query if state.query and state.query.search_query and #state.query.search_query > 0 and node.text then - local matches = utils.match_text_line(state.query.search_query, node.text, 0) + -- Safely get matches with error handling + local matches = {} + local success, result = pcall(function() + return utils.match_text_line(state.query.search_query, node.text, 0) + end) + + if success and type(result) == "table" then + matches = result + end + local last_pos = 0 local max_width = vim.api.nvim_win_get_width(0) - 15 -- Leave some space for icons and padding local truncated_text = utils.truncate(node.text, max_width) + -- Find if this node has been replaced + local is_replaced = false + if state.total_item and node.display_lnum ~= nil then + for _, item in ipairs(state.total_item) do + if item and item.display_lnum and item.display_lnum == node.display_lnum and item.is_replace then + is_replaced = true + break + end + end + end + for _, match in ipairs(matches) do -- Add text before the match if match[1] > last_pos then line:append(truncated_text:sub(last_pos + 1, match[1])) end + -- Add highlighted match line:append(truncated_text:sub(match[1] + 1, match[2]), state.user_config.highlight.search) + + -- Add replacement preview if exists and not replaced yet + if state.query.replace_query and #state.query.replace_query > 0 and not is_replaced then + -- Get the regex engine with safety check + local regex = nil + local success, result = pcall(state_utils.get_regex) + if success then + regex = result + else + -- Fallback to vim regex + regex = require('spectre.regex.vim') + end + + -- Calculate replace_match with error handling + local replace_match = {} + success, result = pcall(function() + return utils.get_hl_line_text({ + search_query = state.query.search_query, + replace_query = state.query.replace_query, + search_text = truncated_text:sub(match[1] + 1, match[2]), + show_search = true, + show_replace = true + }, regex).replace + end) + + if success then + replace_match = result + end + + if type(replace_match) == "table" and #replace_match > 0 then + -- Calculate replace_text with error handling + local replace_text = "" + success, result = pcall(function() + return " → (" .. utils.get_hl_line_text({ + search_query = state.query.search_query, + replace_query = state.query.replace_query, + search_text = truncated_text:sub(match[1] + 1, match[2]), + }, regex).text .. ")" + end) + + if success then + replace_text = result + line:append(replace_text, state.user_config.highlight.replace) + end + end + end + last_pos = match[2] end -- Add remaining text after last match @@ -225,7 +304,6 @@ local function create_search_ui() on_press = function() vim.schedule(function() require('spectre.actions').run_replace() - M.on_search_change() end) end, }) @@ -312,7 +390,8 @@ function M.search(query) col = result.col, lnum = result.lnum, text = string.format("%d:%d: %s", result.lnum, result.col, result.text), - is_done = false + is_done = false, + display_lnum = #state.total_item }) table.insert(results, entry) -- Store the entry in state.total_item with all required fields diff --git a/lua/spectre/utils.lua b/lua/spectre/utils.lua index 79e3b23..74b4149 100644 --- a/lua/spectre/utils.lua +++ b/lua/spectre/utils.lua @@ -158,7 +158,7 @@ end M.match_text_line = match_text_line --- get highlight text from search_text and replace_text ---- @params opts {search_query, replace_query, search_text, padding} +--- @params opts {search_query, replace_query, search_text, padding, is_replace} --- @param regex RegexEngine --- @return table { text, search = {}, replace = {}} M.get_hl_line_text = function(opts, regex) @@ -170,22 +170,47 @@ M.get_hl_line_text = function(opts, regex) result.search = match_text_line(search_match, opts.search_text, 0) if opts.replace_query and #opts.replace_query > 0 and opts.show_replace ~= false then local replace_match = regex.replace_all(opts.search_query, opts.replace_query, search_match) - local replace_length = #replace_match local total_increase = 0 - if opts.show_search == false then + + if opts.show_search == false or opts.is_replace then + -- After replacement: Just show the replaced text result.text = regex.replace_all(opts.search_query, opts.replace_query, opts.search_text) result.replace = match_text_line(replace_match, result.text, 0) result.search = {} + + -- If we want to show both the original and replaced text after replacement + if opts.is_replace then + -- Find all instances of replaced text and add the original in parentheses + local positions = match_text_line(replace_match, result.text, 0) + local new_text = result.text + local offset = 0 + + for _, pos in ipairs(positions) do + local display_original = " (was: " .. search_match .. ")" + local insert_pos = pos[2] + offset + new_text = new_text:sub(1, insert_pos) .. display_original .. new_text:sub(insert_pos + 1) + offset = offset + #display_original + + -- Add highlight for the "was" text + table.insert(result.search, {insert_pos + 6, insert_pos + 6 + #search_match}) + end + + result.text = new_text + end else - -- highlight and join replace text + -- Before replacement or preview: Show original text with replacement preview for _, v in pairs(result.search) do v[1] = v[1] + total_increase v[2] = v[2] + total_increase - local pos = { v[2], v[2] + replace_length } + + -- Add replacement text in parentheses after the search match with an arrow + local display_replace = " → (" .. replace_match .. ")" + local pos = { v[2], v[2] + #display_replace } table.insert(result.replace, pos) + local text = result.text - result.text = text:sub(0, v[2]) .. replace_match .. text:sub(v[2] + 1) - total_increase = total_increase + replace_length + result.text = text:sub(0, v[2]) .. display_replace .. text:sub(v[2] + 1) + total_increase = total_increase + #display_replace end end end From 6df68b4ffad8e64f281d9164851e77a1bb20693a Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Wed, 30 Apr 2025 18:52:17 +0500 Subject: [PATCH 07/15] (WIP) Old UI return --- README.md | 30 ++-- lua/spectre/config.lua | 1 + lua/spectre/init.lua | 134 ++++++++++++++-- lua/spectre/ui/legacy/init.lua | 276 +++++++++++++++++++++++++++++++++ 4 files changed, 422 insertions(+), 19 deletions(-) create mode 100644 lua/spectre/ui/legacy/init.lua diff --git a/README.md b/README.md index f6c161a..25b8610 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,23 @@ vim.keymap.set('n', 'sp', 'lua require("spectre").open_file_search( }) ``` +You can also toggle between the legacy UI and the modern nui-based UI: + +```lua +-- Toggle between legacy and modern UI +vim.keymap.set('n', 'sU', 'lua require("spectre").toggle_ui()', { + desc = "Toggle between legacy and modern UI" +}) + +-- Switch to a specific UI +vim.keymap.set('n', 'su', 'lua require("spectre").set_ui_type(false)', { + desc = "Use modern UI" +}) +vim.keymap.set('n', 'sL', 'lua require("spectre").set_ui_type(true)', { + desc = "Use legacy UI" +}) +``` + Use command: `:Spectre` ## Warnings @@ -286,12 +303,9 @@ require('spectre').setup({ use_trouble_qf = false, -- use trouble.nvim as quickfix list is_open_target_win = true, --open file on opener window is_insert_mode = false, -- start open panel on is_insert_mode - is_block_ui_break = false -- mapping backspace and enter key to avoid ui break - open_template = { - -- an template to use on open function - -- see the 'custom function' section below to learn how to configure the template - -- { search_text = 'text1', replace_text = '', path = "" } - } + is_block_ui_break = false, -- mapping backspace and enter key to avoid ui break + open_template = {}, -- an template to use on open function + use_legacy_ui = false, -- set to true to use the legacy UI instead of nui-based modern UI }) ``` @@ -398,6 +412,4 @@ require('spectre').setup({ is_block_ui_break = true }) - Why is it called Spectre? -I wanted to call it `Search Panel` but this name is not cool. -I got the name of a hero on a game. -Spectre has a skill to find enemy on global map so I use it:) +I wanted to call it ` \ No newline at end of file diff --git a/lua/spectre/config.lua b/lua/spectre/config.lua index 54a85c8..635a009 100644 --- a/lua/spectre/config.lua +++ b/lua/spectre/config.lua @@ -220,6 +220,7 @@ local config = { is_insert_mode = false, is_block_ui_break = false, open_template = {}, + use_legacy_ui = false, -- set to true to use the legacy UI } return config diff --git a/lua/spectre/init.lua b/lua/spectre/init.lua index 69fa970..337b0bc 100644 --- a/lua/spectre/init.lua +++ b/lua/spectre/init.lua @@ -24,7 +24,8 @@ local config = require('spectre.config') local state = require('spectre.state') local state_utils = require('spectre.state_utils') local utils = require('spectre.utils') -local ui = require('spectre.ui.nui_components') +-- Dynamically choose UI based on configuration +local ui = nil local log = require('spectre._log') local async = require('plenary.async') @@ -40,6 +41,18 @@ M.setup = function(opts) end require('spectre.highlight').set_hl() M.check_replace_cmd_bins() + + -- Initialize UI based on configuration + M.init_ui() +end + +-- Initialize UI based on user config +M.init_ui = function() + if state.user_config.use_legacy_ui then + ui = require('spectre.ui.legacy') + else + ui = require('spectre.ui.nui_components') + end end M.check_replace_cmd_bins = function() @@ -80,11 +93,20 @@ M.open = function(opts) } state.regex = nil + -- Ensure UI is initialized + if ui == nil then + M.init_ui() + end + ui.open() end M.close = function() state.is_open = false + -- Ensure UI is initialized + if ui == nil then + M.init_ui() + end ui.close() end @@ -182,14 +204,26 @@ M.change_options = function(key) end state.regex.change_options(state_utils.get_replace_engine_config().options_value) if state.query.search_query ~= nil then - ui.render_search_ui() + -- Ensure UI is initialized + if ui == nil then + M.init_ui() + end + + if ui and ui.render_search_ui then + ui.render_search_ui() + end M.search() end end M.show_options = function() - if not ui then return end - ui.show_options() + if not ui then + M.init_ui() + end + + if ui and ui.show_options then + ui.show_options() + end end M.get_fold = function(lnum) @@ -215,18 +249,98 @@ M.get_fold = function(lnum) end M.tab = function() - if not ui then return end - ui.tab() + if not ui then + M.init_ui() + end + + if ui and ui.tab then + ui.tab() + end end M.tab_shift = function() - if not ui then return end - ui.tab_shift() + if not ui then + M.init_ui() + end + + if ui and ui.tab_shift then + ui.tab_shift() + end end M.toggle_preview = function() - if not ui then return end - ui.toggle_preview() + if not ui then + M.init_ui() + end + + if ui and ui.toggle_preview then + ui.toggle_preview() + end +end + +-- Function to toggle between different view modes +M.change_view = function() + if not ui then + M.init_ui() + end + + -- Toggle view mode + if state.view.mode == "both" then + state.view.mode = "replace" + state.view.show_search = false + state.view.show_replace = true + elseif state.view.mode == "replace" then + state.view.mode = "search" + state.view.show_search = true + state.view.show_replace = false + else + state.view.mode = "both" + state.view.show_search = true + state.view.show_replace = true + end + + -- Trigger UI update if available + if ui and ui.render_search_ui then + ui.render_search_ui() + end +end + +-- Function to toggle between UI types +M.toggle_ui = function() + state.user_config.use_legacy_ui = not state.user_config.use_legacy_ui + + -- Re-initialize the UI + M.init_ui() + + -- Notify the user + local ui_type = state.user_config.use_legacy_ui and "legacy" or "modern" + vim.notify("Switched to " .. ui_type .. " UI. Reopen spectre panel to apply changes.", vim.log.levels.INFO) + + -- If spectre is open, close and reopen it to apply changes + if state.is_open then + local query_backup = vim.deepcopy(state.query) + M.close() + M.open(query_backup) + end +end + +-- Function to set the UI type +M.set_ui_type = function(use_legacy) + state.user_config.use_legacy_ui = use_legacy + + -- Re-initialize the UI + M.init_ui() + + -- Notify the user + local ui_type = state.user_config.use_legacy_ui and "legacy" or "modern" + vim.notify("Set to " .. ui_type .. " UI. Reopen spectre panel to apply changes.", vim.log.levels.INFO) + + -- If spectre is open, close and reopen it to apply changes + if state.is_open then + local query_backup = vim.deepcopy(state.query) + M.close() + M.open(query_backup) + end end return M diff --git a/lua/spectre/ui/legacy/init.lua b/lua/spectre/ui/legacy/init.lua new file mode 100644 index 0000000..e47aca9 --- /dev/null +++ b/lua/spectre/ui/legacy/init.lua @@ -0,0 +1,276 @@ +local has_devicons, devicons = pcall(require, 'nvim-web-devicons') +local config = require('spectre.config') +local state = require('spectre.state') +local state_utils = require('spectre.state_utils') +local utils = require('spectre.utils') + +local Path = require('plenary.path') + +local popup = require('plenary.popup') +local api = vim.api + +local M = {} + +---@param regex RegexEngine +M.render_line = function(bufnr, namespace, text_opts, view_opts, regex) + local cfg = state.user_config + local diff = utils.get_hl_line_text({ + search_query = text_opts.search_query, + replace_query = text_opts.replace_query, + search_text = text_opts.search_text, + show_search = view_opts.show_search, + show_replace = view_opts.show_replace, + is_replace = text_opts.is_replace, + }, regex) + local end_lnum = text_opts.is_replace == true and text_opts.lnum + 1 or text_opts.lnum + + local item_line_len = 0 + if cfg.lnum_for_results == true then + item_line_len = string.len(text_opts.item_line) + 1 + api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { + view_opts.padding_text .. text_opts.item_line .. ' ' .. diff.text, + }) + else + api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { + view_opts.padding_text .. diff.text, + }) + end + + if not view_opts.is_disable then + for _, value in pairs(diff.search) do + api.nvim_buf_add_highlight( + bufnr, + namespace, + cfg.highlight.search, + text_opts.lnum, + value[1] + view_opts.padding + item_line_len, + value[2] + view_opts.padding + item_line_len + ) + end + for _, value in pairs(diff.replace) do + api.nvim_buf_add_highlight( + bufnr, + namespace, + cfg.highlight.replace, + text_opts.lnum, + value[1] + view_opts.padding + item_line_len, + value[2] + view_opts.padding + item_line_len + ) + end + api.nvim_buf_add_highlight( + state.bufnr, + config.namespace, + cfg.highlight.border, + text_opts.lnum, + 0, + view_opts.padding + ) + else + api.nvim_buf_add_highlight(state.bufnr, config.namespace, cfg.highlight.border, text_opts.lnum, 0, -1) + end +end + +local get_devicons = (function() + if has_devicons then + if not devicons.has_loaded() then + devicons.setup() + end + + return function(filename, enable_icon, default) + if not enable_icon or not filename then + return default or '|', '' + end + local icon, icon_highlight = devicons.get_icon(filename, string.match(filename, '%a+$'), { default = true }) + return icon, icon_highlight + end + else + return function(_, _) + return '' + end + end +end)() + +M.render_filename = function(bufnr, namespace, line, entry) + local u_config = state.user_config + local filename = vim.fn.fnamemodify(entry.filename, ':t') + local directory = vim.fn.fnamemodify(entry.filename, ':h') + if directory == '.' then + directory = '' + else + directory = directory .. Path.path.sep + end + + local icon_length = state.user_config.color_devicons and 4 or 2 + local icon, icon_highlight = get_devicons(filename, state.user_config.color_devicons, '+') + + api.nvim_buf_set_lines(state.bufnr, line, line, false, { + string.format('%s %s%s:', icon, directory, filename), + }) + + local width = vim.api.nvim_strwidth(filename) + local hl = { + { { 0, icon_length }, icon_highlight }, + { { 0, vim.api.nvim_strwidth(directory) }, u_config.highlight.filedirectory }, + { { 0, width + 1 }, u_config.highlight.filename }, + } + if icon == '' then + table.remove(hl, 1) + end + local pos = 0 + for _, value in pairs(hl) do + pcall(function() + api.nvim_buf_add_highlight(bufnr, namespace, value[2], line, pos + value[1][1], pos + value[1][2]) + pos = value[1][2] + pos + end) + end +end + +function M.render_search_ui() + api.nvim_buf_clear_namespace(state.bufnr, config.namespace_ui, 0, config.lnum_UI) + local details_ui = {} + local search_message = 'Search: ' + local cfg = state_utils.get_search_engine_config() + for key, value in pairs(state.options) do + if value == true and cfg.options[key] then + search_message = search_message .. cfg.options[key].icon + end + end + + table.insert(details_ui, { { search_message, state.user_config.highlight.ui } }) + table.insert(details_ui, { { 'Replace: ', state.user_config.highlight.ui } }) + local path_message = 'Path:' + if state.cwd then + path_message = path_message .. string.format(' cwd=%s', state.cwd) + end + table.insert(details_ui, { { path_message, state.user_config.highlight.ui } }) + + local c_line = 1 + for _, vt_text in ipairs(details_ui) do + utils.write_virtual_text(state.bufnr, config.namespace_ui, c_line, vt_text) + c_line = c_line + 2 + end + M.render_header(state.user_config) +end + +function M.render_header(opts) + api.nvim_buf_clear_namespace(state.bufnr, config.namespace_header, 0, config.lnum_UI) + local help_text = string.format( + '[Nvim Spectre] (Search by %s) %s (Replace by %s) (Press ? for mappings)', + state.user_config.default.find.cmd, + opts.live_update and '(Auto update)' or '', + state.user_config.default.replace.cmd + ) + utils.write_virtual_text( + state.bufnr, + config.namespace_header, + 0, + { { help_text, state.user_config.highlight.headers } } + ) +end + +M.show_menu_options = function(title, content) + local win_width, win_height = vim.lsp.util._make_floating_popup_size(content, {}) + + local bufnr = vim.api.nvim_create_buf(false, true) + api.nvim_buf_set_option(bufnr, 'bufhidden', 'wipe') + api.nvim_buf_set_lines(bufnr, 0, -1, true, content) + + local help_win = vim.api.nvim_open_win(bufnr, false, { + style = 'minimal', + title = ' ' .. title .. ' ', + title_pos = 'center', + relative = 'cursor', + width = win_width, + height = win_height, + col = 0, + row = 1, + border = 'rounded', + }) + api.nvim_win_set_option(help_win, 'winblend', 0) + api.nvim_buf_set_keymap( + bufnr, + 'n', + '', + 'lua vim.api.nvim_win_close(' .. help_win .. ', true)', + { noremap = true } + ) + + api.nvim_create_autocmd({ + 'CursorMovedI', + 'CursorMoved', + 'CursorMovedI', + 'BufHidden', + 'BufLeave', + 'InsertEnter', + 'WinScrolled', + 'BufDelete', + }, { + callback = function() + pcall(vim.api.nvim_win_close, help_win, true) + end, + }) +end + +M.show_help = function() + local help_msg = {} + local map_tbl = {} + for _, map in pairs(state.user_config.mapping) do + table.insert(map_tbl, map) + end + -- sort by length + table.sort(map_tbl, function(a, b) + return (#a.map or 0) < (#b.map or 0) + end) + + for _, map in pairs(map_tbl) do + table.insert(help_msg, string.format('%9s : %s', map.map, map.desc)) + end + + M.show_menu_options('Mappings', help_msg) +end + +M.show_options = function() + local cfg = state_utils.get_search_engine_config() + local help_msg = { ' Press number to select option.' } + local option_cmd = {} + local i = 1 + + for key, option in pairs(cfg.options) do + table.insert(help_msg, string.format(' %s : toggle %s', i, option.desc or ' ')) + table.insert(option_cmd, key) + i = i + 1 + end + + M.show_menu_options('Options', help_msg) + return option_cmd +end + +M.show_find_engine = function() + local engines = state.user_config.find_engine + + local help_msg = { ' Press number to select option.' } + local option_cmd = {} + local i = 1 + + for key, option in pairs(engines) do + table.insert(help_msg, string.format(' %s : engine %s', i, option.desc or ' ')) + table.insert(option_cmd, key) + i = i + 1 + end +end + +M.render_text_query = function(opts) + -- set empty line for virtual text + local lines = {} + local length = config.lnum_UI + for _ = 1, length, 1 do + table.insert(lines, '') + end + api.nvim_buf_set_lines(state.bufnr, 0, 0, false, lines) + api.nvim_buf_set_lines(state.bufnr, 2, 2, false, { opts.search_text }) + api.nvim_buf_set_lines(state.bufnr, 4, 4, false, { opts.replace_text }) + api.nvim_buf_set_lines(state.bufnr, 6, 6, false, { opts.path }) + api.nvim_win_set_cursor(0, { opts.begin_line_num or 3, 0 }) +end + +return M From 4d8d6945fbeac18acddf179439b96abb79f1f014 Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Thu, 1 May 2025 17:37:16 +0500 Subject: [PATCH 08/15] Refactor for old code and format --- lua/spectre/actions.lua | 90 ++-- lua/spectre/config.lua | 2 +- lua/spectre/init.lua | 169 ++++--- lua/spectre/state.lua | 60 +-- lua/spectre/state_utils.lua | 17 +- lua/spectre/ui.lua | 289 ----------- lua/spectre/ui/legacy/init.lua | 276 ----------- lua/spectre/ui/nui_components/init.lua | 177 +++---- lua/spectre/ui/plenary/init.lua | 641 +++++++++++++++++++++++++ lua/spectre/utils.lua | 20 +- plugin/spectre.lua | 56 +-- tests/cwd_spec.lua | 12 +- tests/escape_spec.lua | 39 +- tests/helper.lua | 36 +- tests/line_render_spec.lua | 183 ++++--- tests/search/rg_spec.lua | 43 +- tests/ui_spec.lua | 23 +- 17 files changed, 1125 insertions(+), 1008 deletions(-) delete mode 100644 lua/spectre/ui.lua delete mode 100644 lua/spectre/ui/legacy/init.lua create mode 100644 lua/spectre/ui/plenary/init.lua diff --git a/lua/spectre/actions.lua b/lua/spectre/actions.lua index c9468ec..00edc14 100644 --- a/lua/spectre/actions.lua +++ b/lua/spectre/actions.lua @@ -40,12 +40,16 @@ end M.select_entry = function() local entry = M.get_current_entry() - if not entry then return end + if not entry then + return + end - local full_path = vim.fn.fnamemodify(entry.filename, ":p") - if not vim.fn.filereadable(full_path) then return end + local full_path = vim.fn.fnamemodify(entry.filename, ':p') + if not vim.fn.filereadable(full_path) then + return + end - vim.cmd("edit " .. full_path) + vim.cmd('edit ' .. full_path) api.nvim_win_set_cursor(0, { entry.lnum, entry.col - 1 }) end @@ -60,11 +64,13 @@ end M.set_entry_finish = function(display_lnum) -- Safety check: ensure display_lnum is valid and state.total_item exists - if not display_lnum or not state.total_item then return end - + if not display_lnum or not state.total_item then + return + end + -- In Lua, arrays are 1-indexed but display_lnum might be 0-indexed local index = display_lnum + 1 - + -- Check if the item exists in total_item local item = state.total_item[index] if item then @@ -77,16 +83,20 @@ function M.get_current_entry() local cursor_pos = api.nvim_win_get_cursor(0) local line = api.nvim_buf_get_lines(bufnr, cursor_pos[1] - 1, cursor_pos[1], false)[1] - if not line then return nil end + if not line then + return nil + end - local filename, lnum, col = line:match("([^:]+):(%d+):(%d+):") - if not filename or not lnum or not col then return nil end + local filename, lnum, col = line:match('([^:]+):(%d+):(%d+):') + if not filename or not lnum or not col then + return nil + end return { filename = filename, lnum = tonumber(lnum), col = tonumber(col), - text = line:match(":[^:]+$"):sub(2), + text = line:match(':[^:]+$'):sub(2), } end @@ -100,7 +110,7 @@ function M.get_all_entries() col = item.col, text = item.text, display_lnum = display_lnum - 1, - is_replace_finish = item.is_replace_finish or false + is_replace_finish = item.is_replace_finish or false, }) end end @@ -110,7 +120,7 @@ end M.send_to_qf = function() local entries = M.get_all_entries() if #entries == 0 then - vim.notify("No entries to send to quickfix") + vim.notify('No entries to send to quickfix') return end @@ -125,7 +135,7 @@ M.send_to_qf = function() end vim.fn.setqflist(qf_list) - vim.cmd("copen") + vim.cmd('copen') end -- input that comand to run on vim @@ -161,7 +171,7 @@ function M.run_current_replace() if entry then M.run_replace({ entry }) else - vim.notify("Not found any entry to replace.") + vim.notify('Not found any entry to replace.') end end @@ -170,7 +180,7 @@ local is_running = false function M.run_replace(entries) entries = entries or M.get_all_entries() if #entries == 0 then - vim.notify("No entries to replace") + vim.notify('No entries to replace') return end @@ -181,12 +191,12 @@ function M.run_replace(entries) if result.ref and result.ref.display_lnum ~= nil then -- Set the entry as finished and mark it as replaced M.set_entry_finish(result.ref.display_lnum) - + -- Add a safety check before accessing state.total_item if state.total_item and state.total_item[result.ref.display_lnum] then state.total_item[result.ref.display_lnum].is_replace = true end - + -- Update UI by adding a checkmark to the line local bufnr = api.nvim_get_current_buf() local line = result.ref.display_lnum @@ -197,41 +207,47 @@ function M.run_replace(entries) 0, { virt_text = { { '✓', 'String' } }, virt_text_pos = 'eol' } ) - + -- If we have a renderer, trigger a full redraw if state.renderer then -- Update the node in the UI - local tree = state.renderer:get_component_by_id("results-tree") + local tree = state.renderer:get_component_by_id('results-tree') -- Check if tree exists and has the get_nodes method - if tree and type(tree) == "table" and type(tree.get_nodes) == "function" then - local success, nodes = pcall(function() - return tree:get_nodes() + if tree and type(tree) == 'table' and type(tree.get_nodes) == 'function' then + local success, nodes = pcall(function() + return tree:get_nodes() end) - + if success and nodes then for _, node in ipairs(nodes) do -- Add safety check for node.display_lnum if node.display_lnum and node.display_lnum == result.ref.display_lnum then node.is_done = true -- This triggers the prepare_node function - pcall(function() state.renderer:redraw() end) + pcall(function() + state.renderer:redraw() + end) break end end else -- If we can't get nodes, just redraw - pcall(function() state.renderer:redraw() end) + pcall(function() + state.renderer:redraw() + end) end else -- If tree doesn't exist or doesn't have get_nodes, just redraw - pcall(function() state.renderer:redraw() end) + pcall(function() + state.renderer:redraw() + end) end end end end, on_error = function(result) if result.ref and result.ref.display_lnum ~= nil then - vim.notify("Error replacing: " .. (result.value or "unknown error"), vim.log.levels.ERROR) + vim.notify('Error replacing: ' .. (result.value or 'unknown error'), vim.log.levels.ERROR) -- Add error mark to the line local bufnr = api.nvim_get_current_buf() local line = result.ref.display_lnum @@ -245,8 +261,10 @@ function M.run_replace(entries) -- Trigger renderer redraw if state.renderer then -- Make sure renderer has redraw method - if type(state.renderer) == "table" and type(state.renderer.redraw) == "function" then - pcall(function() state.renderer:redraw() end) + if type(state.renderer) == 'table' and type(state.renderer.redraw) == 'function' then + pcall(function() + state.renderer:redraw() + end) end end end @@ -305,8 +323,10 @@ M.run_delete_line = function(entries) -- Trigger renderer redraw if state.renderer then -- Make sure renderer has redraw method - if type(state.renderer) == "table" and type(state.renderer.redraw) == "function" then - pcall(function() state.renderer:redraw() end) + if type(state.renderer) == 'table' and type(state.renderer.redraw) == 'function' then + pcall(function() + state.renderer:redraw() + end) end end end @@ -331,8 +351,10 @@ M.run_delete_line = function(entries) -- Trigger renderer redraw if state.renderer then -- Make sure renderer has redraw method - if type(state.renderer) == "table" and type(state.renderer.redraw) == "function" then - pcall(function() state.renderer:redraw() end) + if type(state.renderer) == 'table' and type(state.renderer.redraw) == 'function' then + pcall(function() + state.renderer:redraw() + end) end end end diff --git a/lua/spectre/config.lua b/lua/spectre/config.lua index 635a009..5852365 100644 --- a/lua/spectre/config.lua +++ b/lua/spectre/config.lua @@ -220,7 +220,7 @@ local config = { is_insert_mode = false, is_block_ui_break = false, open_template = {}, - use_legacy_ui = false, -- set to true to use the legacy UI + ui = 'plenary', -- set to true to use the legacy UI } return config diff --git a/lua/spectre/init.lua b/lua/spectre/init.lua index 337b0bc..4507e37 100644 --- a/lua/spectre/init.lua +++ b/lua/spectre/init.lua @@ -35,21 +35,21 @@ local M = {} M.setup = function(opts) opts = opts or {} - state.user_config = vim.tbl_deep_extend("force", state.user_config, opts) + state.user_config = vim.tbl_deep_extend('force', state.user_config, opts) for _, opt in pairs(state.user_config.default.find.options) do state.options[opt] = true end require('spectre.highlight').set_hl() M.check_replace_cmd_bins() - + -- Initialize UI based on configuration M.init_ui() end -- Initialize UI based on user config M.init_ui = function() - if state.user_config.use_legacy_ui then - ui = require('spectre.ui.legacy') + if state.user_config.ui == 'plenary' then + ui = require('spectre.ui.plenary') else ui = require('spectre.ui.nui_components') end @@ -64,10 +64,7 @@ M.check_replace_cmd_bins = function() args = { 'oxi' }, on_exit = function(j, return_val) if return_val ~= 0 then - vim.notify( - 'oxi not found. Please install it with: cargo install oxi', - vim.log.levels.WARN - ) + vim.notify('oxi not found. Please install it with: cargo install oxi', vim.log.levels.WARN) end end, }):sync() @@ -87,7 +84,7 @@ M.open = function(opts) state.status_line = '' state.async_id = nil state.view = { - mode = "both", + mode = 'both', show_search = true, show_replace = true, } @@ -97,7 +94,7 @@ M.open = function(opts) if ui == nil then M.init_ui() end - + ui.open() end @@ -111,7 +108,9 @@ M.close = function() end M.on_write = function() - if not state.is_open then return end + if not state.is_open then + return + end if state.user_config.live_update then M.search(state.query) end @@ -119,7 +118,9 @@ end M.search = function(query) query = query or state.query - if not query.search_query or #query.search_query == 0 then return end + if not query.search_query or #query.search_query == 0 then + return + end state.is_running = true state.query = query @@ -128,20 +129,51 @@ M.search = function(query) local finder_creator = state_utils.get_finder_creator() state.finder_instance = finder_creator:new(state_utils.get_search_engine_config(), { - on_result = function(result) - if not state.is_running then return end - table.insert(state.total_item, result) + on_start = function() + state.total_item = {} + state.status_line = 'Start search' + end, + on_result = function(item) + if not state.is_running then + return + end + + -- Process the item for display + if string.match(item.filename, '^%.%/') then + item.filename = item.filename:sub(3, #item.filename) + end + item.search_text = utils.truncate(utils.trim(item.text), 255) + item.replace_text = '' + + if #state.query.replace_query > 1 then + local regex = state_utils.get_regex() + if regex then + item.replace_text = + regex.replace_all(state.query.search_query, state.query.replace_query, item.search_text) + end + end + + table.insert(state.total_item, item) end, on_error = function(error_msg) - if not state.is_running then return end + if not state.is_running then + return + end state.status_line = 'Error: ' .. error_msg state.finder_instance = nil end, on_finish = function() - if not state.is_running then return end - state.status_line = 'Search completed' + if not state.is_running then + return + end + state.status_line = string.format('Total: %s matches', #state.total_item) state.finder_instance = nil state.is_running = false + + -- Render the results in the UI + if ui and ui.render_results then + ui.render_results() + end end, }) @@ -208,7 +240,7 @@ M.change_options = function(key) if ui == nil then M.init_ui() end - + if ui and ui.render_search_ui then ui.render_search_ui() end @@ -217,16 +249,25 @@ M.change_options = function(key) end M.show_options = function() - if not ui then + if not ui then M.init_ui() end - + if ui and ui.show_options then - ui.show_options() + ui.show_options() end end M.get_fold = function(lnum) + if not ui then + M.init_ui() + end + + if ui and ui.get_fold then + return ui.get_fold(lnum) + end + + -- Fallback implementation if lnum < config.lnum_UI then return '0' end @@ -249,98 +290,76 @@ M.get_fold = function(lnum) end M.tab = function() - if not ui then + if not ui then M.init_ui() end - + if ui and ui.tab then ui.tab() end end M.tab_shift = function() - if not ui then + if not ui then M.init_ui() end - + if ui and ui.tab_shift then ui.tab_shift() end end M.toggle_preview = function() - if not ui then + if not ui then M.init_ui() end - + if ui and ui.toggle_preview then ui.toggle_preview() end end --- Function to toggle between different view modes +M.toggle_checked = function() + if not ui then + M.init_ui() + end + + local lnum = unpack(vim.api.nvim_win_get_cursor(0)) + local item = state.total_item[lnum] + if item and item.display_lnum == lnum - 1 then + item.disable = not item.disable + + if ui and ui.render_results then + ui.render_results() + end + end +end + +-- TODO: Should we need it? M.change_view = function() - if not ui then + if not ui then M.init_ui() end - + -- Toggle view mode - if state.view.mode == "both" then - state.view.mode = "replace" + if state.view.mode == 'both' then + state.view.mode = 'replace' state.view.show_search = false state.view.show_replace = true - elseif state.view.mode == "replace" then - state.view.mode = "search" + elseif state.view.mode == 'replace' then + state.view.mode = 'search' state.view.show_search = true state.view.show_replace = false else - state.view.mode = "both" + state.view.mode = 'both' state.view.show_search = true state.view.show_replace = true end - + -- Trigger UI update if available if ui and ui.render_search_ui then ui.render_search_ui() end end --- Function to toggle between UI types -M.toggle_ui = function() - state.user_config.use_legacy_ui = not state.user_config.use_legacy_ui - - -- Re-initialize the UI - M.init_ui() - - -- Notify the user - local ui_type = state.user_config.use_legacy_ui and "legacy" or "modern" - vim.notify("Switched to " .. ui_type .. " UI. Reopen spectre panel to apply changes.", vim.log.levels.INFO) - - -- If spectre is open, close and reopen it to apply changes - if state.is_open then - local query_backup = vim.deepcopy(state.query) - M.close() - M.open(query_backup) - end -end - --- Function to set the UI type -M.set_ui_type = function(use_legacy) - state.user_config.use_legacy_ui = use_legacy - - -- Re-initialize the UI - M.init_ui() - - -- Notify the user - local ui_type = state.user_config.use_legacy_ui and "legacy" or "modern" - vim.notify("Set to " .. ui_type .. " UI. Reopen spectre panel to apply changes.", vim.log.levels.INFO) - - -- If spectre is open, close and reopen it to apply changes - if state.is_open then - local query_backup = vim.deepcopy(state.query) - M.close() - M.open(query_backup) - end -end - return M diff --git a/lua/spectre/state.lua b/lua/spectre/state.lua index 865d114..9e5c84c 100644 --- a/lua/spectre/state.lua +++ b/lua/spectre/state.lua @@ -25,58 +25,58 @@ local M = {} M.user_config = { default = { find = { - cmd = "rg", + cmd = 'rg', }, replace = { - cmd = "sed", + cmd = 'sed', }, }, find_engine = { rg = { - cmd = "rg", + cmd = 'rg', args = { - "--color=never", - "--no-heading", - "--with-filename", - "--line-number", - "--column", + '--color=never', + '--no-heading', + '--with-filename', + '--line-number', + '--column', }, options = { - ["ignore-case"] = { - value = "-i", - icon = "[I]", - desc = "ignore case", + ['ignore-case'] = { + value = '-i', + icon = '[I]', + desc = 'ignore case', }, - ["hidden"] = { - value = "--hidden", - desc = "hidden file", - icon = "[H]", + ['hidden'] = { + value = '--hidden', + desc = 'hidden file', + icon = '[H]', }, }, }, }, replace_engine = { oxi = { - cmd = "oxi", + cmd = 'oxi', args = {}, options = { - ["ignore-case"] = { - value = "-i", - icon = "[I]", - desc = "ignore case", + ['ignore-case'] = { + value = '-i', + icon = '[I]', + desc = 'ignore case', }, }, }, }, live_update = false, - line_sep = "└──────────────────────────────────────────────────────", - result_padding = "│ ", - line_sep_start = "┌──────────────────────────────────────────────────────", + line_sep = '└──────────────────────────────────────────────────────', + result_padding = '│ ', + line_sep_start = '┌──────────────────────────────────────────────────────', highlight = { - ui = "SpectreBody", - search = "SpectreSearch", - replace = "SpectreReplace", - border = "SpectreBorder", + ui = 'SpectreBody', + search = 'SpectreSearch', + replace = 'SpectreReplace', + border = 'SpectreBorder', }, } @@ -89,10 +89,10 @@ M.target_bufnr = nil M.finder_instance = nil M.total_item = {} M.is_running = false -M.status_line = "" +M.status_line = '' M.async_id = nil M.view = { - mode = "both", + mode = 'both', show_search = true, show_replace = true, } diff --git a/lua/spectre/state_utils.lua b/lua/spectre/state_utils.lua index e52c3b7..a3a4f26 100644 --- a/lua/spectre/state_utils.lua +++ b/lua/spectre/state_utils.lua @@ -17,11 +17,16 @@ M.get_regex = function() if not state.regex then -- Default to vim regex as a fallback local regex_engine_name = 'vim' - + -- Try to use the current replace engine's regex - if state.user_config and state.user_config.default and state.user_config.default.replace and state.user_config.default.replace.cmd then + if + state.user_config + and state.user_config.default + and state.user_config.default.replace + and state.user_config.default.replace.cmd + then local replace_cmd = state.user_config.default.replace.cmd - + -- Map replace engines to regex engines if replace_cmd == 'oxi' then regex_engine_name = 'rust' @@ -31,12 +36,12 @@ M.get_regex = function() regex_engine_name = 'rust' end end - + -- Require the regex engine local success, regex = pcall(require, 'spectre.regex.' .. regex_engine_name) if success then state.regex = regex - + -- Initialize options if available local cfg = M.get_replace_engine_config() if cfg and cfg.options_value then @@ -47,7 +52,7 @@ M.get_regex = function() state.regex = require('spectre.regex.vim') end end - + return state.regex end diff --git a/lua/spectre/ui.lua b/lua/spectre/ui.lua deleted file mode 100644 index 78295e5..0000000 --- a/lua/spectre/ui.lua +++ /dev/null @@ -1,289 +0,0 @@ -local has_devicons, devicons = pcall(require, 'nvim-web-devicons') -local has_mini_icons, mini_icons = pcall(require, 'mini.icons') -local config = require('spectre.config') -local state = require('spectre.state') -local state_utils = require('spectre.state_utils') -local utils = require('spectre.utils') - -local Path = require('plenary.path') - -local popup = require('plenary.popup') -local api = vim.api - -local M = {} - ----@param regex RegexEngine -M.render_line = function(bufnr, namespace, text_opts, view_opts, regex) - local cfg = state.user_config - local diff = utils.get_hl_line_text({ - search_query = text_opts.search_query, - replace_query = text_opts.replace_query, - search_text = text_opts.search_text, - show_search = view_opts.show_search, - show_replace = view_opts.show_replace, - is_replace = text_opts.is_replace, - }, regex) - local end_lnum = text_opts.is_replace == true and text_opts.lnum + 1 or text_opts.lnum - - local item_line_len = 0 - if cfg.lnum_for_results == true then - item_line_len = string.len(text_opts.item_line) + 1 - api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { - view_opts.padding_text .. text_opts.item_line .. ' ' .. diff.text, - }) - else - api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { - view_opts.padding_text .. diff.text, - }) - end - - if not view_opts.is_disable then - for _, value in pairs(diff.search) do - api.nvim_buf_add_highlight( - bufnr, - namespace, - cfg.highlight.search, - text_opts.lnum, - value[1] + view_opts.padding + item_line_len, - value[2] + view_opts.padding + item_line_len - ) - end - for _, value in pairs(diff.replace) do - api.nvim_buf_add_highlight( - bufnr, - namespace, - cfg.highlight.replace, - text_opts.lnum, - value[1] + view_opts.padding + item_line_len, - value[2] + view_opts.padding + item_line_len - ) - end - api.nvim_buf_add_highlight( - state.bufnr, - config.namespace, - cfg.highlight.border, - text_opts.lnum, - 0, - view_opts.padding - ) - else - api.nvim_buf_add_highlight(state.bufnr, config.namespace, cfg.highlight.border, text_opts.lnum, 0, -1) - end -end - -local get_devicons = (function() - if has_devicons then - if not devicons.has_loaded() then - devicons.setup() - end - - return function(filename, enable_icon, default) - if not enable_icon or not filename then - return default or '|', '' - end - local icon, icon_highlight = devicons.get_icon(filename, string.match(filename, '%a+$'), { default = true }) - return icon, icon_highlight - end - elseif has_mini_icons then - if not _G.MiniIcons then - mini_icons.setup() - end - - return function(filename, enable_icon, default) - if not enable_icon or not filename then - return default or '|', '' - end - local icon, icon_highlight = mini_icons.get('file', filename) - return icon, icon_highlight - end - else - return function(_, _) - return '' - end - end -end)() - -M.render_filename = function(bufnr, namespace, line, entry) - local u_config = state.user_config - local filename = vim.fn.fnamemodify(entry.filename, ':t') - local directory = vim.fn.fnamemodify(entry.filename, ':h') - if directory == '.' then - directory = '' - else - directory = directory .. Path.path.sep - end - - local icon_length = state.user_config.color_devicons and 4 or 2 - local icon, icon_highlight = get_devicons(filename, state.user_config.color_devicons, '+') - - api.nvim_buf_set_lines(state.bufnr, line, line, false, { - string.format('%s %s%s:', icon, directory, filename), - }) - - local width = vim.api.nvim_strwidth(filename) - local hl = { - { { 0, icon_length }, icon_highlight }, - { { 0, vim.api.nvim_strwidth(directory) }, u_config.highlight.filedirectory }, - { { 0, width + 1 }, u_config.highlight.filename }, - } - if icon == '' then - table.remove(hl, 1) - end - local pos = 0 - for _, value in pairs(hl) do - pcall(function() - api.nvim_buf_add_highlight(bufnr, namespace, value[2], line, pos + value[1][1], pos + value[1][2]) - pos = value[1][2] + pos - end) - end -end - -function M.render_search_ui() - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_ui, 0, config.lnum_UI) - local details_ui = {} - local search_message = 'Search: ' - local cfg = state_utils.get_search_engine_config() - for key, value in pairs(state.options) do - if value == true and cfg.options[key] then - search_message = search_message .. cfg.options[key].icon - end - end - - table.insert(details_ui, { { search_message, state.user_config.highlight.ui } }) - table.insert(details_ui, { { 'Replace: ', state.user_config.highlight.ui } }) - local path_message = 'Path:' - if state.cwd then - path_message = path_message .. string.format(' cwd=%s', state.cwd) - end - table.insert(details_ui, { { path_message, state.user_config.highlight.ui } }) - - local c_line = 1 - for _, vt_text in ipairs(details_ui) do - utils.write_virtual_text(state.bufnr, config.namespace_ui, c_line, vt_text) - c_line = c_line + 2 - end - M.render_header(state.user_config) -end - -function M.render_header(opts) - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_header, 0, config.lnum_UI) - local help_text = string.format( - '[Nvim Spectre] (Search by %s) %s (Replace by %s) (Press ? for mappings)', - state.user_config.default.find.cmd, - opts.live_update and '(Auto update)' or '', - state.user_config.default.replace.cmd - ) - utils.write_virtual_text( - state.bufnr, - config.namespace_header, - 0, - { { help_text, state.user_config.highlight.headers } } - ) -end - -M.show_menu_options = function(title, content) - local win_width, win_height = vim.lsp.util._make_floating_popup_size(content, {}) - - local bufnr = vim.api.nvim_create_buf(false, true) - api.nvim_buf_set_option(bufnr, 'bufhidden', 'wipe') - api.nvim_buf_set_lines(bufnr, 0, -1, true, content) - - local help_win = vim.api.nvim_open_win(bufnr, false, { - style = 'minimal', - title = ' ' .. title .. ' ', - title_pos = 'center', - relative = 'cursor', - width = win_width, - height = win_height, - col = 0, - row = 1, - border = 'rounded', - }) - api.nvim_win_set_option(help_win, 'winblend', 0) - api.nvim_buf_set_keymap( - bufnr, - 'n', - '', - 'lua vim.api.nvim_win_close(' .. help_win .. ', true)', - { noremap = true } - ) - - api.nvim_create_autocmd({ - 'CursorMovedI', - 'CursorMoved', - 'CursorMovedI', - 'BufHidden', - 'BufLeave', - 'InsertEnter', - 'WinScrolled', - 'BufDelete', - }, { - callback = function() - pcall(vim.api.nvim_win_close, help_win, true) - end, - }) -end - -M.show_help = function() - local help_msg = {} - local map_tbl = {} - for _, map in pairs(state.user_config.mapping) do - table.insert(map_tbl, map) - end - -- sort by length - table.sort(map_tbl, function(a, b) - return (#a.map or 0) < (#b.map or 0) - end) - - for _, map in pairs(map_tbl) do - table.insert(help_msg, string.format('%9s : %s', map.map, map.desc)) - end - - M.show_menu_options('Mappings', help_msg) -end - -M.show_options = function() - local cfg = state_utils.get_search_engine_config() - local help_msg = { ' Press number to select option.' } - local option_cmd = {} - local i = 1 - - for key, option in pairs(cfg.options) do - table.insert(help_msg, string.format(' %s : toggle %s', i, option.desc or ' ')) - table.insert(option_cmd, key) - i = i + 1 - end - - M.show_menu_options('Options', help_msg) - return option_cmd -end - -M.show_find_engine = function() - local engines = state.user_config.find_engine - - local help_msg = { ' Press number to select option.' } - local option_cmd = {} - local i = 1 - - for key, option in pairs(engines) do - table.insert(help_msg, string.format(' %s : engine %s', i, option.desc or ' ')) - table.insert(option_cmd, key) - i = i + 1 - end -end - -M.render_text_query = function(opts) - -- set empty line for virtual text - local lines = {} - local length = config.lnum_UI - for _ = 1, length, 1 do - table.insert(lines, '') - end - api.nvim_buf_set_lines(state.bufnr, 0, 0, false, lines) - api.nvim_buf_set_lines(state.bufnr, 2, 2, false, { opts.search_text }) - api.nvim_buf_set_lines(state.bufnr, 4, 4, false, { opts.replace_text }) - api.nvim_buf_set_lines(state.bufnr, 6, 6, false, { opts.path }) - api.nvim_win_set_cursor(0, { opts.begin_line_num or 3, 0 }) -end - -return M diff --git a/lua/spectre/ui/legacy/init.lua b/lua/spectre/ui/legacy/init.lua deleted file mode 100644 index e47aca9..0000000 --- a/lua/spectre/ui/legacy/init.lua +++ /dev/null @@ -1,276 +0,0 @@ -local has_devicons, devicons = pcall(require, 'nvim-web-devicons') -local config = require('spectre.config') -local state = require('spectre.state') -local state_utils = require('spectre.state_utils') -local utils = require('spectre.utils') - -local Path = require('plenary.path') - -local popup = require('plenary.popup') -local api = vim.api - -local M = {} - ----@param regex RegexEngine -M.render_line = function(bufnr, namespace, text_opts, view_opts, regex) - local cfg = state.user_config - local diff = utils.get_hl_line_text({ - search_query = text_opts.search_query, - replace_query = text_opts.replace_query, - search_text = text_opts.search_text, - show_search = view_opts.show_search, - show_replace = view_opts.show_replace, - is_replace = text_opts.is_replace, - }, regex) - local end_lnum = text_opts.is_replace == true and text_opts.lnum + 1 or text_opts.lnum - - local item_line_len = 0 - if cfg.lnum_for_results == true then - item_line_len = string.len(text_opts.item_line) + 1 - api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { - view_opts.padding_text .. text_opts.item_line .. ' ' .. diff.text, - }) - else - api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { - view_opts.padding_text .. diff.text, - }) - end - - if not view_opts.is_disable then - for _, value in pairs(diff.search) do - api.nvim_buf_add_highlight( - bufnr, - namespace, - cfg.highlight.search, - text_opts.lnum, - value[1] + view_opts.padding + item_line_len, - value[2] + view_opts.padding + item_line_len - ) - end - for _, value in pairs(diff.replace) do - api.nvim_buf_add_highlight( - bufnr, - namespace, - cfg.highlight.replace, - text_opts.lnum, - value[1] + view_opts.padding + item_line_len, - value[2] + view_opts.padding + item_line_len - ) - end - api.nvim_buf_add_highlight( - state.bufnr, - config.namespace, - cfg.highlight.border, - text_opts.lnum, - 0, - view_opts.padding - ) - else - api.nvim_buf_add_highlight(state.bufnr, config.namespace, cfg.highlight.border, text_opts.lnum, 0, -1) - end -end - -local get_devicons = (function() - if has_devicons then - if not devicons.has_loaded() then - devicons.setup() - end - - return function(filename, enable_icon, default) - if not enable_icon or not filename then - return default or '|', '' - end - local icon, icon_highlight = devicons.get_icon(filename, string.match(filename, '%a+$'), { default = true }) - return icon, icon_highlight - end - else - return function(_, _) - return '' - end - end -end)() - -M.render_filename = function(bufnr, namespace, line, entry) - local u_config = state.user_config - local filename = vim.fn.fnamemodify(entry.filename, ':t') - local directory = vim.fn.fnamemodify(entry.filename, ':h') - if directory == '.' then - directory = '' - else - directory = directory .. Path.path.sep - end - - local icon_length = state.user_config.color_devicons and 4 or 2 - local icon, icon_highlight = get_devicons(filename, state.user_config.color_devicons, '+') - - api.nvim_buf_set_lines(state.bufnr, line, line, false, { - string.format('%s %s%s:', icon, directory, filename), - }) - - local width = vim.api.nvim_strwidth(filename) - local hl = { - { { 0, icon_length }, icon_highlight }, - { { 0, vim.api.nvim_strwidth(directory) }, u_config.highlight.filedirectory }, - { { 0, width + 1 }, u_config.highlight.filename }, - } - if icon == '' then - table.remove(hl, 1) - end - local pos = 0 - for _, value in pairs(hl) do - pcall(function() - api.nvim_buf_add_highlight(bufnr, namespace, value[2], line, pos + value[1][1], pos + value[1][2]) - pos = value[1][2] + pos - end) - end -end - -function M.render_search_ui() - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_ui, 0, config.lnum_UI) - local details_ui = {} - local search_message = 'Search: ' - local cfg = state_utils.get_search_engine_config() - for key, value in pairs(state.options) do - if value == true and cfg.options[key] then - search_message = search_message .. cfg.options[key].icon - end - end - - table.insert(details_ui, { { search_message, state.user_config.highlight.ui } }) - table.insert(details_ui, { { 'Replace: ', state.user_config.highlight.ui } }) - local path_message = 'Path:' - if state.cwd then - path_message = path_message .. string.format(' cwd=%s', state.cwd) - end - table.insert(details_ui, { { path_message, state.user_config.highlight.ui } }) - - local c_line = 1 - for _, vt_text in ipairs(details_ui) do - utils.write_virtual_text(state.bufnr, config.namespace_ui, c_line, vt_text) - c_line = c_line + 2 - end - M.render_header(state.user_config) -end - -function M.render_header(opts) - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_header, 0, config.lnum_UI) - local help_text = string.format( - '[Nvim Spectre] (Search by %s) %s (Replace by %s) (Press ? for mappings)', - state.user_config.default.find.cmd, - opts.live_update and '(Auto update)' or '', - state.user_config.default.replace.cmd - ) - utils.write_virtual_text( - state.bufnr, - config.namespace_header, - 0, - { { help_text, state.user_config.highlight.headers } } - ) -end - -M.show_menu_options = function(title, content) - local win_width, win_height = vim.lsp.util._make_floating_popup_size(content, {}) - - local bufnr = vim.api.nvim_create_buf(false, true) - api.nvim_buf_set_option(bufnr, 'bufhidden', 'wipe') - api.nvim_buf_set_lines(bufnr, 0, -1, true, content) - - local help_win = vim.api.nvim_open_win(bufnr, false, { - style = 'minimal', - title = ' ' .. title .. ' ', - title_pos = 'center', - relative = 'cursor', - width = win_width, - height = win_height, - col = 0, - row = 1, - border = 'rounded', - }) - api.nvim_win_set_option(help_win, 'winblend', 0) - api.nvim_buf_set_keymap( - bufnr, - 'n', - '', - 'lua vim.api.nvim_win_close(' .. help_win .. ', true)', - { noremap = true } - ) - - api.nvim_create_autocmd({ - 'CursorMovedI', - 'CursorMoved', - 'CursorMovedI', - 'BufHidden', - 'BufLeave', - 'InsertEnter', - 'WinScrolled', - 'BufDelete', - }, { - callback = function() - pcall(vim.api.nvim_win_close, help_win, true) - end, - }) -end - -M.show_help = function() - local help_msg = {} - local map_tbl = {} - for _, map in pairs(state.user_config.mapping) do - table.insert(map_tbl, map) - end - -- sort by length - table.sort(map_tbl, function(a, b) - return (#a.map or 0) < (#b.map or 0) - end) - - for _, map in pairs(map_tbl) do - table.insert(help_msg, string.format('%9s : %s', map.map, map.desc)) - end - - M.show_menu_options('Mappings', help_msg) -end - -M.show_options = function() - local cfg = state_utils.get_search_engine_config() - local help_msg = { ' Press number to select option.' } - local option_cmd = {} - local i = 1 - - for key, option in pairs(cfg.options) do - table.insert(help_msg, string.format(' %s : toggle %s', i, option.desc or ' ')) - table.insert(option_cmd, key) - i = i + 1 - end - - M.show_menu_options('Options', help_msg) - return option_cmd -end - -M.show_find_engine = function() - local engines = state.user_config.find_engine - - local help_msg = { ' Press number to select option.' } - local option_cmd = {} - local i = 1 - - for key, option in pairs(engines) do - table.insert(help_msg, string.format(' %s : engine %s', i, option.desc or ' ')) - table.insert(option_cmd, key) - i = i + 1 - end -end - -M.render_text_query = function(opts) - -- set empty line for virtual text - local lines = {} - local length = config.lnum_UI - for _ = 1, length, 1 do - table.insert(lines, '') - end - api.nvim_buf_set_lines(state.bufnr, 0, 0, false, lines) - api.nvim_buf_set_lines(state.bufnr, 2, 2, false, { opts.search_text }) - api.nvim_buf_set_lines(state.bufnr, 4, 4, false, { opts.replace_text }) - api.nvim_buf_set_lines(state.bufnr, 6, 6, false, { opts.path }) - api.nvim_win_set_cursor(0, { opts.begin_line_num or 3, 0 }) -end - -return M diff --git a/lua/spectre/ui/nui_components/init.lua b/lua/spectre/ui/nui_components/init.lua index 874bef7..e527c25 100644 --- a/lua/spectre/ui/nui_components/init.lua +++ b/lua/spectre/ui/nui_components/init.lua @@ -1,14 +1,14 @@ local M = {} -local n = require("nui-components") +local n = require('nui-components') if not n then - error("Failed to load nui-components") + error('Failed to load nui-components') end -local state = require("spectre.state") +local state = require('spectre.state') local api = vim.api -local state_utils = require("spectre.state_utils") +local state_utils = require('spectre.state_utils') local has_devicons, devicons = pcall(require, 'nvim-web-devicons') -local utils = require("spectre.utils") +local utils = require('spectre.utils') local preview_buf = nil local preview_namespace = api.nvim_create_namespace('SPECTRE_PREVIEW') @@ -16,22 +16,22 @@ local preview_namespace = api.nvim_create_namespace('SPECTRE_PREVIEW') local function create_search_ui() -- Create a new buffer for the results local bufnr = api.nvim_create_buf(false, true) - api.nvim_buf_set_option(bufnr, "filetype", "spectre_panel") - api.nvim_buf_set_option(bufnr, "buftype", "nofile") - api.nvim_buf_set_option(bufnr, "bufhidden", "wipe") - api.nvim_buf_set_option(bufnr, "buflisted", false) + api.nvim_buf_set_option(bufnr, 'filetype', 'spectre_panel') + api.nvim_buf_set_option(bufnr, 'buftype', 'nofile') + api.nvim_buf_set_option(bufnr, 'bufhidden', 'wipe') + api.nvim_buf_set_option(bufnr, 'buflisted', false) -- Create a separate buffer for preview local preview_bufnr = api.nvim_create_buf(false, true) - api.nvim_buf_set_option(preview_bufnr, "buflisted", false) - api.nvim_buf_set_option(preview_bufnr, "wrap", true) - api.nvim_buf_set_option(preview_bufnr, "number", true) - api.nvim_buf_set_option(preview_bufnr, "relativenumber", true) + api.nvim_buf_set_option(preview_bufnr, 'buflisted', false) + api.nvim_buf_set_option(preview_bufnr, 'wrap', true) + api.nvim_buf_set_option(preview_bufnr, 'number', true) + api.nvim_buf_set_option(preview_bufnr, 'relativenumber', true) local signal = n.create_signal({ - search_text = "", - replace_text = "", - path = "", + search_text = '', + replace_text = '', + path = '', is_file = false, results = {}, has_search = false, @@ -43,8 +43,8 @@ local function create_search_ui() n.columns( { flex = 0 }, n.text_input({ - id = "search-input", - border_label = "Search", + id = 'search-input', + border_label = 'Search', autofocus = true, flex = 1, max_lines = 1, @@ -60,8 +60,8 @@ local function create_search_ui() n.columns( { flex = 0 }, n.text_input({ - id = "replace-input", - border_label = "Replace", + id = 'replace-input', + border_label = 'Replace', flex = 1, max_lines = 1, on_change = function(value) @@ -74,9 +74,9 @@ local function create_search_ui() ), n.tree({ - id = "results-tree", + id = 'results-tree', flex = 1, - border_label = "Results", + border_label = 'Results', data = signal.results, hidden = signal.has_search:negate(), on_select = function(node, component) @@ -92,7 +92,7 @@ local function create_search_ui() end, on_change = function(focused_node) if focused_node.filename then - local full_path = vim.fn.fnamemodify(focused_node.filename, ":p") + local full_path = vim.fn.fnamemodify(focused_node.filename, ':p') if vim.fn.filereadable(full_path) then local lines = vim.fn.readfile(full_path) api.nvim_buf_set_lines(preview_bufnr, 0, -1, false, lines) @@ -108,11 +108,11 @@ local function create_search_ui() local success, result = pcall(function() return utils.match_text_line(state.query.search_query, line, 0) end) - - if success and type(result) == "table" then + + if success and type(result) == 'table' then matches = result end - + for _, match in ipairs(matches) do -- Safely add highlight pcall(function() @@ -135,7 +135,7 @@ local function create_search_ui() if line_num then -- Set cursor to the line number in the preview buffer api.nvim_buf_call(preview_bufnr, function() - vim.cmd("normal! " .. line_num .. "G") + vim.cmd('normal! ' .. line_num .. 'G') end) end end @@ -147,15 +147,15 @@ local function create_search_ui() prepare_node = function(node, line, component) if node.is_done ~= nil then if node.is_done then - local icon = "✔" - local hl = "String" + local icon = '✔' + local hl = 'String' if has_devicons then icon = '󰱒' end line:append(' ' .. icon .. ' ', hl) else - local icon = "◻" - local hl = "Comment" + local icon = '◻' + local hl = 'Comment' if has_devicons then icon = '' end @@ -164,7 +164,7 @@ local function create_search_ui() end if node.icon then - line:append(" " .. node.icon .. " ", node.icon_highlight) + line:append(' ' .. node.icon .. ' ', node.icon_highlight) end -- Add search highlighting if there's a search query @@ -174,21 +174,25 @@ local function create_search_ui() local success, result = pcall(function() return utils.match_text_line(state.query.search_query, node.text, 0) end) - - if success and type(result) == "table" then + + if success and type(result) == 'table' then matches = result end - + local last_pos = 0 - local max_width = vim.api.nvim_win_get_width(0) - - 15 -- Leave some space for icons and padding + local max_width = vim.api.nvim_win_get_width(0) - 15 -- Leave some space for icons and padding local truncated_text = utils.truncate(node.text, max_width) -- Find if this node has been replaced local is_replaced = false if state.total_item and node.display_lnum ~= nil then for _, item in ipairs(state.total_item) do - if item and item.display_lnum and item.display_lnum == node.display_lnum and item.is_replace then + if + item + and item.display_lnum + and item.display_lnum == node.display_lnum + and item.is_replace + then is_replaced = true break end @@ -200,10 +204,10 @@ local function create_search_ui() if match[1] > last_pos then line:append(truncated_text:sub(last_pos + 1, match[1])) end - + -- Add highlighted match line:append(truncated_text:sub(match[1] + 1, match[2]), state.user_config.highlight.search) - + -- Add replacement preview if exists and not replaced yet if state.query.replace_query and #state.query.replace_query > 0 and not is_replaced then -- Get the regex engine with safety check @@ -215,7 +219,7 @@ local function create_search_ui() -- Fallback to vim regex regex = require('spectre.regex.vim') end - + -- Calculate replace_match with error handling local replace_match = {} success, result = pcall(function() @@ -224,32 +228,34 @@ local function create_search_ui() replace_query = state.query.replace_query, search_text = truncated_text:sub(match[1] + 1, match[2]), show_search = true, - show_replace = true + show_replace = true, }, regex).replace end) - + if success then replace_match = result end - - if type(replace_match) == "table" and #replace_match > 0 then + + if type(replace_match) == 'table' and #replace_match > 0 then -- Calculate replace_text with error handling - local replace_text = "" + local replace_text = '' success, result = pcall(function() - return " → (" .. utils.get_hl_line_text({ - search_query = state.query.search_query, - replace_query = state.query.replace_query, - search_text = truncated_text:sub(match[1] + 1, match[2]), - }, regex).text .. ")" + return ' → (' + .. utils.get_hl_line_text({ + search_query = state.query.search_query, + replace_query = state.query.replace_query, + search_text = truncated_text:sub(match[1] + 1, match[2]), + }, regex).text + .. ')' end) - + if success then replace_text = result line:append(replace_text, state.user_config.highlight.replace) end end end - + last_pos = match[2] end -- Add remaining text after last match @@ -257,17 +263,16 @@ local function create_search_ui() line:append(truncated_text:sub(last_pos + 1)) end else - local max_width = vim.api.nvim_win_get_width(0) - - 15 -- Leave some space for icons and padding + local max_width = vim.api.nvim_win_get_width(0) - 15 -- Leave some space for icons and padding line:append(utils.truncate(node.text, max_width)) end return line end, }), n.buffer({ - id = "preview-buffer", + id = 'preview-buffer', flex = 1, - border_label = "Preview", + border_label = 'Preview', hidden = signal.preview_visible:negate(), is_focusable = false, buf = preview_bufnr, @@ -276,8 +281,8 @@ local function create_search_ui() n.columns( { flex = 0 }, n.text_input({ - id = "path-input", - border_label = "Path", + id = 'path-input', + border_label = 'Path', flex = 1, max_lines = 1, on_change = function(value) @@ -291,7 +296,7 @@ local function create_search_ui() n.columns( { flex = 0 }, n.button({ - label = "Options", + label = 'Options', on_press = function() vim.schedule(function() M.show_options() @@ -300,7 +305,7 @@ local function create_search_ui() }), n.gap(3), n.button({ - label = "Replace All", + label = 'Replace All', on_press = function() vim.schedule(function() require('spectre.actions').run_replace() @@ -317,16 +322,16 @@ local function create_search_ui() buf = bufnr, parent = vim.api.nvim_get_current_win(), border = { - style = "rounded", + style = 'rounded', text = { - top = "[Nvim Spectre]", - top_align = "center", + top = '[Nvim Spectre]', + top_align = 'center', }, }, }) if not new_renderer then - error("Failed to create renderer") + error('Failed to create renderer') end new_renderer:render(body) @@ -344,23 +349,29 @@ function M.open() end function M.on_search_change() - if not state.renderer then return end + if not state.renderer then + return + end local query = { - search_query = state.renderer:get_component_by_id("search-input"):get_current_value(), - replace_query = state.renderer:get_component_by_id("replace-input"):get_current_value(), - path = state.renderer:get_component_by_id("path-input"):get_current_value(), + search_query = state.renderer:get_component_by_id('search-input'):get_current_value(), + replace_query = state.renderer:get_component_by_id('replace-input'):get_current_value(), + path = state.renderer:get_component_by_id('path-input'):get_current_value(), } state.query = query -- Store the query in state M.search(query) end function M.search(query) - if not state.renderer then return end - local results_component = state.renderer:get_component_by_id("results-tree") - if not results_component then return end + if not state.renderer then + return + end + local results_component = state.renderer:get_component_by_id('results-tree') + if not results_component then + return + end local results = {} - local last_filename = "" + local last_filename = '' local current_group = nil state.total_item = {} -- Reset total_item @@ -369,16 +380,16 @@ function M.search(query) state.finder_instance = finder_creator:new(state_utils.get_search_engine_config(), { on_result = function(result) if last_filename ~= result.filename then - local icon, icon_highlight = "", "" + local icon, icon_highlight = '', '' if has_devicons then - icon, icon_highlight = devicons.get_icon(result.filename, "", { default = true }) + icon, icon_highlight = devicons.get_icon(result.filename, '', { default = true }) end current_group = n.node({ text = result.filename, icon = icon, icon_highlight = icon_highlight, - children = {} + children = {}, }) table.insert(results, current_group) last_filename = result.filename @@ -389,9 +400,9 @@ function M.search(query) filename = result.filename, col = result.col, lnum = result.lnum, - text = string.format("%d:%d: %s", result.lnum, result.col, result.text), + text = string.format('%d:%d: %s', result.lnum, result.col, result.text), is_done = false, - display_lnum = #state.total_item + display_lnum = #state.total_item, }) table.insert(results, entry) -- Store the entry in state.total_item with all required fields @@ -401,7 +412,7 @@ function M.search(query) lnum = result.lnum, text = result.text, display_lnum = #state.total_item, - is_replace_finish = false + is_replace_finish = false, }) end end, @@ -422,13 +433,15 @@ function M.search(query) end function M.show_options() - if not state.renderer then return end + if not state.renderer then + return + end local cfg = state_utils.get_search_engine_config() local options = {} local i = 1 for key, option in pairs(cfg.options) do - table.insert(options, n.option(string.format("%d: %s", i, option.desc or ""), { id = key })) + table.insert(options, n.option(string.format('%d: %s', i, option.desc or ''), { id = key })) i = i + 1 end @@ -437,7 +450,7 @@ function M.show_options() }) local select_component = n.select({ - border_label = "Options", + border_label = 'Options', data = options, selected = signal.selected, multiselect = true, diff --git a/lua/spectre/ui/plenary/init.lua b/lua/spectre/ui/plenary/init.lua new file mode 100644 index 0000000..a432de2 --- /dev/null +++ b/lua/spectre/ui/plenary/init.lua @@ -0,0 +1,641 @@ +local has_devicons, devicons = pcall(require, 'nvim-web-devicons') +local has_mini_icons, mini_icons = pcall(require, 'mini.icons') +local config = require('spectre.config') +local state = require('spectre.state') +local state_utils = require('spectre.state_utils') +local utils = require('spectre.utils') + +local Path = require('plenary.path') +local popup = require('plenary.popup') +local api = vim.api + +local M = {} + +-- Buffer and UI state +M.bufnr = nil +M.namespace = vim.api.nvim_create_namespace('spectre_ui') +M.namespace_result = vim.api.nvim_create_namespace('spectre_result') +M.namespace_header = vim.api.nvim_create_namespace('spectre_header') +M.namespace_status = vim.api.nvim_create_namespace('spectre_status') +M.namespace_ui = vim.api.nvim_create_namespace('spectre_ui_components') + +-- Setup foldexpr function +local foldexpr = function(lnum) + if lnum < config.lnum_UI then + return '0' + end + local line = vim.fn.getline(lnum) + local padding = line:sub(0, #state.user_config.result_padding) + if padding ~= state.user_config.result_padding then + return '>1' + end + + local nextline = vim.fn.getline(lnum + 1) + padding = nextline:sub(0, #state.user_config.result_padding) + if padding ~= state.user_config.result_padding then + return '<1' + end + local item = state.total_item[lnum] + if item ~= nil then + return '1' + end + return '0' +end + +-- Setup folding using Vim script approach +M.setup_folding = function() + -- We'll use the autoload/spectre.vim function, which is already defined + -- Just set up folding parameters + vim.opt_local.foldexpr = 'spectre#foldexpr()' + vim.opt_local.foldmethod = 'expr' +end + +---@param regex RegexEngine +M.render_line = function(bufnr, namespace, text_opts, view_opts, regex) + local cfg = state.user_config + local diff = utils.get_hl_line_text({ + search_query = text_opts.search_query, + replace_query = text_opts.replace_query, + search_text = text_opts.search_text, + show_search = view_opts.show_search, + show_replace = view_opts.show_replace, + }, regex) + local end_lnum = text_opts.is_replace == true and text_opts.lnum + 1 or text_opts.lnum + + local item_line_len = 0 + if cfg.lnum_for_results == true then + item_line_len = string.len(text_opts.item_line) + 1 + api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { + view_opts.padding_text .. text_opts.item_line .. ' ' .. diff.text, + }) + else + api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { + view_opts.padding_text .. diff.text, + }) + end + + if not view_opts.is_disable then + for _, value in pairs(diff.search) do + api.nvim_buf_add_highlight( + bufnr, + namespace, + cfg.highlight.search, + text_opts.lnum, + value[1] + view_opts.padding + item_line_len, + value[2] + view_opts.padding + item_line_len + ) + end + for _, value in pairs(diff.replace) do + api.nvim_buf_add_highlight( + bufnr, + namespace, + cfg.highlight.replace, + text_opts.lnum, + value[1] + view_opts.padding + item_line_len, + value[2] + view_opts.padding + item_line_len + ) + end + api.nvim_buf_add_highlight( + M.bufnr, + config.namespace, + cfg.highlight.border, + text_opts.lnum, + 0, + view_opts.padding + ) + else + api.nvim_buf_add_highlight(M.bufnr, config.namespace, cfg.highlight.border, text_opts.lnum, 0, -1) + end +end + +local get_devicons = (function() + if has_devicons then + if not devicons.has_loaded() then + devicons.setup() + end + + return function(filename, enable_icon, default) + if not enable_icon or not filename then + return default or '|', '' + end + local icon, icon_highlight = devicons.get_icon(filename, string.match(filename, '%a+$'), { default = true }) + return icon, icon_highlight + end + elseif has_mini_icons then + if not _G.MiniIcons then + mini_icons.setup() + end + + return function(filename, enable_icon, default) + if not enable_icon or not filename then + return default or '|', '' + end + local icon, icon_highlight = mini_icons.get('file', filename) + return icon, icon_highlight + end + else + return function(_, _) + return '' + end + end +end)() + +M.render_filename = function(bufnr, namespace, line, entry) + local u_config = state.user_config + local filename = vim.fn.fnamemodify(entry.filename, ':t') + local directory = vim.fn.fnamemodify(entry.filename, ':h') + if directory == '.' then + directory = '' + else + directory = directory .. Path.path.sep + end + + local icon_length = state.user_config.color_devicons and 4 or 2 + local icon, icon_highlight = get_devicons(filename, state.user_config.color_devicons, '+') + + api.nvim_buf_set_lines(M.bufnr, line, line, false, { + string.format('%s %s%s:', icon, directory, filename), + }) + + local width = vim.api.nvim_strwidth(filename) + local hl = { + { { 0, icon_length }, icon_highlight }, + { { 0, vim.api.nvim_strwidth(directory) }, u_config.highlight.filedirectory }, + { { 0, width + 1 }, u_config.highlight.filename }, + } + if icon == '' then + table.remove(hl, 1) + end + local pos = 0 + for _, value in pairs(hl) do + pcall(function() + api.nvim_buf_add_highlight(bufnr, namespace, value[2], line, pos + value[1][1], pos + value[1][2]) + pos = value[1][2] + pos + end) + end +end + +function M.render_search_ui() + api.nvim_buf_clear_namespace(M.bufnr, M.namespace_ui, 0, config.lnum_UI) + local details_ui = {} + local search_message = 'Search: ' + local cfg = state_utils.get_search_engine_config() + for key, value in pairs(state.options) do + if value == true and cfg.options[key] then + search_message = search_message .. cfg.options[key].icon + end + end + + table.insert(details_ui, { { search_message, state.user_config.highlight.ui } }) + table.insert(details_ui, { { 'Replace: ', state.user_config.highlight.ui } }) + local path_message = 'Path:' + if state.cwd then + path_message = path_message .. string.format(' cwd=%s', state.cwd) + end + table.insert(details_ui, { { path_message, state.user_config.highlight.ui } }) + + local c_line = 1 + for _, vt_text in ipairs(details_ui) do + utils.write_virtual_text(M.bufnr, M.namespace_ui, c_line, vt_text) + c_line = c_line + 2 + end + M.render_header(state.user_config) +end + +function M.render_header(opts) + api.nvim_buf_clear_namespace(M.bufnr, M.namespace_header, 0, config.lnum_UI) + local help_text = string.format( + '[Nvim Spectre] (Search by %s) %s (Replace by %s) (Press ? for mappings)', + state.user_config.default.find.cmd, + opts.live_update and '(Auto update)' or '', + state.user_config.default.replace.cmd + ) + utils.write_virtual_text(M.bufnr, M.namespace_header, 0, { { help_text, state.user_config.highlight.headers } }) +end + +M.show_menu_options = function(title, content) + local win_width, win_height = vim.lsp.util._make_floating_popup_size(content, {}) + + local bufnr = vim.api.nvim_create_buf(false, true) + api.nvim_buf_set_option(bufnr, 'bufhidden', 'wipe') + api.nvim_buf_set_lines(bufnr, 0, -1, true, content) + + local help_win = vim.api.nvim_open_win(bufnr, false, { + style = 'minimal', + title = ' ' .. title .. ' ', + title_pos = 'center', + relative = 'cursor', + width = win_width, + height = win_height, + col = 0, + row = 1, + border = 'rounded', + }) + api.nvim_win_set_option(help_win, 'winblend', 0) + api.nvim_buf_set_keymap( + bufnr, + 'n', + '', + 'lua vim.api.nvim_win_close(' .. help_win .. ', true)', + { noremap = true } + ) + + api.nvim_create_autocmd({ + 'CursorMovedI', + 'CursorMoved', + 'CursorMovedI', + 'BufHidden', + 'BufLeave', + 'InsertEnter', + 'WinScrolled', + 'BufDelete', + }, { + callback = function() + pcall(vim.api.nvim_win_close, help_win, true) + end, + }) +end + +M.show_help = function() + local help_msg = {} + local map_tbl = {} + for _, map in pairs(state.user_config.mapping) do + table.insert(map_tbl, map) + end + -- sort by length + table.sort(map_tbl, function(a, b) + return (#a.map or 0) < (#b.map or 0) + end) + + for _, map in pairs(map_tbl) do + table.insert(help_msg, string.format('%9s : %s', map.map, map.desc)) + end + + M.show_menu_options('Mappings', help_msg) +end + +M.show_options = function() + local cfg = state_utils.get_search_engine_config() + local help_msg = { ' Press number to select option.' } + local option_cmd = {} + local i = 1 + + for key, option in pairs(cfg.options) do + table.insert(help_msg, string.format(' %s : toggle %s', i, option.desc or ' ')) + table.insert(option_cmd, key) + i = i + 1 + end + + M.show_menu_options('Options', help_msg) + return option_cmd +end + +M.show_find_engine = function() + local engines = state.user_config.find_engine + + local help_msg = { ' Press number to select option.' } + local option_cmd = {} + local i = 1 + + for key, option in pairs(engines) do + table.insert(help_msg, string.format(' %s : engine %s', i, option.desc or ' ')) + table.insert(option_cmd, key) + i = i + 1 + end +end + +M.render_text_query = function(opts) + -- set empty line for virtual text + local lines = {} + local length = config.lnum_UI + for _ = 1, length, 1 do + table.insert(lines, '') + end + api.nvim_buf_set_lines(M.bufnr, 0, 0, false, lines) + api.nvim_buf_set_lines(M.bufnr, 2, 2, false, { opts.search_text }) + api.nvim_buf_set_lines(M.bufnr, 4, 4, false, { opts.replace_text }) + api.nvim_buf_set_lines(M.bufnr, 6, 6, false, { opts.path }) + api.nvim_win_set_cursor(0, { opts.begin_line_num or 3, 0 }) +end + +-- Open the Spectre UI +M.open = function() + -- If already open, return + if M.bufnr and vim.api.nvim_buf_is_valid(M.bufnr) then + local wins = vim.fn.win_findbuf(M.bufnr) + if #wins >= 1 then + for _, win_id in pairs(wins) do + if vim.fn.win_gotoid(win_id) == 1 then + return + end + end + end + end + + -- Open a new window and buffer + if type(state.user_config.open_cmd) == 'function' then + state.user_config.open_cmd() + else + vim.cmd(state.user_config.open_cmd) + end + + vim.wo.foldenable = false + vim.bo.buftype = 'nofile' + vim.bo.buflisted = false + M.bufnr = api.nvim_get_current_buf() + state.bufnr = M.bufnr + vim.cmd(string.format('file %s/spectre', M.bufnr)) + vim.bo.filetype = config.filetype + + api.nvim_buf_clear_namespace(M.bufnr, M.namespace_status, 0, -1) + api.nvim_buf_clear_namespace(M.bufnr, M.namespace_result, 0, -1) + api.nvim_buf_set_lines(M.bufnr, 0, -1, false, {}) + + -- Setup folding + M.setup_folding() + + -- Setup the UI + M.render_text_query({ + search_text = state.query.search_query or '', + replace_text = state.query.replace_query or '', + path = state.query.path or '', + begin_line_num = 3, + }) + + M.render_search_ui() + + -- Create mappings + M.mapping_buffer() + + -- Focus on search line + if state.user_config.is_insert_mode == true then + vim.api.nvim_feedkeys('A', 'n', true) + end +end + +-- Close the Spectre UI +M.close = function() + if M.bufnr and vim.api.nvim_buf_is_valid(M.bufnr) then + local wins = vim.fn.win_findbuf(M.bufnr) + if #wins >= 1 then + for _, win_id in pairs(wins) do + vim.api.nvim_win_close(win_id, true) + end + end + end + M.bufnr = nil + state.bufnr = nil +end + +-- Function to handle search changes +M.on_search_change = function() + local lines = api.nvim_buf_get_lines(M.bufnr, 0, config.lnum_UI, false) + + local query = { + replace_query = '', + search_query = '', + path = '', + } + + for index, line in pairs(lines) do + if index <= 3 and #line > 0 then + query.search_query = query.search_query .. line + end + if index >= 5 and index < 7 and #line > 0 then + query.replace_query = query.replace_query .. line + end + if index >= 7 and index <= 9 and #line > 0 then + query.path = query.path .. line + end + end + + local line = vim.fn.getpos('.') + -- Update state + state.query = query + + -- Trigger appropriate action + if line[2] >= 5 and line[2] < 7 then + require('spectre').run_replace() + else + require('spectre').search(query) + end +end + +-- Setup buffer mappings +M.mapping_buffer = function() + -- Set up autocmds + vim.cmd([[augroup spectre_panel + au! + au InsertEnter lua require"spectre.ui.plenary".on_insert_enter() + au InsertLeave lua require"spectre.ui.plenary".on_search_change() + au BufLeave lua require("spectre").on_write() + au BufUnload lua require("spectre").close() + augroup END ]]) + + vim.opt_local.wrap = false + + -- Folding is already set up in setup_folding() + + local map_opt = { noremap = true, silent = _G.__is_dev == nil } + api.nvim_buf_set_keymap( + M.bufnr, + 'n', + 'x', + 'xlua require("spectre.ui.plenary").on_search_change()', + map_opt + ) + api.nvim_buf_set_keymap( + M.bufnr, + 'n', + 'p', + "plua require('spectre.ui.plenary').on_search_change()", + map_opt + ) + api.nvim_buf_set_keymap( + M.bufnr, + 'v', + 'p', + "plua require('spectre.ui.plenary').on_search_change()", + map_opt + ) + api.nvim_buf_set_keymap( + M.bufnr, + 'v', + 'P', + "Plua require('spectre.ui.plenary').on_search_change()", + map_opt + ) + api.nvim_buf_set_keymap(M.bufnr, 'n', 'd', '', map_opt) + api.nvim_buf_set_keymap(M.bufnr, 'i', '', '', map_opt) + api.nvim_buf_set_keymap(M.bufnr, 'v', 'd', 'lua require("spectre").toggle_checked()', map_opt) + api.nvim_buf_set_keymap(M.bufnr, 'n', 'o', 'ji', map_opt) -- don't append line on can make the UI wrong + api.nvim_buf_set_keymap(M.bufnr, 'n', 'O', 'ki', map_opt) + api.nvim_buf_set_keymap(M.bufnr, 'n', 'u', '', map_opt) -- disable undo, It breaks the UI. + api.nvim_buf_set_keymap(M.bufnr, 'n', 'yy', "lua require('spectre.actions').copy_current_line()", map_opt) + api.nvim_buf_set_keymap(M.bufnr, 'n', '?', "lua require('spectre.ui.plenary').show_help()", map_opt) + + for _, map in pairs(state.user_config.mapping) do + if map.cmd then + api.nvim_buf_set_keymap( + M.bufnr, + 'n', + map.map, + map.cmd, + vim.tbl_deep_extend('force', map_opt, { desc = map.desc }) + ) + end + end + + vim.api.nvim_create_autocmd('BufWritePost', { + group = vim.api.nvim_create_augroup('SpectrePanelWrite', { clear = true }), + pattern = '*', + callback = require('spectre').on_write, + desc = 'spectre write autocmd', + }) + + vim.api.nvim_create_autocmd('WinClosed', { + group = vim.api.nvim_create_augroup('SpectreStateOpened', { clear = true }), + buffer = M.bufnr, + callback = function() + if vim.api.nvim_buf_get_option(vim.api.nvim_get_current_buf(), 'filetype') == 'spectre_panel' then + state.is_open = false + end + end, + desc = 'Ensure spectre state when its window is closed by any mean', + }) + + if state.user_config.is_block_ui_break then + -- Anti UI breakage + -- * If the user enters insert mode on a forbidden line: leave insert mode. + -- * If the user passes over a forbidden line on insert mode: leave insert mode. + -- * Disable backspace jumping lines. + local backspace = vim.api.nvim_get_option('backspace') + local anti_insert_breakage_group = vim.api.nvim_create_augroup('SpectreAntiInsertBreakage', { clear = true }) + vim.api.nvim_create_autocmd({ 'InsertEnter', 'CursorMovedI' }, { + group = anti_insert_breakage_group, + buffer = M.bufnr, + callback = function() + local current_filetype = vim.bo.filetype + if current_filetype == 'spectre_panel' then + vim.cmd('set backspace=indent,start') + local line = vim.api.nvim_win_get_cursor(0)[1] + if line == 1 or line == 2 or line == 4 or line == 6 or line >= 8 then + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'n', true) + end + end + end, + desc = 'spectre anti-insert-breakage → protect the user from breaking the UI while on insert mode.', + }) + vim.api.nvim_create_autocmd({ 'WinLeave' }, { + group = anti_insert_breakage_group, + buffer = M.bufnr, + callback = function() + local current_filetype = vim.bo.filetype + if current_filetype == 'spectre_panel' then + vim.cmd('set backspace=' .. backspace) + end + end, + desc = "spectre anti-insert-breakage → restore the 'backspace' option.", + }) + api.nvim_buf_set_keymap(M.bufnr, 'i', '', '', map_opt) -- disable ENTER on insert mode, it breaks the UI. + end +end + +M.on_insert_enter = function() + local line = vim.fn.getpos('.') + if line[2] > config.lnum_UI then + local key = api.nvim_replace_termcodes('', true, false, true) + api.nvim_feedkeys(key, 'm', true) + print("You can't make changes in results.") + end +end + +M.tab = function() + local line = vim.api.nvim_win_get_cursor(0)[1] + if line == 3 then + vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 5, 1 }) + end + if line == 5 then + vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 7, 1 }) + end +end + +M.tab_shift = function() + local line = vim.api.nvim_win_get_cursor(0)[1] + if line == 5 then + vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 3, 1 }) + end + if line == 7 then + vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 5, 1 }) + end +end + +-- Placeholder for toggle_preview - not implemented in the plenary UI +M.toggle_preview = function() + -- Not implemented in plenary UI + vim.notify('Preview not available in plenary UI mode', vim.log.levels.WARN) +end + +-- Get fold level +M.get_fold = function(lnum) + return foldexpr(lnum) +end + +-- Function to render search results +M.render_results = function() + if #state.total_item == 0 then + return + end + + -- Clear old search results + api.nvim_buf_clear_namespace(M.bufnr, M.namespace_result, config.lnum_UI, -1) + api.nvim_buf_set_lines(M.bufnr, config.lnum_UI, -1, false, {}) + + -- Add separator + api.nvim_buf_set_lines(M.bufnr, config.lnum_UI, config.lnum_UI, false, { state.user_config.line_sep_start }) + api.nvim_buf_add_highlight(M.bufnr, M.namespace, state.user_config.highlight.border, config.lnum_UI, 0, -1) + + local c_line = config.lnum_UI + 1 + local total = 0 + local padding = #state.user_config.result_padding + local cfg = state.user_config + local last_filename = '' + + -- Add status line for total count + state.status_line = string.format('Total: %s matches', #state.total_item) + utils.write_virtual_text(M.bufnr, M.namespace_status, config.lnum_UI - 1, { { state.status_line, 'Question' } }) + + -- Render each item + for _, item in ipairs(state.total_item) do + if last_filename ~= item.filename then + M.render_filename(M.bufnr, M.namespace, c_line, item) + c_line = c_line + 1 + last_filename = item.filename + end + + item.display_lnum = c_line + M.render_line(M.bufnr, M.namespace, { + search_query = state.query.search_query, + replace_query = state.query.replace_query, + search_text = item.search_text, + lnum = item.display_lnum, + item_line = item.lnum, + is_replace = false, + }, { + is_disable = item.disable, + padding_text = cfg.result_padding, + padding = padding, + show_search = state.view.show_search, + show_replace = state.view.show_replace, + }, state_utils.get_regex()) + + c_line = c_line + 1 + total = total + 1 + end + + -- Add final separator + api.nvim_buf_set_lines(M.bufnr, c_line, c_line, false, { cfg.line_sep }) + api.nvim_buf_add_highlight(M.bufnr, M.namespace, cfg.highlight.border, c_line, 0, -1) +end + +return M diff --git a/lua/spectre/utils.lua b/lua/spectre/utils.lua index 74b4149..fdf7238 100644 --- a/lua/spectre/utils.lua +++ b/lua/spectre/utils.lua @@ -171,30 +171,30 @@ M.get_hl_line_text = function(opts, regex) if opts.replace_query and #opts.replace_query > 0 and opts.show_replace ~= false then local replace_match = regex.replace_all(opts.search_query, opts.replace_query, search_match) local total_increase = 0 - + if opts.show_search == false or opts.is_replace then -- After replacement: Just show the replaced text result.text = regex.replace_all(opts.search_query, opts.replace_query, opts.search_text) result.replace = match_text_line(replace_match, result.text, 0) result.search = {} - + -- If we want to show both the original and replaced text after replacement if opts.is_replace then -- Find all instances of replaced text and add the original in parentheses local positions = match_text_line(replace_match, result.text, 0) local new_text = result.text local offset = 0 - + for _, pos in ipairs(positions) do - local display_original = " (was: " .. search_match .. ")" + local display_original = ' (was: ' .. search_match .. ')' local insert_pos = pos[2] + offset new_text = new_text:sub(1, insert_pos) .. display_original .. new_text:sub(insert_pos + 1) offset = offset + #display_original - + -- Add highlight for the "was" text - table.insert(result.search, {insert_pos + 6, insert_pos + 6 + #search_match}) + table.insert(result.search, { insert_pos + 6, insert_pos + 6 + #search_match }) end - + result.text = new_text end else @@ -202,12 +202,12 @@ M.get_hl_line_text = function(opts, regex) for _, v in pairs(result.search) do v[1] = v[1] + total_increase v[2] = v[2] + total_increase - + -- Add replacement text in parentheses after the search match with an arrow - local display_replace = " → (" .. replace_match .. ")" + local display_replace = ' → (' .. replace_match .. ')' local pos = { v[2], v[2] + #display_replace } table.insert(result.replace, pos) - + local text = result.text result.text = text:sub(0, v[2]) .. display_replace .. text:sub(v[2] + 1) total_increase = total_increase + #display_replace diff --git a/plugin/spectre.lua b/plugin/spectre.lua index c33a3b0..7e92278 100644 --- a/plugin/spectre.lua +++ b/plugin/spectre.lua @@ -1,37 +1,37 @@ local function get_arg(str) - local key, value = str:match([=[^([^%s]*)=([^%s]*)$]=]) + local key, value = str:match([=[^([^%s]*)=([^%s]*)$]=]) - -- translate string 'true' and 'false' to boolen type - value = value == "true" or value - value = (value == "false" and { false } or { value })[1] + -- translate string 'true' and 'false' to boolen type + value = value == 'true' or value + value = (value == 'false' and { false } or { value })[1] - return key, value + return key, value end -vim.api.nvim_create_user_command("Spectre", function(ctx) - local spectre = require("spectre") - local args = {} - local user_args - if #ctx.fargs == 1 or vim.tbl_isempty(ctx.fargs) then - user_args = ctx.fargs[1] and ctx.fargs or { "" } - elseif #ctx.fargs > 1 then - user_args = ctx.fargs - end +vim.api.nvim_create_user_command('Spectre', function(ctx) + local spectre = require('spectre') + local args = {} + local user_args + if #ctx.fargs == 1 or vim.tbl_isempty(ctx.fargs) then + user_args = ctx.fargs[1] and ctx.fargs or { '' } + elseif #ctx.fargs > 1 then + user_args = ctx.fargs + end - for _, user_arg in ipairs(user_args) do - if user_arg == "%" then - args["path"] = vim.fn.expand("%") - elseif get_arg(user_arg) == nil then - args["path"] = user_arg - elseif get_arg(user_arg) then - local key, value = get_arg(user_arg) - args[key] = value - end - end + for _, user_arg in ipairs(user_args) do + if user_arg == '%' then + args['path'] = vim.fn.expand('%') + elseif get_arg(user_arg) == nil then + args['path'] = user_arg + elseif get_arg(user_arg) then + local key, value = get_arg(user_arg) + args[key] = value + end + end - spectre.open(args) + spectre.open(args) end, { - nargs = "*", - complete = "file", - desc = "Global find and replace", + nargs = '*', + complete = 'file', + desc = 'Global find and replace', }) diff --git a/tests/cwd_spec.lua b/tests/cwd_spec.lua index 25df012..e999556 100644 --- a/tests/cwd_spec.lua +++ b/tests/cwd_spec.lua @@ -22,11 +22,7 @@ describe('check search on another directory', function() }) local bufnr = api.nvim_get_current_buf() - local test1 = helper.defer_get_line( - bufnr, - config.lnum_UI + 2, - config.lnum_UI + 4 - ) + local test1 = helper.defer_get_line(bufnr, config.lnum_UI + 2, config.lnum_UI + 4) eq(' test2.txt:', test1[1], 'should have correct text') vim.api.nvim_win_set_cursor(0, { 12, 0 }) vim.api.nvim_feedkeys(helper.t(''), 'x', true) @@ -44,11 +40,7 @@ describe('check search on another directory', function() path = 'test2.txt', }) local bufnr = api.nvim_get_current_buf() - local test1 = helper.defer_get_line( - bufnr, - config.lnum_UI + 2, - config.lnum_UI + 4 - ) + local test1 = helper.defer_get_line(bufnr, config.lnum_UI + 2, config.lnum_UI + 4) eq(' test2.txt:', test1[1], 'should have correct text') api.nvim_feedkeys(helper.t('R'), 'x', true) vim.wait(1000) diff --git a/tests/escape_spec.lua b/tests/escape_spec.lua index 40d49b8..e2653ec 100644 --- a/tests/escape_spec.lua +++ b/tests/escape_spec.lua @@ -1,39 +1,38 @@ - -local utils = require("spectre.utils") +local utils = require('spectre.utils') local eq = assert.are.same local esc_test_data = { - {[[ . ]], [[ \. ]], " dot"}, - {[[ \aaaa]], [[ \\aaaa]], " character"}, - {[[ \ ]], [[ \\ ]], " slash"}, - {[[ \\ ]], [[ \\ ]], " don't escape double slash"}, - {[[ \ a. ]], [[ \\ a\. ]], " with dot"}, - {[[ \. \ ]], [[ \\. \\ ]], " with dot slash"}, - {[[ ( \\ ]], [[ \( \\ ]], " square slahs"}, - {[[ \{ ]], [[ \{ ]], " bracket"}, - {[[ \} ]], [[ \} ]], " bracket"}, - {[[ \\\ ]], [[ \\\ ]], " don't escape tripple slash"} + { [[ . ]], [[ \. ]], ' dot' }, + { [[ \aaaa]], [[ \\aaaa]], ' character' }, + { [[ \ ]], [[ \\ ]], ' slash' }, + { [[ \\ ]], [[ \\ ]], " don't escape double slash" }, + { [[ \ a. ]], [[ \\ a\. ]], ' with dot' }, + { [[ \. \ ]], [[ \\. \\ ]], ' with dot slash' }, + { [[ ( \\ ]], [[ \( \\ ]], ' square slahs' }, + { [[ \{ ]], [[ \{ ]], ' bracket' }, + { [[ \} ]], [[ \} ]], ' bracket' }, + { [[ \\\ ]], [[ \\\ ]], " don't escape tripple slash" }, } describe('escape chars ', function() for _, value in pairs(esc_test_data) do - it("should escape " .. value[3], function() - eq(value[2], utils.escape_chars(value[1]), "ERROR:" .. value[3]) + it('should escape ' .. value[3], function() + eq(value[2], utils.escape_chars(value[1]), 'ERROR:' .. value[3]) end) end end) local esc_vim_magic = { - {[[ > ]], [[ \> ]], " >"}, - {[[ = ]], [[ \= ]], " >"}, - {[[ < ]], [[ \< ]], " ="}, - {[[ \< ]], [[ \< ]], " <"}, + { [[ > ]], [[ \> ]], ' >' }, + { [[ = ]], [[ \= ]], ' >' }, + { [[ < ]], [[ \< ]], ' =' }, + { [[ \< ]], [[ \< ]], ' <' }, } describe('escape vim magic ', function() for _, value in pairs(esc_vim_magic) do - it("should escape " .. value[3], function() - eq(value[2], utils.escape_vim_magic(value[1]), "ERROR:" .. value[3]) + it('should escape ' .. value[3], function() + eq(value[2], utils.escape_vim_magic(value[1]), 'ERROR:' .. value[3]) end) end end) diff --git a/tests/helper.lua b/tests/helper.lua index 65bcaa9..072366d 100644 --- a/tests/helper.lua +++ b/tests/helper.lua @@ -1,16 +1,16 @@ local utils = require('spectre.utils') -local Path=require('plenary.path') +local Path = require('plenary.path') local M = {} local api = vim.api _G._pwd = '' -M.init = function () - _G._pwd = vim.fn.system('pwd'):gsub("\n", "") - vim.cmd ("set rtp +=".._G._pwd) +M.init = function() + _G._pwd = vim.fn.system('pwd'):gsub('\n', '') + vim.cmd('set rtp +=' .. _G._pwd) end M.get_cwd = function(path) - local root = Path:new (_G._pwd) + local root = Path:new(_G._pwd) local cwd = root:joinpath(path) return cwd.filename end @@ -18,46 +18,46 @@ M.defer_get_line = function(bufnr, start_col, end_col, time) assert(bufnr ~= nil, 'buffer not nil') time = time or 600 local done = false - local text='' + local text = '' vim.defer_fn(function() done = true text = api.nvim_buf_get_lines(bufnr, start_col, end_col, false) end, time) while not done do - vim.cmd [[ sleep 20ms]] + vim.cmd([[ sleep 20ms]]) end return text end - M.checkoutfile = function(filename) - utils.run_os_cmd({'git', 'checkout', 'HEAD', filename}) + utils.run_os_cmd({ 'git', 'checkout', 'HEAD', filename }) return end -M.t=function(cmd) - return vim.api.nvim_replace_termcodes(cmd, true, false, true) +M.t = function(cmd) + return vim.api.nvim_replace_termcodes(cmd, true, false, true) end M.test_replace = function(opts, f_replace) local eq = assert.are.same M.checkoutfile(opts.filename) local finish = false - local handler= { + local handler = { on_finish = function() finish = true - end + end, } local replacer = f_replace(handler) replacer:replace({ lnum = opts.lnum, filename = opts.filename, search_text = opts.search_text, - replace_text = opts.replace_text + replace_text = opts.replace_text, }) - vim.wait(1000, function() return finish end) - local output_txt = utils.run_os_cmd({"cat", opts.filename}) - eq(output_txt[opts.lnum], opts.expected, "test " .. opts.filename) + vim.wait(1000, function() + return finish + end) + local output_txt = utils.run_os_cmd({ 'cat', opts.filename }) + eq(output_txt[opts.lnum], opts.expected, 'test ' .. opts.filename) end return M - diff --git a/tests/line_render_spec.lua b/tests/line_render_spec.lua index 32a1264..56e01d7 100644 --- a/tests/line_render_spec.lua +++ b/tests/line_render_spec.lua @@ -1,150 +1,147 @@ -local utils = require("spectre.utils") +local utils = require('spectre.utils') local eq = assert.are.same local fixtures_different = { { - name = "case 1 ", - search_query = "data", - replace_query = "no\\0", - search_text = " data1 data_2 data 3", - replace_text = " datanodata1 datanodata_2 datanodata 3", - result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } } + name = 'case 1 ', + search_query = 'data', + replace_query = 'no\\0', + search_text = ' data1 data_2 data 3', + replace_text = ' datanodata1 datanodata_2 datanodata 3', + result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } }, }, { - name = "case 2 ", - search_query = "d\\S*a", - replace_query = "no\\0", - search_text = " data1 data_2 data 3", - replace_text = " datanodata1 datanodata_2 datanodata 3", - result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } } + name = 'case 2 ', + search_query = 'd\\S*a', + replace_query = 'no\\0', + search_text = ' data1 data_2 data 3', + replace_text = ' datanodata1 datanodata_2 datanodata 3', + result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } }, }, { - name = "case 3 ", - search_query = "data(.*)", - replace_query = "no", - search_text = " data1 data_2 data 3", - replace_text = " data1 data_2 data 3no", - result = { search = { { 1, 20 } }, replace = { { 20, 22 } } } + name = 'case 3 ', + search_query = 'data(.*)', + replace_query = 'no', + search_text = ' data1 data_2 data 3', + replace_text = ' data1 data_2 data 3no', + result = { search = { { 1, 20 } }, replace = { { 20, 22 } } }, }, { - name = "case 4 ", + name = 'case 4 ', search_query = [[data\(]], - replace_query = "no", - search_text = " data( data_2 data 3", - replace_text = " data(no data_2 data 3", - result = { search = { { 1, 6 } }, replace = { { 6, 8 } } } + replace_query = 'no', + search_text = ' data( data_2 data 3', + replace_text = ' data(no data_2 data 3', + result = { search = { { 1, 6 } }, replace = { { 6, 8 } } }, }, { - name = "case 5 ", + name = 'case 5 ', search_query = [[abcd\(]], - replace_query = "no", - search_text = " test function abcd(no)", - replace_text = " test function abcd(nono)", - result = { search = { { 19, 24 } }, replace = { { 24, 26 } } } + replace_query = 'no', + search_text = ' test function abcd(no)', + replace_text = ' test function abcd(nono)', + result = { search = { { 19, 24 } }, replace = { { 24, 26 } } }, }, { - name = "case 6 ", + name = 'case 6 ', search_query = [[^local]], - replace_query = "public", - search_text = "local data", - replace_text = "localpublic data", - result = { search = { { 0, 5 } }, replace = { { 5, 11 } } } + replace_query = 'public', + search_text = 'local data', + replace_text = 'localpublic data', + result = { search = { { 0, 5 } }, replace = { { 5, 11 } } }, }, { - name = "case 7 ", + name = 'case 7 ', search_query = [[]], - replace_query = "public", - search_text = "", - replace_text = "public", - result = { search = { { 0, 10 } }, replace = { { 10, 16 } } } + replace_query = 'public', + search_text = '', + replace_text = 'public', + result = { search = { { 0, 10 } }, replace = { { 10, 16 } } }, }, { - name = "case 8 ", + name = 'case 8 ', search_query = [[^local]], - replace_query = "public", - search_text = "local data", + replace_query = 'public', + search_text = 'local data', show_search = false, - replace_text = "public data", - result = { search = {}, replace = { { 0, 6 } } } + replace_text = 'public data', + result = { search = {}, replace = { { 0, 6 } } }, }, - } - local rust_fixtures_different = { { - name = "case 1 ", - search_query = "data", - replace_query = "no${0}", - search_text = " data1 data_2 data 3", - replace_text = " datanodata1 datanodata_2 datanodata 3", - result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } } + name = 'case 1 ', + search_query = 'data', + replace_query = 'no${0}', + search_text = ' data1 data_2 data 3', + replace_text = ' datanodata1 datanodata_2 datanodata 3', + result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } }, }, { - name = "case 2 ", - search_query = "d\\S*a", - replace_query = "no$0", - search_text = " data1 data_2 data 3", - replace_text = " datanodata1 datanodata_2 datanodata 3", - result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } } + name = 'case 2 ', + search_query = 'd\\S*a', + replace_query = 'no$0', + search_text = ' data1 data_2 data 3', + replace_text = ' datanodata1 datanodata_2 datanodata 3', + result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } }, }, { - name = "case 3 ", - search_query = "data(.*)", - replace_query = "no", - search_text = " data1 data_2 data 3", - replace_text = " data1 data_2 data 3no", - result = { search = { { 1, 20 } }, replace = { { 20, 22 } } } + name = 'case 3 ', + search_query = 'data(.*)', + replace_query = 'no', + search_text = ' data1 data_2 data 3', + replace_text = ' data1 data_2 data 3no', + result = { search = { { 1, 20 } }, replace = { { 20, 22 } } }, }, { - name = "case 4 ", + name = 'case 4 ', search_query = [[data\(]], - replace_query = "no", - search_text = " data( data_2 data 3", - replace_text = " data(no data_2 data 3", - result = { search = { { 1, 6 } }, replace = { { 6, 8 } } } + replace_query = 'no', + search_text = ' data( data_2 data 3', + replace_text = ' data(no data_2 data 3', + result = { search = { { 1, 6 } }, replace = { { 6, 8 } } }, }, { - name = "case 5 ", + name = 'case 5 ', search_query = [[abcd\(]], - replace_query = "no", - search_text = " test function abcd(no)", - replace_text = " test function abcd(nono)", - result = { search = { { 19, 24 } }, replace = { { 24, 26 } } } + replace_query = 'no', + search_text = ' test function abcd(no)', + replace_text = ' test function abcd(nono)', + result = { search = { { 19, 24 } }, replace = { { 24, 26 } } }, }, { - name = "case 6 ", + name = 'case 6 ', search_query = [[^local]], - replace_query = "public", - search_text = "local data", - replace_text = "localpublic data", - result = { search = { { 0, 5 } }, replace = { { 5, 11 } } } + replace_query = 'public', + search_text = 'local data', + replace_text = 'localpublic data', + result = { search = { { 0, 5 } }, replace = { { 5, 11 } } }, }, { - name = "case 7 ", + name = 'case 7 ', search_query = [[]], - replace_query = "public", - search_text = "", - replace_text = "public", - result = { search = { { 0, 10 } }, replace = { { 10, 16 } } } + replace_query = 'public', + search_text = '', + replace_text = 'public', + result = { search = { { 0, 10 } }, replace = { { 10, 16 } } }, }, { - name = "case 8 ", + name = 'case 8 ', search_query = [[^local]], - replace_query = "public", - search_text = "local data", + replace_query = 'public', + search_text = 'local data', show_search = false, - replace_text = "public data", - result = { search = {}, replace = { { 0, 6 } } } + replace_text = 'public data', + result = { search = {}, replace = { { 0, 6 } } }, }, - } describe('utils test different highlight', function() local regex = require('spectre.regex.vim') for _, value in pairs(fixtures_different) do - it(value.name .. " " .. value.search_query, function() + it(value.name .. ' ' .. value.search_query, function() local result = utils.get_hl_line_text(value, regex) eq(value.replace_text, result.text, 'text error :' .. value.name) eq(value.result.search, result.search, 'search error :' .. value.name) @@ -153,9 +150,9 @@ describe('utils test different highlight', function() end regex = require('spectre.regex.rust') - regex.change_options({ "i" }) + regex.change_options({ 'i' }) for _, value in pairs(rust_fixtures_different) do - it(value.name .. " " .. value.search_query, function() + it(value.name .. ' ' .. value.search_query, function() local result = utils.get_hl_line_text(value, regex) eq(value.replace_text, result.text, 'text error :' .. value.name) eq(value.result.search, result.search, 'search error :' .. value.name) diff --git a/tests/search/rg_spec.lua b/tests/search/rg_spec.lua index 2989aa0..6b04b64 100644 --- a/tests/search/rg_spec.lua +++ b/tests/search/rg_spec.lua @@ -1,13 +1,12 @@ local rg = require('spectre.search').rg local eq = assert.are.same -vim.cmd [[tcd tests/project]] +vim.cmd([[tcd tests/project]]) local time_wait = 1000 -describe("[rg] search ", function() - - it("should not empty", function() +describe('[rg] search ', function() + it('should not empty', function() local finish = false local total = {} local total_item = 0 @@ -18,17 +17,16 @@ describe("[rg] search ", function() end, on_finish = function() finish = true - end + end, }) - finder:search({search_text = "spectre"}) + finder:search({ search_text = 'spectre' }) vim.wait(time_wait, function() return finish end) - eq(2, total_item, "should have 2 item") - + eq(2, total_item, 'should have 2 item') end) - it("should call finish function", function() + it('should call finish function', function() local finish = false local total = {} local total_item = 0 @@ -39,16 +37,16 @@ describe("[rg] search ", function() end, on_finish = function() finish = true - end + end, }) - finder:search({search_text = "spectre"}) + finder:search({ search_text = 'spectre' }) vim.wait(time_wait, function() return finish end) - eq(true, finish, "finish is not call") + eq(true, finish, 'finish is not call') end) - it("search with path should not empty", function() + it('search with path should not empty', function() local finish = false local total = {} local total_item = 0 @@ -59,17 +57,16 @@ describe("[rg] search ", function() end, on_finish = function() finish = true - end + end, }) - finder:search({search_text = "spectre", path = "**/rg_spec/*.txt"}) + finder:search({ search_text = 'spectre', path = '**/rg_spec/*.txt' }) vim.wait(time_wait, function() return finish end) - eq(1, total_item, "should have 1 item") - + eq(1, total_item, 'should have 1 item') end) - it("search with multiple paths should not be empty", function() + it('search with multiple paths should not be empty', function() local finish = false local total = {} local total_item = 0 @@ -80,17 +77,15 @@ describe("[rg] search ", function() end, on_finish = function() finish = true - end + end, }) finder:search({ - search_text = "(data|spectre)", - path = "**/rg_spec/*.txt **/sed_spec/*.txt" + search_text = '(data|spectre)', + path = '**/rg_spec/*.txt **/sed_spec/*.txt', }) vim.wait(time_wait, function() return finish end) - eq(4, total_item, "should have 4 items") - + eq(4, total_item, 'should have 4 items') end) - end) diff --git a/tests/ui_spec.lua b/tests/ui_spec.lua index 2c96d1b..b4ef544 100644 --- a/tests/ui_spec.lua +++ b/tests/ui_spec.lua @@ -7,36 +7,35 @@ local helper = require('tests.helper') local eq = assert.are.same local api = vim.api -vim.cmd [[tcd tests/project]] +vim.cmd([[tcd tests/project]]) describe('spectre panel UI', function() -- before_each(function() -- -- vim.cmd[[:bd]] -- end) - it("check buffer option ", function() + it('check buffer option ', function() spectre.open() local bufnr = api.nvim_get_current_buf() eq(config.filetype, api.nvim_buf_get_option(bufnr, 'filetype'), 'should have corret file type') eq('spectre', api.nvim_buf_get_name(bufnr):match('spectre$'), 'shoule have correct filename') end) - it("open search and result not empty", function() - spectre.open({search_text = "spectre"}) + it('open search and result not empty', function() + spectre.open({ search_text = 'spectre' }) local bufnr = api.nvim_get_current_buf() local test1 = helper.defer_get_line(bufnr, config.lnum_UI + 2, config.lnum_UI + 4) eq(true, #test1[1] > 5, "it don't have result item") - end) - it("replace text ", function() - local filename = "test1.txt" + it('replace text ', function() + local filename = 'test1.txt' helper.checkoutfile(filename) - spectre.open({search_text = "spectre", replace_text = "data", path = 'test1.txt'}) + spectre.open({ search_text = 'spectre', replace_text = 'data', path = 'test1.txt' }) local bufnr = api.nvim_get_current_buf() local test1 = helper.defer_get_line(bufnr, config.lnum_UI + 2, config.lnum_UI + 4) - eq(" test1.txt:", test1[1], "should have correct text") - api.nvim_feedkeys(helper.t"R", 'x', true) + eq(' test1.txt:', test1[1], 'should have correct text') + api.nvim_feedkeys(helper.t('R'), 'x', true) vim.wait(500) - local output_txt = utils.run_os_cmd({"cat", 'test1.txt'}) - eq(output_txt[1], "data abcde", " test should match") + local output_txt = utils.run_os_cmd({ 'cat', 'test1.txt' }) + eq(output_txt[1], 'data abcde', ' test should match') helper.checkoutfile(filename) end) end) From 140c2ff8bff871dc610b5e4ac94f094d37bce81f Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Thu, 1 May 2025 17:50:22 +0500 Subject: [PATCH 09/15] Prepare nui and nui-components for test --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d921ab..9b59cc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,9 @@ jobs: } mkdir -p ~/.local/share/nvim/site/pack/vendor/start git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/vendor/start/plenary.nvim + git clone --depth 1 https://github.com/MunifTanjim/nui.nvim ~/.local/share/nvim/site/pack/vendor/start/nui.nvim + git clone --depth 1 https://github.com/grapp-dev/nui-components.nvim ~/.local/share/nvim/site/pack/vendor/start/nui-components.nvim ln -s $(pwd) ~/.local/share/nvim/site/pack/vendor/start - - name: Install latest stable `rustc` uses: dtolnay/rust-toolchain@stable with: From 940663149a2270cb20f58bffa5d7f01a4fee64a0 Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Thu, 1 May 2025 17:50:27 +0500 Subject: [PATCH 10/15] Stylua --- lua/spectre/actions.lua | 157 +++++++++++----------------------------- 1 file changed, 42 insertions(+), 115 deletions(-) diff --git a/lua/spectre/actions.lua b/lua/spectre/actions.lua index 00edc14..8654631 100644 --- a/lua/spectre/actions.lua +++ b/lua/spectre/actions.lua @@ -40,16 +40,12 @@ end M.select_entry = function() local entry = M.get_current_entry() - if not entry then - return - end + if not entry then return end - local full_path = vim.fn.fnamemodify(entry.filename, ':p') - if not vim.fn.filereadable(full_path) then - return - end + local full_path = vim.fn.fnamemodify(entry.filename, ":p") + if not vim.fn.filereadable(full_path) then return end - vim.cmd('edit ' .. full_path) + vim.cmd("edit " .. full_path) api.nvim_win_set_cursor(0, { entry.lnum, entry.col - 1 }) end @@ -63,16 +59,7 @@ M.get_state = function() end M.set_entry_finish = function(display_lnum) - -- Safety check: ensure display_lnum is valid and state.total_item exists - if not display_lnum or not state.total_item then - return - end - - -- In Lua, arrays are 1-indexed but display_lnum might be 0-indexed - local index = display_lnum + 1 - - -- Check if the item exists in total_item - local item = state.total_item[index] + local item = state.total_item[display_lnum + 1] if item then item.is_replace_finish = true end @@ -83,20 +70,16 @@ function M.get_current_entry() local cursor_pos = api.nvim_win_get_cursor(0) local line = api.nvim_buf_get_lines(bufnr, cursor_pos[1] - 1, cursor_pos[1], false)[1] - if not line then - return nil - end + if not line then return nil end - local filename, lnum, col = line:match('([^:]+):(%d+):(%d+):') - if not filename or not lnum or not col then - return nil - end + local filename, lnum, col = line:match("([^:]+):(%d+):(%d+):") + if not filename or not lnum or not col then return nil end return { filename = filename, lnum = tonumber(lnum), col = tonumber(col), - text = line:match(':[^:]+$'):sub(2), + text = line:match(":[^:]+$"):sub(2), } end @@ -110,7 +93,7 @@ function M.get_all_entries() col = item.col, text = item.text, display_lnum = display_lnum - 1, - is_replace_finish = item.is_replace_finish or false, + is_replace_finish = item.is_replace_finish or false }) end end @@ -120,7 +103,7 @@ end M.send_to_qf = function() local entries = M.get_all_entries() if #entries == 0 then - vim.notify('No entries to send to quickfix') + vim.notify("No entries to send to quickfix") return end @@ -135,7 +118,7 @@ M.send_to_qf = function() end vim.fn.setqflist(qf_list) - vim.cmd('copen') + vim.cmd("copen") end -- input that comand to run on vim @@ -171,7 +154,7 @@ function M.run_current_replace() if entry then M.run_replace({ entry }) else - vim.notify('Not found any entry to replace.') + vim.notify("Not found any entry to replace.") end end @@ -180,7 +163,7 @@ local is_running = false function M.run_replace(entries) entries = entries or M.get_all_entries() if #entries == 0 then - vim.notify('No entries to replace') + vim.notify("No entries to replace") return end @@ -188,15 +171,8 @@ function M.run_replace(entries) local replacer_creator = state_utils.get_replace_creator() local replacer = replacer_creator:new(state_utils.get_replace_engine_config(), { on_done = function(result) - if result.ref and result.ref.display_lnum ~= nil then - -- Set the entry as finished and mark it as replaced + if result.ref then M.set_entry_finish(result.ref.display_lnum) - - -- Add a safety check before accessing state.total_item - if state.total_item and state.total_item[result.ref.display_lnum] then - state.total_item[result.ref.display_lnum].is_replace = true - end - -- Update UI by adding a checkmark to the line local bufnr = api.nvim_get_current_buf() local line = result.ref.display_lnum @@ -207,47 +183,16 @@ function M.run_replace(entries) 0, { virt_text = { { '✓', 'String' } }, virt_text_pos = 'eol' } ) - - -- If we have a renderer, trigger a full redraw + -- Trigger renderer redraw if state.renderer then - -- Update the node in the UI - local tree = state.renderer:get_component_by_id('results-tree') - -- Check if tree exists and has the get_nodes method - if tree and type(tree) == 'table' and type(tree.get_nodes) == 'function' then - local success, nodes = pcall(function() - return tree:get_nodes() - end) - - if success and nodes then - for _, node in ipairs(nodes) do - -- Add safety check for node.display_lnum - if node.display_lnum and node.display_lnum == result.ref.display_lnum then - node.is_done = true - -- This triggers the prepare_node function - pcall(function() - state.renderer:redraw() - end) - break - end - end - else - -- If we can't get nodes, just redraw - pcall(function() - state.renderer:redraw() - end) - end - else - -- If tree doesn't exist or doesn't have get_nodes, just redraw - pcall(function() - state.renderer:redraw() - end) - end + print("redrawing") + state.renderer:redraw() end end end, on_error = function(result) - if result.ref and result.ref.display_lnum ~= nil then - vim.notify('Error replacing: ' .. (result.value or 'unknown error'), vim.log.levels.ERROR) + if result.ref then + vim.notify("Error replacing: " .. result.value, vim.log.levels.ERROR) -- Add error mark to the line local bufnr = api.nvim_get_current_buf() local line = result.ref.display_lnum @@ -260,12 +205,8 @@ function M.run_replace(entries) ) -- Trigger renderer redraw if state.renderer then - -- Make sure renderer has redraw method - if type(state.renderer) == 'table' and type(state.renderer.redraw) == 'function' then - pcall(function() - state.renderer:redraw() - end) - end + print("redrawing") + state.renderer:redraw() end end end, @@ -304,58 +245,44 @@ M.run_delete_line = function(entries) local replacer_creator = state_utils.get_replace_creator() local replacer = replacer_creator:new(state_utils.get_replace_engine_config(), { on_done = function(result) - if result.ref and result.ref.display_lnums then + if result.ref then done_item = done_item + 1 local value = result.ref state.status_line = 'Delete line: ' .. done_item .. ' Error:' .. error_item for _, display_lnum in ipairs(value.display_lnums) do - if display_lnum ~= nil then - M.set_entry_finish(display_lnum) - api.nvim_buf_set_extmark( - state.bufnr, - config.namespace, - display_lnum, - 0, - { virt_text = { { '󰄲 DONE', 'String' } }, virt_text_pos = 'eol' } - ) - end + M.set_entry_finish(display_lnum) + api.nvim_buf_set_extmark( + state.bufnr, + config.namespace, + display_lnum, + 0, + { virt_text = { { '󰄲 DONE', 'String' } }, virt_text_pos = 'eol' } + ) end -- Trigger renderer redraw if state.renderer then - -- Make sure renderer has redraw method - if type(state.renderer) == 'table' and type(state.renderer.redraw) == 'function' then - pcall(function() - state.renderer:redraw() - end) - end + state.renderer:redraw() end end end, on_error = function(result) - if result.ref and result.ref.display_lnums then + if result.ref then error_item = error_item + 1 local value = result.ref state.status_line = 'Delete line: ' .. done_item .. ' Error:' .. error_item for _, display_lnum in ipairs(value.display_lnums) do - if display_lnum ~= nil then - M.set_entry_finish(display_lnum) - api.nvim_buf_set_extmark( - state.bufnr, - config.namespace, - display_lnum, - 0, - { virt_text = { { '󰄱 ERROR', 'Error' } }, virt_text_pos = 'eol' } - ) - end + M.set_entry_finish(display_lnum) + api.nvim_buf_set_extmark( + state.bufnr, + config.namespace, + display_lnum, + 0, + { virt_text = { { '󰄱 ERROR', 'Error' } }, virt_text_pos = 'eol' } + ) end -- Trigger renderer redraw if state.renderer then - -- Make sure renderer has redraw method - if type(state.renderer) == 'table' and type(state.renderer.redraw) == 'function' then - pcall(function() - state.renderer:redraw() - end) - end + state.renderer:redraw() end end end, From 802c6d44f3a5a6c7ddc9c06eb8544f3ede4604e5 Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Thu, 1 May 2025 17:51:20 +0500 Subject: [PATCH 11/15] Update actions.lua --- lua/spectre/actions.lua | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/lua/spectre/actions.lua b/lua/spectre/actions.lua index 8654631..cb3db8c 100644 --- a/lua/spectre/actions.lua +++ b/lua/spectre/actions.lua @@ -40,12 +40,16 @@ end M.select_entry = function() local entry = M.get_current_entry() - if not entry then return end + if not entry then + return + end - local full_path = vim.fn.fnamemodify(entry.filename, ":p") - if not vim.fn.filereadable(full_path) then return end + local full_path = vim.fn.fnamemodify(entry.filename, ':p') + if not vim.fn.filereadable(full_path) then + return + end - vim.cmd("edit " .. full_path) + vim.cmd('edit ' .. full_path) api.nvim_win_set_cursor(0, { entry.lnum, entry.col - 1 }) end @@ -70,16 +74,20 @@ function M.get_current_entry() local cursor_pos = api.nvim_win_get_cursor(0) local line = api.nvim_buf_get_lines(bufnr, cursor_pos[1] - 1, cursor_pos[1], false)[1] - if not line then return nil end + if not line then + return nil + end - local filename, lnum, col = line:match("([^:]+):(%d+):(%d+):") - if not filename or not lnum or not col then return nil end + local filename, lnum, col = line:match('([^:]+):(%d+):(%d+):') + if not filename or not lnum or not col then + return nil + end return { filename = filename, lnum = tonumber(lnum), col = tonumber(col), - text = line:match(":[^:]+$"):sub(2), + text = line:match(':[^:]+$'):sub(2), } end @@ -93,7 +101,7 @@ function M.get_all_entries() col = item.col, text = item.text, display_lnum = display_lnum - 1, - is_replace_finish = item.is_replace_finish or false + is_replace_finish = item.is_replace_finish or false, }) end end @@ -103,7 +111,7 @@ end M.send_to_qf = function() local entries = M.get_all_entries() if #entries == 0 then - vim.notify("No entries to send to quickfix") + vim.notify('No entries to send to quickfix') return end @@ -118,7 +126,7 @@ M.send_to_qf = function() end vim.fn.setqflist(qf_list) - vim.cmd("copen") + vim.cmd('copen') end -- input that comand to run on vim @@ -154,7 +162,7 @@ function M.run_current_replace() if entry then M.run_replace({ entry }) else - vim.notify("Not found any entry to replace.") + vim.notify('Not found any entry to replace.') end end @@ -163,7 +171,7 @@ local is_running = false function M.run_replace(entries) entries = entries or M.get_all_entries() if #entries == 0 then - vim.notify("No entries to replace") + vim.notify('No entries to replace') return end @@ -185,14 +193,14 @@ function M.run_replace(entries) ) -- Trigger renderer redraw if state.renderer then - print("redrawing") + print('redrawing') state.renderer:redraw() end end end, on_error = function(result) if result.ref then - vim.notify("Error replacing: " .. result.value, vim.log.levels.ERROR) + vim.notify('Error replacing: ' .. result.value, vim.log.levels.ERROR) -- Add error mark to the line local bufnr = api.nvim_get_current_buf() local line = result.ref.display_lnum @@ -205,7 +213,7 @@ function M.run_replace(entries) ) -- Trigger renderer redraw if state.renderer then - print("redrawing") + print('redrawing') state.renderer:redraw() end end From e609603d199a418915c1070e6aae682caa7d1c2b Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Mon, 5 May 2025 15:31:48 +0500 Subject: [PATCH 12/15] Fix for mac os sed --- lua/spectre/replace/sed.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/spectre/replace/sed.lua b/lua/spectre/replace/sed.lua index 4ce71a3..080f302 100644 --- a/lua/spectre/replace/sed.lua +++ b/lua/spectre/replace/sed.lua @@ -8,7 +8,7 @@ sed.init = function(_, config) local uname = vim.loop.os_uname().sysname local sed_args if uname == 'Darwin' then - sed_args = { '-i', '', '-e' } + sed_args = { '-i', '', '-E' } else sed_args = { '-i', '-E' } end From 0780bf688bef0b3615a3d3957471122fc29a69ce Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Mon, 5 May 2025 15:47:43 +0500 Subject: [PATCH 13/15] Rename plenary to buffer and nui to float --- lua/spectre/config.lua | 8 +------- lua/spectre/init.lua | 8 +++++--- lua/spectre/ui/{plenary => buffer}/init.lua | 14 +++++++------- lua/spectre/ui/{nui_components => float}/init.lua | 0 4 files changed, 13 insertions(+), 17 deletions(-) rename lua/spectre/ui/{plenary => buffer}/init.lua (97%) rename lua/spectre/ui/{nui_components => float}/init.lua (100%) diff --git a/lua/spectre/config.lua b/lua/spectre/config.lua index 5852365..df0a260 100644 --- a/lua/spectre/config.lua +++ b/lua/spectre/config.lua @@ -12,12 +12,6 @@ local config = { lnum_UI = 8, -- total line for ui you can edit it line_result = 10, -- line begin result - -- result_padding = '│ ', - -- color_devicons = true, - -- line_sep_start = '┌-----------------------------------------', - -- result_padding = '¦ ', - -- line_sep = '├──────────────────────────────────────', - line_sep_start = '┌──────────────────────────────────────────────────────', result_padding = '│ ', line_sep = '└──────────────────────────────────────────────────────', @@ -220,7 +214,7 @@ local config = { is_insert_mode = false, is_block_ui_break = false, open_template = {}, - ui = 'plenary', -- set to true to use the legacy UI + ui = 'buffer', } return config diff --git a/lua/spectre/init.lua b/lua/spectre/init.lua index 4507e37..738763d 100644 --- a/lua/spectre/init.lua +++ b/lua/spectre/init.lua @@ -48,10 +48,12 @@ end -- Initialize UI based on user config M.init_ui = function() - if state.user_config.ui == 'plenary' then - ui = require('spectre.ui.plenary') + if state.user_config.ui == 'buffer' then + ui = require('spectre.ui.buffer') + elseif state.user_config.ui == 'float' then + ui = require('spectre.ui.float') else - ui = require('spectre.ui.nui_components') + ui = require('spectre.ui.buffer') end end diff --git a/lua/spectre/ui/plenary/init.lua b/lua/spectre/ui/buffer/init.lua similarity index 97% rename from lua/spectre/ui/plenary/init.lua rename to lua/spectre/ui/buffer/init.lua index a432de2..037fb70 100644 --- a/lua/spectre/ui/plenary/init.lua +++ b/lua/spectre/ui/buffer/init.lua @@ -426,8 +426,8 @@ M.mapping_buffer = function() -- Set up autocmds vim.cmd([[augroup spectre_panel au! - au InsertEnter lua require"spectre.ui.plenary".on_insert_enter() - au InsertLeave lua require"spectre.ui.plenary".on_search_change() + au InsertEnter lua require"spectre.ui.buffer".on_insert_enter() + au InsertLeave lua require"spectre.ui.buffer".on_search_change() au BufLeave lua require("spectre").on_write() au BufUnload lua require("spectre").close() augroup END ]]) @@ -441,28 +441,28 @@ M.mapping_buffer = function() M.bufnr, 'n', 'x', - 'xlua require("spectre.ui.plenary").on_search_change()', + 'xlua require("spectre.ui.buffer").on_search_change()', map_opt ) api.nvim_buf_set_keymap( M.bufnr, 'n', 'p', - "plua require('spectre.ui.plenary').on_search_change()", + "plua require('spectre.ui.buffer').on_search_change()", map_opt ) api.nvim_buf_set_keymap( M.bufnr, 'v', 'p', - "plua require('spectre.ui.plenary').on_search_change()", + "plua require('spectre.ui.buffer').on_search_change()", map_opt ) api.nvim_buf_set_keymap( M.bufnr, 'v', 'P', - "Plua require('spectre.ui.plenary').on_search_change()", + "Plua require('spectre.ui.buffer').on_search_change()", map_opt ) api.nvim_buf_set_keymap(M.bufnr, 'n', 'd', '', map_opt) @@ -472,7 +472,7 @@ M.mapping_buffer = function() api.nvim_buf_set_keymap(M.bufnr, 'n', 'O', 'ki', map_opt) api.nvim_buf_set_keymap(M.bufnr, 'n', 'u', '', map_opt) -- disable undo, It breaks the UI. api.nvim_buf_set_keymap(M.bufnr, 'n', 'yy', "lua require('spectre.actions').copy_current_line()", map_opt) - api.nvim_buf_set_keymap(M.bufnr, 'n', '?', "lua require('spectre.ui.plenary').show_help()", map_opt) + api.nvim_buf_set_keymap(M.bufnr, 'n', '?', "lua require('spectre.ui.buffer').show_help()", map_opt) for _, map in pairs(state.user_config.mapping) do if map.cmd then diff --git a/lua/spectre/ui/nui_components/init.lua b/lua/spectre/ui/float/init.lua similarity index 100% rename from lua/spectre/ui/nui_components/init.lua rename to lua/spectre/ui/float/init.lua From e02cc462e96c8dd7eb3003f3de5be3541bf2fe9a Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Mon, 5 May 2025 15:47:57 +0500 Subject: [PATCH 14/15] Null check --- lua/spectre/state_utils.lua | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lua/spectre/state_utils.lua b/lua/spectre/state_utils.lua index a3a4f26..d11f4a5 100644 --- a/lua/spectre/state_utils.lua +++ b/lua/spectre/state_utils.lua @@ -58,9 +58,12 @@ end local get_options = function(cfg) local options_value = {} - for key, value in pairs(state.options) do - if value and cfg.options[key] ~= nil then - table.insert(options_value, cfg.options[key].value) + -- Check if cfg.options exists before trying to iterate through it + if cfg.options then + for key, value in pairs(state.options) do + if value and cfg.options[key] ~= nil then + table.insert(options_value, cfg.options[key].value) + end end end return options_value From c4299e917d932ee33bda90fe239ce94c226c3ed4 Mon Sep 17 00:00:00 2001 From: Nurdaulet Kurenshe Date: Mon, 5 May 2025 20:33:47 +0500 Subject: [PATCH 15/15] Just ignore, I trusted to AI too much --- lua/spectre/init.lua | 4 ---- lua/spectre/state.lua | 1 - lua/spectre/ui/buffer/init.lua | 2 -- lua/spectre/ui/float/init.lua | 1 - lua/spectre/utils.lua | 17 +---------------- 5 files changed, 1 insertion(+), 24 deletions(-) diff --git a/lua/spectre/init.lua b/lua/spectre/init.lua index 738763d..4f12842 100644 --- a/lua/spectre/init.lua +++ b/lua/spectre/init.lua @@ -88,7 +88,6 @@ M.open = function(opts) state.view = { mode = 'both', show_search = true, - show_replace = true, } state.regex = nil @@ -347,15 +346,12 @@ M.change_view = function() if state.view.mode == 'both' then state.view.mode = 'replace' state.view.show_search = false - state.view.show_replace = true elseif state.view.mode == 'replace' then state.view.mode = 'search' state.view.show_search = true - state.view.show_replace = false else state.view.mode = 'both' state.view.show_search = true - state.view.show_replace = true end -- Trigger UI update if available diff --git a/lua/spectre/state.lua b/lua/spectre/state.lua index 9e5c84c..557063c 100644 --- a/lua/spectre/state.lua +++ b/lua/spectre/state.lua @@ -94,7 +94,6 @@ M.async_id = nil M.view = { mode = 'both', show_search = true, - show_replace = true, } M.regex = nil M.renderer = nil diff --git a/lua/spectre/ui/buffer/init.lua b/lua/spectre/ui/buffer/init.lua index 037fb70..592e1e5 100644 --- a/lua/spectre/ui/buffer/init.lua +++ b/lua/spectre/ui/buffer/init.lua @@ -58,7 +58,6 @@ M.render_line = function(bufnr, namespace, text_opts, view_opts, regex) replace_query = text_opts.replace_query, search_text = text_opts.search_text, show_search = view_opts.show_search, - show_replace = view_opts.show_replace, }, regex) local end_lnum = text_opts.is_replace == true and text_opts.lnum + 1 or text_opts.lnum @@ -626,7 +625,6 @@ M.render_results = function() padding_text = cfg.result_padding, padding = padding, show_search = state.view.show_search, - show_replace = state.view.show_replace, }, state_utils.get_regex()) c_line = c_line + 1 diff --git a/lua/spectre/ui/float/init.lua b/lua/spectre/ui/float/init.lua index e527c25..8b332c7 100644 --- a/lua/spectre/ui/float/init.lua +++ b/lua/spectre/ui/float/init.lua @@ -228,7 +228,6 @@ local function create_search_ui() replace_query = state.query.replace_query, search_text = truncated_text:sub(match[1] + 1, match[2]), show_search = true, - show_replace = true, }, regex).replace end) diff --git a/lua/spectre/utils.lua b/lua/spectre/utils.lua index fdf7238..ebfd889 100644 --- a/lua/spectre/utils.lua +++ b/lua/spectre/utils.lua @@ -168,7 +168,7 @@ M.get_hl_line_text = function(opts, regex) result.text = opts.search_text if search_match then result.search = match_text_line(search_match, opts.search_text, 0) - if opts.replace_query and #opts.replace_query > 0 and opts.show_replace ~= false then + if opts.replace_query and #opts.replace_query > 0 then local replace_match = regex.replace_all(opts.search_query, opts.replace_query, search_match) local total_increase = 0 @@ -197,21 +197,6 @@ M.get_hl_line_text = function(opts, regex) result.text = new_text end - else - -- Before replacement or preview: Show original text with replacement preview - for _, v in pairs(result.search) do - v[1] = v[1] + total_increase - v[2] = v[2] + total_increase - - -- Add replacement text in parentheses after the search match with an arrow - local display_replace = ' → (' .. replace_match .. ')' - local pos = { v[2], v[2] + #display_replace } - table.insert(result.replace, pos) - - local text = result.text - result.text = text:sub(0, v[2]) .. display_replace .. text:sub(v[2] + 1) - total_increase = total_increase + #display_replace - end end end end