diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 626abe1..5914ba2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,9 +17,9 @@ jobs: - uses: actions/checkout@v4 - name: Install Neovim - uses: rhymond/setup-neovim@v1 + uses: rhysd/action-setup-vim@v1 with: - neovim-version: ${{ matrix.neovim_version }} + neovim: true - name: Run tests - run: make test \ No newline at end of file + run: make test diff --git a/lua/eca/api.lua b/lua/eca/api.lua index 55e10f7..7f236db 100644 --- a/lua/eca/api.lua +++ b/lua/eca/api.lua @@ -1,4 +1,5 @@ local Utils = require("eca.utils") +local Logger = require("eca.logger") -- Load nui.nvim components for floating windows local Popup = require("nui.popup") @@ -46,72 +47,73 @@ function M.send_message(message) M.chat() sidebar = eca.get() end - + if sidebar then sidebar:_send_message(message) else - Utils.error("Could not open ECA sidebar") + Logger.notify("Could not open ECA sidebar", vim.log.levels.ERROR) end end ---@param file_path string function M.add_file_context(file_path) - Utils.info("Adding file context: " .. file_path) - local eca = require("eca") + Logger.info("Adding file context: " .. file_path) + local eca = require("eca") + if not eca.server or not eca.server:is_running() then - Utils.error("ECA server is not running") + Logger.notify("ECA server is not running", vim.log.levels.ERROR) return end - + -- Read file content local content = Utils.read_file(file_path) if not content then - Utils.error("Could not read file: " .. file_path) + Logger.notify("Could not read file: " .. file_path, vim.log.levels.ERROR) return end - + -- Create context object local context = { type = "file", path = file_path, - content = content + content = content, } - + -- Get current sidebar and add context local sidebar = eca.get() if not sidebar then - Utils.info("Opening ECA sidebar to add context...") + Logger.info("Opening ECA sidebar to add context...") M.chat() sidebar = eca.get() end - + if sidebar then sidebar:add_context(context) else - Utils.error("Failed to create ECA sidebar") + Logger.notify("Failed to create ECA sidebar", vim.log.levels.ERROR) end end ---@param directory_path string function M.add_directory_context(directory_path) - Utils.info("Adding directory context: " .. directory_path) + Logger.info("Adding directory context: " .. directory_path) local eca = require("eca") - + if not eca.server or not eca.server:is_running() then - Utils.error("ECA server is not running") + Logger.notify("ECA server is not running", vim.log.levels.ERROR) return end - + -- Create context object for directory local context = { type = "directory", - path = directory_path + path = directory_path, } - + -- For now, store it for next message -- TODO: Implement context management - Utils.debug("Directory context added: " .. directory_path) + Logger.debug("Directory context added: " .. directory_path) end function M.add_current_file_context() @@ -119,7 +121,7 @@ function M.add_current_file_context() if current_file and current_file ~= "" then M.add_file_context(current_file) else - Utils.warn("No current file to add as context") + Logger.notify("No current file to add as context", vim.log.levels.WARN) end end @@ -127,22 +129,22 @@ function M.add_selection_context() -- Get visual selection marks (should be set by the command before calling this) local start_pos = vim.fn.getpos("'<") local end_pos = vim.fn.getpos("'>") - + if start_pos[2] == 0 or end_pos[2] == 0 then - Utils.warn("No selection to add as context. Please make a visual selection first.") + Logger.notify("No selection to add as context. Please make a visual selection first.", vim.log.levels.WARN) return end - + -- Ensure we have the right line order local start_line = math.min(start_pos[2], end_pos[2]) local end_line = math.max(start_pos[2], end_pos[2]) - + local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) if #lines > 0 then local selection_text = table.concat(lines, "\n") local current_file = vim.api.nvim_buf_get_name(0) local context_path = current_file .. ":" .. start_line .. "-" .. end_line - + -- Create context object local context = { type = "selection", @@ -150,37 +152,37 @@ function M.add_selection_context() content = selection_text, source_file = current_file, start_line = start_line, - end_line = end_line + end_line = end_line, } - + -- Get current sidebar and add context local eca = require("eca") local sidebar = eca.get() if not sidebar then - Utils.info("Opening ECA sidebar to add context...") + Logger.info("Opening ECA sidebar to add context...") M.chat() sidebar = eca.get() end - + if sidebar then sidebar:add_context(context) - + -- Also set as selected code for visual display local selected_code = { filepath = current_file, content = selection_text, start_line = start_line, end_line = end_line, - filetype = vim.api.nvim_get_option_value("filetype", { buf = 0 }) + filetype = vim.api.nvim_get_option_value("filetype", { buf = 0 }), } sidebar:set_selected_code(selected_code) - - Utils.info("Added selection context (" .. #lines .. " lines from lines " .. start_line .. "-" .. end_line .. ")") + + Logger.info("Added selection context (" .. #lines .. " lines from lines " .. start_line .. "-" .. end_line .. ")") else - Utils.error("Failed to create ECA sidebar") + Logger.notify("Failed to create ECA sidebar", vim.log.levels.ERROR) end else - Utils.warn("No lines found in selection") + Logger.notify("No lines found in selection", vim.log.levels.WARN) end end @@ -188,24 +190,24 @@ function M.list_contexts() local eca = require("eca") local sidebar = eca.get() if not sidebar then - Utils.warn("No active ECA sidebar") + Logger.notify("No active ECA sidebar", vim.log.levels.WARN) return end - + local contexts = sidebar:get_contexts() if #contexts == 0 then - Utils.info("No active contexts") + Logger.notify("No active contexts", vim.log.levels.INFO) return end - - Utils.info("Active contexts (" .. #contexts .. "):") + + Logger.info("Active contexts (" .. #contexts .. "):") for i, context in ipairs(contexts) do local size_info = "" if context.content then local lines = vim.split(context.content, "\n") size_info = " (" .. #lines .. " lines)" end - Utils.info(i .. ". " .. context.type .. ": " .. context.path .. size_info) + Logger.info(i .. ". " .. context.type .. ": " .. context.path .. size_info) end end @@ -213,10 +215,10 @@ function M.clear_contexts() local eca = require("eca") local sidebar = eca.get() if not sidebar then - Utils.warn("No active ECA sidebar") + Logger.notify("No active ECA sidebar", vim.log.levels.WARN) return end - + sidebar:clear_contexts() end @@ -224,10 +226,10 @@ function M.remove_context(path) local eca = require("eca") local sidebar = eca.get() if not sidebar then - Utils.warn("No active ECA sidebar") + Logger.notify("No active ECA sidebar", vim.log.levels.WARN) return end - + sidebar:remove_context(path) end @@ -235,30 +237,30 @@ function M.add_repo_map_context() local eca = require("eca") local sidebar = eca.get() if not sidebar then - Utils.info("Opening ECA sidebar to add repoMap context...") + Logger.info("Opening ECA sidebar to add repoMap context...") M.chat() sidebar = eca.get() end - + if sidebar then -- Check if repoMap already exists local contexts = sidebar:get_contexts() for _, context in ipairs(contexts) do if context.type == "repoMap" then - Utils.info("RepoMap context already added") + Logger.notify("RepoMap context already added", vim.log.levels.INFO) return end end - + -- Add repoMap context sidebar:add_context({ type = "repoMap", path = "repoMap", - content = "Repository structure and code mapping for better project understanding" + content = "Repository structure and code mapping for better project understanding", }) - Utils.info("Added repoMap context") + Logger.info("Added repoMap context") else - Utils.error("Failed to create ECA sidebar") + Logger.notify("Failed to create ECA sidebar", vim.log.levels.ERROR) end end @@ -273,7 +275,7 @@ function M.start_server() if eca.server then eca.server:start() else - Utils.error("ECA server not initialized") + Logger.notify("ECA server not initialized", vim.log.levels.ERROR) end end @@ -308,13 +310,21 @@ function M.show_selected_code() if sidebar then local selected_code = sidebar._selected_code if selected_code then - Utils.info("Selected code: " .. selected_code.filepath .. " (lines " .. - (selected_code.start_line or "?") .. "-" .. (selected_code.end_line or "?") .. ")") + Logger.notify( + "Selected code: " + .. selected_code.filepath + .. " (lines " + .. (selected_code.start_line or "?") + .. "-" + .. (selected_code.end_line or "?") + .. ")", + vim.log.levels.INFO + ) else - Utils.info("No code currently selected") + Logger.notify("No code currently selected", vim.log.levels.INFO) end else - Utils.warn("ECA sidebar not available") + Logger.notify("ECA sidebar not available", vim.log.levels.WARN) end end @@ -324,7 +334,7 @@ function M.clear_selected_code() if sidebar then sidebar:clear_selected_code() else - Utils.warn("ECA sidebar not available") + Logger.notify("ECA sidebar not available", vim.log.levels.WARN) end end @@ -334,19 +344,19 @@ function M.add_todo(content) local eca = require("eca") local sidebar = eca.get() if not sidebar then - Utils.info("Opening ECA sidebar to add TODO...") + Logger.info("Opening ECA sidebar to add TODO...") M.chat() sidebar = eca.get() end - + if sidebar then local todo = { content = content, - status = "pending" + status = "pending", } sidebar:add_todo(todo) else - Utils.error("Failed to create ECA sidebar") + Logger.notify("Failed to create ECA sidebar", vim.log.levels.ERROR) end end @@ -356,17 +366,17 @@ function M.list_todos() if sidebar then local todos = sidebar:get_todos() if #todos == 0 then - Utils.info("No active TODOs") + Logger.notify("No active TODOs", vim.log.levels.INFO) return end - - Utils.info("Active TODOs:") + + Logger.notify("Active TODOs:", vim.log.levels.INFO) for i, todo in ipairs(todos) do local status_icon = todo.status == "completed" and "✓" or "○" - Utils.info(string.format("%d. %s %s", i, status_icon, todo.content)) + Logger.notify(string.format("%d. %s %s", i, status_icon, todo.content), vim.log.levels.INFO) end else - Utils.warn("ECA sidebar not available") + Logger.notify("ECA sidebar not available", vim.log.levels.WARN) end end @@ -376,7 +386,7 @@ function M.toggle_todo(index) if sidebar then return sidebar:toggle_todo(index) else - Utils.warn("ECA sidebar not available") + Logger.notify("ECA sidebar not available", vim.log.levels.WARN) return false end end @@ -387,7 +397,7 @@ function M.clear_todos() if sidebar then sidebar:clear_todos() else - Utils.warn("ECA sidebar not available") + Logger.notify("ECA sidebar not available", vim.log.levels.WARN) end end @@ -395,80 +405,96 @@ end local logs_popup = nil function M.show_logs() - local eca = require("eca") - - if not eca.server then - Utils.warn("ECA server not initialized") - return + -- File logging is always enabled now + local display = Logger.get_display() + if display == "popup" then + M._show_logs_in_popup() + else + M._show_logs_in_buffer() end - - local logs = eca.server:get_logs() - - if #logs == 0 then - Utils.info("No ECA server logs available") +end + +--- Show logs in a regular Neovim buffer (when file logging is enabled) +function M._show_logs_in_buffer() + local log_path = Logger.get_log_path() + + if vim.fn.filereadable(log_path) ~= 1 then + Logger.info("Log file does not exist yet: " .. log_path) return end - - -- Format logs - local lines = {} - - for _, log_entry in ipairs(logs) do - -- Split message by newlines to handle multi-line log entries - local message_lines = vim.split(log_entry.message, "\n", { plain = true }) - - for i, message_line in ipairs(message_lines) do - if i == 1 then - -- First line gets full timestamp and level - local formatted_line = string.format("[%s] %s: %s", - log_entry.timestamp, - log_entry.level, - message_line) - table.insert(lines, formatted_line) - else - -- Continuation lines are indented - table.insert(lines, " " .. message_line) - end + + vim.cmd("edit " .. vim.fn.fnameescape(log_path)) + local bufnr = vim.fn.bufnr("%") + + -- Set buffer options for log viewing + vim.api.nvim_set_option_value("modifiable", false, { buf = bufnr }) + vim.cmd("normal! G") + + Logger.debug("Opened ECA log file: " .. log_path) +end + +--- Show logs in a popup window +function M._show_logs_in_popup() + local log_path = Logger.get_log_path() + + -- Helper function to read latest log content + local function get_latest_log_content() + if vim.fn.filereadable(log_path) == 1 then + local content = vim.fn.readfile(log_path) + return #content > 0 and content or { "No log entries found" } + else + return { "Log file does not exist yet: " .. log_path } end end - - if #logs > 0 then - table.insert(lines, "") - table.insert(lines, string.format("--- %d log entries ---", #logs)) + + -- Helper function to setup popup close keymaps + local function setup_popup_keymaps(popup) + popup:map("n", "q", function() + popup:unmount() + end, { noremap = true, silent = true }) + + popup:map("n", "", function() + popup:unmount() + end, { noremap = true, silent = true }) + + -- Clean up reference when popup is closed + popup:on("BufWinLeave", function() + logs_popup = nil + end) end - - -- If popup already exists and is mounted, update content + + -- Helper function to get responsive popup size + local function get_popup_size() + return { + width = math.floor(vim.o.columns * 0.8), + height = math.floor(vim.o.lines * 0.7), + } + end + + local function set_popup_content(popup, lines) + vim.api.nvim_set_option_value("modifiable", true, { buf = popup.bufnr }) + vim.api.nvim_buf_set_lines(popup.bufnr, 0, -1, false, lines) + vim.api.nvim_set_option_value("modifiable", false, { buf = popup.bufnr }) + vim.api.nvim_win_set_cursor(popup.winid, { #lines, 0 }) + end + if logs_popup and logs_popup.winid and vim.api.nvim_win_is_valid(logs_popup.winid) then - -- Update existing popup content - vim.api.nvim_set_option_value("modifiable", true, { buf = logs_popup.bufnr }) - vim.api.nvim_buf_set_lines(logs_popup.bufnr, 0, -1, false, lines) - vim.api.nvim_set_option_value("modifiable", false, { buf = logs_popup.bufnr }) - - -- Jump to end to show latest logs - vim.api.nvim_win_set_cursor(logs_popup.winid, {#lines, 0}) - Utils.info("Updated ECA server logs (" .. #logs .. " entries)") + set_popup_content(logs_popup, get_latest_log_content()) return end - - -- Calculate popup size (responsive) - local width = math.floor(vim.o.columns * 0.8) - local height = math.floor(vim.o.lines * 0.7) - - -- Create new floating popup + logs_popup = Popup({ enter = true, focusable = true, border = { style = "rounded", text = { - top = " 📋 ECA Server Logs ", + top = " 📋 ECA Logs ", top_align = "center", }, }, position = "50%", - size = { - width = width, - height = height, - }, + size = get_popup_size(), buf_options = { buftype = "nofile", bufhidden = "hide", @@ -478,38 +504,15 @@ function M.show_logs() }, win_options = { wrap = false, - number = false, - relativenumber = false, + number = true, signcolumn = "no", cursorline = true, }, }) - - -- Mount the popup logs_popup:mount() - - -- Set content - vim.api.nvim_buf_set_lines(logs_popup.bufnr, 0, -1, false, lines) - vim.api.nvim_set_option_value("modifiable", false, { buf = logs_popup.bufnr }) - - -- Jump to end to show latest logs - vim.api.nvim_win_set_cursor(logs_popup.winid, {#lines, 0}) - - -- Setup close keymaps - logs_popup:map("n", "q", function() - logs_popup:unmount() - end, { noremap = true, silent = true }) - - logs_popup:map("n", "", function() - logs_popup:unmount() - end, { noremap = true, silent = true }) - - -- Clean up reference when popup is closed - logs_popup:on("BufWinLeave", function() - logs_popup = nil - end) - - Utils.info("Opened ECA server logs (" .. #logs .. " entries) - Press 'q' or to close") + + set_popup_content(logs_popup, get_latest_log_content()) + setup_popup_keymaps(logs_popup) end return M diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 0b5e586..4dfcce8 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -1,7 +1,9 @@ local Utils = require("eca.utils") +local Logger = require("eca.logger") local M = {} +--- Setup ECA commands function M.setup() -- Define ECA commands vim.api.nvim_create_user_command("EcaChat", function(opts) @@ -43,7 +45,7 @@ function M.setup() vim.api.nvim_create_user_command("EcaAddSelection", function() -- Force exit visual mode and set marks - vim.cmd('normal! \\') + vim.cmd("normal! \\") vim.defer_fn(function() require("eca.api").add_selection_context() end, 50) -- Small delay to ensure marks are set @@ -68,7 +70,7 @@ function M.setup() if opts.args and opts.args ~= "" then require("eca.api").remove_context(opts.args) else - Utils.warn("Please provide a file path to remove") + Logger.notify("Please provide a file path to remove", vim.log.levels.WARN) end end, { desc = "Remove specific context from ECA", @@ -83,7 +85,7 @@ function M.setup() }) -- ===== Selected Code Commands ===== - + vim.api.nvim_create_user_command("EcaShowSelection", function() require("eca.api").show_selected_code() end, { @@ -97,12 +99,12 @@ function M.setup() }) -- ===== TODOs Commands ===== - + vim.api.nvim_create_user_command("EcaAddTodo", function(opts) if opts.args and opts.args ~= "" then require("eca.api").add_todo(opts.args) else - Utils.warn("Please provide TODO content") + Logger.notify("Please provide TODO content", vim.log.levels.WARN) end end, { desc = "Add a new TODO to ECA", @@ -121,10 +123,10 @@ function M.setup() if index then require("eca.api").toggle_todo(index) else - Utils.warn("Please provide a valid TODO index") + Logger.notify("Please provide a valid TODO index", vim.log.levels.WARN) end else - Utils.warn("Please provide TODO index to toggle") + Logger.notify("Please provide TODO index to toggle", vim.log.levels.WARN) end end, { desc = "Toggle TODO completion status", @@ -159,7 +161,7 @@ function M.setup() local eca = require("eca") local status = eca.server and eca.server:status() or "Not initialized" local status_icon = "○" - + if status == "Running" then status_icon = "✓" elseif status == "Starting" then @@ -167,33 +169,66 @@ function M.setup() elseif status == "Failed" then status_icon = "✗" end - - Utils.info("ECA Server Status: " .. status .. " " .. status_icon) - + + Logger.notify("ECA Server Status: " .. status .. " " .. status_icon, vim.log.levels.INFO) + -- Also show path info if available if eca.server and eca.server._path_finder then local config = require("eca.config") if config.server_path and config.server_path ~= "" then - Utils.info("Server path: " .. config.server_path .. " (custom)") + Logger.notify("Server path: " .. config.server_path .. " (custom)", vim.log.levels.INFO) else - Utils.info("Server path: auto-detected/downloaded") + Logger.notify("Server path: auto-detected/downloaded", vim.log.levels.INFO) end end end, { desc = "Show ECA server status with details", }) - vim.api.nvim_create_user_command("EcaLogs", function() - require("eca.api").show_logs() + vim.api.nvim_create_user_command("EcaLogs", function(opts) + local Api = require("eca.api") + local subcommand = opts.args and opts.args:match("%S+") or "show" + + if subcommand == "show" then + Api.show_logs() + elseif subcommand == "log_path" then + local log_path = Logger.get_log_path() + Logger.notify("ECA log file: " .. log_path, vim.log.levels.INFO) + elseif subcommand == "clear" then + if Logger.clear_log() then + Logger.notify("ECA log file cleared", vim.log.levels.INFO) + else + Logger.notify("Failed to clear log file", vim.log.levels.WARN) + end + elseif subcommand == "stats" then + local stats = Logger.get_log_stats() + if stats then + Logger.notify(string.format("Log file: %s", stats.path), vim.log.levels.INFO) + Logger.notify(string.format("Size: %.2fMB (%d bytes)", stats.size_mb, stats.size), vim.log.levels.INFO) + Logger.notify(string.format("Last modified: %s", stats.modified), vim.log.levels.INFO) + else + Logger.notify("No log file stats available", vim.log.levels.INFO) + end + else + Logger.notify("Unknown EcaLogs subcommand: " .. subcommand, vim.log.levels.WARN) + Logger.notify("Available subcommands: show, log_path, clear, stats", vim.log.levels.INFO) + end end, { - desc = "Open ECA server logs in a new buffer", + desc = "ECA logging commands (show, log_path, clear, stats)", + nargs = "?", + complete = function(ArgLead, CmdLine, CursorPos) + local subcommands = { "show", "log_path", "clear", "stats" } + return vim.tbl_filter(function(cmd) + return cmd:match("^" .. ArgLead) + end, subcommands) + end, }) vim.api.nvim_create_user_command("EcaSend", function(opts) if opts.args and opts.args ~= "" then require("eca.api").send_message(opts.args) else - Utils.warn("Please provide a message to send") + Logger.notify("Please provide a message to send", vim.log.levels.WARN) end end, { desc = "Send a message to ECA", @@ -205,7 +240,10 @@ function M.setup() local width = Config.get_window_width() local columns = vim.o.columns local percentage = Config.options.windows.width - Utils.info(string.format("Width: %d columns (%.1f%% of %d total columns)", width, percentage, columns)) + Logger.notify( + string.format("Width: %d columns (%.1f%% of %d total columns)", width, percentage, columns), + vim.log.levels.INFO + ) end, { desc = "Debug window width calculation", }) @@ -213,23 +251,23 @@ function M.setup() vim.api.nvim_create_user_command("EcaRedownload", function() local eca = require("eca") local Utils = require("eca.utils") - + if eca.server and eca.server:is_running() then - Utils.info("Stopping server before re-download...") + Logger.notify("Stopping server before re-download...", vim.log.levels.INFO) eca.server:stop() end - + -- Remove existing version file to force re-download local cache_dir = Utils.get_cache_dir() local version_file = cache_dir .. "/eca-version" os.remove(version_file) - + -- Remove existing binary local server_binary = cache_dir .. "/eca" os.remove(server_binary) - - Utils.info("Removed cached ECA server. Will re-download on next start.") - + + Logger.notify("Removed cached ECA server. Will re-download on next start.", vim.log.levels.INFO) + -- Restart server vim.defer_fn(function() if eca.server then @@ -243,13 +281,13 @@ function M.setup() vim.api.nvim_create_user_command("EcaStopResponse", function() local eca = require("eca") local Utils = require("eca.utils") - + -- Force stop any ongoing streaming response if eca.sidebar then eca.sidebar:_finalize_streaming_response() - Utils.info("Forced stop of streaming response") + Logger.notify("Forced stop of streaming response", vim.log.levels.INFO) else - Utils.warn("No active sidebar to stop") + Logger.notify("No active sidebar to stop", vim.log.levels.WARN) end end, { desc = "Emergency stop for infinite loops or runaway responses", @@ -257,7 +295,7 @@ function M.setup() vim.api.nvim_create_user_command("EcaFixTreesitter", function() local Utils = require("eca.utils") - + -- Emergency treesitter fix for chat buffer vim.schedule(function() local eca = require("eca") @@ -266,7 +304,7 @@ function M.setup() if bufnr and vim.api.nvim_buf_is_valid(bufnr) then -- Disable all highlighting for this buffer pcall(vim.api.nvim_set_option_value, "syntax", "off", { buf = bufnr }) - + -- Destroy treesitter highlighter if it exists pcall(function() if vim.treesitter.highlighter.active[bufnr] then @@ -274,21 +312,21 @@ function M.setup() vim.treesitter.highlighter.active[bufnr] = nil end end) - - Utils.info("Disabled treesitter highlighting for ECA chat buffer") - Utils.info("Buffer " .. bufnr .. " highlighting disabled") + + Logger.notify("Disabled treesitter highlighting for ECA chat buffer", vim.log.levels.INFO) + Logger.notify("Buffer " .. bufnr .. " highlighting disabled", vim.log.levels.INFO) else - Utils.warn("No valid chat buffer found") + Logger.notify("No valid chat buffer found", vim.log.levels.WARN) end else - Utils.warn("No active ECA sidebar found") + Logger.notify("No active ECA sidebar found", vim.log.levels.WARN) end end) end, { desc = "Emergency fix for treesitter issues in ECA chat", }) - Utils.debug("ECA commands registered") + Logger.debug("ECA commands registered") end return M diff --git a/lua/eca/config.lua b/lua/eca/config.lua index 56c73ce..79376ad 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -3,13 +3,23 @@ local M = {} ---@class eca.Config M._defaults = { - debug = false, ---@type string server_path = "", -- Path to the ECA binary, will download automatically if empty ---@type string server_args = "", -- Extra args for the eca start command ---@type string usage_string_format = "{messageCost} / {sessionCost}", + ---@class eca.LogConfig + ---@field display 'popup'|'split' + ---@field level integer + ---@field file string + ---@field max_file_size_mb number + log = { + display = "split", + level = vim.log.levels.INFO, + file = "", + max_file_size_mb = 10, -- Maximum log file size in MB before warning + }, behaviour = { auto_set_keymaps = true, auto_focus_sidebar = true, diff --git a/lua/eca/init.lua b/lua/eca/init.lua index d8eee01..b87e7cc 100644 --- a/lua/eca/init.lua +++ b/lua/eca/init.lua @@ -2,6 +2,7 @@ local api = vim.api local Utils = require("eca.utils") +local Logger = require("eca.logger") local Sidebar = require("eca.sidebar") local Config = require("eca.config") local Server = require("eca.server") @@ -24,29 +25,31 @@ M.did_setup = false local H = {} function H.keymaps() - vim.keymap.set({ "n", "v" }, "(EcaChat)", function() require("eca.api").chat() end, { noremap = true }) - vim.keymap.set("n", "(EcaToggle)", function() M.toggle() end, { noremap = true }) - vim.keymap.set("n", "(EcaFocus)", function() require("eca.api").focus() end, { noremap = true }) + vim.keymap.set({ "n", "v" }, "(EcaChat)", function() + require("eca.api").chat() + end, { noremap = true }) + vim.keymap.set("n", "(EcaToggle)", function() + M.toggle() + end, { noremap = true }) + vim.keymap.set("n", "(EcaFocus)", function() + require("eca.api").focus() + end, { noremap = true }) if Config.behaviour.auto_set_keymaps then - Utils.safe_keymap_set( - { "n", "v" }, - Config.mappings.chat, - function() require("eca.api").chat() end, - { desc = "eca: open chat" } - ) - Utils.safe_keymap_set( - "n", - Config.mappings.focus, - function() require("eca.api").focus() end, - { desc = "eca: focus" } - ) - Utils.safe_keymap_set("n", Config.mappings.toggle, function() M.toggle() end, { desc = "eca: toggle" }) + Utils.safe_keymap_set({ "n", "v" }, Config.mappings.chat, function() + require("eca.api").chat() + end, { desc = "eca: open chat" }) + Utils.safe_keymap_set("n", Config.mappings.focus, function() + require("eca.api").focus() + end, { desc = "eca: focus" }) + Utils.safe_keymap_set("n", Config.mappings.toggle, function() + M.toggle() + end, { desc = "eca: toggle" }) end end -function H.signs() - vim.fn.sign_define("EcaInputPromptSign", { text = Config.windows.input.prefix }) +function H.signs() + vim.fn.sign_define("EcaInputPromptSign", { text = Config.windows.input.prefix }) end H.augroup = api.nvim_create_augroup("eca_autocmds", { clear = true }) @@ -66,8 +69,12 @@ function H.autocmds() group = H.augroup, callback = function() local sidebar = M.get() - if not sidebar then return end - if not sidebar:is_open() then return end + if not sidebar then + return + end + if not sidebar:is_open() then + return + end sidebar:resize() end, }) @@ -76,7 +83,9 @@ function H.autocmds() group = H.augroup, callback = function() local current_buf = vim.api.nvim_get_current_buf() - if Utils.is_sidebar_buffer(current_buf) then return end + if Utils.is_sidebar_buffer(current_buf) then + return + end local non_sidebar_wins = 0 local sidebar_wins = {} @@ -106,8 +115,12 @@ function H.autocmds() callback = function(ev) local tab = tonumber(ev.file) local s = M.sidebars[tab] - if s then s:reset() end - if tab ~= nil then M.sidebars[tab] = nil end + if s then + s:reset() + end + if tab ~= nil then + M.sidebars[tab] = nil + end end, }) @@ -116,21 +129,25 @@ function H.autocmds() end) local function setup_colors() - Utils.debug("Setting up eca colors") + Logger.debug("Setting up eca colors") require("eca.highlights").setup() end api.nvim_create_autocmd("ColorSchemePre", { group = H.augroup, callback = function() - vim.schedule(function() setup_colors() end) + vim.schedule(function() + setup_colors() + end) end, }) api.nvim_create_autocmd("ColorScheme", { group = H.augroup, callback = function() - vim.schedule(function() setup_colors() end) + vim.schedule(function() + setup_colors() + end) end, }) @@ -179,7 +196,9 @@ end function M.is_sidebar_open() local sidebar = M.get() - if not sidebar then return false end + if not sidebar then + return false + end return sidebar:is_open() end @@ -187,26 +206,37 @@ end function M.open_sidebar(opts) opts = opts or {} local sidebar = M.get() - if not sidebar then M._init(api.nvim_get_current_tabpage()) end + if not sidebar then + M._init(api.nvim_get_current_tabpage()) + end M.current.sidebar:open(opts) end function M.close_sidebar() local sidebar = M.get() - if not sidebar then return end + if not sidebar then + return + end sidebar:close() end setmetatable(M.toggle, { __index = M.toggle, - __call = function() M.toggle_sidebar() end, + __call = function() + M.toggle_sidebar() + end, }) ---@param opts? eca.Config function M.setup(opts) Config.setup(opts or {}) - if M.did_setup then return end + if M.did_setup then + return + end + + -- Initialize logger with configuration + require("eca.logger").setup(Config.options.log) require("eca.highlights").setup() require("eca.commands").setup() @@ -223,16 +253,16 @@ function M.setup(opts) M.server = Server:new({ on_started = function(connection) M.status_bar:update("Running") - Utils.info("ECA server started and ready!") + Logger.info("ECA server started and ready!") end, on_status_changed = function(status) M.status_bar:update(status) if status == "Failed" then - Utils.error("ECA server failed to start. Check :messages for details.") + Logger.notify("ECA server failed to start. Check :messages for details.", vim.log.levels.ERROR) elseif status == "Starting" then - Utils.info("Starting ECA server...") + Logger.info("Starting ECA server...") end - end + end, }) -- Start server automatically in background diff --git a/lua/eca/logger.lua b/lua/eca/logger.lua new file mode 100644 index 0000000..8ccf9d5 --- /dev/null +++ b/lua/eca/logger.lua @@ -0,0 +1,194 @@ +local uv = vim.uv or vim.loop + +---@class eca.Logger +local M = {} + +---@type eca.LogConfig +M.config = nil + +local LEVEL_NAMES = { + [vim.log.levels.TRACE] = "TRACE", + [vim.log.levels.DEBUG] = "DEBUG", + [vim.log.levels.INFO] = "INFO", + [vim.log.levels.WARN] = "WARN", + [vim.log.levels.ERROR] = "ERROR", +} + +---Get XDG-compliant default log path +---@return string +local function get_default_log_path() + return vim.fn.stdpath("state") .. "/eca.log" +end + +---@type eca.LogConfig +local DEFAULT_CONFIG = { + level = vim.log.levels.INFO, + file = get_default_log_path(), + display = "split", + max_file_size_mb = 10, +} + +---Get log level name +---@param level integer +---@return string +local function get_level_name(level) + return LEVEL_NAMES[level] or "UNKNOWN" +end + +---Check if log file is over size limit and notify if needed +---@param log_path string +---@param max_size_mb number +local function check_log_size_and_notify(log_path, max_size_mb) + uv.fs_stat(log_path, function(err, stat) + if err or not stat then + return + end + + local max_size = max_size_mb * 1024 * 1024 -- Convert MB to bytes + if stat.size > max_size then + local size_mb = math.floor(stat.size / 1024 / 1024 * 100) / 100 + vim.notify( + string.format("ECA log file is large (%.1fMB). Consider clearing it with :EcaLogs clear", size_mb), + vim.log.levels.WARN, + { title = "ECA" } + ) + end + end) +end + +--- Initialize logger with configuration +---@param config eca.LogConfig +function M.setup(config) + config = config or {} + local strings = require("eca.strings") + + M.config = { + level = config.level or DEFAULT_CONFIG.level, + file = strings.is_nil_or_empty(config.file) and DEFAULT_CONFIG.file or config.file, + display = config.display or DEFAULT_CONFIG.display, + max_file_size_mb = config.max_file_size_mb or DEFAULT_CONFIG.max_file_size_mb, + } + + local log_path = M.get_log_path() + check_log_size_and_notify(log_path, M.config.max_file_size_mb) +end + +---@return string +function M.get_display() + return M.config and M.config.display or "split" +end + +--- Get current log file path +---@return string +function M.get_log_path() + assert(M.config and M.config.file ~= "", "Logger must be configured with a non-empty file path") + return vim.fn.expand(M.config.file) +end + +--- Log a message to the log file +---@param message string +---@param level integer +---@param opts? {title?: string} +function M.log(message, level, opts) + opts = opts or {} + + if not M.config then + return + end + + if level < M.config.level then + return + end + + local log_path = M.get_log_path() + vim.fn.mkdir(vim.fn.fnamemodify(log_path, ":h"), "p") + + local timestamp = os.date("%Y-%m-%d %H:%M:%S") + local level_name = get_level_name(level) + local prefix = opts.title == "SERVER" and "SERVER" or "CLIENT" + local formatted = string.format("[%s] %-5s [%s] %s\n", timestamp, level_name, prefix, message) + + uv.fs_open(log_path, "a", 420, function(err, fd) + if err or not fd then + return + end + uv.fs_write(fd, formatted, -1, function() + uv.fs_close(fd) + end) + end) +end + +--- Log debug message +---@param message string +---@param opts? {title?: string} +function M.debug(message, opts) + M.log(message, vim.log.levels.DEBUG, opts) +end + +--- Log info message +---@param message string +---@param opts? {title?: string} +function M.info(message, opts) + M.log(message, vim.log.levels.INFO, opts) +end + +--- Log warn message +---@param message string +---@param opts? {title?: string} +function M.warn(message, opts) + M.log(message, vim.log.levels.WARN, opts) +end + +--- Log error message +---@param message string +---@param opts? {title?: string} +function M.error(message, opts) + M.log(message, vim.log.levels.ERROR, opts) +end + +--- Send notification to user via vim.notify +---@param message string +---@param level? integer vim.log.levels (default: INFO) +---@param opts? {title?: string, once?: boolean} +function M.notify(message, level, opts) + level = level or vim.log.levels.INFO + opts = opts or {} + + M.log(message, level, opts) + + vim.notify(message, level, { + title = opts.title or "ECA", + once = opts.once, + }) +end + +--- Get log file statistics +---@return table|nil +function M.get_log_stats() + local log_path = M.get_log_path() + local stat = uv.fs_stat(log_path) + if not stat then + return nil + end + + return { + path = log_path, + size = stat.size, + size_mb = math.floor(stat.size / 1024 / 1024 * 100) / 100, + modified = os.date("%Y-%m-%d %H:%M:%S", stat.mtime.sec), + } +end + +--- Clear log file +---@return boolean +function M.clear_log() + local log_path = M.get_log_path() + local file = io.open(log_path, "w") + if file then + file:close() + return true + end + return false +end + +return M diff --git a/lua/eca/nui_sidebar.lua b/lua/eca/nui_sidebar.lua index b1117c5..f2dcb75 100644 --- a/lua/eca/nui_sidebar.lua +++ b/lua/eca/nui_sidebar.lua @@ -1,4 +1,5 @@ local Utils = require("eca.utils") +local Logger = require("eca.logger") local Config = require("eca.config") -- Check if nui.nvim is available @@ -6,12 +7,12 @@ local nui_available, Split = pcall(require, "nui.split") local nui_event_available, event = pcall(require, "nui.utils.autocmd") if not nui_available then - Utils.warn("nui.nvim not found. Install MunifTanjim/nui.nvim for enhanced UI experience.") + Logger.notify("nui.nvim not found. Install MunifTanjim/nui.nvim for enhanced UI experience.", vim.log.levels.WARN) return nil end if not nui_event_available then - Utils.warn("nui.utils.autocmd not found. Some features may not work properly.") + Logger.notify("nui.utils.autocmd not found. Some features may not work properly.", vim.log.levels.WARN) event = nil end @@ -80,10 +81,10 @@ function M:open(opts) -- Setup containers if not initialized or if we need to refresh content if not self._initialized then - Utils.debug("Setting up nui containers (first time)") + Logger.debug("Setting up nui containers (first time)") self:_setup_containers() else - Utils.debug("Reusing existing nui containers") + Logger.debug("Reusing existing nui containers") self:_refresh_container_content() end @@ -91,7 +92,7 @@ function M:open(opts) vim.api.nvim_set_current_win(self.containers.input.winid) end - Utils.debug("ECA nui sidebar opened") + Logger.debug("ECA nui sidebar opened") end function M:close() @@ -106,7 +107,7 @@ function M:_close_windows_only() container.winid = nil end end - Utils.debug("ECA nui sidebar windows closed") + Logger.debug("ECA nui sidebar windows closed") end function M:_close_and_cleanup() @@ -125,7 +126,7 @@ function M:_close_and_cleanup() end end self.containers = {} - Utils.debug("ECA nui sidebar closed and cleaned up") + Logger.debug("ECA nui sidebar closed and cleaned up") end ---@param opts? table @@ -181,7 +182,7 @@ end function M:new_chat() self:reset() self._force_welcome = true - Utils.debug("New chat initiated - will show welcome content on next open") + Logger.debug("New chat initiated - will show welcome content on next open") end ---@private @@ -246,7 +247,7 @@ function M:_create_nui_containers() }) self.containers.chat:mount() self:_setup_container_events(self.containers.chat, "chat") - Utils.debug("Mounted nui container: chat (winid: " .. self.containers.chat.winid .. ")") + Logger.debug("Mounted nui container: chat (winid: " .. self.containers.chat.winid .. ")") -- Track the last mounted container winid for relative positioning local last_winid = self.containers.chat.winid @@ -271,7 +272,7 @@ function M:_create_nui_containers() self.containers.selected_code:mount() self:_setup_container_events(self.containers.selected_code, "selected_code") last_winid = self.containers.selected_code.winid - Utils.debug("Mounted nui container: selected_code (winid: " .. last_winid .. ")") + Logger.debug("Mounted nui container: selected_code (winid: " .. last_winid .. ")") end -- 3. Create todos container (conditional) @@ -293,7 +294,7 @@ function M:_create_nui_containers() self.containers.todos:mount() self:_setup_container_events(self.containers.todos, "todos") last_winid = self.containers.todos.winid - Utils.debug("Mounted nui container: todos (winid: " .. last_winid .. ")") + Logger.debug("Mounted nui container: todos (winid: " .. last_winid .. ")") end -- 4. Create contexts container (always present) @@ -314,7 +315,7 @@ function M:_create_nui_containers() self.containers.contexts:mount() self:_setup_container_events(self.containers.contexts, "contexts") last_winid = self.containers.contexts.winid - Utils.debug("Mounted nui container: contexts (winid: " .. last_winid .. ")") + Logger.debug("Mounted nui container: contexts (winid: " .. last_winid .. ")") -- 5. Create usage container (always present) self.containers.usage = Split({ @@ -335,7 +336,7 @@ function M:_create_nui_containers() self.containers.usage:mount() self:_setup_container_events(self.containers.usage, "usage") last_winid = self.containers.usage.winid - Utils.debug("Mounted nui container: usage (winid: " .. last_winid .. ")") + Logger.debug("Mounted nui container: usage (winid: " .. last_winid .. ")") -- 6. Create input container (always present) self.containers.input = Split({ @@ -354,9 +355,9 @@ function M:_create_nui_containers() }) self.containers.input:mount() self:_setup_container_events(self.containers.input, "input") - Utils.debug("Mounted nui container: input (winid: " .. self.containers.input.winid .. ")") + Logger.debug("Mounted nui container: input (winid: " .. self.containers.input.winid .. ")") - Utils.debug(string.format("Created nui containers: chat=%d, selected_code=%s, todos=%s, contexts=%d, usage=%d, input=%d", + Logger.debug(string.format("Created nui containers: chat=%d, selected_code=%s, todos=%s, contexts=%d, usage=%d, input=%d", chat_height, selected_code_height > 0 and tostring(selected_code_height) or "hidden", todos_height > 0 and tostring(todos_height) or "hidden", @@ -373,17 +374,17 @@ function M:_setup_container_events(container, name) -- Setup event handling for each container container:on(event.event.BufWinEnter, function() - Utils.debug("Container " .. name .. " entered") + Logger.debug("Container " .. name .. " entered") -- Container-specific setup can go here end) container:on(event.event.BufLeave, function() - Utils.debug("Container " .. name .. " left") + Logger.debug("Container " .. name .. " left") -- Container-specific cleanup can go here end) container:on(event.event.WinClosed, function() - Utils.debug("Container " .. name .. " window closed") + Logger.debug("Container " .. name .. " window closed") self:_handle_container_closed(name) end) @@ -529,7 +530,7 @@ function M:add_context(context) -- Update existing context self._contexts[i] = context self:_update_contexts_display() - Utils.info("Updated context: " .. context.path) + Logger.info("Updated context: " .. context.path) return end end @@ -537,7 +538,7 @@ function M:add_context(context) -- Add new context table.insert(self._contexts, context) self:_update_contexts_display() - Utils.info("Added context: " .. context.path .. " (" .. #self._contexts .. " total)") + Logger.info("Added context: " .. context.path .. " (" .. #self._contexts .. " total)") end ---@param path string Path to remove from contexts @@ -546,11 +547,11 @@ function M:remove_context(path) if context.path == path then table.remove(self._contexts, i) self:_update_contexts_display() - Utils.info("Removed context: " .. path) + Logger.info("Removed context: " .. path) return true end end - Utils.warn("Context not found: " .. path) + Logger.warn("Context not found: " .. path) return false end @@ -568,40 +569,40 @@ function M:clear_contexts() local count = #self._contexts self._contexts = {} self:_update_contexts_display() - Utils.info("Cleared " .. count .. " contexts") + Logger.info("Cleared " .. count .. " contexts") end ---@param code table Selected code object with filepath, content, start_line, end_line function M:set_selected_code(code) self._selected_code = code self:_update_selected_code_display() - Utils.info("Selected code updated: " .. (code and code.filepath or "none")) + Logger.info("Selected code updated: " .. (code and code.filepath or "none")) end function M:clear_selected_code() self._selected_code = nil self:_update_selected_code_display() - Utils.info("Selected code cleared") + Logger.info("Selected code cleared") end ---@param todo table Todo object with content, status ("pending", "completed") function M:add_todo(todo) table.insert(self._todos, todo) self:_update_todos_display() - Utils.info("Added TODO: " .. todo.content) + Logger.info("Added TODO: " .. todo.content) end ---@param index integer Index of todo to toggle function M:toggle_todo(index) if index <= 0 or index > #self._todos then - Utils.warn("Invalid TODO index: " .. index) + Logger.warn("Invalid TODO index: " .. index) return false end local todo = self._todos[index] todo.status = todo.status == "completed" and "pending" or "completed" self:_update_todos_display() - Utils.info("Toggled TODO " .. index .. ": " .. todo.content) + Logger.info("Toggled TODO " .. index .. ": " .. todo.content) return true end @@ -609,7 +610,7 @@ function M:clear_todos() local count = #self._todos self._todos = {} self:_update_todos_display() - Utils.info("Cleared " .. count .. " TODOs") + Logger.info("Cleared " .. count .. " TODOs") end ---@return table List of active todos @@ -764,12 +765,12 @@ function M:_set_welcome_content() -- Only set welcome content if buffer is empty or has no meaningful content if has_content then - Utils.debug("Preserving existing chat content") + Logger.debug("Preserving existing chat content") return end else -- Force welcome content and reset the flag - Utils.debug("Forcing welcome content for new chat") + Logger.debug("Forcing welcome content for new chat") self._force_welcome = false end @@ -793,7 +794,7 @@ function M:_set_welcome_content() "", } - Utils.debug("Setting welcome content for new chat") + Logger.debug("Setting welcome content for new chat") vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, lines) -- Auto-add repoMap context if enabled and not already present @@ -813,7 +814,7 @@ function M:_set_welcome_content() path = "repoMap", content = "Repository structure and code mapping for better project understanding" }) - Utils.debug("Auto-added repoMap context on welcome") + Logger.debug("Auto-added repoMap context on welcome") end end end @@ -1030,7 +1031,7 @@ end ---@param message string function M:_send_message(message) - Utils.debug("Sending message: " .. message) + Logger.debug("Sending message: " .. message) -- Store the last user message to avoid duplication self._last_user_message = message @@ -1043,10 +1044,10 @@ function M:_send_message(message) if eca.server and eca.server:is_running() then -- Include active contexts in the message local contexts = self:get_contexts() - Utils.debug("Sending message with " .. #contexts .. " contexts") + Logger.debug("Sending message with " .. #contexts .. " contexts") eca.server:send_chat_message(message, contexts, function(err, result) if err then - Utils.error("Failed to send message to ECA server: " .. tostring(err)) + Logger.error("Failed to send message to ECA server: " .. tostring(err)) self:_add_message("assistant", "❌ **Error**: Failed to send message to ECA server") end -- Response will come through server notification handler @@ -1127,7 +1128,7 @@ function M:_handle_streaming_text(text) -- If the accumulated text so far exactly matches the user message, skip it if trimmed_text == trimmed_user_msg then - Utils.debug("Skipping echo of user message: " .. trimmed_text) + Logger.debug("Skipping echo of user message: " .. trimmed_text) return end end diff --git a/lua/eca/path_finder.lua b/lua/eca/path_finder.lua index dee5927..733fa07 100644 --- a/lua/eca/path_finder.lua +++ b/lua/eca/path_finder.lua @@ -1,6 +1,7 @@ local uv = vim.uv or vim.loop local Utils = require("eca.utils") local Config = require("eca.config") +local Logger = require("eca.logger") ---@class eca.PathFinder ---@field private _cache_dir string @@ -30,7 +31,7 @@ function M:_get_artifacts() }, windows = { x86_64 = "eca-native-windows-amd64.zip", - } + }, } end @@ -49,14 +50,14 @@ function M:_get_artifact_name(platform, arch) elseif platform:match("windows") or platform:match("mingw") or platform:match("msys") then platform = "windows" end - + local artifacts = self:_get_artifacts() local platform_artifacts = artifacts[platform] - + if not platform_artifacts then error("Unsupported platform: " .. platform) end - + return platform_artifacts[arch] or "eca.jar" end @@ -83,7 +84,7 @@ function M:_read_version_file() if not file then return nil end - + local version = file:read("*a"):gsub("%s+", "") file:close() return version ~= "" and version or nil @@ -93,33 +94,33 @@ end function M:_write_version_file(version) -- Ensure cache directory exists vim.fn.mkdir(self._cache_dir, "p") - + local file = io.open(self._version_file, "w") if file then file:write(version) file:close() else - Utils.warn("Could not write version file: " .. self._version_file) + Logger.notify("Could not write version file: " .. self._version_file, vim.log.levels.WARN) end end ---@return string? function M:_get_latest_version() - local cmd = 'curl -s https://api.github.com/repos/editor-code-assistant/eca/releases/latest' - local handle = io.popen(cmd .. ' 2>/dev/null') - + local cmd = "curl -s https://api.github.com/repos/editor-code-assistant/eca/releases/latest" + local handle = io.popen(cmd .. " 2>/dev/null") + if not handle then - Utils.warn("Failed to check for latest ECA version") + Logger.notify("Failed to check for latest ECA version", vim.log.levels.WARN) return nil end - + local response = handle:read("*a") handle:close() - + if not response or response == "" then return nil end - + -- Parse JSON to get tag_name local tag_match = response:match('"tag_name"%s*:%s*"([^"]+)"') return tag_match @@ -130,64 +131,58 @@ end ---@return boolean success function M:_download_latest_server(server_path, version) local artifact_name = self:_get_artifact_name() - local download_url = string.format( - "https://github.com/editor-code-assistant/eca/releases/download/%s/%s", - version, - artifact_name - ) - + local download_url = + string.format("https://github.com/editor-code-assistant/eca/releases/download/%s/%s", version, artifact_name) + local download_path = self._cache_dir .. "/" .. artifact_name - - Utils.info("Downloading latest ECA server version from: " .. download_url) - + + Logger.notify("Downloading latest ECA server version from: " .. download_url, vim.log.levels.INFO) + -- Ensure cache directory exists vim.fn.mkdir(self._cache_dir, "p") - + -- Download the file local download_cmd = string.format( "curl -L --fail --show-error --silent -o %s %s", vim.fn.shellescape(download_path), vim.fn.shellescape(download_url) ) - + local download_result = os.execute(download_cmd) if download_result ~= 0 then - Utils.error("Failed to download ECA server from: " .. download_url) + Logger.notify("Failed to download ECA server from: " .. download_url, vim.log.levels.ERROR) return false end - + -- Extract if it's a zip file if artifact_name:match("%.zip$") then - local extract_cmd = string.format( - "cd %s && unzip -o %s", - vim.fn.shellescape(self._cache_dir), - vim.fn.shellescape(artifact_name) - ) - + local extract_cmd = + string.format("cd %s && unzip -o %s", vim.fn.shellescape(self._cache_dir), vim.fn.shellescape(artifact_name)) + local extract_result = os.execute(extract_cmd) if extract_result ~= 0 then - Utils.error("Failed to extract ECA server") + Logger.notify("Failed to extract ECA server", vim.log.levels.ERROR) return false end - + -- Remove the zip file after extraction os.remove(download_path) end - + -- Make executable (if not Windows) if not vim.loop.os_uname().sysname:lower():match("windows") then os.execute("chmod +x " .. vim.fn.shellescape(server_path)) end - + if not Utils.file_exists(server_path) then - Utils.error("ECA server binary not found after download and extraction") + Logger.notify("ECA server binary not found after download and extraction", vim.log.levels.ERROR) return false end - + -- Write version file self:_write_version_file(version) - - Utils.info("ECA server downloaded successfully") + + Logger.notify("ECA server downloaded successfully", vim.log.levels.INFO) return true end @@ -197,38 +192,40 @@ function M:find() local custom_path = Config.server_path if custom_path and custom_path:gsub("%s+", "") ~= "" then if Utils.file_exists(custom_path) then - Utils.debug("Using custom server path: " .. custom_path) + Logger.debug("Using custom server path: " .. custom_path) return custom_path else - Utils.warn("Custom server path does not exist: " .. custom_path) + Logger.notify("Custom server path does not exist: " .. custom_path, vim.log.levels.WARN) end end - + local server_path = self:_get_extension_server_path() local latest_version = self:_get_latest_version() local current_version = self:_read_version_file() - + local server_exists = Utils.file_exists(server_path) - + -- If we can't get the latest version and server doesn't exist, that's an error if not latest_version and not server_exists then - error("Could not fetch latest version of ECA. Please check your internet connection and try again. You can also download ECA manually and set the path in the settings.") + error( + "Could not fetch latest version of ECA. Please check your internet connection and try again. You can also download ECA manually and set the path in the settings." + ) end - + -- Download if server doesn't exist or version is outdated if not server_exists or (latest_version and current_version ~= latest_version) then if not latest_version then - Utils.warn("Could not check for latest version, using existing server") + Logger.notify("Could not check for latest version, using existing server", vim.log.levels.WARN) return server_path end - + local success = self:_download_latest_server(server_path, latest_version) if not success then error("Failed to download ECA server") end end - - Utils.debug("Using ECA server: " .. server_path) + + Logger.debug("Using ECA server: " .. server_path) return server_path end diff --git a/lua/eca/server.lua b/lua/eca/server.lua index d8bd137..3482b31 100644 --- a/lua/eca/server.lua +++ b/lua/eca/server.lua @@ -2,6 +2,7 @@ local Utils = require("eca.utils") local Config = require("eca.config") local RPC = require("eca.rpc") local PathFinder = require("eca.path_finder") +local Logger = require("eca.logger") ---@class eca.Server ---@field private _proc? userdata Process handle @@ -12,7 +13,6 @@ local PathFinder = require("eca.path_finder") ---@field private _server_capabilities? table Server capabilities ---@field private _chat_id? string Current chat ID ---@field private _path_finder eca.PathFinder Server path finder ----@field private _logs table Array of log entries local M = {} M.__index = M @@ -33,7 +33,6 @@ function M:new(opts) instance._on_started = opts.on_started instance._on_status_changed = opts.on_status_changed instance._path_finder = PathFinder:new() - instance._logs = {} return instance end @@ -55,29 +54,6 @@ function M:is_running() return self._status == ServerStatus.Running end ----@param level string Log level (INFO, WARN, ERROR, DEBUG) ----@param message string Log message -function M:_add_log(level, message) - local timestamp = os.date("%Y-%m-%d %H:%M:%S") - local log_entry = { - timestamp = timestamp, - level = level, - message = message - } - - table.insert(self._logs, log_entry) - - -- Keep only last 1000 log entries to prevent memory issues - if #self._logs > 1000 then - table.remove(self._logs, 1) - end -end - ----@return table Array of log entries -function M:get_logs() - return vim.deepcopy(self._logs) -end - ---@return table? function M:connection() return self._rpc @@ -88,7 +64,7 @@ function M:_handle_chat_content(params) -- Broadcast chat content to any listening components local eca = require("eca") local sidebar = eca.get(false) - + if sidebar and params then sidebar:_handle_server_content(params) end @@ -96,41 +72,39 @@ end function M:start() if self._status ~= ServerStatus.Stopped then - Utils.debug("Server already starting or running") + Logger.notify("Server already starting or running", vim.log.levels.INFO) return end - + self:_change_status(ServerStatus.Starting) - + local server_path local ok, err = pcall(function() server_path = self._path_finder:find() end) - + if not ok or not server_path then self:_change_status(ServerStatus.Failed) local error_msg = "Could not find or download ECA server: " .. tostring(err) - Utils.error(error_msg) - self:_add_log("ERROR", error_msg) + Logger.error(error_msg) return end - - Utils.debug("Starting ECA server: " .. server_path) - self:_add_log("INFO", "Starting ECA server: " .. server_path) - + + Logger.debug("Starting ECA server: " .. server_path) + local args = { server_path, "server" } if Config.server_args and Config.server_args ~= "" then vim.list_extend(args, vim.split(Config.server_args, " ")) end - + -- Use vim.fn.jobstart for interactive processes local job_opts = { on_stdout = function(_, data, _) - Utils.debug("Server stdout received: " .. vim.inspect(data)) + Logger.debug("Server stdout received: " .. vim.inspect(data)) if data and self._rpc then local output = table.concat(data, "\n") if output and output ~= "" and output ~= "\n" then - Utils.debug("Processing stdout: " .. output) + Logger.debug("Processing stdout: " .. output) self._rpc:_handle_stdout(output) end end @@ -145,22 +119,21 @@ function M:start() table.insert(meaningful_lines, trimmed) end end - + if #meaningful_lines > 0 then local error_output = table.concat(meaningful_lines, "\n") - Utils.debug("ECA server stderr: " .. error_output) + Logger.debug("ECA server stderr: " .. error_output) -- Capture logs for the logs buffer - self:_add_log("SERVER", error_output) end end end, on_exit = function(_, code, _) - Utils.debug("ECA server process exited with code: " .. code) + Logger.debug("ECA server process exited with code: " .. code) if code ~= 0 then self:_change_status(ServerStatus.Failed) - Utils.error("ECA server process exited with code: " .. code) + Logger.error("ECA server process exited with code: " .. code) else - Utils.debug("ECA server process exited normally") + Logger.debug("ECA server process exited normally") end self._proc = nil end, @@ -168,26 +141,24 @@ function M:start() stdout_buffered = false, stderr_buffered = false, } - + self._proc = vim.fn.jobstart(args, job_opts) - + if self._proc <= 0 then self:_change_status(ServerStatus.Failed) local error_msg = "Failed to start ECA server process. Job ID: " .. tostring(self._proc) - Utils.error(error_msg) - self:_add_log("ERROR", error_msg) + Logger.error(error_msg) return end - - Utils.debug("ECA server started with job ID: " .. self._proc) - self:_add_log("INFO", "ECA server process started successfully (Job ID: " .. self._proc .. ")") - + + Logger.debug("ECA server started with job ID: " .. self._proc) + -- Create RPC connection with the job ID self._rpc = RPC:new(self._proc) - + -- Setup message handling self:_setup_rpc_handlers() - + -- Initialize the server with a small delay vim.defer_fn(function() self:_initialize_server() @@ -195,8 +166,10 @@ function M:start() end function M:_setup_rpc_handlers() - if not self._rpc then return end - + if not self._rpc then + return + end + -- Handle notifications from server self._rpc:on_notification(function(method, params) if method == "chat/contentReceived" then @@ -205,13 +178,13 @@ function M:_setup_rpc_handlers() self:_handle_tool_server_update(params) end end) - + -- No need for manual stdout reading with jobstart - it's handled in on_stdout callback end ---@param params table function M:_handle_tool_server_update(params) - Utils.debug("Tool server updated: " .. vim.inspect(params)) + Logger.debug("Tool server updated: " .. vim.inspect(params)) end function M:_create_rpc_connection() @@ -223,45 +196,42 @@ function M:_initialize_server() local workspace_folders = { { name = vim.fn.fnamemodify(Utils.get_project_root(), ":t"), - uri = "file://" .. Utils.get_project_root() - } + uri = "file://" .. Utils.get_project_root(), + }, } - + self._rpc:send_request("initialize", { processId = vim.fn.getpid(), clientInfo = { name = "Neovim", - version = vim.version().major .. "." .. vim.version().minor + version = vim.version().major .. "." .. vim.version().minor, }, capabilities = { codeAssistant = { - chat = true - } + chat = true, + }, }, - workspaceFolders = workspace_folders + workspaceFolders = workspace_folders, }, function(err, result) if err then self:_change_status(ServerStatus.Failed) local error_msg = "Failed to initialize ECA server: " .. tostring(err) - Utils.error(error_msg) - self:_add_log("ERROR", error_msg) + Logger.error(error_msg) return end - + -- Store server capabilities if result then self._server_capabilities = result - Utils.debug("Server capabilities: " .. vim.inspect(result)) - self:_add_log("INFO", "Server capabilities received and stored") + Logger.debug("Server capabilities: " .. vim.inspect(result)) end - + self:_change_status(ServerStatus.Running) - Utils.info("ECA server started successfully") - self:_add_log("INFO", "ECA server initialized and running successfully") - + Logger.info("ECA server started successfully") + -- Send initialized notification self._rpc:send_notification("initialized", {}) - + if self._on_started then self._on_started(self._rpc) end @@ -272,20 +242,18 @@ function M:stop() if self._status == ServerStatus.Stopped then return end - - self:_add_log("INFO", "Stopping ECA server...") - + if self._rpc then self._rpc:send_request("shutdown", {}, function() self._rpc:send_notification("exit", {}) end) self._rpc:stop() end - + if self._proc and self._proc > 0 then -- Use vim.fn.jobstop for jobs started with jobstart vim.fn.jobstop(self._proc) - + -- Wait a bit for graceful shutdown vim.defer_fn(function() -- Force kill if still running (jobstop with SIGKILL) @@ -294,13 +262,12 @@ function M:stop() end end, 5000) end - + self._rpc = nil self._proc = nil self._chat_id = nil self:_change_status(ServerStatus.Stopped) - Utils.info("ECA server stopped") - self:_add_log("INFO", "ECA server stopped successfully") + Logger.info("ECA server stopped") end ---@param method string @@ -308,13 +275,13 @@ end ---@param callback? function function M:send_request(method, params, callback) if not self:is_running() or not self._rpc then - Utils.error("ECA server is not running") + Logger.error("ECA server is not running") if callback then callback("Server not running", nil) end return end - + self._rpc:send_request(method, params, callback) end @@ -322,10 +289,10 @@ end ---@param params table function M:send_notification(method, params) if not self:is_running() or not self._rpc then - Utils.error("ECA server is not running") + Logger.error("ECA server is not running") return end - + self._rpc:send_notification(method, params) end @@ -339,28 +306,28 @@ function M:send_chat_message(message, contexts, callback) end return end - + local chat_params = { chatId = self._chat_id, requestId = tostring(os.time()), message = message, - contexts = contexts or {} + contexts = contexts or {}, } - + self:send_request("chat/prompt", chat_params, function(err, result) if err then - Utils.error("Chat request failed: " .. tostring(err)) + Logger.error("Chat request failed: " .. tostring(err)) if callback then callback(err, nil) end return end - + if result and result.chatId then self._chat_id = result.chatId - Utils.debug("Chat message sent, chatId: " .. result.chatId) + Logger.debug("Chat message sent, chatId: " .. result.chatId) end - + if callback then callback(nil, result) end diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 8c30731..e80ce72 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1,4 +1,5 @@ local Utils = require("eca.utils") +local Logger = require("eca.logger") local Config = require("eca.config") -- Load nui.nvim components (required dependency) @@ -85,17 +86,17 @@ function M:open(opts) -- Setup containers if not initialized or if we need to refresh content if not self._initialized then - Utils.debug("Setting up containers (first time)") + Logger.debug("Setting up containers (first time)") self:_setup_containers() else - Utils.debug("Reusing existing containers") + Logger.debug("Reusing existing containers") self:_refresh_container_content() end -- Always focus input when opening self:_focus_input() - Utils.debug("ECA sidebar opened") + Logger.debug("ECA sidebar opened") end function M:close() @@ -110,7 +111,7 @@ function M:_close_windows_only() container.winid = nil end end - Utils.debug("ECA sidebar windows closed") + Logger.debug("ECA sidebar windows closed") end function M:_close_and_cleanup() @@ -129,7 +130,7 @@ function M:_close_and_cleanup() end end self.containers = {} - Utils.debug("ECA sidebar closed and cleaned up") + Logger.debug("ECA sidebar closed and cleaned up") end ---@param opts? table @@ -186,7 +187,7 @@ end function M:new_chat() self:reset() self._force_welcome = true - Utils.debug("New chat initiated - will show welcome content on next open") + Logger.debug("New chat initiated - will show welcome content on next open") end ---@private @@ -226,10 +227,10 @@ function M:_create_containers() local available_height = vim.o.lines - UI_ELEMENTS_HEIGHT if total_height > available_height then - Utils.debug(string.format("Total height (%d) exceeds available height (%d), adjusting chat height", total_height, available_height)) + Logger.debug(string.format("Total height (%d) exceeds available height (%d), adjusting chat height", total_height, available_height)) local extra_height = total_height - (available_height - SAFETY_MARGIN) chat_height = math.max(MIN_CHAT_HEIGHT, chat_height - extra_height) - Utils.debug(string.format("Adjusted chat height from %d to %d", original_chat_height, chat_height)) + Logger.debug(string.format("Adjusted chat height from %d to %d", original_chat_height, chat_height)) end -- Base options for all containers @@ -269,7 +270,7 @@ function M:_create_containers() -- Track the current container for hierarchical mounting with proper space management local current_winid = self.containers.chat.winid - Utils.debug("Mounted container: chat (winid: " .. current_winid .. ")") + Logger.debug("Mounted container: chat (winid: " .. current_winid .. ")") -- 2. Create selected_code container (conditional) if selected_code_height > 0 then @@ -291,7 +292,7 @@ function M:_create_containers() self.containers.selected_code:mount() self:_setup_container_events(self.containers.selected_code, "selected_code") current_winid = self.containers.selected_code.winid - Utils.debug("Mounted container: selected_code (winid: " .. current_winid .. ")") + Logger.debug("Mounted container: selected_code (winid: " .. current_winid .. ")") end -- 3. Create todos container (conditional) @@ -313,7 +314,7 @@ function M:_create_containers() self.containers.todos:mount() self:_setup_container_events(self.containers.todos, "todos") current_winid = self.containers.todos.winid - Utils.debug("Mounted container: todos (winid: " .. current_winid .. ")") + Logger.debug("Mounted container: todos (winid: " .. current_winid .. ")") end -- 4. Create status container (always present) - for processing messages @@ -334,7 +335,7 @@ function M:_create_containers() self.containers.status:mount() self:_setup_container_events(self.containers.status, "status") current_winid = self.containers.status.winid - Utils.debug("Mounted container: status (winid: " .. current_winid .. ")") + Logger.debug("Mounted container: status (winid: " .. current_winid .. ")") -- 5. Create contexts container between status and input self.containers.contexts = Split({ @@ -354,7 +355,7 @@ function M:_create_containers() self.containers.contexts:mount() self:_setup_container_events(self.containers.contexts, "contexts") current_winid = self.containers.contexts.winid - Utils.debug("Mounted container: contexts (winid: " .. current_winid .. ")") + Logger.debug("Mounted container: contexts (winid: " .. current_winid .. ")") -- 6. Create input container (always present) self.containers.input = Split({ @@ -374,7 +375,7 @@ function M:_create_containers() self.containers.input:mount() self:_setup_container_events(self.containers.input, "input") current_winid = self.containers.input.winid - Utils.debug("Mounted container: input (winid: " .. current_winid .. ")") + Logger.debug("Mounted container: input (winid: " .. current_winid .. ")") -- 7. Create usage container (always present) - moved to bottom self.containers.usage = Split({ @@ -394,9 +395,9 @@ function M:_create_containers() }) self.containers.usage:mount() self:_setup_container_events(self.containers.usage, "usage") - Utils.debug("Mounted container: usage (winid: " .. self.containers.usage.winid .. ")") + Logger.debug("Mounted container: usage (winid: " .. self.containers.usage.winid .. ")") - Utils.debug(string.format("Created containers: contexts=%d, chat=%d, selected_code=%s, todos=%s, status=%d, input=%d, usage=%d", + Logger.debug(string.format("Created containers: contexts=%d, chat=%d, selected_code=%s, todos=%s, status=%d, input=%d, usage=%d", contexts_height, chat_height, selected_code_height > 0 and tostring(selected_code_height) or "hidden", @@ -414,17 +415,17 @@ function M:_setup_container_events(container, name) -- Setup event handling for each container container:on(event.event.BufWinEnter, function() - Utils.debug("Container " .. name .. " entered") + Logger.debug("Container " .. name .. " entered") -- Container-specific setup can go here end) container:on(event.event.BufLeave, function() - Utils.debug("Container " .. name .. " left") + Logger.debug("Container " .. name .. " left") -- Container-specific cleanup can go here end) container:on(event.event.WinClosed, function() - Utils.debug("Container " .. name .. " window closed") + Logger.debug("Container " .. name .. " window closed") self:_handle_container_closed(name) end) @@ -574,7 +575,7 @@ function M:add_context(context) -- Update existing context self._contexts[i] = context self:_update_contexts_display() - Utils.info("Updated context: " .. context.path) + Logger.info("Updated context: " .. context.path) return end end @@ -582,7 +583,7 @@ function M:add_context(context) -- Add new context table.insert(self._contexts, context) self:_update_contexts_display() - Utils.info("Added context: " .. context.path .. " (" .. #self._contexts .. " total)") + Logger.info("Added context: " .. context.path .. " (" .. #self._contexts .. " total)") end ---@param path string Path to remove from contexts @@ -591,11 +592,11 @@ function M:remove_context(path) if context.path == path then table.remove(self._contexts, i) self:_update_contexts_display() - Utils.info("Removed context: " .. path) + Logger.info("Removed context: " .. path) return true end end - Utils.warn("Context not found: " .. path) + Logger.warn("Context not found: " .. path) return false end @@ -613,40 +614,40 @@ function M:clear_contexts() local count = #self._contexts self._contexts = {} self:_update_contexts_display() - Utils.info("Cleared " .. count .. " contexts") + Logger.info("Cleared " .. count .. " contexts") end ---@param code table Selected code object with filepath, content, start_line, end_line function M:set_selected_code(code) self._selected_code = code self:_update_selected_code_display() - Utils.info("Selected code updated: " .. (code and code.filepath or "none")) + Logger.info("Selected code updated: " .. (code and code.filepath or "none")) end function M:clear_selected_code() self._selected_code = nil self:_update_selected_code_display() - Utils.info("Selected code cleared") + Logger.info("Selected code cleared") end ---@param todo table Todo object with content, status ("pending", "completed") function M:add_todo(todo) table.insert(self._todos, todo) self:_update_todos_display() - Utils.info("Added TODO: " .. todo.content) + Logger.info("Added TODO: " .. todo.content) end ---@param index integer Index of todo to toggle function M:toggle_todo(index) if index <= 0 or index > #self._todos then - Utils.warn("Invalid TODO index: " .. index) + Logger.warn("Invalid TODO index: " .. index) return false end local todo = self._todos[index] todo.status = todo.status == "completed" and "pending" or "completed" self:_update_todos_display() - Utils.info("Toggled TODO " .. index .. ": " .. todo.content) + Logger.info("Toggled TODO " .. index .. ": " .. todo.content) return true end @@ -654,7 +655,7 @@ function M:clear_todos() local count = #self._todos self._todos = {} self:_update_todos_display() - Utils.info("Cleared " .. count .. " TODOs") + Logger.info("Cleared " .. count .. " TODOs") end ---@return table List of active todos @@ -822,12 +823,12 @@ function M:_set_welcome_content() -- Only set welcome content if buffer is empty or has no meaningful content if has_content then - Utils.debug("Preserving existing chat content") + Logger.debug("Preserving existing chat content") return end else -- Force welcome content and reset the flag - Utils.debug("Forcing welcome content for new chat") + Logger.debug("Forcing welcome content for new chat") self._force_welcome = false end @@ -848,7 +849,7 @@ function M:_set_welcome_content() "---", } - Utils.debug("Setting welcome content for new chat") + Logger.debug("Setting welcome content for new chat") vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, lines) -- Auto-add repoMap context if enabled and not already present @@ -868,7 +869,7 @@ function M:_set_welcome_content() path = "repoMap", content = "Repository structure and code mapping for better project understanding" }) - Utils.debug("Auto-added repoMap context on welcome") + Logger.debug("Auto-added repoMap context on welcome") end end end @@ -889,7 +890,7 @@ end function M:_focus_input() local input = self.containers.input if not input or not vim.api.nvim_win_is_valid(input.winid) then - Utils.debug("Cannot focus input: invalid window") + Logger.notify("Cannot focus input: invalid window", vim.log.levels.ERROR) return end @@ -924,7 +925,7 @@ function M:_focus_input() vim.cmd('startinsert!') end - Utils.debug("Input focused successfully with plenary") + Logger.debug("Input focused successfully with plenary") end end, 50) -- Small delay to ensure UI is ready end) @@ -951,7 +952,7 @@ function M:_focus_input() vim.cmd('startinsert!') end - Utils.debug("Input focused successfully (fallback)") + Logger.debug("Input focused successfully (fallback)") end end, 50) end @@ -1159,7 +1160,7 @@ end ---@param message string function M:_send_message(message) - Utils.debug("Sending message: " .. message) + Logger.debug("Sending message: " .. message) -- Store the last user message to avoid duplication self._last_user_message = message @@ -1169,10 +1170,10 @@ function M:_send_message(message) if eca.server and eca.server:is_running() then -- Include active contexts in the message local contexts = self:get_contexts() - Utils.debug("Sending message with " .. #contexts .. " contexts") + Logger.debug("Sending message with " .. #contexts .. " contexts") eca.server:send_chat_message(message, contexts, function(err, result) if err then - Utils.error("Failed to send message to ECA server: " .. tostring(err)) + Logger.error("Failed to send message to ECA server: " .. tostring(err)) self:_add_message("assistant", "❌ **Error**: Failed to send message to ECA server") end -- Response will come through server notification handler @@ -1248,14 +1249,14 @@ end function M:_handle_streaming_text(text) -- Only check for empty text if not text or text == "" then - Utils.debug("DEBUG: Empty text received, ignoring") + Logger.debug("DEBUG: Empty text received, ignoring") return end - Utils.debug("DEBUG: Received text chunk: '" .. text:sub(1, 50) .. (text:len() > 50 and "..." or "") .. "'") + Logger.debug("DEBUG: Received text chunk: '" .. text:sub(1, 50) .. (text:len() > 50 and "..." or "") .. "'") if not self._is_streaming then - Utils.debug("DEBUG: Starting streaming response") + Logger.debug("DEBUG: Starting streaming response") -- Start streaming - simple and direct self._is_streaming = true self._current_response_buffer = "" @@ -1266,7 +1267,7 @@ function M:_handle_streaming_text(text) -- Simple accumulation - no complex checks self._current_response_buffer = (self._current_response_buffer or "") .. text - Utils.debug("DEBUG: Buffer now has " .. #self._current_response_buffer .. " chars") + Logger.debug("DEBUG: Buffer now has " .. #self._current_response_buffer .. " chars") -- Update the assistant's message in place self:_update_streaming_message(self._current_response_buffer) @@ -1276,14 +1277,14 @@ end function M:_update_streaming_message(content) local chat = self.containers.chat if not chat or self._last_assistant_line == 0 then - Utils.debug("DEBUG: Cannot update - no chat or no assistant line") + Logger.notify("Cannot update - no chat or no assistant line", vim.log.levels.ERROR) return end - Utils.debug("DEBUG: Updating streaming message with " .. #content .. " chars") + Logger.debug("DEBUG: Updating streaming message with " .. #content .. " chars") if not vim.api.nvim_buf_is_valid(chat.bufnr) then - Utils.debug("DEBUG: Invalid buffer, cannot update") + Logger.notify("Invalid buffer, cannot update", vim.log.levels.ERROR) return end @@ -1297,8 +1298,8 @@ function M:_update_streaming_message(content) local content_lines = Utils.split_lines(content) local start_line = self._last_assistant_line + 2 -- Skip "## 🤖 ECA" and empty line - Utils.debug("DEBUG: Assistant line: " .. self._last_assistant_line .. ", start_line: " .. start_line) - Utils.debug("DEBUG: Content lines: " .. #content_lines) + Logger.debug("DEBUG: Assistant line: " .. self._last_assistant_line .. ", start_line: " .. start_line) + Logger.debug("DEBUG: Content lines: " .. #content_lines) -- Replace assistant content directly local new_lines = {} @@ -1319,11 +1320,11 @@ function M:_update_streaming_message(content) -- Set all lines at once vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, new_lines) - Utils.debug("DEBUG: Buffer updated successfully with " .. #new_lines .. " total lines") + Logger.debug("DEBUG: Buffer updated successfully with " .. #new_lines .. " total lines") end) if not success then - Utils.debug("DEBUG: Error updating buffer: " .. tostring(err)) + Logger.notify("Error updating buffer: " .. tostring(err), vim.log.levels.ERROR) else -- Auto-scroll to bottom during streaming to follow the text self:_scroll_to_bottom() @@ -1398,17 +1399,17 @@ end function M:_finalize_streaming_response() if self._is_streaming then - Utils.debug("DEBUG: Finalizing streaming response") - Utils.debug("DEBUG: Final buffer had " .. #(self._current_response_buffer or "") .. " chars") + Logger.debug("DEBUG: Finalizing streaming response") + Logger.debug("DEBUG: Final buffer had " .. #(self._current_response_buffer or "") .. " chars") self._is_streaming = false self._current_response_buffer = "" self._last_assistant_line = 0 self._response_start_time = 0 - Utils.debug("DEBUG: Streaming state cleared") + Logger.debug("DEBUG: Streaming state cleared") else - Utils.debug("DEBUG: _finalize_streaming_response called but not streaming") + Logger.debug("DEBUG: _finalize_streaming_response called but not streaming") end end @@ -1466,7 +1467,7 @@ function M:_safe_buffer_update(bufnr, callback) -- Disable treesitter highlighting for this buffer temporarily pcall(function() if vim.treesitter.highlighter.active[bufnr] then - Utils.debug("Temporarily disabling treesitter for buffer " .. bufnr) + Logger.debug("Temporarily disabling treesitter for buffer " .. bufnr) vim.treesitter.highlighter.active[bufnr]:destroy() vim.treesitter.highlighter.active[bufnr] = nil end @@ -1475,7 +1476,7 @@ function M:_safe_buffer_update(bufnr, callback) -- Execute the buffer update with maximum protection local success, err = pcall(callback) if not success then - Utils.debug("Buffer update failed: " .. tostring(err)) + Logger.notify("Buffer update failed: " .. tostring(err), vim.log.levels.ERROR) end -- Restore original state immediately (no delay for critical settings) @@ -1496,11 +1497,11 @@ function M:_safe_buffer_update(bufnr, callback) if ok and parser then -- Only create highlighter if one doesn't exist and buffer is still valid if not vim.treesitter.highlighter.active[bufnr] and vim.api.nvim_buf_is_valid(bufnr) then - Utils.debug("Re-enabling treesitter for buffer " .. bufnr) + Logger.debug("Re-enabling treesitter for buffer " .. bufnr) vim.treesitter.highlighter.new(parser, {}) end else - Utils.debug("No treesitter parser available for buffer " .. bufnr) + Logger.debug("No treesitter parser available for buffer " .. bufnr) end end) end diff --git a/lua/eca/strings.lua b/lua/eca/strings.lua new file mode 100644 index 0000000..5244534 --- /dev/null +++ b/lua/eca/strings.lua @@ -0,0 +1,9 @@ +local M = {} + +---@param s string +---@return boolean +function M.is_nil_or_empty(s) + return s == nil or s == "" +end + +return M diff --git a/lua/eca/utils.lua b/lua/eca/utils.lua index 77b8367..b2ce9a5 100644 --- a/lua/eca/utils.lua +++ b/lua/eca/utils.lua @@ -1,5 +1,7 @@ local uv = vim.uv or vim.loop +local Logger = require("eca.logger") + local M = {} local CONSTANTS = { @@ -7,49 +9,6 @@ local CONSTANTS = { SIDEBAR_BUFFER_NAME = "__ECA__", } ----@param msg string ----@param opts? {title?: string, once?: boolean} -function M.debug(msg, opts) - opts = opts or {} - local Config = require("eca.config") - if Config.debug then - vim.notify(msg, vim.log.levels.DEBUG, { - title = opts.title or "ECA - Debug", - once = opts.once, - }) - end -end - ----@param msg string ----@param opts? {title?: string, once?: boolean} -function M.info(msg, opts) - opts = opts or {} - vim.notify(msg, vim.log.levels.INFO, { - title = opts.title or "ECA", - once = opts.once, - }) -end - ----@param msg string ----@param opts? {title?: string, once?: boolean} -function M.warn(msg, opts) - opts = opts or {} - vim.notify(msg, vim.log.levels.WARN, { - title = opts.title or "ECA", - once = opts.once, - }) -end - ----@param msg string ----@param opts? {title?: string, once?: boolean} -function M.error(msg, opts) - opts = opts or {} - vim.notify(msg, vim.log.levels.ERROR, { - title = opts.title or "ECA", - once = opts.once, - }) -end - ---@param bufnr integer ---@return boolean function M.is_sidebar_buffer(bufnr) @@ -65,7 +24,7 @@ function M.safe_keymap_set(mode, lhs, rhs, opts) -- Check if the keymap is already set local existing = vim.fn.maparg(lhs, type(mode) == "table" and mode[1] or mode, false, true) if existing and existing.rhs then - M.debug("Keymap " .. lhs .. " already exists, skipping") + Logger.debug("Keymap " .. lhs .. " already exists, skipping") return end vim.keymap.set(mode, lhs, rhs, opts) diff --git a/plugin-spec.lua b/plugin-spec.lua index 25370b8..a441d89 100644 --- a/plugin-spec.lua +++ b/plugin-spec.lua @@ -9,9 +9,14 @@ return { config = function() require("eca").setup({ -- Default configuration - debug = false, server_path = "", server_args = "", + log = { + display = "split", -- "split" or "popup" + level = vim.log.levels.INFO, + file = "", -- Empty string uses default path + max_file_size_mb = 10, + }, behaviour = { auto_set_keymaps = true, auto_focus_sidebar = true, diff --git a/scripts/minimal_init.lua b/scripts/minimal_init.lua index 6548e7c..7414242 100644 --- a/scripts/minimal_init.lua +++ b/scripts/minimal_init.lua @@ -1,6 +1,11 @@ -- Minimal init.lua for testing vim.cmd([[let &rtp.=','.getcwd()]]) +-- Disable swap files and other unnecessary features for testing +vim.o.swapfile = false +vim.o.backup = false +vim.o.writebackup = false + -- Add dependencies to runtime path if headless if #vim.api.nvim_list_uis() == 0 then vim.cmd('set rtp+=deps/mini.nvim') diff --git a/tests/test_logging.lua b/tests/test_logging.lua new file mode 100644 index 0000000..bb9a93f --- /dev/null +++ b/tests/test_logging.lua @@ -0,0 +1,503 @@ +local MiniTest = require("mini.test") +local child = MiniTest.new_child_neovim() +local eq = MiniTest.expect.equality +local new_set = MiniTest.new_set + +local expect_match = MiniTest.new_expectation("string matching", function(str, pattern) + return str:find(pattern) ~= nil +end, function(str, pattern) + return string.format("Pattern: %s\nObserved string: %s", vim.inspect(pattern), str) +end) + +local expect_no_match = MiniTest.new_expectation("string not matching", function(str, pattern) + return str:find(pattern) == nil +end, function(str, pattern) + return string.format("Pattern: %s\nObserved string: %s", vim.inspect(pattern), str) +end) + +local setup_test_environment = function() + child.lua([[ + _G.test_log_dir = vim.fn.tempname() .. '_logs' + vim.fn.mkdir(_G.test_log_dir, 'p') + + _G.captured_notifications = {} + local original_notify = vim.notify + vim.notify = function(msg, level, opts) + table.insert(_G.captured_notifications, { + message = msg, + level = level, + opts = opts or {} + }) + return original_notify(msg, level, opts) + end + ]]) +end + +local cleanup_test_files = function() + child.lua([[ + if _G.test_log_dir then + vim.fn.delete(_G.test_log_dir, 'rf') + end + ]]) +end + +--- Get absolute path for a test log file within the temporary test directory +--- +--- @param filename string The filename within the test directory (e.g., "test.log", "nested/dir/file.log") +--- @return string Absolute path to the test file +local get_test_file_path = function(filename) + return child.lua_get("_G.test_log_dir") .. "/" .. filename +end + +--- Setup the Logger module with a given configuration in the child Neovim process +--- Use this when you need custom logger configuration that doesn't fit the common patterns +--- covered by setup_logger_with_file(). +--- +--- @param config table Logger configuration options (level, file, display, etc.) +local setup_logger = function(config) + child.lua("Logger.setup(...)", { config }) +end + +--- Setup logger with a test file and common defaults, with optional overrides +--- +--- @param filename string The log filename within the test directory +--- @param overrides? table Optional config overrides (level, display, etc.) +local setup_logger_with_file = function(filename, overrides) + local config = vim.tbl_extend("force", { + level = vim.log.levels.INFO, + file = get_test_file_path(filename), + }, overrides or {}) + setup_logger(config) +end + +--- Read entire contents of a test log file as a single string +--- +--- @param relative_path string Path relative to test directory, should start with "/" (e.g., "/test.log") +--- @return string Complete file contents joined with newlines, or empty string if file not found +local read_log_file = function(relative_path) + return child.lua([[ + local log_path = _G.test_log_dir .. ']] .. relative_path .. [[' + if vim.fn.filereadable(log_path) == 1 then + return table.concat(vim.fn.readfile(log_path), '\n') + end + return '' + ]]) +end + +--- Read only the first line of a test log file +--- +--- @param relative_path string Path relative to test directory, should start with "/" (e.g., "/test.log") +--- @return string First line of the file, or empty string if file not found or empty +--- +--- Example usage: +--- local first_line = read_log_first_line("/format_test.log") +--- expect_match(first_line, "%[%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d%]") -- timestamp format +local read_log_first_line = function(relative_path) + return child.lua([[ + local log_path = _G.test_log_dir .. ']] .. relative_path .. [[' + if vim.fn.filereadable(log_path) == 1 then + return vim.fn.readfile(log_path)[1] or '' + end + return '' + ]]) +end + +--- Wait for async logging operations to complete +--- +--- @param ms? number Milliseconds to wait (default: 100) +--- +local wait_for_async = function(ms) + vim.uv.sleep(ms or 100) +end + +--- Common test loop: sconfigure logger → run logging code → verify file contents +--- +--- @param filename string The log filename within the test directory (no leading slash) +--- @param log_commands string Lua code to execute in child process (usually Logger.* calls) +--- @param config_overrides? table Optional logger config overrides (level, notify, etc.) +--- @return string Complete log file contents for assertion testing +--- +--- Example usage: +--- -- basic +--- local contents = log_and_read("test.log", "Logger.info('hello world')") +--- expect_match(contents, "hello world") +--- +--- -- Test with custom log level +--- local contents = log_and_read("debug.log", [[ +--- Logger.debug('debug msg') +--- Logger.info('info msg') +--- ]], { level = vim.log.levels.DEBUG }) +--- +--- -- Test multiple log calls +--- local contents = log_and_read("multi.log", [[ +--- Logger.warn('warning') +--- Logger.error('error') +--- ]]) +local log_and_read = function(filename, log_commands, config_overrides) + setup_logger_with_file(filename, config_overrides) + child.lua(log_commands) + wait_for_async() + return read_log_file("/" .. filename) +end + +local T = new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua([[Logger = require('eca.logger')]]) + setup_test_environment() + end, + post_case = cleanup_test_files, + post_once = child.stop, + }, +}) + +T["configuration"] = new_set() + +T["configuration"]["default configuration"] = function() + child.lua([[ + Logger.setup() + ]]) + + eq(child.lua_get("Logger.get_log_path()"), vim.fn.stdpath("state") .. "/eca.log") + eq(child.lua_get("Logger.config.level"), vim.log.levels.INFO) + eq(child.lua_get("Logger.config.display"), "split") +end + +T["configuration"]["file logging with default path"] = function() + setup_logger({ + level = vim.log.levels.INFO, + }) + + local log_path = child.lua_get("Logger.get_log_path()") + eq(vim.fn.stdpath("state") .. "/eca.log", log_path) +end + +T["configuration"]["file logging with custom path"] = function() + setup_logger({ + level = vim.log.levels.INFO, + file = get_test_file_path("custom.log"), + }) + + local log_path = child.lua_get("Logger.get_log_path()") + expect_match(log_path, "custom%.log$") +end + +T["log_levels"] = new_set() + +T["log_levels"]["respects minimum log level"] = function() + local contents = log_and_read( + "level_test.log", + [[ + Logger.debug('debug message') + Logger.info('info message') + Logger.warn('warn message') + Logger.error('error message') + ]], + { level = vim.log.levels.WARN } + ) + + expect_no_match(contents, "DEBUG") + expect_no_match(contents, "INFO") + expect_match(contents, "WARN") + expect_match(contents, "ERROR") +end + +T["log_levels"]["invalid level defaults to info"] = function() + local contents = log_and_read( + "invalid_level.log", + [[ + Logger.debug('debug message') + Logger.info('info message') + ]], + { level = 999 } + ) + + expect_no_match(contents, "DEBUG") + expect_no_match(contents, "INFO") +end + +T["file_logging"] = new_set() + +T["file_logging"]["writes to file with correct format"] = function() + setup_logger_with_file("format_test.log") + child.lua([[Logger.info('test message')]]) + wait_for_async() + + local contents = read_log_first_line("/format_test.log") + + expect_match(contents, "%[%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d%]") + expect_match(contents, "INFO") + expect_match(contents, "%[CLIENT%]") + expect_match(contents, "test message") +end + +T["file_logging"]["creates parent directory"] = function() + setup_logger({ + level = vim.log.levels.INFO, + file = get_test_file_path("nested/dir/test.log"), + }) + + child.lua([[Logger.info('nested test')]]) + + wait_for_async() + + local dir_exists = child.lua([[ + return vim.fn.isdirectory(_G.test_log_dir .. '/nested/dir') == 1 + ]]) + local file_exists = child.lua([[ + return vim.fn.filereadable(_G.test_log_dir .. '/nested/dir/test.log') == 1 + ]]) + + eq(dir_exists, true) + eq(file_exists, true) +end + +T["file_logging"]["supports path expansion"] = function() + setup_logger({ + level = vim.log.levels.INFO, + file = "~/test-eca.log", + }) + + local expanded_path = child.lua_get("Logger.get_log_path()") + expect_no_match(expanded_path, "^~") + expect_match(expanded_path, "test%-eca%.log$") +end + +T["notifications"] = new_set() + +T["notifications"]["Logger.notify sends vim.notify"] = function() + setup_logger_with_file("notify_test.log") + + child.lua([[ + Logger.notify('warning message', vim.log.levels.WARN) + Logger.notify('error message', vim.log.levels.ERROR) + ]]) + + local notifications = child.lua_get("_G.captured_notifications") + eq(#notifications, 2) + eq(notifications[1].message, "warning message") + eq(notifications[1].level, vim.log.levels.WARN) + eq(notifications[2].message, "error message") + eq(notifications[2].level, vim.log.levels.ERROR) +end + +T["notifications"]["Logger.notify defaults to INFO level"] = function() + setup_logger_with_file("notify_default_test.log") + + child.lua([[Logger.notify('default level message')]]) + + local notifications = child.lua_get("_G.captured_notifications") + eq(#notifications, 1) + eq(notifications[1].message, "default level message") + eq(notifications[1].level, vim.log.levels.INFO) +end + +T["notifications"]["Logger.notify writes to log file"] = function() + local contents = log_and_read( + "notify_log_test.log", + [[ + Logger.notify('test notification message', vim.log.levels.WARN) + ]] + ) + + expect_match(contents, "WARN") + expect_match(contents, "test notification message") + expect_match(contents, "%[CLIENT%]") + + local notifications = child.lua_get("_G.captured_notifications") + eq(#notifications, 1) + eq(notifications[1].message, "test notification message") + eq(notifications[1].level, vim.log.levels.WARN) +end + +T["display modes"] = new_set() + +T["display modes"]["default display mode is split"] = function() + setup_logger_with_file("display_test.log") + + local display = child.lua_get("Logger.get_display()") + eq(display, "split") +end + +T["display modes"]["can configure popup mode"] = function() + setup_logger({ + level = vim.log.levels.INFO, + file = get_test_file_path("popup_test.log"), + display = "popup", + }) + + local display = child.lua_get("Logger.get_display()") + eq(display, "popup") +end + +T["utilities"] = new_set() + +T["utilities"]["get_log_stats"] = function() + setup_logger_with_file("stats_test.log") + + child.lua([[Logger.info('message for stats')]]) + + wait_for_async() + + local stats = child.lua_get("Logger.get_log_stats()") + eq(type(stats), "table") + expect_match(stats.path, "stats_test%.log$") + eq(type(stats.size), "number") + eq(stats.size > 0, true) + eq(type(stats.size_mb), "number") + eq(type(stats.modified), "string") +end + +T["utilities"]["clear_log"] = function() + local content_before = log_and_read("clear_test.log", [[Logger.info('message to be cleared')]]) + expect_match(content_before, "message to be cleared") + + local cleared = child.lua_get("Logger.clear_log()") + eq(cleared, true) + + local content_after = read_log_file("/clear_test.log") + eq(content_after, "") +end + +T["integration"] = new_set() + +T["integration"]["logger functions work correctly"] = function() + local contents = log_and_read( + "logger_integration.log", + [[ + Logger.debug('debug message') + Logger.info('info message') + Logger.warn('warn message') + Logger.error('error message') + ]], + { level = vim.log.levels.DEBUG } + ) + + expect_match(contents, "debug message") + expect_match(contents, "info message") + expect_match(contents, "warn message") + expect_match(contents, "error message") +end + +T["integration"]["server logging with SERVER prefix"] = function() + local contents = log_and_read( + "server_integration.log", + [[ + Logger.info('server message 1', { title = 'SERVER' }) + Logger.warn('server warning', { title = 'SERVER' }) + Logger.error('server error', { title = 'SERVER' }) + + Logger.info('client message') + ]] + ) + + expect_match(contents, "%[SERVER%] server message 1") + expect_match(contents, "%[SERVER%] server warning") + expect_match(contents, "%[SERVER%] server error") + expect_match(contents, "%[CLIENT%] client message") +end + +T["integration"]["EcaLogs command behavior"] = function() + setup_logger_with_file("eca_logs_test.log") + + child.lua([[Logger.info('Test log entry for EcaLogs')]]) + + wait_for_async() + + local log_path = child.lua_get("Logger.get_log_path()") + expect_match(log_path, "eca_logs_test%.log$") + + local api_result = child.lua([[ + local Api = require('eca.api') + local Logger = require('eca.logger') + + local log_path = Logger.get_log_path() + return { + log_path_exists = vim.fn.filereadable(log_path) == 1, + log_path = log_path, + file_readable = vim.fn.filereadable(log_path) == 1 + } + ]]) + + eq(api_result.log_path_exists, true) + eq(api_result.file_readable, true) + expect_match(api_result.log_path, "eca_logs_test%.log$") +end + +T["integration"]["EcaLogs subcommands"] = function() + setup_logger_with_file("subcommands_test.log") + + child.lua([[ + Logger.info('Test content for subcommands') + Logger.warn('Test warning message') + ]]) + + wait_for_async() + + local log_path_result = child.lua([[ + local Logger = require("eca.logger") + return Logger.get_log_path() + ]]) + expect_match(log_path_result, "subcommands_test%.log$") + + local stats_result = child.lua([[ + local Logger = require("eca.logger") + return Logger.get_log_stats() + ]]) + eq(type(stats_result), "table") + expect_match(stats_result.path, "subcommands_test%.log$") + eq(stats_result.size > 0, true) + + local clear_result = child.lua([[ + local Logger = require("eca.logger") + return Logger.clear_log() + ]]) + eq(clear_result, true) + + local file_empty = child.lua([[ + local Logger = require("eca.logger") + local stats = Logger.get_log_stats() + return stats and stats.size == 0 + ]]) + eq(file_empty, true) +end + +T["integration"]["large log file notification"] = function() + -- Create a small log file first + setup_logger_with_file("notification_test.log") + child.lua([[Logger.info('Initial log entry')]]) + wait_for_async() + + -- Clear notifications before testing + child.lua([[_G.captured_notifications = {}]]) + + -- Setup logger with max_file_size_mb = 0 to trigger notification immediately + setup_logger({ + level = vim.log.levels.INFO, + file = get_test_file_path("notification_test.log"), + max_file_size_mb = 0, -- Set to 0 to trigger notification for any file size + }) + + wait_for_async(200) + + local notifications = child.lua_get("_G.captured_notifications") + eq(#notifications >= 1, true) + + local large_file_notification = nil + for _, notification in ipairs(notifications) do + if notification.message and notification.message:match("ECA log file is large") then + large_file_notification = notification + break + end + end + + eq(large_file_notification ~= nil, true) + expect_match(large_file_notification.message, "ECA log file is large") + expect_match(large_file_notification.message, "MB%)") + expect_match(large_file_notification.message, "Consider clearing it with :EcaLogs clear") + eq(large_file_notification.level, vim.log.levels.WARN) + eq(large_file_notification.opts.title, "ECA") +end + +return T